packlist/frontend/src/pages/TripChecklist.tsx

391 lines
No EOL
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// filepath: frontend/src/pages/TripChecklist.tsx
import React, { useState, useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { getTripItems, toggleTripItem, updateTrip, getTags } from "../api";
import TagAutocompleteInput from "../components/TagAutocompleteInput";
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);
const [addingTag, setAddingTag] = useState(false);
const inputRef = useRef<HTMLInputElement>(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);
// Items neu laden
if (id) {
const updated = await getTripItems(id);
setItems(updated);
}
}
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);
// Items neu laden
if (id) {
const updated = await getTripItems(id);
setItems(updated);
}
}
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("");
// Items neu laden
if (id) {
const updated = await getTripItems(id);
setItems(updated);
}
}
if (!trip) return <div>Trip not found</div>;
// 1. Items ohne Tag
const itemsWithoutTag = items.filter((item) => !item.tag);
// 2. Items mit Tag, gruppiert nach tag.id
const itemsByTag: Record<string, { tag: any; items: any[] }> = {};
items
.filter((item) => item.tag)
.forEach((item) => {
const tagId = item.tag.id;
if (!itemsByTag[tagId]) {
itemsByTag[tagId] = { tag: item.tag, items: [] };
}
itemsByTag[tagId].items.push(item);
});
// Nur markierte Tag-Gruppen
const markedTagIds = new Set(markedTags.map((t: any) => t.id));
const markedTagGroups = Object.values(itemsByTag)
.filter((g) => markedTagIds.has(g.tag.id))
.sort((a, b) => a.tag.name.localeCompare(b.tag.name));
// Hilfsfunktion für Kategorie-Gruppierung
function renderItemsWithCategories(items: any[]) {
const slashCategoryMap: Record<string, any[]> = {};
const normalItems: any[] = [];
items.forEach((item) => {
const name = item.name_calculated || "";
if (name.includes("/")) {
const [cat, sub] = name.split("/", 2);
if (!slashCategoryMap[cat]) slashCategoryMap[cat] = [];
slashCategoryMap[cat].push({ ...item, _sub: sub });
} else {
normalItems.push(item);
}
});
return (
<>
{/* Haupt-Items als Grid */}
{normalItems.length > 0 && (
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-4 mb-4">
{normalItems.map((item) => (
<li
key={item.id}
className={
"flex items-center gap-2 cursor-pointer select-none px-2 py-1 rounded group " +
(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}
readOnly
tabIndex={-1}
className="pointer-events-none"
/>
<span
className={item.checked ? "line-through text-gray-400" : ""}
title={
item.item && item.item.tags && item.item.tags.length > 0
? item.item.tags
.map(
(tag: any) =>
`#${tag.name}${tag.mandatory ? " *" : ""}`
)
.join(" ")
: ""
}
>
{item.name_calculated}
</span>
{/* Tags NICHT mehr direkt anzeigen */}
</li>
))}
</ul>
)}
{/* Slash-Kategorien wie gehabt */}
{Object.entries(slashCategoryMap).map(([cat, catItems]) => (
<React.Fragment key={cat}>
<li className="mt-2 mb-1 flex items-center gap-2">
<h3 className="text-base font-semibold text-gray-600 m-0">
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</h3>
</li>
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-4">
{catItems.map((item) => (
<li
key={item.id}
className={
"flex items-center gap-2 cursor-pointer select-none px-2 py-1 rounded group " +
(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}
readOnly
tabIndex={-1}
className="pointer-events-none"
/>
<span
className={item.checked ? "line-through text-gray-400" : ""}
title={
item.item && item.item.tags && item.item.tags.length > 0
? item.item.tags
.map(
(tag: any) =>
`#${tag.name}${tag.mandatory ? " *" : ""}`
)
.join(" ")
: ""
}
>
{item._sub}
</span>
{/* Tags NICHT mehr direkt anzeigen */}
</li>
))}
</ul>
</React.Fragment>
))}
</>
);
}
function formatAsHeading(str: string) {
if (!str) return "";
// Replace hyphens with spaces and capitalize first letter
const withSpaces = str.replace(/-/g, " ");
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
}
// Progress calculation
const totalItems = items.length;
const checkedItems = items.filter((item) => item.checked).length;
const progress = totalItems > 0 ? Math.round((checkedItems / totalItems) * 100) : 0;
return (
<div className="py-4 max-w-5xl mx-auto">
{/* Trip-Titel und Zeitraum */}
<div className="mb-6 p-6 rounded-xl border-2 border-blue-200 bg-blue-50 shadow flex flex-col gap-2">
<h2 className="text-2xl font-bold text-blue-900">{trip.name}</h2>
<div className="text-gray-600 text-base">
{trip.start_date} {trip.end_date}
</div>
<div className="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" link and dropdown */}
{!addingTag ? (
<button
className="text-blue-500 underline text-sm ml-2"
onClick={() => {
setAddingTag(true);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
type="button"
>
+ Tag
</button>
) : (
<TagAutocompleteInput
ref={inputRef}
allTags={allTags}
selectedTags={selectedTags}
onAddTag={(tagId, viaTab) => {
handleAddTag(tagId);
if (viaTab) {
setTimeout(() => {
setAddingTag(true);
inputRef.current?.focus();
}, 0);
} else {
setAddingTag(false);
}
}}
onEscape={() => setAddingTag(false)}
placeholder="Tag suchen..."
/>
)}
</div>
{/* Progressbar integriert am unteren Rand */}
<div className="mt-6">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-gray-700">Fortschritt</span>
<span className="text-sm text-gray-500">{checkedItems} / {totalItems} erledigt</span>
</div>
<div
className="w-full h-5 rounded-full overflow-hidden shadow"
style={{
background: "linear-gradient(90deg, #f59e42 0%, #facc15 50%, #22c55e 100%)",
position: "relative",
}}
>
<div
className="h-full"
style={{
width: `${progress}%`,
background: "none", // inherit the gradient from parent
position: "absolute",
left: 0,
top: 0,
transition: "width 0.3s",
}}
/>
{/* gray overlay for unfilled part */}
<div
className="h-full w-full"
style={{
background: "rgba(243,244,246,0.9)", // tailwind gray-200 with opacity
position: "absolute",
left: 0,
top: 0,
pointerEvents: "none",
zIndex: 1,
width: `${100 - progress}%`,
marginLeft: `${progress}%`,
transition: "width 0.3s, margin-left 0.3s",
}}
/>
</div>
<div className="text-right text-xs text-gray-500 mt-1">{progress}%</div>
</div>
</div>
{/* ...existing code... */}
<ul
className="grid grid-cols-1 gap-x-8 gap-y-2"
>
{/* 1. Ohne Tag */}
{renderItemsWithCategories(itemsWithoutTag)}
{/* 2. Markierte Tag-Gruppen */}
{markedTagGroups.map(({ tag, items }) => (
<li
key={tag.id}
className="mt-6 mb-6 p-4 rounded-lg border-2 border-yellow-200 bg-yellow-50 shadow-sm"
style={{ listStyle: "none" }}
>
<div className="mb-2 flex items-center gap-2">
<h2 className="text-lg font-bold text-yellow-700 m-0">
{formatAsHeading(tag.name)}
</h2>
</div>
<ul>
{renderItemsWithCategories(items)}
</ul>
</li>
))}
</ul>
</div>
);
}