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 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 [
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onChange={async () => {
|
readOnly
|
||||||
await toggleTripItem(item.id);
|
tabIndex={-1}
|
||||||
const updated = await getTripItems(id!);
|
className="pointer-events-none"
|
||||||
setItems(updated);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span>{item.name_calculated}</span>
|
<span className={item.checked ? "line-through text-gray-400" : ""}>
|
||||||
|
{item.name_calculated}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue