diff --git a/README.md b/README.md index 47fe422..6545e8a 100644 --- a/README.md +++ b/README.md @@ -253,9 +253,12 @@ frontend/ src/styles.css Grund-Styling und Matrix-Design (responsiv, volle Breite) src/themes.css Blackwall und Fabrik (überschreiben styles.css) src/theme.js, motion.js Design-Auswahl, Animations-Schalter - src/components/ Layout + Menü, Hintergründe der Designs, Editor, Weltkarte, … - src/pages/ Login, Start, Projekte, Artikel, Vorrat, Reisen, Kanban, Monitoring, … - src/components/ServerDetail.jsx, LineChart.jsx Große Server-Ansicht, Verlaufsdiagramme (SVG) + src/useLiveData.js Laden, regelmäßiges Nachladen und Speichern einer Seite (gemeinsamer Hook) + src/storage.js Kleine Einstellungen pro Browser (localStorage) + src/components/Layout.jsx Kopfzeile + Menü + src/components// Bausteine je Bereich: haushalt, reisen, kanban, monitoring, projekte, start, + design (Hintergründe der Designs) + src/pages/ Seiten: Login, Start, Projekte, Artikel, Vorrat, Reisen, Kanban, Monitoring, … src/data/laender.json Länderdaten (aus world-countries, ODbL) public/maps/ Weltkarte (Natural Earth über world-atlas) diff --git a/frontend/src/components/BlackwallField.jsx b/frontend/src/components/design/BlackwallField.jsx similarity index 99% rename from frontend/src/components/BlackwallField.jsx rename to frontend/src/components/design/BlackwallField.jsx index f17158f..4980ef2 100644 --- a/frontend/src/components/BlackwallField.jsx +++ b/frontend/src/components/design/BlackwallField.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { isCalm, onMotionChange } from '../motion.js'; +import { isCalm, onMotionChange } from '../../motion.js'; // Hintergrund "Blackwall" nach Einzelbildern aus dem Referenzvideo (Cyberpunk 2077): // - Wand-Korridor: senkrechte Lichtlinien, die perspektivisch auf einen Fluchtpunkt am Horizont zulaufen diff --git a/frontend/src/components/FabrikField.jsx b/frontend/src/components/design/FabrikField.jsx similarity index 100% rename from frontend/src/components/FabrikField.jsx rename to frontend/src/components/design/FabrikField.jsx diff --git a/frontend/src/components/MatrixRain.jsx b/frontend/src/components/design/MatrixRain.jsx similarity index 97% rename from frontend/src/components/MatrixRain.jsx rename to frontend/src/components/design/MatrixRain.jsx index ddfce82..4d49008 100644 --- a/frontend/src/components/MatrixRain.jsx +++ b/frontend/src/components/design/MatrixRain.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { isCalm, onMotionChange } from '../motion.js'; +import { isCalm, onMotionChange } from '../../motion.js'; // Dezenter "digitaler Regen" im Hintergrund – Farbe kommt aus dem Stylesheet (--accent-rgb, --bright). // Pausiert in inaktiven Tabs, entfällt bei "Bewegung reduzieren" und lässt sich im Menü abschalten. diff --git a/frontend/src/components/ThemeBackground.jsx b/frontend/src/components/design/ThemeBackground.jsx similarity index 92% rename from frontend/src/components/ThemeBackground.jsx rename to frontend/src/components/design/ThemeBackground.jsx index 4e4eb7d..6aae52b 100644 --- a/frontend/src/components/ThemeBackground.jsx +++ b/frontend/src/components/design/ThemeBackground.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { getTheme, onThemeChange } from '../theme.js'; +import { getTheme, onThemeChange } from '../../theme.js'; import BlackwallField from './BlackwallField.jsx'; import FabrikField from './FabrikField.jsx'; import MatrixRain from './MatrixRain.jsx'; diff --git a/frontend/src/components/haushalt/ListDialog.jsx b/frontend/src/components/haushalt/ListDialog.jsx new file mode 100644 index 0000000..c73c8e7 --- /dev/null +++ b/frontend/src/components/haushalt/ListDialog.jsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { personName } from '../../api.js'; +import { useAuth } from '../../auth.jsx'; + +// Liste anlegen oder bearbeiten: Name und wer sie sehen kann; löschen nur beim Bearbeiten +export default function ListDialog({ ref, list, users, onSave, onDelete, onClosed }) { + const { user } = useAuth(); + const ownerId = list ? list.createdBy?.id : user?.id; + const [form, setForm] = useState(() => ({ + name: list?.name || '', + everyone: list ? list.everyone : true, + members: list?.members || [], + })); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + // Der Ersteller sieht seine Liste immer, deshalb steht er nicht zur Auswahl + const choosable = users.filter((u) => u.id !== ownerId); + const toggle = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] })); + + async function submit(e) { + e.preventDefault(); + setBusy(true); + setError(null); + const err = await onSave({ name: form.name, everyone: form.everyone, members: form.everyone ? [] : form.members }); + setBusy(false); + if (err) setError(err); + else ref.current.close(); + } + + async function remove() { + setBusy(true); + const done = await onDelete(list); + setBusy(false); + if (done) ref.current.close(); + } + + return ( + +
+

