categories as dropdown

This commit is contained in:
MDeeApp
2025-10-11 14:02:20 +02:00
parent 6d15382464
commit 17e094e8ac
11 changed files with 471 additions and 25 deletions

View 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"
});
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 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}>
<TextField
label={t("contractForm.fields.category")}
fullWidth
InputLabelProps={{ shrink: Boolean(watchedCategory?.trim()) }}
{...register("category")}
<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
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)}

View File

@@ -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);
});
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 }}
>
<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>

View File

@@ -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>