feat: add trip-specific items
including trip_id in item creation and listing
This commit is contained in:
parent
0f407373b5
commit
2187045add
8 changed files with 83 additions and 21 deletions
|
|
@ -48,8 +48,8 @@ def render_name(name_template: str, start: Optional[date], end: Optional[date])
|
|||
return _placeholder_re.sub(repl, name_template)
|
||||
|
||||
|
||||
def items_for_trip(db: Session, user_id: UUID_t, selected_tag_ids: List[UUID_t]) -> List[models.Item]:
|
||||
# Items without tags (always) + items with any of the selected_tags,
|
||||
def items_for_trip(db: Session, user_id: UUID_t, trip: models.Trip, selected_tag_ids: List[UUID_t]) -> List[models.Item]:
|
||||
# Items without trip_id and tags (always) + items without trip_id and with any of the selected_tags + items with trip_id equal to the current trip
|
||||
# but: if an item has a mandatory tag, it is only included if at least one of its mandatory tags is selected.
|
||||
q = (
|
||||
db.query(models.Item)
|
||||
|
|
@ -61,16 +61,20 @@ def items_for_trip(db: Session, user_id: UUID_t, selected_tag_ids: List[UUID_t])
|
|||
selected_set = set(selected_tag_ids)
|
||||
result: List[models.Item] = []
|
||||
for it in items:
|
||||
item_tag_ids = {tag.id for tag in it.tags}
|
||||
mandatory_tag_ids = {tag.id for tag in it.tags if getattr(tag, "mandatory", False)}
|
||||
if not item_tag_ids:
|
||||
result.append(it)
|
||||
elif mandatory_tag_ids:
|
||||
# Only include if at least one mandatory tag is selected
|
||||
if selected_set & mandatory_tag_ids:
|
||||
if it.trip_id is None:
|
||||
item_tag_ids = {tag.id for tag in it.tags}
|
||||
mandatory_tag_ids = {tag.id for tag in it.tags if getattr(tag, "mandatory", False)}
|
||||
if not item_tag_ids:
|
||||
result.append(it)
|
||||
elif selected_set & item_tag_ids:
|
||||
elif mandatory_tag_ids:
|
||||
# Only include if at least one mandatory tag is selected
|
||||
if selected_set & mandatory_tag_ids:
|
||||
result.append(it)
|
||||
elif selected_set & item_tag_ids:
|
||||
result.append(it)
|
||||
elif it.trip_id == trip.id:
|
||||
result.append(it)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -93,7 +97,7 @@ def generate_trip_items(
|
|||
db.delete(ti)
|
||||
db.flush()
|
||||
|
||||
items = items_for_trip(db, trip.user_id, selected_tag_ids)
|
||||
items = items_for_trip(db, trip.user_id, trip, selected_tag_ids)
|
||||
|
||||
created_ids: List[UUID_t] = []
|
||||
marked_set = set(marked_tag_ids)
|
||||
|
|
|
|||
|
|
@ -62,10 +62,13 @@ class Item(Base):
|
|||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
# Optional association to a specific trip
|
||||
trip_id = Column(UUID(as_uuid=True), ForeignKey("trip.id"), nullable=True)
|
||||
|
||||
user = relationship("User", backref="items")
|
||||
tags = relationship("Tag", secondary=item_tag_table, backref="items")
|
||||
trip_items = relationship("TripItem", back_populates="item", cascade="all, delete-orphan")
|
||||
trip = relationship("Trip", backref="special_items")
|
||||
|
||||
|
||||
class Trip(Base):
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ def list_items(db: Session = Depends(get_db)):
|
|||
|
||||
@router.post("/", response_model=ItemOut)
|
||||
def create_item(payload: ItemCreate, db: Session = Depends(get_db)):
|
||||
# Demo: use first user or create one if none exists
|
||||
user = db.query(models.User).first()
|
||||
if not user:
|
||||
from uuid import uuid4
|
||||
|
|
@ -24,8 +23,7 @@ def create_item(payload: ItemCreate, db: Session = Depends(get_db)):
|
|||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
# Create the item
|
||||
item = models.Item(user_id=user.id, name=payload.name)
|
||||
item = models.Item(user_id=user.id, name=payload.name, trip_id=payload.trip_id)
|
||||
|
||||
# Attach tags if provided
|
||||
if payload.tag_ids:
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ class ItemBase(BaseModel):
|
|||
|
||||
class ItemCreate(ItemBase):
|
||||
tag_ids: List[UUID] = []
|
||||
trip_id: Optional[UUID] = None
|
||||
|
||||
class ItemOut(ItemBase):
|
||||
id: UUID
|
||||
tags: List[TagOut] = []
|
||||
trip_id: Optional[UUID] = None
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface Item {
|
|||
id: string;
|
||||
name: string;
|
||||
tags: Tag[];
|
||||
trip_id: string;
|
||||
}
|
||||
|
||||
const API_BASE = "http://localhost:8000"; // ggf. anpassen
|
||||
|
|
@ -74,13 +75,14 @@ export async function addItemTag(itemId: string, tagId: string): Promise<Item> {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
export async function createItem(name: string, tagIds: string[]): Promise<Item> {
|
||||
export async function createItem(name: string, tagIds: string[], tripId?: string): Promise<Item> {
|
||||
const res = await fetch(`${API_BASE}/items/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
tag_ids: tagIds,
|
||||
trip_id: tripId || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create item");
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ interface ItemListProps {
|
|||
export default function ItemList({
|
||||
items,
|
||||
allTags,
|
||||
trips,
|
||||
onUpdateName,
|
||||
onDeleteTag,
|
||||
onAddTag,
|
||||
onDeleteItem,
|
||||
}: ItemListProps) {
|
||||
}: ItemListProps & { trips: { id: string; name: string }[] }) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-gray-500 italic">Keine Items gefunden.</p>;
|
||||
}
|
||||
|
|
@ -30,6 +31,7 @@ export default function ItemList({
|
|||
key={item.id}
|
||||
item={item}
|
||||
allTags={allTags}
|
||||
trips={trips}
|
||||
onUpdateName={onUpdateName}
|
||||
onDeleteTag={onDeleteTag}
|
||||
onAddTag={onAddTag}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@ interface ItemRowProps {
|
|||
export default function ItemRow({
|
||||
item,
|
||||
allTags,
|
||||
trips,
|
||||
onUpdateName,
|
||||
onDeleteTag,
|
||||
onAddTag,
|
||||
onDeleteItem,
|
||||
}: ItemRowProps) {
|
||||
}: ItemRowProps & { trips: { id: string; name: string }[] }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(item.name);
|
||||
const [hover, setHover] = useState(false);
|
||||
|
|
@ -49,6 +50,12 @@ export default function ItemRow({
|
|||
);
|
||||
}
|
||||
|
||||
// Trip-Namen lookup
|
||||
const tripName =
|
||||
item.trip_id && trips
|
||||
? trips.find((t) => t.id === item.trip_id)?.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="flex flex-wrap items-center gap-2 py-1 border-b group"
|
||||
|
|
@ -100,6 +107,12 @@ export default function ItemRow({
|
|||
</span>
|
||||
))}
|
||||
|
||||
{tripName && (
|
||||
<span className="ml-2 px-2 py-0.5 rounded bg-yellow-100 text-yellow-800 text-xs font-semibold">
|
||||
{tripName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hover && !addingTag && (
|
||||
<button
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import {
|
|||
import ItemList from "../components/ItemList";
|
||||
import TagFilter from "../components/TagFilter";
|
||||
|
||||
async function fetchTrips(): Promise<{ id: string; name: string }[]> {
|
||||
const res = await fetch("http://localhost:8000/trips/");
|
||||
if (!res.ok) throw new Error("Failed to fetch trips");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name
|
||||
.replace(/\{.*?\}/g, "") // {...} entfernen
|
||||
|
|
@ -23,6 +29,7 @@ function normalizeName(name: string): string {
|
|||
export default function ItemsPage() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [trips, setTrips] = useState<{ id: string; name: string }[]>([]);
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -30,14 +37,20 @@ export default function ItemsPage() {
|
|||
// State für neue ItemRow
|
||||
const [newItemName, setNewItemName] = useState("");
|
||||
const [newItemTags, setNewItemTags] = useState<string[]>([]);
|
||||
const [newItemTripId, setNewItemTripId] = useState<string>("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [itemsData, tagsData] = await Promise.all([getItems(), getTags()]);
|
||||
const [itemsData, tagsData, tripsData] = await Promise.all([
|
||||
getItems(),
|
||||
getTags(),
|
||||
fetchTrips(),
|
||||
]);
|
||||
setItems(itemsData);
|
||||
setTags(tagsData);
|
||||
setTrips(tripsData);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -57,10 +70,11 @@ export default function ItemsPage() {
|
|||
if (!newItemName.trim()) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const newItem = await createItem(newItemName.trim(), newItemTags);
|
||||
const newItem = await createItem(newItemName.trim(), newItemTags, newItemTripId || undefined);
|
||||
setItems((prev) => [...prev, newItem]);
|
||||
setNewItemName("");
|
||||
setNewItemTags([]);
|
||||
setNewItemTripId("");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
|
|
@ -144,7 +158,24 @@ export default function ItemsPage() {
|
|||
}}
|
||||
disabled={adding}
|
||||
/>
|
||||
{tags.map((tag) => (
|
||||
{/* Trip-Auswahl */}
|
||||
<select
|
||||
className="border rounded px-1 py-0.5"
|
||||
value={newItemTripId}
|
||||
onChange={e => {
|
||||
setNewItemTripId(e.target.value);
|
||||
if (e.target.value) setNewItemTags([]); // Tags zurücksetzen, wenn Trip gewählt
|
||||
}}
|
||||
>
|
||||
<option value="">(gültig für alle Trips)</option>
|
||||
{trips.map((trip) => (
|
||||
<option key={trip.id} value={trip.id}>
|
||||
{trip.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Tags-Auswahl, nur wenn kein Trip gewählt */}
|
||||
{!newItemTripId && tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className={
|
||||
|
|
@ -163,6 +194,12 @@ export default function ItemsPage() {
|
|||
)}
|
||||
</span>
|
||||
))}
|
||||
{/* Hinweis, falls Trip gewählt */}
|
||||
{newItemTripId && (
|
||||
<span className="text-xs text-gray-500 italic">
|
||||
Dieses Item gilt nur für den gewählten Trip. Tags werden ignoriert.
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="text-xs text-green-600 bg-green-100 rounded px-2 py-1 ml-auto"
|
||||
onClick={handleAddItem}
|
||||
|
|
@ -175,6 +212,7 @@ export default function ItemsPage() {
|
|||
<ItemList
|
||||
items={sortedItems}
|
||||
allTags={tags}
|
||||
trips={trips}
|
||||
onUpdateName={handleRenameItem}
|
||||
onDeleteTag={handleDeleteTag}
|
||||
onAddTag={handleAddTag}
|
||||
|
|
|
|||
Loading…
Reference in a new issue