{list ? 'Liste bearbeiten' : 'Neue Liste'}

+ {!list &&

Die Liste bekommt einen eigenen Vorrat und eine eigene Einkaufsliste – z. B. für die Ferienwohnung im Urlaub.

} + +
+ Wer kann die Liste sehen? +
    +
  • + +
  • +
  • + +
  • +
+ {!form.everyone && ( + <> +

+ {list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer. Zusätzlich: +

+ {choosable.length === 0 ? ( +

Es gibt keine weiteren Konten.

+ ) : ( +
    + {choosable.map((u) => ( +
  • + +
  • + ))} +
+ )} + + )} +
+ {error &&

{error}

} +
+ {list && ( + + )} + + +
+
+
+ ); +} diff --git a/frontend/src/components/haushalt/ListPanel.jsx b/frontend/src/components/haushalt/ListPanel.jsx new file mode 100644 index 0000000..6125924 --- /dev/null +++ b/frontend/src/components/haushalt/ListPanel.jsx @@ -0,0 +1,249 @@ +import { useRef, useState } from 'react'; +import { personName } from '../../api.js'; +import { fmtItems, parseQty, round2, STEP } from '../../units.js'; +import ProductInput from './ProductInput.jsx'; +import UnitSelect from './UnitSelect.jsx'; + +// Die beiden Bereiche einer Liste: Vorrat (was da ist) und Einkaufen (was fehlt) +export const LISTS = { + vorrat: { title: 'Vorrat', lead: 'Was da ist', empty: 'Noch nichts im Vorrat eingetragen.' }, + einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' }, +}; + +export default function ListPanel({ list, items, products, find, actions, busy, hidden }) { + const { title, lead, empty } = LISTS[list]; + const other = list === 'vorrat' ? 'einkauf' : 'vorrat'; + // Einkauf: Abgehaktes rutscht nach unten und wandert erst mit "Einkauf beenden" in den Vorrat + const open = items.filter((i) => !i.checked); + const inCart = items.filter((i) => i.checked); + + // Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo? + function hint(name) { + const here = find(list, name); + if (here.length) return `schon da: ${fmtItems(here)}`; + const there = find(other, name); + if (there.length) return `${list === 'vorrat' ? 'auf Einkaufsliste' : 'im Vorrat'}: ${fmtItems(there)}`; + return null; + } + + return ( +
+
+

{title}

