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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from uuid import UUID from uuid import UUID
from backend.database import get_db from backend.database import get_db
from backend import models from backend import models
@ -17,6 +17,13 @@ def list_trip_items(trip_id: UUID, db: Session = Depends(get_db)):
db.query(models.TripItem) db.query(models.TripItem)
.options(joinedload(models.TripItem.tag)) .options(joinedload(models.TripItem.tag))
.filter(models.TripItem.trip_id == trip_id) .filter(models.TripItem.trip_id == trip_id)
.order_by(
func.regexp_replace(
models.TripItem.name_calculated,
r'^\s*\d+\s*',
''
).asc()
)
.all() .all()
) )
return [ 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 trip.end_date = payload.end_date
db.flush() 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) tag = db.query(models.Tag).get(tag_id)
if tag and tag not in trip.selected_tags: if tag and tag not in trip.selected_tags:
trip.selected_tags.append(tag) 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) tag = db.query(models.Tag).get(tag_id)
if tag and tag not in trip.marked_tags: if tag and tag not in trip.marked_tags:
trip.marked_tags.append(tag) trip.marked_tags.append(tag)
# remove tags not in the new list # 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 selected_tag_ids]
trip.selected_tags = [tag for tag in trip.selected_tags if tag.id in payload.selected_tag_ids] trip.marked_tags = [tag for tag in trip.marked_tags if tag.id in marked_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]
db.flush() db.flush()
created_ids, deleted_checked = generate_trip_items( 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() db.commit()
return { return {

View file

@ -109,3 +109,22 @@ export async function createTrip(data: {
if (!res.ok) throw new Error("Failed to create trip"); if (!res.ok) throw new Error("Failed to create trip");
return res.json(); 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 // filepath: frontend/src/pages/TripChecklist.tsx
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom"; 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[] }) { export default function TripChecklist({ trips }: { trips: any[] }) {
const { id } = useParams(); const { id } = useParams();
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [allTags, setAllTags] = useState<any[]>([]);
const [tagInput, setTagInput] = useState("");
const [hoveredTag, setHoveredTag] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
getTripItems(id).then(setItems); getTripItems(id).then(setItems);
getTags().then(setAllTags);
} }
}, [id]); }, [id]);
const trip = trips.find((t) => t.id === 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>; if (!trip) return <div>Trip not found</div>;
return ( return (
<div> <div>
<h2 className="font-bold text-xl mb-2">{trip.name}</h2> {/* Tag-Liste */}
<p className="mb-4">{trip.start_date} {trip.end_date}</p> <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> <ul>
{items.map((item) => ( {items.map((item) => (
<li key={item.id} className="flex items-center gap-2"> <li
<input key={item.id}
type="checkbox" className={
checked={item.checked} "flex items-center gap-2 cursor-pointer select-none px-2 py-1 rounded " +
onChange={async () => { (item.checked ? "bg-green-100" : "hover:bg-gray-100")
}
onClick={async () => {
await toggleTripItem(item.id); await toggleTripItem(item.id);
const updated = await getTripItems(id!); const updated = await getTripItems(id!);
setItems(updated); setItems(updated);
}} }}
>
<input
type="checkbox"
checked={item.checked}
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> </li>
))} ))}
</ul> </ul>

View file

@ -77,7 +77,12 @@ export default function TripsPage() {
</form> </form>
{trips.map(trip => ( {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 className="flex justify-between items-center">
<div> <div>
<h2 className="font-bold">{trip.name}</h2> <h2 className="font-bold">{trip.name}</h2>
@ -86,21 +91,18 @@ export default function TripsPage() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link
to={`/trips/${trip.id}`}
className="text-sm text-blue-500 underline"
>
Packliste anzeigen
</Link>
<button <button
className="text-sm text-red-500 underline" className="text-sm text-red-500 underline"
onClick={() => handleDeleteTrip(trip.id)} onClick={e => {
e.preventDefault();
handleDeleteTrip(trip.id);
}}
> >
Löschen Löschen
</button> </button>
</div> </div>
</div> </div>
</div> </Link>
))} ))}
</div> </div>
); );