packlist/frontend/src/pages/TripChecklist.tsx

382 lines
No EOL
13 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";
import { getTagColor } from "../utils/tagColors";
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;
// Progress gradient for header background (always full width)
const progressGradient = `linear-gradient(140deg, #f59e42 0%, #facc15 50%, #22c55e 100%)`;
return (
<div className="py-4 max-w-5xl mx-auto">
<div
className="mb-6 px-4 py-3 rounded-xl shadow flex flex-wrap items-center gap-4 relative"
style={{
background: progressGradient,
transition: "background 0.3s",
overflow: "hidden",
}}
>
{/* Overlay: shrinking box from the right */}
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: `${100 - progress}%`,
background: "rgba(255, 255, 255, 0.85)",
pointerEvents: "none",
transition: "width 0.3s",
zIndex: 1,
}}
/>
<div className="flex flex-col flex-1 min-w-[180px]" style={{ position: "relative", zIndex: 2 }}>
<div className="flex items-end gap-2">
<h2 className="text-xl text-gray-900 font-bold m-0">{trip.name}</h2>
<span className="text-xs text-gray-500 px-2 py-0.5 rounded font-semibold">
{trip.start_date} {trip.end_date}
</span>
</div>
<div className="flex flex-wrap gap-2 items-center mt-2">
{selectedTags.length === 0 ? (
<span className="text-gray-400 text-sm">keine</span>
) : (
selectedTags.map((tag: any) => {
const isMarked = markedTags.some((mt: any) => mt.id === tag.id);
const color = getTagColor(tag.id);
return (
<span
key={tag.id}
className={
`relative px-2 py-0.5 rounded mr-1 text-xs shadow shadow-md cursor-pointer transition font-medium ${color.bg} ` +
(isMarked ? "ring-2 ring-yellow-400" : "")
}
onClick={() => handleToggleMark(tag.id)}
onMouseEnter={() => setHoveredTag(tag.id)}
onMouseLeave={() => setHoveredTag(null)}
>
#{tag.name}
{tag.mandatory && <span className="text-red-500 font-bold ml-0.5">!</span>}
{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-xs 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>
</div>
{/* Fortschritt kompakt rechts */}
<div className="flex flex-col items-end min-w-[120px]" style={{ position: "relative", zIndex: 2 }}>
<span className="font-semibold text-gray-700 text-sm">Fortschritt</span>
<span className="text-xs text-gray-500">{checkedItems} / {totalItems}</span>
<span className="text-xs text-gray-500">{progress}%</span>
</div>
</div>
{/* ...restliche Seite... */}
<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>
);
}