feat: extract TripItemList to component and add TripEdit page

This commit is contained in:
Felix Zett 2025-09-22 14:25:14 +02:00
parent c7b720ba4a
commit 14905dd033
4 changed files with 379 additions and 166 deletions

View file

@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate, us
import { getSeed, getTrips, getNextTripId } from "./api";
import ItemsPage from "./pages/ItemsPage";
import TripChecklist from "./pages/TripChecklist";
import TripEdit from "./pages/TripEdit";
import TripsPage from "./pages/TripsPage";
import TagsPage from "./pages/TagsPage";
import logo from "./assets/logo.svg";
@ -173,6 +174,7 @@ export default function App() {
<Routes>
<Route path="/trips" element={<TripsPage />} />
<Route path="/trips/:id" element={<TripChecklist trips={trips} />} />
<Route path="/trips/:id/edit" element={<TripEdit trips={trips} />} />
<Route path="/items" element={<ItemsPage />} />
<Route path="/tags" element={<TagsPage />} />
<Route path="/" element={<NextTripRedirect trips={trips} />} />

View file

@ -0,0 +1,172 @@
import React from "react";
import { toggleTripItem, getTripItems } from "../api";
export function TripItemList({
tripId,
items,
markedTags,
reloadItems,
}: {
tripId: string;
items: any[];
markedTags: any[];
reloadItems: () => Promise<void>;
}) {
// 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));
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);
}
// 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>
))}
</>
);
}
return (
<ul className="grid grid-cols-1 gap-x-8 gap-y-2">
{renderItemsWithCategories(itemsWithoutTag)}
{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">
<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>
);
}

View file

@ -5,6 +5,7 @@ import { getTripItems, toggleTripItem, updateTrip, getTags } from "../api";
import TagAutocompleteInput from "../components/TagAutocompleteInput";
import TripTag from "../components/Tag";
import Tag from "../components/Tag";
import { TripItemList } from "../components/TripItemList";
export default function TripChecklist({ trips }: { trips: any[] }) {
const { id } = useParams();
@ -94,148 +95,6 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
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;
@ -335,30 +194,16 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
</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>
<TripItemList
tripId={trip.id}
items={items}
markedTags={markedTags}
reloadItems={async () => {
const updated = await getTripItems(id!);
setItems(updated);
}}
/>
</div>
);
}

View file

@ -0,0 +1,194 @@
import React, { useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getTrips, updateTrip, getTags, getTripItems } from "../api";
import TagAutocompleteInput from "../components/TagAutocompleteInput";
import Tag from "../components/Tag";
import { TripItemList } from "../components/TripItemList";
export default function TripEdit({ trips }: { trips: any[] }) {
const { id } = useParams();
const navigate = useNavigate();
const trip = trips.find((t) => t.id === id);
const [name, setName] = useState(trip?.name || "");
const [startDate, setStartDate] = useState(trip?.start_date || "");
const [endDate, setEndDate] = useState(trip?.end_date || "");
const [selectedTags, setSelectedTags] = useState<any[]>(trip?.selected_tags || []);
const [markedTags, setMarkedTags] = useState<any[]>(trip?.marked_tags || []);
const [allTags, setAllTags] = useState<any[]>([]);
const [addingTag, setAddingTag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [items, setItems] = useState<any[]>([]);
useEffect(() => {
getTags().then(setAllTags);
}, []);
useEffect(() => {
if (trip?.id) {
getTripItems(trip.id).then(setItems);
}
}, [trip]);
async function handleSave() {
await updateTrip(trip.id, {
name,
start_date: startDate,
end_date: endDate,
selected_tag_ids: selectedTags.map((t) => t.id),
marked_tag_ids: markedTags.map((t) => t.id),
});
navigate(`/trips/${trip.id}`);
}
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)];
setMarkedTags(newMarked);
}
async function handleRemoveTag(tagId: string) {
setSelectedTags(selectedTags.filter((t) => t.id !== tagId));
setMarkedTags(markedTags.filter((t) => t.id !== tagId));
}
async function handleAddTag(tagId: string) {
if (selectedTags.some((t) => t.id === tagId)) return;
const tagObj = allTags.find((t) => t.id === tagId);
setSelectedTags([...selectedTags, tagObj]);
}
const itemsWithoutTag = items.filter((item) => !item.tag);
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);
});
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));
function renderItemsWithCategories(items: any[]) {
return items.map((item) => (
<div key={item.id} className="py-2">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-700">{item.name}</div>
<div className="text-xs text-gray-500">{item.date}</div>
</div>
</div>
));
}
async function reloadItems() {
if (trip?.id) {
const updated = await getTripItems(trip.id);
setItems(updated);
}
}
if (!trip) return <div>Trip not found</div>;
return (
<div className="py-4 max-w-5xl mx-auto">
<div className="mb-6 px-4 py-3 pb-6 rounded-xl border shadow flex flex-wrap items-center gap-4 relative">
<div className="flex flex-col flex-1 min-w-[180px]" style={{ position: "relative", zIndex: 2 }}>
<div className="flex flex-nowrap items-end gap-2">
<input
className="text-xl font-bold text-gray-900 bg-transparent border-b border-gray-300 px-2 py-1 mr-2"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Trip Name"
/>
<input
type="date"
className="text-xs text-gray-500 px-2 py-0.5 rounded font-semibold border border-gray-300"
value={startDate}
onChange={e => setStartDate(e.target.value)}
/>
<span className="mx-1 text-gray-500"></span>
<input
type="date"
className="text-xs text-gray-500 px-2 py-0.5 rounded font-semibold border border-gray-300"
value={endDate}
onChange={e => setEndDate(e.target.value)}
/>
</div>
</div>
{/* Tags kompakt rechts */}
<div className="flex flex-wrap gap-2 items-center min-w-[180px]" style={{ position: "relative", zIndex: 2 }}>
{selectedTags.length === 0 ? (
<span className="text-gray-400 text-sm">keine</span>
) : (
selectedTags.map((tag: any) => (
<Tag
key={tag.id}
tag={tag}
isMarked={markedTags.some((mt: any) => mt.id === tag.id)}
onToggleMark={handleToggleMark}
onRemoveTag={handleRemoveTag}
onMouseEnter={() => {}}
onMouseLeave={() => {}}
/>
))
)}
{!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>
<button
className="ml-4 px-4 py-2 rounded bg-green-500 text-white font-semibold shadow"
onClick={handleSave}
>
Speichern
</button>
</div>
{/* Optional: Vorschau der Items wie in TripChecklist */}
<TripItemList
tripId={trip.id}
items={items}
renderItemsWithCategories={renderItemsWithCategories}
markedTagGroups={markedTagGroups}
itemsWithoutTag={itemsWithoutTag}
reloadItems={reloadItems}
/>
</div>
);
}