feat: enhance trip management with tag functionality and update TripsPage for navigation

This commit is contained in:
Felix Zett 2025-08-31 12:00:36 +02:00
parent 6456b320c2
commit 8fe64792d7
5 changed files with 201 additions and 27 deletions

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from uuid import UUID
from backend.database import get_db
from backend import models
@ -17,6 +17,13 @@ def list_trip_items(trip_id: UUID, db: Session = Depends(get_db)):
db.query(models.TripItem)
.options(joinedload(models.TripItem.tag))
.filter(models.TripItem.trip_id == trip_id)
.order_by(
func.regexp_replace(
models.TripItem.name_calculated,
r'^\s*\d+\s*',
''
).asc()
)
.all()
)
return [

View file

@ -102,26 +102,28 @@ def reconfigure_trip(trip_id: UUID, payload: TripUpdate, db: Session = Depends(g
trip.end_date = payload.end_date
db.flush()
for tag_id in payload.selected_tag_ids or []:
# Always use a list, never None
selected_tag_ids = payload.selected_tag_ids or []
marked_tag_ids = payload.marked_tag_ids or []
for tag_id in selected_tag_ids:
tag = db.query(models.Tag).get(tag_id)
if tag and tag not in trip.selected_tags:
trip.selected_tags.append(tag)
for tag_id in payload.marked_tag_ids or []:
for tag_id in marked_tag_ids:
tag = db.query(models.Tag).get(tag_id)
if tag and tag not in trip.marked_tags:
trip.marked_tags.append(tag)
# remove tags not in the new list
if payload.selected_tag_ids is not None:
trip.selected_tags = [tag for tag in trip.selected_tags if tag.id in payload.selected_tag_ids]
if payload.marked_tag_ids is not None:
trip.marked_tags = [tag for tag in trip.marked_tags if tag.id in payload.marked_tag_ids]
trip.selected_tags = [tag for tag in trip.selected_tags if tag.id in selected_tag_ids]
trip.marked_tags = [tag for tag in trip.marked_tags if tag.id in marked_tag_ids]
db.flush()
created_ids, deleted_checked = generate_trip_items(
db, trip=trip, selected_tag_ids=payload.selected_tag_ids, marked_tag_ids=payload.marked_tag_ids,
db, trip=trip, selected_tag_ids=selected_tag_ids, marked_tag_ids=marked_tag_ids,
)
db.commit()
return {

View file

@ -109,3 +109,22 @@ export async function createTrip(data: {
if (!res.ok) throw new Error("Failed to create trip");
return res.json();
}
export async function updateTrip(
tripId: string,
data: {
name: string;
start_date: string;
end_date: string;
selected_tag_ids: string[];
marked_tag_ids: string[];
}
): Promise<any> {
const res = await fetch(`${API_BASE}/trips/${tripId}/reconfigure`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to update trip");
return res.json();
}

View file

@ -1,39 +1,183 @@
// filepath: frontend/src/pages/TripChecklist.tsx
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { getTripItems, toggleTripItem } from "../api";
import { getTripItems, toggleTripItem, updateTrip, getTags } from "../api";
export default function TripChecklist({ trips }: { trips: any[] }) {
const { id } = useParams();
const [items, setItems] = useState<any[]>([]);
const [allTags, setAllTags] = useState<any[]>([]);
const [tagInput, setTagInput] = useState("");
const [hoveredTag, setHoveredTag] = useState<string | null>(null);
useEffect(() => {
if (id) {
getTripItems(id).then(setItems);
getTags().then(setAllTags);
}
}, [id]);
const trip = trips.find((t) => t.id === id);
const [selectedTags, setSelectedTags] = useState<any[]>([]);
const [markedTags, setMarkedTags] = useState<any[]>([]);
useEffect(() => {
if (trip) {
setSelectedTags(trip.selected_tags);
setMarkedTags(trip.marked_tags);
}
}, [trip]);
async function handleToggleMark(tagId: string) {
const isMarked = markedTags.some((t) => t.id === tagId);
const newMarked = isMarked
? markedTags.filter((t) => t.id !== tagId)
: [...markedTags, selectedTags.find((t) => t.id === tagId)];
await updateTrip(trip.id, {
name: trip.name,
start_date: trip.start_date,
end_date: trip.end_date,
selected_tag_ids: selectedTags.map((t) => t.id),
marked_tag_ids: newMarked.map((t) => t.id),
});
setMarkedTags(newMarked);
}
async function handleRemoveTag(tagId: string) {
const newSelected = selectedTags.filter((t) => t.id !== tagId);
const newMarked = markedTags.filter((t) => t.id !== tagId);
await updateTrip(trip.id, {
name: trip.name,
start_date: trip.start_date,
end_date: trip.end_date,
selected_tag_ids: newSelected.map((t) => t.id),
marked_tag_ids: newMarked.map((t) => t.id),
});
setSelectedTags(newSelected);
setMarkedTags(newMarked);
}
async function handleAddTag(tagId: string) {
if (selectedTags.some((t) => t.id === tagId)) return;
const tagObj = allTags.find((t) => t.id === tagId);
const newSelected = [...selectedTags, tagObj];
await updateTrip(trip.id, {
name: trip.name,
start_date: trip.start_date,
end_date: trip.end_date,
selected_tag_ids: newSelected.map((t) => t.id),
marked_tag_ids: markedTags.map((t) => t.id),
});
setSelectedTags(newSelected);
setTagInput("");
}
if (!trip) return <div>Trip not found</div>;
return (
<div>
<h2 className="font-bold text-xl mb-2">{trip.name}</h2>
<p className="mb-4">{trip.start_date} {trip.end_date}</p>
{/* Tag-Liste */}
<div className="mb-2 flex flex-wrap gap-2 items-center">
<span className="font-semibold">Tags: </span>
{selectedTags.length === 0 ? (
<span className="text-gray-400">keine</span>
) : (
selectedTags.map((tag: any) => {
const isMarked = markedTags.some((mt: any) => mt.id === tag.id);
return (
<span
key={tag.id}
className={
"relative px-2 py-0.5 rounded mr-1 text-sm cursor-pointer transition " +
(isMarked
? "bg-yellow-200 text-yellow-900 font-bold"
: "bg-blue-100 text-blue-800")
}
onClick={() => handleToggleMark(tag.id)}
onMouseEnter={() => setHoveredTag(tag.id)}
onMouseLeave={() => setHoveredTag(null)}
>
#{tag.name}
{hoveredTag === tag.id && (
<button
className="absolute -top-2 -right-2 text-xs text-red-500 bg-white rounded-full px-1 shadow"
onClick={e => {
e.stopPropagation();
handleRemoveTag(tag.id);
}}
>
×
</button>
)}
</span>
);
})
)}
{/* Tag hinzufügen */}
<div className="relative">
<input
type="text"
placeholder="+ Tag"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
className="border rounded px-2 py-0.5 text-sm ml-2"
list="tag-suggestions"
/>
<datalist id="tag-suggestions">
{allTags
.filter(
(t) =>
t.name.toLowerCase().includes(tagInput.toLowerCase()) &&
!selectedTags.some((st) => st.id === t.id)
)
.map((t) => (
<option key={t.id} value={t.name} />
))}
</datalist>
{tagInput &&
allTags
.filter(
(t) =>
t.name.toLowerCase() === tagInput.toLowerCase() &&
!selectedTags.some((st) => st.id === t.id)
)
.map((t) => (
<button
key={t.id}
className="ml-2 text-xs text-blue-500 underline"
onClick={() => handleAddTag(t.id)}
type="button"
>
Hinzufügen
</button>
))}
</div>
</div>
{/* ...Rest der Checklist... */}
<ul>
{items.map((item) => (
<li key={item.id} className="flex items-center gap-2">
<li
key={item.id}
className={
"flex items-center gap-2 cursor-pointer select-none px-2 py-1 rounded " +
(item.checked ? "bg-green-100" : "hover:bg-gray-100")
}
onClick={async () => {
await toggleTripItem(item.id);
const updated = await getTripItems(id!);
setItems(updated);
}}
>
<input
type="checkbox"
checked={item.checked}
onChange={async () => {
await toggleTripItem(item.id);
const updated = await getTripItems(id!);
setItems(updated);
}}
readOnly
tabIndex={-1}
className="pointer-events-none"
/>
<span>{item.name_calculated}</span>
<span className={item.checked ? "line-through text-gray-400" : ""}>
{item.name_calculated}
</span>
</li>
))}
</ul>

View file

@ -77,7 +77,12 @@ export default function TripsPage() {
</form>
{trips.map(trip => (
<div key={trip.id} className="border rounded p-2 mb-4">
<Link
to={`/trips/${trip.id}`}
key={trip.id}
className="block border rounded p-2 mb-4 hover:bg-blue-50 transition"
style={{ textDecoration: "none", color: "inherit" }}
>
<div className="flex justify-between items-center">
<div>
<h2 className="font-bold">{trip.name}</h2>
@ -86,21 +91,18 @@ export default function TripsPage() {
</p>
</div>
<div className="flex gap-2">
<Link
to={`/trips/${trip.id}`}
className="text-sm text-blue-500 underline"
>
Packliste anzeigen
</Link>
<button
className="text-sm text-red-500 underline"
onClick={() => handleDeleteTrip(trip.id)}
onClick={e => {
e.preventDefault();
handleDeleteTrip(trip.id);
}}
>
Löschen
</button>
</div>
</div>
</div>
</Link>
))}
</div>
);