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)
|
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]:
|
def items_for_trip(db: Session, user_id: UUID_t, trip: models.Trip, selected_tag_ids: List[UUID_t]) -> List[models.Item]:
|
||||||
# Items without tags (always) + items with any of the selected_tags,
|
# 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.
|
# but: if an item has a mandatory tag, it is only included if at least one of its mandatory tags is selected.
|
||||||
q = (
|
q = (
|
||||||
db.query(models.Item)
|
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)
|
selected_set = set(selected_tag_ids)
|
||||||
result: List[models.Item] = []
|
result: List[models.Item] = []
|
||||||
for it in items:
|
for it in items:
|
||||||
item_tag_ids = {tag.id for tag in it.tags}
|
if it.trip_id is None:
|
||||||
mandatory_tag_ids = {tag.id for tag in it.tags if getattr(tag, "mandatory", False)}
|
item_tag_ids = {tag.id for tag in it.tags}
|
||||||
if not item_tag_ids:
|
mandatory_tag_ids = {tag.id for tag in it.tags if getattr(tag, "mandatory", False)}
|
||||||
result.append(it)
|
if not 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)
|
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)
|
result.append(it)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -93,7 +97,7 @@ def generate_trip_items(
|
||||||
db.delete(ti)
|
db.delete(ti)
|
||||||
db.flush()
|
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] = []
|
created_ids: List[UUID_t] = []
|
||||||
marked_set = set(marked_tag_ids)
|
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)
|
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)
|
user_id = Column(UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
|
||||||
name = Column(String, 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")
|
user = relationship("User", backref="items")
|
||||||
tags = relationship("Tag", secondary=item_tag_table, backref="items")
|
tags = relationship("Tag", secondary=item_tag_table, backref="items")
|
||||||
trip_items = relationship("TripItem", back_populates="item", cascade="all, delete-orphan")
|
trip_items = relationship("TripItem", back_populates="item", cascade="all, delete-orphan")
|
||||||
|
trip = relationship("Trip", backref="special_items")
|
||||||
|
|
||||||
|
|
||||||
class Trip(Base):
|
class Trip(Base):
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ def list_items(db: Session = Depends(get_db)):
|
||||||
|
|
||||||
@router.post("/", response_model=ItemOut)
|
@router.post("/", response_model=ItemOut)
|
||||||
def create_item(payload: ItemCreate, db: Session = Depends(get_db)):
|
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()
|
user = db.query(models.User).first()
|
||||||
if not user:
|
if not user:
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
@ -24,8 +23,7 @@ def create_item(payload: ItemCreate, db: Session = Depends(get_db)):
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Create the item
|
item = models.Item(user_id=user.id, name=payload.name, trip_id=payload.trip_id)
|
||||||
item = models.Item(user_id=user.id, name=payload.name)
|
|
||||||
|
|
||||||
# Attach tags if provided
|
# Attach tags if provided
|
||||||
if payload.tag_ids:
|
if payload.tag_ids:
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@ class ItemBase(BaseModel):
|
||||||
|
|
||||||
class ItemCreate(ItemBase):
|
class ItemCreate(ItemBase):
|
||||||
tag_ids: List[UUID] = []
|
tag_ids: List[UUID] = []
|
||||||
|
trip_id: Optional[UUID] = None
|
||||||
|
|
||||||
class ItemOut(ItemBase):
|
class ItemOut(ItemBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
tags: List[TagOut] = []
|
tags: List[TagOut] = []
|
||||||
|
trip_id: Optional[UUID] = None
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export interface Item {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
trip_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = "http://localhost:8000"; // ggf. anpassen
|
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();
|
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/`, {
|
const res = await fetch(`${API_BASE}/items/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
tag_ids: tagIds,
|
tag_ids: tagIds,
|
||||||
|
trip_id: tripId || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to create item");
|
if (!res.ok) throw new Error("Failed to create item");
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@ interface ItemListProps {
|
||||||
export default function ItemList({
|
export default function ItemList({
|
||||||
items,
|
items,
|
||||||
allTags,
|
allTags,
|
||||||
|
trips,
|
||||||
onUpdateName,
|
onUpdateName,
|
||||||
onDeleteTag,
|
onDeleteTag,
|
||||||
onAddTag,
|
onAddTag,
|
||||||
onDeleteItem,
|
onDeleteItem,
|
||||||
}: ItemListProps) {
|
}: ItemListProps & { trips: { id: string; name: string }[] }) {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return <p className="text-gray-500 italic">Keine Items gefunden.</p>;
|
return <p className="text-gray-500 italic">Keine Items gefunden.</p>;
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +31,7 @@ export default function ItemList({
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
allTags={allTags}
|
allTags={allTags}
|
||||||
|
trips={trips}
|
||||||
onUpdateName={onUpdateName}
|
onUpdateName={onUpdateName}
|
||||||
onDeleteTag={onDeleteTag}
|
onDeleteTag={onDeleteTag}
|
||||||
onAddTag={onAddTag}
|
onAddTag={onAddTag}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@ interface ItemRowProps {
|
||||||
export default function ItemRow({
|
export default function ItemRow({
|
||||||
item,
|
item,
|
||||||
allTags,
|
allTags,
|
||||||
|
trips,
|
||||||
onUpdateName,
|
onUpdateName,
|
||||||
onDeleteTag,
|
onDeleteTag,
|
||||||
onAddTag,
|
onAddTag,
|
||||||
onDeleteItem,
|
onDeleteItem,
|
||||||
}: ItemRowProps) {
|
}: ItemRowProps & { trips: { id: string; name: string }[] }) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editName, setEditName] = useState(item.name);
|
const [editName, setEditName] = useState(item.name);
|
||||||
const [hover, setHover] = useState(false);
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
className="flex flex-wrap items-center gap-2 py-1 border-b group"
|
className="flex flex-wrap items-center gap-2 py-1 border-b group"
|
||||||
|
|
@ -100,6 +107,12 @@ export default function ItemRow({
|
||||||
</span>
|
</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 && (
|
{hover && !addingTag && (
|
||||||
<button
|
<button
|
||||||
className="text-xs text-blue-500 hover:underline"
|
className="text-xs text-blue-500 hover:underline"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ import {
|
||||||
import ItemList from "../components/ItemList";
|
import ItemList from "../components/ItemList";
|
||||||
import TagFilter from "../components/TagFilter";
|
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 {
|
function normalizeName(name: string): string {
|
||||||
return name
|
return name
|
||||||
.replace(/\{.*?\}/g, "") // {...} entfernen
|
.replace(/\{.*?\}/g, "") // {...} entfernen
|
||||||
|
|
@ -23,6 +29,7 @@ function normalizeName(name: string): string {
|
||||||
export default function ItemsPage() {
|
export default function ItemsPage() {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [trips, setTrips] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [filterText, setFilterText] = useState("");
|
const [filterText, setFilterText] = useState("");
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -30,14 +37,20 @@ export default function ItemsPage() {
|
||||||
// State für neue ItemRow
|
// State für neue ItemRow
|
||||||
const [newItemName, setNewItemName] = useState("");
|
const [newItemName, setNewItemName] = useState("");
|
||||||
const [newItemTags, setNewItemTags] = useState<string[]>([]);
|
const [newItemTags, setNewItemTags] = useState<string[]>([]);
|
||||||
|
const [newItemTripId, setNewItemTripId] = useState<string>("");
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [itemsData, tagsData] = await Promise.all([getItems(), getTags()]);
|
const [itemsData, tagsData, tripsData] = await Promise.all([
|
||||||
|
getItems(),
|
||||||
|
getTags(),
|
||||||
|
fetchTrips(),
|
||||||
|
]);
|
||||||
setItems(itemsData);
|
setItems(itemsData);
|
||||||
setTags(tagsData);
|
setTags(tagsData);
|
||||||
|
setTrips(tripsData);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -57,10 +70,11 @@ export default function ItemsPage() {
|
||||||
if (!newItemName.trim()) return;
|
if (!newItemName.trim()) return;
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
try {
|
try {
|
||||||
const newItem = await createItem(newItemName.trim(), newItemTags);
|
const newItem = await createItem(newItemName.trim(), newItemTags, newItemTripId || undefined);
|
||||||
setItems((prev) => [...prev, newItem]);
|
setItems((prev) => [...prev, newItem]);
|
||||||
setNewItemName("");
|
setNewItemName("");
|
||||||
setNewItemTags([]);
|
setNewItemTags([]);
|
||||||
|
setNewItemTripId("");
|
||||||
} finally {
|
} finally {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +158,24 @@ export default function ItemsPage() {
|
||||||
}}
|
}}
|
||||||
disabled={adding}
|
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
|
<span
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className={
|
className={
|
||||||
|
|
@ -163,6 +194,12 @@ export default function ItemsPage() {
|
||||||
)}
|
)}
|
||||||
</span>
|
</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
|
<button
|
||||||
className="text-xs text-green-600 bg-green-100 rounded px-2 py-1 ml-auto"
|
className="text-xs text-green-600 bg-green-100 rounded px-2 py-1 ml-auto"
|
||||||
onClick={handleAddItem}
|
onClick={handleAddItem}
|
||||||
|
|
@ -175,6 +212,7 @@ export default function ItemsPage() {
|
||||||
<ItemList
|
<ItemList
|
||||||
items={sortedItems}
|
items={sortedItems}
|
||||||
allTags={tags}
|
allTags={tags}
|
||||||
|
trips={trips}
|
||||||
onUpdateName={handleRenameItem}
|
onUpdateName={handleRenameItem}
|
||||||
onDeleteTag={handleDeleteTag}
|
onDeleteTag={handleDeleteTag}
|
||||||
onAddTag={handleAddTag}
|
onAddTag={handleAddTag}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue