feat: add trip-specific items

including trip_id in item creation and listing
This commit is contained in:
Felix Zett 2025-09-13 22:42:44 +02:00
parent 0f407373b5
commit 2187045add
8 changed files with 83 additions and 21 deletions

View file

@ -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,6 +61,7 @@ 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:
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:
@ -71,6 +72,9 @@ def items_for_trip(db: Session, user_id: UUID_t, selected_tag_ids: List[UUID_t])
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)

View file

@ -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):

View file

@ -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:

View file

@ -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

View file

@ -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");

View file

@ -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}

View file

@ -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"

View file

@ -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}