diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9c29f39..c76a3c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/TripItemList.tsx b/frontend/src/components/TripItemList.tsx new file mode 100644 index 0000000..5b97979 --- /dev/null +++ b/frontend/src/components/TripItemList.tsx @@ -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; +}) { + // 1. Items ohne Tag + const itemsWithoutTag = items.filter((item) => !item.tag); + + // 2. Items mit Tag, gruppiert nach tag.id + const itemsByTag: Record = {}; + 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 = {}; + 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 && ( +
    + {normalItems.map((item) => ( +
  • { + await toggleTripItem(item.id); + const updated = await getTripItems(id!); + setItems(updated); + }} + > + + 0 + ? item.item.tags + .map( + (tag: any) => + `#${tag.name}${tag.mandatory ? " *" : ""}` + ) + .join(" ") + : "" + } + > + {item.name_calculated} + + {/* Tags NICHT mehr direkt anzeigen */} +
  • + ))} +
+ )} + {/* Slash-Kategorien wie gehabt */} + {Object.entries(slashCategoryMap).map(([cat, catItems]) => ( + +
  • +

    + {cat.charAt(0).toUpperCase() + cat.slice(1)} +

    +
  • +
      + {catItems.map((item) => ( +
    • { + await toggleTripItem(item.id); + const updated = await getTripItems(id!); + setItems(updated); + }} + > + + 0 + ? item.item.tags + .map( + (tag: any) => + `#${tag.name}${tag.mandatory ? " *" : ""}` + ) + .join(" ") + : "" + } + > + {item._sub} + + {/* Tags NICHT mehr direkt anzeigen */} +
    • + ))} +
    +
    + ))} + + ); + } + + return ( +
      + {renderItemsWithCategories(itemsWithoutTag)} + {markedTagGroups.map(({ tag, items }) => ( +
    • +
      +

      {formatAsHeading(tag.name)}

      +
      +
        + {renderItemsWithCategories(items)} +
      +
    • + ))} +
    + ); +} \ No newline at end of file diff --git a/frontend/src/pages/TripChecklist.tsx b/frontend/src/pages/TripChecklist.tsx index bceae96..e70b298 100644 --- a/frontend/src/pages/TripChecklist.tsx +++ b/frontend/src/pages/TripChecklist.tsx @@ -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
    Trip not found
    ; - // 1. Items ohne Tag - const itemsWithoutTag = items.filter((item) => !item.tag); - - // 2. Items mit Tag, gruppiert nach tag.id - const itemsByTag: Record = {}; - 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 = {}; - 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 && ( -
      - {normalItems.map((item) => ( -
    • { - await toggleTripItem(item.id); - const updated = await getTripItems(id!); - setItems(updated); - }} - > - - 0 - ? item.item.tags - .map( - (tag: any) => - `#${tag.name}${tag.mandatory ? " *" : ""}` - ) - .join(" ") - : "" - } - > - {item.name_calculated} - - {/* Tags NICHT mehr direkt anzeigen */} -
    • - ))} -
    - )} - {/* Slash-Kategorien wie gehabt */} - {Object.entries(slashCategoryMap).map(([cat, catItems]) => ( - -
  • -

    - {cat.charAt(0).toUpperCase() + cat.slice(1)} -

    -
  • -
      - {catItems.map((item) => ( -
    • { - await toggleTripItem(item.id); - const updated = await getTripItems(id!); - setItems(updated); - }} - > - - 0 - ? item.item.tags - .map( - (tag: any) => - `#${tag.name}${tag.mandatory ? " *" : ""}` - ) - .join(" ") - : "" - } - > - {item._sub} - - {/* Tags NICHT mehr direkt anzeigen */} -
    • - ))} -
    -
    - ))} - - ); - } - - 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[] }) { {/* ...restliche Seite... */} -
      - {/* 1. Ohne Tag */} - {renderItemsWithCategories(itemsWithoutTag)} - - {/* 2. Markierte Tag-Gruppen */} - {markedTagGroups.map(({ tag, items }) => ( -
    • -
      -

      - {formatAsHeading(tag.name)} -

      -
      -
        - {renderItemsWithCategories(items)} -
      -
    • - ))} -
    + + { + const updated = await getTripItems(id!); + setItems(updated); + }} + /> ); } \ No newline at end of file diff --git a/frontend/src/pages/TripEdit.tsx b/frontend/src/pages/TripEdit.tsx new file mode 100644 index 0000000..7ef91b1 --- /dev/null +++ b/frontend/src/pages/TripEdit.tsx @@ -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(trip?.selected_tags || []); + const [markedTags, setMarkedTags] = useState(trip?.marked_tags || []); + const [allTags, setAllTags] = useState([]); + const [addingTag, setAddingTag] = useState(false); + const inputRef = useRef(null); + const [items, setItems] = useState([]); + + 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 = {}; + 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) => ( +
    +
    +
    {item.name}
    +
    {item.date}
    +
    +
    + )); + } + + async function reloadItems() { + if (trip?.id) { + const updated = await getTripItems(trip.id); + setItems(updated); + } + } + + if (!trip) return
    Trip not found
    ; + + return ( +
    +
    +
    +
    + setName(e.target.value)} + placeholder="Trip Name" + /> + setStartDate(e.target.value)} + /> + + setEndDate(e.target.value)} + /> +
    +
    + {/* Tags kompakt rechts */} +
    + {selectedTags.length === 0 ? ( + keine + ) : ( + selectedTags.map((tag: any) => ( + mt.id === tag.id)} + onToggleMark={handleToggleMark} + onRemoveTag={handleRemoveTag} + onMouseEnter={() => {}} + onMouseLeave={() => {}} + /> + )) + )} + {!addingTag ? ( + + ) : ( + { + handleAddTag(tagId); + if (viaTab) { + setTimeout(() => { + setAddingTag(true); + inputRef.current?.focus(); + }, 0); + } else { + setAddingTag(false); + } + }} + onEscape={() => setAddingTag(false)} + placeholder="Tag suchen..." + /> + )} +
    + +
    + {/* Optional: Vorschau der Items wie in TripChecklist */} + +
    + ); +} \ No newline at end of file