391 lines
No EOL
14 KiB
TypeScript
391 lines
No EOL
14 KiB
TypeScript
// 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>
|
||
);
|
||
} |