categories as dropdown
This commit is contained in:
18
README.md
18
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
|
||||
|
||||
24
frontend/src/api/categories.ts
Normal file
24
frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { request } from "./client";
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function fetchCategories(): Promise<Category[]> {
|
||||
return request<Category[]>("/categories", { method: "GET" });
|
||||
}
|
||||
|
||||
export async function createCategory(name: string): Promise<Category> {
|
||||
return request<Category>("/categories", {
|
||||
method: "POST",
|
||||
body: { name }
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: number): Promise<void> {
|
||||
await request(`/categories/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<PaperlessDocument | null>(null);
|
||||
const paperlessDocumentId = watch("paperlessDocumentId");
|
||||
|
||||
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) {
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="category"
|
||||
render={({ field }) => {
|
||||
const options = categories ?? [];
|
||||
const value = field.value ?? "";
|
||||
const hasCurrentValue = Boolean(value) && options.some((category) => category.name.toLowerCase() === String(value).toLowerCase());
|
||||
return (
|
||||
<TextField
|
||||
label={t("contractForm.fields.category")}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: Boolean(watchedCategory?.trim()) }}
|
||||
{...register("category")}
|
||||
select
|
||||
name={field.name}
|
||||
inputRef={field.ref}
|
||||
value={value}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(event) => {
|
||||
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}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>{t("contracts.categoryNone")}</em>
|
||||
</MenuItem>
|
||||
{loadingCategories ? (
|
||||
<MenuItem value="__loading__" disabled>
|
||||
{t("contracts.categoryLoading")}
|
||||
</MenuItem>
|
||||
) : categoriesError ? (
|
||||
<MenuItem value="__error__" disabled>
|
||||
{t("contracts.categoryLoadError")}
|
||||
</MenuItem>
|
||||
) : (
|
||||
options.map((category) => (
|
||||
<MenuItem key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
{!loadingCategories && !categoriesError && value && !hasCurrentValue && (
|
||||
<MenuItem value={value as string}>
|
||||
{String(value)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem value="__add__">{t("contracts.categoryAdd")}</MenuItem>
|
||||
</TextField>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
@@ -460,6 +541,51 @@ export default function ContractForm({ mode }: Props) {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
|
||||
<Dialog
|
||||
open={categoryDialogOpen}
|
||||
onClose={() => {
|
||||
setCategoryDialogOpen(false);
|
||||
setNewCategoryName("");
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t("contracts.categoryAddTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
{t("contracts.categoryAddDescription")}
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
label={t("contracts.categoryNameLabel")}
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => {
|
||||
setCategoryDialogOpen(false);
|
||||
setNewCategoryName("");
|
||||
}}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const trimmed = newCategoryName.trim();
|
||||
if (!trimmed) {
|
||||
showMessage(t("contracts.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmed);
|
||||
}}
|
||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
||||
variant="contained"
|
||||
>
|
||||
{t("contracts.categoryCreate")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<PaperlessSearchDialog
|
||||
open={searchDialogOpen}
|
||||
onClose={() => setSearchDialogOpen(false)}
|
||||
|
||||
@@ -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<string>("all");
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const values = new Set<string>();
|
||||
contracts?.forEach((contract) => {
|
||||
if (contract.category) values.add(contract.category);
|
||||
const { data: categoryOptions } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: fetchCategories
|
||||
});
|
||||
return Array.from(values).sort();
|
||||
}, [contracts]);
|
||||
|
||||
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 }}
|
||||
>
|
||||
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
|
||||
{categories.map((item) => (
|
||||
<MenuItem key={item} value={item}>
|
||||
{item}
|
||||
{(categoryOptions ?? []).map((item) => (
|
||||
<MenuItem key={item.id} value={item.name}>
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
@@ -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<HealthResponse>("/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<FormValues>(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() {
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={3}>
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("settings.categories")}
|
||||
</Typography>
|
||||
{loadingCategories ? (
|
||||
<CircularProgress size={24} />
|
||||
) : categoriesError ? (
|
||||
<Alert severity="error">{t("settings.categoryLoadError")}</Alert>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label={t("settings.categoryName")}
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const trimmed = newCategoryName.trim();
|
||||
if (!trimmed) {
|
||||
showMessage(t("settings.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmed);
|
||||
}}
|
||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
||||
>
|
||||
{t("settings.categoryAdd")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{categories && categories.length > 0 ? (
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{categories.map((category) => (
|
||||
<Chip
|
||||
key={category.id}
|
||||
label={category.name}
|
||||
onDelete={() => deleteCategoryMutation.mutate(category.id)}
|
||||
disabled={deleteCategoryMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("settings.categoryEmpty")}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
|
||||
82
src/categoriesStore.ts
Normal file
82
src/categoriesStore.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
26
src/db.ts
26
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;
|
||||
|
||||
40
src/index.ts
40
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();
|
||||
|
||||
Reference in New Issue
Block a user