feat: enhance trip management with tag functionality and update TripsPage for navigation
This commit is contained in:
parent
6456b320c2
commit
8fe64792d7
5 changed files with 201 additions and 27 deletions
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue