From 17e094e8acecfcbf4ce1932104b110eebabde31e Mon Sep 17 00:00:00 2001 From: MDeeApp <6595194+MDeeApp@users.noreply.github.com> Date: Sat, 11 Oct 2025 14:02:20 +0200 Subject: [PATCH] categories as dropdown --- README.md | 18 ++++ frontend/src/api/categories.ts | 24 +++++ frontend/src/locales/de/common.json | 24 ++++- frontend/src/locales/en/common.json | 24 ++++- frontend/src/routes/ContractForm.tsx | 140 ++++++++++++++++++++++++-- frontend/src/routes/ContractsList.tsx | 20 ++-- frontend/src/routes/Settings.tsx | 92 ++++++++++++++++- src/categoriesStore.ts | 82 +++++++++++++++ src/contractsStore.ts | 6 +- src/db.ts | 26 +++++ src/index.ts | 40 ++++++++ 11 files changed, 471 insertions(+), 25 deletions(-) create mode 100644 frontend/src/api/categories.ts create mode 100644 src/categoriesStore.ts diff --git a/README.md b/README.md index 8eebf2d..51f70b8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Begleitdienst zur Verwaltung von Vertragsmetadaten (Laufzeiten, Kündigungsfrist - ✅ **Eigenständige Vertragsdatenbank** auf SQLite (`better-sqlite3`) mit REST-API (Express). - ✅ **Paperless-Integration (optional):** Verknüpft Verträge mit Dokumenten, zieht Metadaten per Token, durchsucht paperless direkt aus der UI. +- ✅ **Kategorie-Verwaltung:** Dropdown mit Vorschlägen, Inline-Neuanlage im Formular & Verwaltung unter Einstellungen. - ✅ **Modernes React-Frontend** (Vite + MUI) mit Dashboard, Vertragsliste, Kalender, Detailansicht und umfangreichen Einstellungen. - ✅ **Benachrichtigungen & Automatisierung:** Scheduler prüft Deadlines, optionaler Mailversand, ntfy-Push, iCal-Feed zum Abonnieren. - ✅ **Konfigurierbare Authentifizierung:** Optionales Login mit JWT, Benutzername/Passwort verwaltbar in den Settings. @@ -101,6 +102,23 @@ Paperless ist **nicht verpflichtend**. Lässt du `PAPERLESS_*` leer, bleibt die --- +## API-Übersicht + +- `GET /contracts` – Liste aller Verträge (`skip` / `limit`). +- `POST /contracts` – Vertrag anlegen. +- `GET /contracts/:id` – Einzelvertrag abrufen. +- `PUT /contracts/:id` – Vertrag aktualisieren. +- `DELETE /contracts/:id` – Vertrag löschen. +- `GET /contracts/:id/paperless-document` – Verknüpftes Paperless-Dokument laden. +- `GET /categories` – Alle Kategorien. +- `POST /categories` – Neue Kategorie (legt an oder liefert vorhandene). +- `DELETE /categories/:id` – Kategorie entfernen. +- `GET /integrations/paperless/search?q=` – Paperless-Dokumente per Text oder ID finden. +- `GET /reports/upcoming?days=` – Deadlines innerhalb der nächsten `days` Tage. +- `GET /calendar/feed.ics?token=` – iCal-Feed für Kündigungsfristen. + +--- + ## Integrationen ### Bereits vorhanden diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts new file mode 100644 index 0000000..3a3e689 --- /dev/null +++ b/frontend/src/api/categories.ts @@ -0,0 +1,24 @@ +import { request } from "./client"; + +export interface Category { + id: number; + name: string; + createdAt: string; +} + +export async function fetchCategories(): Promise { + return request("/categories", { method: "GET" }); +} + +export async function createCategory(name: string): Promise { + return request("/categories", { + method: "POST", + body: { name } + }); +} + +export async function deleteCategory(id: number): Promise { + await request(`/categories/${id}`, { + method: "DELETE" + }); +} diff --git a/frontend/src/locales/de/common.json b/frontend/src/locales/de/common.json index 3894d12..46f2981 100644 --- a/frontend/src/locales/de/common.json +++ b/frontend/src/locales/de/common.json @@ -78,7 +78,17 @@ "details": "Details anzeigen", "edit": "Bearbeiten", "deleted": "Vertrag gelöscht", - "deleteError": "Löschen fehlgeschlagen" + "deleteError": "Löschen fehlgeschlagen", + "categoryAdd": "Neue Kategorie hinzufügen", + "categoryAddDescription": "Erstelle eine neue Kategorie, die du sofort auswählen kannst.", + "categoryNameLabel": "Name der Kategorie", + "categoryCreate": "Kategorie anlegen", + "categoryNameRequired": "Bitte einen Kategorienamen angeben.", + "categoryCreated": "Kategorie \"{{name}}\" gespeichert", + "categoryCreateError": "Kategorie konnte nicht angelegt werden", + "categoryNone": "Keine Kategorie", + "categoryLoading": "Kategorien werden geladen…", + "categoryLoadError": "Kategorien konnten nicht geladen werden." }, "contractForm": { "createTitle": "Neuen Vertrag anlegen", @@ -219,7 +229,17 @@ "ntfyTestError": "ntfy-Test fehlgeschlagen", "mailConfigMissing": "E-Mail-Konfiguration unvollständig", "ntfyConfigMissing": "ntfy-Konfiguration unvollständig", - "actionFailed": "Aktion fehlgeschlagen" + "actionFailed": "Aktion fehlgeschlagen", + "categories": "Kategorien", + "categoryName": "Neue Kategorie", + "categoryAdd": "Kategorie hinzufügen", + "categoryNameRequired": "Bitte einen Namen für die Kategorie eingeben.", + "categoryCreated": "Kategorie \"{{name}}\" gespeichert", + "categoryCreateError": "Kategorie konnte nicht angelegt werden", + "categoryDeleted": "Kategorie gelöscht", + "categoryDeleteError": "Kategorie konnte nicht gelöscht werden", + "categoryEmpty": "Noch keine Kategorien vorhanden.", + "categoryLoadError": "Kategorien konnten nicht geladen werden." }, "paperlessDialog": { "title": "Paperless-Dokument verknüpfen", diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index d1c1a7a..cfff61f 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -78,7 +78,17 @@ "details": "Show details", "edit": "Edit", "deleted": "Contract deleted", - "deleteError": "Failed to delete" + "deleteError": "Failed to delete", + "categoryAdd": "Add new category", + "categoryAddDescription": "Create a new category you can select immediately.", + "categoryNameLabel": "Category name", + "categoryCreate": "Create category", + "categoryNameRequired": "Please provide a category name.", + "categoryCreated": "Category \"{{name}}\" saved", + "categoryCreateError": "Failed to create category", + "categoryNone": "No category", + "categoryLoading": "Loading categories…", + "categoryLoadError": "Could not load categories." }, "contractForm": { "createTitle": "Create contract", @@ -219,7 +229,17 @@ "ntfyTestError": "ntfy test failed", "mailConfigMissing": "E-mail configuration incomplete", "ntfyConfigMissing": "ntfy configuration incomplete", - "actionFailed": "Action failed" + "actionFailed": "Action failed", + "categories": "Categories", + "categoryName": "New category", + "categoryAdd": "Add category", + "categoryNameRequired": "Please enter a category name.", + "categoryCreated": "Category \"{{name}}\" saved", + "categoryCreateError": "Failed to create category", + "categoryDeleted": "Category deleted", + "categoryDeleteError": "Failed to delete category", + "categoryEmpty": "No categories defined yet.", + "categoryLoadError": "Could not load categories." }, "paperlessDialog": { "title": "Link paperless document", diff --git a/frontend/src/routes/ContractForm.tsx b/frontend/src/routes/ContractForm.tsx index 583a82e..27f864c 100644 --- a/frontend/src/routes/ContractForm.tsx +++ b/frontend/src/routes/ContractForm.tsx @@ -2,8 +2,14 @@ import SaveIcon from "@mui/icons-material/Save"; import { Box, Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, FormControlLabel, Grid, + MenuItem, Paper, Stack, Switch, @@ -26,6 +32,7 @@ import { updateContract } from "../api/contracts"; import { fetchServerConfig } from "../api/config"; +import { createCategory as apiCreateCategory, fetchCategories } from "../api/categories"; import PaperlessSearchDialog from "../components/PaperlessSearchDialog"; import PageHeader from "../components/PageHeader"; import { useSnackbar } from "../hooks/useSnackbar"; @@ -133,16 +140,38 @@ export default function ContractForm({ mode }: Props) { enabled: mode === "edit" && id !== null }); + const { data: categories, isLoading: loadingCategories, isError: categoriesError, refetch: refetchCategories } = useQuery({ + queryKey: ["categories"], + queryFn: fetchCategories + }); + const [searchDialogOpen, setSearchDialogOpen] = useState(false); const [selectedDocument, setSelectedDocument] = useState(null); const paperlessDocumentId = watch("paperlessDocumentId"); - const watchedTitle = watch("title"); + const watchedTitle = watch("title"); const watchedProvider = watch("provider"); - const watchedCategory = watch("category"); const watchedPaperlessId = watch("paperlessDocumentId"); const watchedTags = watch("tags"); + const createCategoryMutation = useMutation({ + mutationFn: (name: string) => apiCreateCategory(name), + onSuccess: async (category) => { + await refetchCategories(); + queryClient.invalidateQueries({ queryKey: ["categories"] }); + setValue("category", category.name, { shouldDirty: true }); + showMessage(t("contracts.categoryCreated", { name: category.name }), "success"); + setCategoryDialogOpen(false); + setNewCategoryName(""); + }, + onError: (error: Error) => { + showMessage(error.message ?? t("contracts.categoryCreateError"), "error"); + } + }); + + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(""); + const providerSuggestion = useMemo( () => (selectedDocument ? extractPaperlessProvider(selectedDocument) : null), [selectedDocument] @@ -307,11 +336,63 @@ export default function ContractForm({ mode }: Props) { /> - { + const options = categories ?? []; + const value = field.value ?? ""; + const hasCurrentValue = Boolean(value) && options.some((category) => category.name.toLowerCase() === String(value).toLowerCase()); + return ( + { + const nextValue = event.target.value; + if (nextValue === "__add__") { + setNewCategoryName(""); + setCategoryDialogOpen(true); + return; + } + field.onChange(nextValue); + }} + SelectProps={{ displayEmpty: true }} + disabled={loadingCategories} + error={Boolean(categoriesError)} + helperText={categoriesError ? t("contracts.categoryLoadError") : undefined} + > + + {t("contracts.categoryNone")} + + {loadingCategories ? ( + + {t("contracts.categoryLoading")} + + ) : categoriesError ? ( + + {t("contracts.categoryLoadError")} + + ) : ( + options.map((category) => ( + + {category.name} + + )) + )} + {!loadingCategories && !categoriesError && value && !hasCurrentValue && ( + + {String(value)} + + )} + {t("contracts.categoryAdd")} + + ); + }} /> @@ -460,6 +541,51 @@ export default function ContractForm({ mode }: Props) { + + { + setCategoryDialogOpen(false); + setNewCategoryName(""); + }} + > + {t("contracts.categoryAddTitle")} + + + {t("contracts.categoryAddDescription")} + + setNewCategoryName(event.target.value)} + fullWidth + /> + + + + + + + setSearchDialogOpen(false)} diff --git a/frontend/src/routes/ContractsList.tsx b/frontend/src/routes/ContractsList.tsx index ced51a8..f51b395 100644 --- a/frontend/src/routes/ContractsList.tsx +++ b/frontend/src/routes/ContractsList.tsx @@ -28,6 +28,7 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; +import { fetchCategories } from "../api/categories"; import { fetchContracts, removeContract } from "../api/contracts"; import PageHeader from "../components/PageHeader"; import { useSnackbar } from "../hooks/useSnackbar"; @@ -53,13 +54,10 @@ export default function ContractsList() { const [search, setSearch] = useState(""); const [category, setCategory] = useState("all"); - const categories = useMemo(() => { - const values = new Set(); - contracts?.forEach((contract) => { - if (contract.category) values.add(contract.category); - }); - return Array.from(values).sort(); - }, [contracts]); + const { data: categoryOptions } = useQuery({ + queryKey: ["categories"], + queryFn: fetchCategories + }); const normalizedContracts = useMemo(() => { if (!contracts) return [] as Contract[]; @@ -81,7 +79,7 @@ export default function ContractsList() { const categoryMatch = category === "all" || contract.category === category; return searchMatch && categoryMatch; }); - }, [contracts, search, category]); + }, [normalizedContracts, search, category]); const deleteMutation = useMutation({ mutationFn: (contractId: number) => removeContract(contractId), @@ -130,9 +128,9 @@ export default function ContractsList() { sx={{ width: 200 }} > {t("contracts.filterAll")} - {categories.map((item) => ( - - {item} + {(categoryOptions ?? []).map((item) => ( + + {item.name} ))} diff --git a/frontend/src/routes/Settings.tsx b/frontend/src/routes/Settings.tsx index abda2ef..b5dca52 100644 --- a/frontend/src/routes/Settings.tsx +++ b/frontend/src/routes/Settings.tsx @@ -6,6 +6,7 @@ import StorageIcon from "@mui/icons-material/Storage"; import { Alert, Avatar, + Chip, Box, Button, Card, @@ -25,7 +26,7 @@ import { TextField, Typography } from "@mui/material"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; @@ -41,6 +42,7 @@ import { UpdateSettingsPayload } from "../api/config"; import { request } from "../api/client"; +import { createCategory as apiCreateCategory, deleteCategory as apiDeleteCategory, fetchCategories } from "../api/categories"; import PageHeader from "../components/PageHeader"; import { useAuth } from "../contexts/AuthContext"; import { useSnackbar } from "../hooks/useSnackbar"; @@ -97,6 +99,8 @@ export default function SettingsPage() { const { showMessage } = useSnackbar(); const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { data: health } = useQuery({ queryKey: ["healthz"], queryFn: () => request("/healthz", { method: "GET" }) @@ -119,6 +123,18 @@ export default function SettingsPage() { queryFn: fetchSettings }); + const { + data: categories, + isLoading: loadingCategories, + isError: categoriesError, + refetch: refetchCategories + } = useQuery({ + queryKey: ["categories"], + queryFn: fetchCategories + }); + + const [newCategoryName, setNewCategoryName] = useState(""); + const initialValuesRef = useRef(defaultValues); const [removePaperlessToken, setRemovePaperlessToken] = useState(false); const [removeMailPassword, setRemoveMailPassword] = useState(false); @@ -201,6 +217,27 @@ export default function SettingsPage() { onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error") }); + const createCategoryMutation = useMutation({ + mutationFn: (name: string) => apiCreateCategory(name), + onSuccess: async (category) => { + await refetchCategories(); + queryClient.invalidateQueries({ queryKey: ["categories"] }); + setNewCategoryName(""); + showMessage(t("settings.categoryCreated", { name: category.name }), "success"); + }, + onError: (error: Error) => showMessage(error.message ?? t("settings.categoryCreateError"), "error") + }); + + const deleteCategoryMutation = useMutation({ + mutationFn: (id: number) => apiDeleteCategory(id), + onSuccess: async () => { + await refetchCategories(); + queryClient.invalidateQueries({ queryKey: ["categories"] }); + showMessage(t("settings.categoryDeleted"), "success"); + }, + onError: (error: Error) => showMessage(error.message ?? t("settings.categoryDeleteError"), "error") + }); + const settings = settingsData; const icalSecret = settings?.icalSecret ?? null; const icalUrl = useMemo(() => { @@ -468,6 +505,59 @@ export default function SettingsPage() { + + + + {t("settings.categories")} + + {loadingCategories ? ( + + ) : categoriesError ? ( + {t("settings.categoryLoadError")} + ) : ( + + + setNewCategoryName(event.target.value)} + fullWidth + /> + + + {categories && categories.length > 0 ? ( + + {categories.map((category) => ( + deleteCategoryMutation.mutate(category.id)} + disabled={deleteCategoryMutation.isPending} + /> + ))} + + ) : ( + + {t("settings.categoryEmpty")} + + )} + + )} + + diff --git a/src/categoriesStore.ts b/src/categoriesStore.ts new file mode 100644 index 0000000..f7beaf2 --- /dev/null +++ b/src/categoriesStore.ts @@ -0,0 +1,82 @@ +import db from "./db.js"; + +export interface Category { + id: number; + name: string; + createdAt: string; +} + +const listStmt = db.prepare(` +SELECT id, name, created_at +FROM categories +ORDER BY name COLLATE NOCASE +`); + +const insertStmt = db.prepare(` +INSERT INTO categories (name) +VALUES (?) +`); + +const deleteStmt = db.prepare(` +DELETE FROM categories +WHERE id = ? +`); + +const findByNameStmt = db.prepare(` +SELECT id, name, created_at +FROM categories +WHERE name = ? +`); + +const getByIdStmt = db.prepare(` +SELECT id, name, created_at +FROM categories +WHERE id = ? +`); + +export function findCategoryByName(name: string): Category | null { + const row = findByNameStmt.get(name.trim()); + return row ? mapCategoryRow(row) : null; +} + +export function listCategories(): Category[] { + return listStmt.all().map(mapCategoryRow); +} + +export function createCategory(name: string): Category { + const trimmed = name.trim(); + if (trimmed.length === 0) { + throw new Error("Category name must not be empty"); + } + + const existing = findByNameStmt.get(trimmed); + if (existing) { + return mapCategoryRow(existing); + } + + const info = insertStmt.run(trimmed); + const created = getByIdStmt.get(info.lastInsertRowid); + if (!created) { + throw new Error("Failed to create category"); + } + + return mapCategoryRow(created); +} + +export function getCategory(id: number): Category | null { + const row = getByIdStmt.get(id); + return row ? mapCategoryRow(row) : null; +} + +export function deleteCategory(id: number): boolean { + const info = deleteStmt.run(id); + return info.changes > 0; +} + +function mapCategoryRow(row: any): Category { + return { + id: row.id, + name: row.name, + createdAt: row.created_at + }; +} diff --git a/src/contractsStore.ts b/src/contractsStore.ts index fe81759..68cd236 100644 --- a/src/contractsStore.ts +++ b/src/contractsStore.ts @@ -90,11 +90,13 @@ function mapRow(row: ContractDbRow): Contract { } function serializePayload(payload: ContractPayload): SerializedPayload { + const provider = payload.provider?.trim(); + const category = payload.category?.trim(); return { title: payload.title, paperless_document_id: payload.paperlessDocumentId ?? null, - provider: payload.provider ?? null, - category: payload.category ?? null, + provider: provider && provider.length > 0 ? provider : null, + category: category && category.length > 0 ? category : null, contract_start_date: payload.contractStartDate ?? null, contract_end_date: payload.contractEndDate ?? null, termination_notice_days: payload.terminationNoticeDays ?? null, diff --git a/src/db.ts b/src/db.ts index 8a17a15..6c328f7 100644 --- a/src/db.ts +++ b/src/db.ts @@ -43,8 +43,34 @@ CREATE TABLE IF NOT EXISTS settings ( value TEXT NOT NULL, updated_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); `); + +const defaultCategories = [ + "Versicherung", + "Strom & Energie", + "Internet & Telefon", + "Miete & Wohnen", + "Mobilfunk", + "Streaming & Medien", + "Wartung & Service" +]; + +const categoryRow = db.prepare(`SELECT COUNT(*) as count FROM categories`).get() as { count: number } | undefined; +const categoryCount = categoryRow?.count ?? 0; +if (categoryCount === 0) { + const insertStmt = db.prepare(`INSERT OR IGNORE INTO categories (name) VALUES (?)`); + for (const name of defaultCategories) { + insertStmt.run(name); + } +} + export type ContractDbRow = { id: number; title: string; diff --git a/src/index.ts b/src/index.ts index 763049a..4dbb78c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { listUpcomingDeadlines, updateContract } from "./contractsStore.js"; +import { createCategory, deleteCategory, getCategory, listCategories, findCategoryByName } from "./categoriesStore.js"; import { authenticateRequest, createAccessToken, isAuthEnabled, verifyCredentials } from "./auth.js"; import { createLogger } from "./logger.js"; import { paperlessClient } from "./paperlessClient.js"; @@ -82,6 +83,10 @@ const settingsUpdateSchema = z.object({ icalSecret: z.string().min(10).nullable().optional() }); +const categorySchema = z.object({ + name: z.string().trim().min(1).max(120) +}); + function formatSettingsResponse(runtime: RuntimeSettings) { return { values: { @@ -186,6 +191,41 @@ app.post("/auth/login", (req, res) => { res.json({ token, expiresAt }); }); +app.get("/categories", (_req, res) => { + const categories = listCategories(); + res.json(categories); +}); + +app.post("/categories", (req, res, next) => { + try { + const { name } = validatePayload(categorySchema, req.body); + const existing = findCategoryByName(name); + if (existing) { + return res.json(existing); + } + const category = createCategory(name); + res.status(201).json(category); + } catch (error) { + next(error); + } +}); + +app.delete("/categories/:id", (req, res) => { + const id = parseId(req.params.id); + if (!id) { + return res.status(400).json({ error: "Invalid category id" }); + } + const category = getCategory(id); + if (!category) { + return res.status(404).json({ error: "Category not found" }); + } + const deleted = deleteCategory(id); + if (!deleted) { + return res.status(500).json({ error: "Failed to delete category" }); + } + res.status(204).send(); +}); + app.get("/calendar/feed.ics", (req, res) => { const providedToken = typeof req.query.token === "string" ? req.query.token : null; const secret = ensureIcalSecret();