+ {lead} +
+ + + + {items.length === 0 ? ( +

{empty}

+ ) : ( +
    + {open.map((item) => ( + + ))} + {inCart.length > 0 && ( + + )} + {inCart.map((item) => ( + + ))} +
+ )} + + {inCart.length > 0 && ( +
+ + {inCart.length} {inCart.length === 1 ? 'Produkt' : 'Produkte'} im Wagen + + +
+ )} +
+ ); +} + +function AddForm({ list, products, hint, onAdd, busy }) { + const [name, setName] = useState(''); + const [quantity, setQuantity] = useState('1'); + const [unit, setUnit] = useState('stk'); + const [invalid, setInvalid] = useState(false); + const nameInput = useRef(null); + + async function submit(e) { + e.preventDefault(); + const n = name.trim(); + const q = parseQty(quantity, unit); + setInvalid(q == null); + if (!n || q == null) return; + if (await onAdd(list, n, q, unit)) { + setName(''); + setQuantity('1'); + nameInput.current?.focus(); + } + } + + return ( +
+ + +
+ + { setQuantity(e.target.value); setInvalid(false); }} + aria-invalid={invalid || undefined} + aria-describedby={invalid ? `qty-error-${list}` : undefined} + /> + +
+ + {invalid && ( +

+ {unit === 'stk' ? 'Anzahl als ganze Zahl von 1 bis 999 eingeben.' : 'Menge von 0,01 bis 999 eingeben, z. B. 1,5.'} +

+ )} + + ); +} + +// Menge direkt eintippen; gespeichert wird beim Verlassen des Felds oder mit Enter +function QuantityInput({ item, label, onCommit, disabled }) { + const shown = String(item.quantity).replace('.', ','); + const [draft, setDraft] = useState(null); + + function commit() { + if (draft == null) return; + const q = parseQty(draft, item.unit); + setDraft(null); + if (q != null && q !== item.quantity) onCommit(q); + } + + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + if (e.key === 'Escape') setDraft(null); + }} + aria-label={`Menge ${label}`} + /> + ); +} + +function ItemRow({ item, list, other, actions, busy }) { + const isShopping = list === 'einkauf'; + const step = STEP[item.unit]; + const last = item.quantity - step <= 0; + // Bei gleichem Produkt in mehreren Einheiten die Einheit mit ansagen + const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`; + + return ( +
  • + {isShopping && ( + actions.check(item, !item.checked)} + aria-label={item.checked ? `${label} im Wagen – Haken entfernen` : `${label} abhaken (im Wagen)`} + title={item.checked ? 'Im Wagen – zum Zurücknehmen klicken' : 'Abhaken: kommt mit „Einkauf beenden“ in den Vorrat'} + /> + )} + +
    + {item.name} + + {other.length > 0 && `${isShopping ? 'im Vorrat' : 'auf Einkaufsliste'}: ${fmtItems(other)} · `} + ✎ {personName(item.updatedBy)} + +
    + +
    + + actions.update(item, { quantity })} /> + +
    + actions.update(item, { unit })} + disabled={busy} + compact + label={`Einheit ${item.name}`} + /> + +
    + {!isShopping && ( + + )} + +
    +
  • + ); +} diff --git a/frontend/src/components/haushalt/ListSwitcher.jsx b/frontend/src/components/haushalt/ListSwitcher.jsx new file mode 100644 index 0000000..77b49d9 --- /dev/null +++ b/frontend/src/components/haushalt/ListSwitcher.jsx @@ -0,0 +1,47 @@ +// Listen (je Vorrat + Einkauf) wählen; zeigt, wer die gewählte Liste sieht + +function visibleFor(list, users) { + if (list.everyone) return 'alle Konten'; + const ids = new Set([list.createdBy?.id, ...list.members]); + const names = users.filter((u) => ids.has(u.id)).map((u) => u.name); + return names.length ? names.join(', ') : 'niemanden'; +} + +export default function ListSwitcher({ lists, current, users, onChoose, onNew, onEdit, busy }) { + return ( +
    +
      +
    • Liste:
    • + {lists.map((l) => ( +
    • + +
    • + ))} +
    • + +
    • +
    + {current && ( +

    + Sichtbar für: {visibleFor(current, users)} + {current.createdBy && ` · angelegt von ${current.createdBy.name}`} + {current.canManage && ( + <> + {' · '} + + + )} +

    + )} +
    + ); +} diff --git a/frontend/src/components/ProductInput.jsx b/frontend/src/components/haushalt/ProductInput.jsx similarity index 100% rename from frontend/src/components/ProductInput.jsx rename to frontend/src/components/haushalt/ProductInput.jsx diff --git a/frontend/src/components/haushalt/ProductsDialog.jsx b/frontend/src/components/haushalt/ProductsDialog.jsx new file mode 100644 index 0000000..1b47086 --- /dev/null +++ b/frontend/src/components/haushalt/ProductsDialog.jsx @@ -0,0 +1,28 @@ +// Vorschläge (Produktkatalog) aufräumen, z. B. Tippfehler; Einträge auf den Listen bleiben + +export default function ProductsDialog({ ref, products, onDelete }) { + const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE')); + return ( + +
    +

    Vorschläge verwalten

    +

    + Tippfehler oder doppelte Schreibweisen hier löschen. Einträge auf den Listen bleiben erhalten. +

    +
      + {sorted.map((name) => ( +
    • + {name} + +
    • + ))} +
    +
    + +
    +
    +
    + ); +} diff --git a/frontend/src/components/RecipeCheck.jsx b/frontend/src/components/haushalt/RecipeCheck.jsx similarity index 99% rename from frontend/src/components/RecipeCheck.jsx rename to frontend/src/components/haushalt/RecipeCheck.jsx index 958ec3d..360c9e8 100644 --- a/frontend/src/components/RecipeCheck.jsx +++ b/frontend/src/components/haushalt/RecipeCheck.jsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { api } from '../api.js'; -import { ceil2, fmtItems, fmtQty, parseQty } from '../units.js'; +import { api } from '../../api.js'; +import { ceil2, fmtItems, fmtQty, parseQty } from '../../units.js'; import ProductInput from './ProductInput.jsx'; import UnitSelect from './UnitSelect.jsx'; diff --git a/frontend/src/components/UnitSelect.jsx b/frontend/src/components/haushalt/UnitSelect.jsx similarity index 91% rename from frontend/src/components/UnitSelect.jsx rename to frontend/src/components/haushalt/UnitSelect.jsx index 36b4a84..919f478 100644 --- a/frontend/src/components/UnitSelect.jsx +++ b/frontend/src/components/haushalt/UnitSelect.jsx @@ -1,4 +1,4 @@ -import { UNITS } from '../units.js'; +import { UNITS } from '../../units.js'; // Auswahl Anzahl / kg / Liter; compact für enge Listenzeilen ("Anz.") export default function UnitSelect({ value, onChange, label = 'Einheit', compact = false, ...props }) { diff --git a/frontend/src/components/KanbanProjectDialog.jsx b/frontend/src/components/kanban/KanbanProjectDialog.jsx similarity index 100% rename from frontend/src/components/KanbanProjectDialog.jsx rename to frontend/src/components/kanban/KanbanProjectDialog.jsx diff --git a/frontend/src/components/kanban/TaskCard.jsx b/frontend/src/components/kanban/TaskCard.jsx new file mode 100644 index 0000000..4c0fdf1 --- /dev/null +++ b/frontend/src/components/kanban/TaskCard.jsx @@ -0,0 +1,40 @@ +import { initials, PRIORITIES } from './board.js'; + +// Karte einer Aufgabe im Board (ziehbar) und Anzeige des Bearbeiters + +export function Assignee({ id, users }) { + const person = users.find((u) => u.id === id); + if (!person) return Niemand; + return ( + + + {person.name} + + ); +} + +export default function TaskCard({ task, users, dragging, onOpen, onDragStart, onDragEnd }) { + return ( + + ); +} diff --git a/frontend/src/components/kanban/TaskDialog.jsx b/frontend/src/components/kanban/TaskDialog.jsx new file mode 100644 index 0000000..a3ca9db --- /dev/null +++ b/frontend/src/components/kanban/TaskDialog.jsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { fmtDate, personName } from '../../api.js'; +import { COLUMNS, PRIORITIES } from './board.js'; + +// Aufgabe anlegen oder bearbeiten (Titel, Beschreibung, Status, Priorität, Bearbeiter), löschen + +export default function TaskDialog({ ref, target, users, me, onSave, onDelete }) { + const task = target?.task; + // Wird pro Öffnen neu erzeugt (key), daher direkt aus den Daten vorbelegen + const [form, setForm] = useState(() => (task + ? { title: task.title, description: task.description, status: task.status, assignee: task.assignee ?? '', priority: task.priority || 'mittel' } + : { title: '', description: '', status: target?.status || 'offen', assignee: '', priority: 'mittel' })); + const [busy, setBusy] = useState(false); + + const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) }); + const canDelete = task && (me.isAdmin || task.createdBy?.id === me.id); + + async function submit(e) { + e.preventDefault(); + setBusy(true); + const ok = await onSave(task?.id, { + title: form.title, + description: form.description, + status: form.status, + assignee: form.assignee === '' ? null : Number(form.assignee), + priority: form.priority, + }); + setBusy(false); + if (ok) ref.current.close(); + } + + async function remove() { + if (!window.confirm(`Aufgabe „${task.title}“ löschen?`)) return; + setBusy(true); + const ok = await onDelete(task.id); + setBusy(false); + if (ok) ref.current.close(); + } + + return ( + +
    +

    {task ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}

    + +