This commit is contained in:
MDeeApp
2025-10-11 01:17:31 +02:00
commit 8eb060f380
1223 changed files with 265299 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
import EventIcon from "@mui/icons-material/Event";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Chip,
List,
ListItem,
ListItemButton,
ListItemText,
Paper,
Typography
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { fetchUpcomingDeadlines } from "../api/contracts";
import PageHeader from "../components/PageHeader";
import { UpcomingDeadline } from "../types";
import { formatDate } from "../utils/date";
const UNKNOWN_MONTH_KEY = "__unknown__";
function groupByMonth(deadlines: UpcomingDeadline[], unknownKey: string) {
const groups = new Map<string, UpcomingDeadline[]>();
deadlines.forEach((deadline) => {
const month = deadline.terminationDeadline?.slice(0, 7) ?? unknownKey;
if (!groups.has(month)) {
groups.set(month, []);
}
groups.get(month)!.push(deadline);
});
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, items]) => ({
month,
items: items.sort((a, b) => (a.terminationDeadline ?? "").localeCompare(b.terminationDeadline ?? ""))
}));
}
export default function CalendarView() {
const { data, isLoading } = useQuery({
queryKey: ["deadlines", "calendar"],
queryFn: () => fetchUpcomingDeadlines(365)
});
const groups = useMemo(() => {
if (!data || !Array.isArray(data)) return [] as ReturnType<typeof groupByMonth>;
return groupByMonth(data, UNKNOWN_MONTH_KEY);
}, [data]);
const navigate = useNavigate();
const { t, i18n } = useTranslation();
return (
<>
<PageHeader
title={t("calendar.title")}
subtitle={t("calendar.subtitle")}
/>
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
{isLoading ? (
<Typography sx={{ p: 3 }}>{t("calendar.loading")}</Typography>
) : groups.length === 0 ? (
<Typography sx={{ p: 3 }} color="text.secondary">
{t("calendar.none")}
</Typography>
) : (
groups.map(({ month, items }) => (
<Accordion key={month} defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight={600} display="flex" alignItems="center" gap={1}>
<EventIcon fontSize="small" />
{formatMonth(month, i18n.language, t("calendar.unknownMonth"))}
</Typography>
<Chip
label={t("calendar.deadlineCount", { count: items.length })}
size="small"
color="primary"
sx={{ ml: 2 }}
/>
</AccordionSummary>
<AccordionDetails>
<List>
{items.map((deadline) => (
<ListItem
key={`${month}-${deadline.id}`}
disablePadding
secondaryAction={
deadline.daysUntilDeadline != null ? (
<Chip
label={t("deadlineList.daysLabel", { count: deadline.daysUntilDeadline })}
color={
deadline.daysUntilDeadline <= 7
? "error"
: deadline.daysUntilDeadline <= 21
? "warning"
: "default"
}
variant="outlined"
/>
) : undefined
}
>
<ListItemButton onClick={() => navigate(`/contracts/${deadline.id}`)}>
<ListItemText
primary={deadline.title}
secondary={
<>
{t("deadlineList.terminateBy", {
date: formatDate(deadline.terminationDeadline)
})}
{deadline.contractEndDate
? `${t("deadlineList.contractEnds", {
date: formatDate(deadline.contractEndDate)
})}`
: ""}
</>
}
primaryTypographyProps={{ fontWeight: 600 }}
/>
</ListItemButton>
</ListItem>
))}
</List>
</AccordionDetails>
</Accordion>
))
)}
</Paper>
</>
);
}
function formatMonth(month: string, locale: string, unknownLabel: string): string {
if (month === UNKNOWN_MONTH_KEY) return unknownLabel;
const [year, monthNumber] = month.split("-");
const date = new Date(Number(year), Number(monthNumber) - 1);
return new Intl.DateTimeFormat(locale.startsWith("de") ? "de-DE" : "en-US", {
month: "long",
year: "numeric"
}).format(date);
}

View File

@@ -0,0 +1,189 @@
import LaunchIcon from "@mui/icons-material/Launch";
import {
Box,
Button,
Chip,
Divider,
Grid,
Paper,
Stack,
Typography
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useParams, useNavigate } from "react-router-dom";
import { fetchContract, fetchPaperlessDocument } from "../api/contracts";
import { fetchServerConfig, ServerConfig } from "../api/config";
import PageHeader from "../components/PageHeader";
import { formatCurrency, formatDate } from "../utils/date";
export default function ContractDetail() {
const { contractId } = useParams<{ contractId: string }>();
const navigate = useNavigate();
const id = Number(contractId);
const { t } = useTranslation();
const { data: contract, isLoading } = useQuery({
queryKey: ["contracts", id],
queryFn: () => fetchContract(id),
enabled: Number.isFinite(id)
});
const { data: serverConfig } = useQuery<ServerConfig>({
queryKey: ["server-config"],
queryFn: fetchServerConfig
});
const {
data: paperlessDoc,
error: paperlessError
} = useQuery({
queryKey: ["contracts", id, "paperless"],
queryFn: () => fetchPaperlessDocument(id),
enabled: Number.isFinite(id)
});
const paperlessAppUrl = serverConfig?.paperlessExternalUrl ?? serverConfig?.paperlessBaseUrl ?? null;
const terminationValue =
contract?.terminationNoticeDays !== undefined && contract?.terminationNoticeDays !== null
? t("deadlineList.daysLabel", { count: contract.terminationNoticeDays })
: "";
const renewalValue =
contract?.renewalPeriodMonths
? `${t("contractDetail.monthsLabel", { count: contract.renewalPeriodMonths })}${contract.autoRenew ? `, ${t("contractForm.fields.autoRenew")}` : ""}`
: contract?.autoRenew
? t("contractForm.fields.autoRenew")
: "";
const notesValue = contract?.notes ?? t("contractDetail.noNotes");
if (!Number.isFinite(id)) {
return <Typography>{t("contractForm.loadError")}</Typography>;
}
if (isLoading || !contract) {
return <Typography>{t("contractForm.loading")}</Typography>;
}
return (
<>
<PageHeader
title={contract.title}
subtitle={contract.provider ?? ""}
action={
<Button variant="contained" onClick={() => navigate(`/contracts/${contract.id}/edit`)}>
{t("contractDetail.edit")}
</Button>
}
/>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="h6" gutterBottom>
{t("contractDetail.details")}
</Typography>
<Stack spacing={1.5}>
<Detail label={t("contractDetail.start")} value={formatDate(contract.contractStartDate)} />
<Detail label={t("contractDetail.end")} value={formatDate(contract.contractEndDate)} />
<Detail label={t("contractDetail.notice")} value={terminationValue} />
<Detail label={t("contractDetail.renewal")} value={renewalValue} />
<Detail label={t("contractDetail.price")} value={formatCurrency(contract.price, contract.currency ?? "EUR")} />
<Detail label={t("contractDetail.category")} value={contract.category ?? ""} />
<Detail label={t("contractDetail.notes")} value={notesValue} />
</Stack>
<Divider sx={{ my: 3 }} />
<Typography variant="subtitle1" gutterBottom>
{t("contractDetail.tags")}
</Typography>
<Box display="flex" flexWrap="wrap" gap={1}>
{(contract.tags ?? []).length > 0 ? (
contract.tags!.map((tag) => <Chip key={tag} label={tag} />)
) : (
<Typography variant="body2" color="text.secondary">
{t("contractDetail.noTags")}
</Typography>
)}
</Box>
</Paper>
</Grid>
<Grid item xs={12} md={4}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
{t("contractDetail.document")}
</Typography>
{paperlessError ? (
<Typography variant="body2" color="error">
{t("contractDetail.documentError", { error: (paperlessError as Error).message })}
</Typography>
) : paperlessDoc ? (
<Stack spacing={1}>
<Typography variant="subtitle2">
{String(paperlessDoc.title ?? t("contractDetail.documentFallback"))}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("contractDetail.created")}: {paperlessDoc.created ? formatDate(String(paperlessDoc.created)) : ""}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("contractDetail.updated")}: {paperlessDoc.modified ? formatDate(String(paperlessDoc.modified)) : ""}
</Typography>
{paperlessDoc.notes && (
<Typography variant="body2" color="text.secondary">
{String(paperlessDoc.notes)}
</Typography>
)}
<Button
variant="outlined"
startIcon={<LaunchIcon />}
sx={{ alignSelf: "flex-start", mt: 1 }}
disabled={!serverConfig || !paperlessAppUrl || !contract.paperlessDocumentId}
onClick={() => {
if (!paperlessAppUrl || !contract.paperlessDocumentId) return;
const url = `${paperlessAppUrl.replace(/\/$/, "")}/documents/${contract.paperlessDocumentId}`;
window.open(url, "_blank", "noopener");
}}
>
{t("contractDetail.openInPaperless")}
</Button>
{!paperlessAppUrl && (
<Typography variant="caption" color="text.secondary">
{t("contractDetail.configurePaperless")}
</Typography>
)}
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
{t("contractDetail.documentMissing")}
</Typography>
)}
</Paper>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="h6" gutterBottom>
{t("contractDetail.metadata")}
</Typography>
<Stack spacing={1.5}>
<Detail label={t("contractDetail.id")} value={`#${contract.id}`} />
<Detail label={t("contractDetail.created")} value={formatDate(contract.createdAt)} />
<Detail label={t("contractDetail.updated")} value={formatDate(contract.updatedAt)} />
</Stack>
</Paper>
</Grid>
</Grid>
</>
);
}
function Detail({ label, value }: { label: string; value: string }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
{label}
</Typography>
<Typography variant="body1">{value}</Typography>
</Box>
);
}

View File

@@ -0,0 +1,401 @@
import SaveIcon from "@mui/icons-material/Save";
import {
Box,
Button,
FormControlLabel,
Grid,
Paper,
Stack,
Switch,
TextField,
Typography
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { z } from "zod";
import {
createContract,
fetchContract,
fetchPaperlessDocument,
updateContract
} from "../api/contracts";
import { fetchServerConfig } from "../api/config";
import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
import PageHeader from "../components/PageHeader";
import { useSnackbar } from "../hooks/useSnackbar";
import { ContractPayload, PaperlessDocument } from "../types";
const formSchema = z.object({
title: z.string().min(1, "Titel erforderlich"),
provider: z.string().optional(),
category: z.string().optional(),
paperlessDocumentId: z.string().optional(),
contractStartDate: z.string().optional(),
contractEndDate: z.string().optional(),
terminationNoticeDays: z.string().optional(),
renewalPeriodMonths: z.string().optional(),
autoRenew: z.boolean().optional(),
price: z.string().optional(),
currency: z.string().optional(),
notes: z.string().optional(),
tags: z.string().optional()
});
type FormValues = z.infer<typeof formSchema>;
interface Props {
mode: "create" | "edit";
}
function parseInteger(input?: string | null): number | null {
if (!input) return null;
const value = Number(input);
if (!Number.isFinite(value)) {
throw new Error("Invalid number");
}
return Math.round(value);
}
function parseDecimal(input?: string | null): number | null {
if (!input) return null;
const normalized = input.replace(",", ".").trim();
const value = Number(normalized);
if (!Number.isFinite(value)) {
throw new Error("Invalid number");
}
return value;
}
function parseTags(input?: string | null): string[] {
if (!input) return [];
return input
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
export default function ContractForm({ mode }: Props) {
const navigate = useNavigate();
const { contractId } = useParams<{ contractId: string }>();
const id = contractId ? Number(contractId) : null;
const queryClient = useQueryClient();
const { showMessage } = useSnackbar();
const { t } = useTranslation();
const {
control,
register,
handleSubmit,
formState: { errors },
reset,
setValue,
watch
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
provider: "",
category: "",
contractStartDate: "",
contractEndDate: "",
terminationNoticeDays: "",
renewalPeriodMonths: "",
autoRenew: false,
price: "",
currency: "EUR",
notes: "",
tags: ""
}
});
const { data: contract, isLoading } = useQuery({
queryKey: ["contracts", id],
queryFn: () => fetchContract(id ?? 0),
enabled: mode === "edit" && id !== null
});
const { data: serverConfig } = useQuery({
queryKey: ["server-config"],
queryFn: fetchServerConfig
});
const { data: linkedPaperlessDoc } = useQuery({
queryKey: ["contracts", id, "paperless"],
queryFn: () => fetchPaperlessDocument(id ?? 0),
enabled: mode === "edit" && id !== null
});
const [searchDialogOpen, setSearchDialogOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<PaperlessDocument | null>(null);
const paperlessDocumentId = watch("paperlessDocumentId");
useEffect(() => {
if (mode === "edit" && contract) {
reset({
title: contract.title,
provider: contract.provider ?? "",
category: contract.category ?? "",
contractStartDate: contract.contractStartDate ?? "",
contractEndDate: contract.contractEndDate ?? "",
terminationNoticeDays: contract.terminationNoticeDays
? String(contract.terminationNoticeDays)
: "",
renewalPeriodMonths: contract.renewalPeriodMonths
? String(contract.renewalPeriodMonths)
: "",
autoRenew: contract.autoRenew ?? false,
price: contract.price ? String(contract.price) : "",
currency: contract.currency ?? "EUR",
notes: contract.notes ?? "",
paperlessDocumentId: contract.paperlessDocumentId
? String(contract.paperlessDocumentId)
: "",
tags: contract.tags?.join(", ") ?? ""
});
}
}, [contract, mode, reset]);
useEffect(() => {
if (linkedPaperlessDoc) {
setSelectedDocument(linkedPaperlessDoc);
}
}, [linkedPaperlessDoc]);
useEffect(() => {
if (!paperlessDocumentId) {
setSelectedDocument(null);
return;
}
if (selectedDocument?.id && String(selectedDocument.id) === paperlessDocumentId) {
return;
}
if (selectedDocument && String(selectedDocument.id ?? "") !== paperlessDocumentId) {
setSelectedDocument(null);
}
}, [paperlessDocumentId, selectedDocument]);
const mutation = useMutation({
mutationFn: async (values: FormValues) => {
const payload: ContractPayload = {
title: values.title,
provider: values.provider?.trim() || null,
category: values.category?.trim() || null,
contractStartDate: values.contractStartDate || null,
contractEndDate: values.contractEndDate || null,
terminationNoticeDays: parseInteger(values.terminationNoticeDays ?? undefined),
renewalPeriodMonths: parseInteger(values.renewalPeriodMonths ?? undefined),
autoRenew: values.autoRenew ?? false,
price: parseDecimal(values.price ?? undefined),
currency: values.currency?.trim() || "EUR",
notes: values.notes?.trim() || null,
paperlessDocumentId: parseInteger(values.paperlessDocumentId ?? undefined),
tags: parseTags(values.tags ?? "")
};
if (mode === "create") {
return createContract(payload);
}
if (!id) {
throw new Error("Invalid contract ID");
}
return updateContract(id, payload);
},
onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: ["contracts"] });
showMessage(t("contractForm.saved", { title: updated.title }), "success");
navigate(`/contracts/${updated.id}`);
},
onError: (error: Error) => {
const message = error.message === "Invalid number" ? t("contractForm.invalidNumber") : error.message ?? t("contractForm.saveError");
showMessage(message, "error");
}
});
if (mode === "edit" && (isLoading || !contract)) {
return <Typography>{t("contractForm.loading")}</Typography>;
}
return (
<>
<PageHeader
title={mode === "create" ? t("contractForm.createTitle") : t("contractDetail.edit")}
subtitle={
mode === "create"
? t("contractForm.createSubtitle")
: t("contractForm.editSubtitle", { title: contract?.title ?? "" })
}
/>
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3 }}>
<Box
component="form"
onSubmit={handleSubmit((values) => mutation.mutate(values))}
noValidate
>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
label={t("contractForm.fields.title")}
fullWidth
required
{...register("title")}
error={Boolean(errors.title)}
helperText={errors.title?.message}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.provider")} fullWidth {...register("provider")} />
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.category")} fullWidth {...register("category")} />
</Grid>
<Grid item xs={12} md={6}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="flex-start">
<TextField
label={t("contractForm.fields.paperlessId")}
fullWidth
{...register("paperlessDocumentId")}
error={Boolean(errors.paperlessDocumentId)}
helperText={errors.paperlessDocumentId?.message}
/>
<Button
variant="outlined"
onClick={() => setSearchDialogOpen(true)}
disabled={!serverConfig?.paperlessConfigured}
>
{t("contractForm.fields.searchButton")}
</Button>
</Stack>
{!serverConfig?.paperlessConfigured && (
<Typography variant="caption" color="text.secondary">
{t("contractForm.paperlessNotConfigured")}
</Typography>
)}
{selectedDocument && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
{t("contractForm.paperlessLinked", {
title: selectedDocument.title ?? `#${selectedDocument.id}`
})}
</Typography>
)}
</Grid>
<Grid item xs={12} md={6}>
<TextField
label={t("contractForm.fields.contractStart")}
type="date"
fullWidth
InputLabelProps={{ shrink: true }}
{...register("contractStartDate")}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
label={t("contractForm.fields.contractEnd")}
type="date"
fullWidth
InputLabelProps={{ shrink: true }}
{...register("contractEndDate")}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
label={t("contractForm.fields.terminationNotice")}
fullWidth
{...register("terminationNoticeDays")}
error={Boolean(errors.terminationNoticeDays)}
helperText={errors.terminationNoticeDays?.message}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
label={t("contractForm.fields.renewalPeriod")}
fullWidth
{...register("renewalPeriodMonths")}
error={Boolean(errors.renewalPeriodMonths)}
helperText={errors.renewalPeriodMonths?.message}
/>
</Grid>
<Grid item xs={12} md={4}>
<Controller
control={control}
name="autoRenew"
render={({ field }) => (
<FormControlLabel
label={t("contractForm.fields.autoRenew")}
control={
<Switch
checked={field.value ?? false}
onChange={(event) => field.onChange(event.target.checked)}
/>
}
/>
)}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
label={t("contractForm.fields.price")}
fullWidth
{...register("price")}
error={Boolean(errors.price)}
helperText={errors.price?.message}
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
label={t("contractForm.fields.currency")}
fullWidth
{...register("currency")}
error={Boolean(errors.currency)}
helperText={errors.currency?.message}
/>
</Grid>
<Grid item xs={12}>
<TextField
label={t("contractForm.fields.notes")}
fullWidth
multiline
minRows={3}
{...register("notes")}
/>
</Grid>
<Grid item xs={12}>
<TextField
label={t("contractForm.fields.tags")}
fullWidth
placeholder={t("contractForm.tagsPlaceholder")}
{...register("tags")}
/>
</Grid>
</Grid>
<Box display="flex" justifyContent="flex-end" mt={4}>
<Button
type="submit"
variant="contained"
startIcon={<SaveIcon />}
disabled={mutation.isPending}
>
{mutation.isPending ? "Speichere..." : "Speichern"}
</Button>
</Box>
</Box>
</Paper>
<PaperlessSearchDialog
open={searchDialogOpen}
onClose={() => setSearchDialogOpen(false)}
onSelect={(doc) => {
const idValue = doc.id ? String(doc.id) : "";
setValue("paperlessDocumentId", idValue, { shouldDirty: true });
setSelectedDocument(doc);
}}
/>
</>
);
}

View File

@@ -0,0 +1,219 @@
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import VisibilityIcon from "@mui/icons-material/Visibility";
import {
Box,
Button,
Chip,
IconButton,
InputAdornment,
MenuItem,
Paper,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Tooltip,
Typography
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { fetchContracts, removeContract } from "../api/contracts";
import PageHeader from "../components/PageHeader";
import { useSnackbar } from "../hooks/useSnackbar";
import { Contract } from "../types";
import { formatCurrency, formatDate } from "../utils/date";
export default function ContractsList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showMessage } = useSnackbar();
const { t } = useTranslation();
const {
data: contracts,
isLoading,
isError
} = useQuery({
queryKey: ["contracts", "list"],
queryFn: () => fetchContracts({ limit: 500 })
});
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 normalizedContracts = useMemo(() => {
if (!contracts) return [] as Contract[];
if (Array.isArray(contracts)) return contracts as Contract[];
if (typeof (contracts as any).results === "object" && Array.isArray((contracts as any).results)) {
return (contracts as any).results as Contract[];
}
return [] as Contract[];
}, [contracts]);
const filtered = useMemo(() => {
return normalizedContracts.filter((contract) => {
const searchMatch =
!search ||
[contract.title, contract.provider, contract.notes, contract.category]
.filter(Boolean)
.some((field) => field!.toLowerCase().includes(search.toLowerCase()));
const categoryMatch = category === "all" || contract.category === category;
return searchMatch && categoryMatch;
});
}, [contracts, search, category]);
const deleteMutation = useMutation({
mutationFn: (contractId: number) => removeContract(contractId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["contracts"] });
showMessage(t("contracts.deleted"), "success");
},
onError: (error: Error) => showMessage(error.message ?? t("contracts.deleteError"), "error")
});
const handleDelete = (contract: Contract) => {
if (window.confirm(t("contracts.deleteConfirm", { title: contract.title }))) {
deleteMutation.mutate(contract.id);
}
};
return (
<>
<PageHeader
title={t("contracts.title")}
subtitle={t("contracts.subtitle")}
action={
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate("/contracts/new")}>
{t("contracts.new")}
</Button>
}
/>
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
<Box display="flex" flexWrap="wrap" gap={2} mb={2}>
<TextField
label={t("contracts.searchLabel")}
placeholder={t("contracts.searchPlaceholder")}
value={search}
onChange={(event) => setSearch(event.target.value)}
sx={{ flex: { xs: "1 1 100%", md: "1 1 320px" } }}
InputProps={{
startAdornment: <InputAdornment position="start">🔍</InputAdornment>
}}
/>
<TextField
select
label={t("contracts.columns.category")}
value={category}
onChange={(event) => setCategory(event.target.value)}
sx={{ width: 200 }}
>
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
{categories.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
</Box>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("contracts.columns.title")}</TableCell>
<TableCell>{t("contracts.columns.provider")}</TableCell>
<TableCell>{t("contracts.columns.category")}</TableCell>
<TableCell>{t("contracts.columns.price")}</TableCell>
<TableCell>{t("contracts.columns.end")}</TableCell>
<TableCell>{t("contracts.columns.tags")}</TableCell>
<TableCell align="right">{t("contracts.columns.actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={7}>
<Typography variant="body2" color="text.secondary">
{t("contracts.loading")}
</Typography>
</TableCell>
</TableRow>
)}
{isError && (
<TableRow>
<TableCell colSpan={7}>
<Typography variant="body2" color="error">
{t("dashboard.contractsError")}
</Typography>
</TableCell>
</TableRow>
)}
{!isLoading && !isError && filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7}>
<Typography variant="body2" color="text.secondary">
{t("contracts.empty")}
</Typography>
</TableCell>
</TableRow>
)}
{filtered.map((contract) => (
<TableRow key={contract.id} hover>
<TableCell>
<Typography fontWeight={600}>{contract.title}</Typography>
<Typography variant="caption" color="text.secondary">
#{contract.id}
</Typography>
</TableCell>
<TableCell>{contract.provider ?? ""}</TableCell>
<TableCell>{contract.category ?? ""}</TableCell>
<TableCell>{formatCurrency(contract.price, contract.currency ?? "EUR")}</TableCell>
<TableCell>{formatDate(contract.contractEndDate)}</TableCell>
<TableCell>
<Box display="flex" flexWrap="wrap" gap={1}>
{(contract.tags ?? []).map((tag) => (
<Chip key={tag} label={tag} size="small" />
))}
</Box>
</TableCell>
<TableCell align="right">
<Tooltip title={t("contracts.details")}>
<IconButton onClick={() => navigate(`/contracts/${contract.id}`)}>
<VisibilityIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("contracts.edit")}>
<IconButton onClick={() => navigate(`/contracts/${contract.id}/edit`)}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("actions.delete")}>
<IconButton color="error" onClick={() => handleDelete(contract)}>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
</>
);
}

View File

@@ -0,0 +1,197 @@
import { Grid, Paper, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, Bar, Tooltip, XAxis, YAxis } from "recharts";
import { fetchContracts, fetchUpcomingDeadlines } from "../api/contracts";
import DeadlineList from "../components/DeadlineList";
import PageHeader from "../components/PageHeader";
import StatCard from "../components/StatCard";
import { Contract, UpcomingDeadline } from "../types";
import { formatCurrency } from "../utils/date";
function buildDeadlineSeries(deadlines: UpcomingDeadline[]) {
const grouped = new Map<string, number>();
deadlines.forEach((deadline) => {
if (!deadline.terminationDeadline) return;
const month = deadline.terminationDeadline.slice(0, 7);
grouped.set(month, (grouped.get(month) ?? 0) + 1);
});
return Array.from(grouped.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, count]) => ({ month, count }));
}
function countActiveContracts(contracts: Contract[]): number {
const now = new Date();
return contracts.filter((contract) => {
if (!contract.contractEndDate) {
return true;
}
const end = new Date(`${contract.contractEndDate}T00:00:00Z`);
return end >= now;
}).length;
}
function calcMonthlySpend(contracts: Contract[]): number {
return contracts.reduce((total, contract) => {
if (!contract.price) return total;
return total + contract.price;
}, 0);
}
export default function Dashboard() {
const {
data: contracts,
isLoading: loadingContracts,
isError: errorContracts
} = useQuery({
queryKey: ["contracts", "dashboard"],
queryFn: () => fetchContracts({ limit: 200 })
});
const {
data: deadlines,
isLoading: loadingDeadlines,
isError: errorDeadlines
} = useQuery({
queryKey: ["deadlines", 60],
queryFn: () => fetchUpcomingDeadlines(60)
});
const normalizedContracts = useMemo(() => {
if (!contracts) return [] as Contract[];
if (Array.isArray(contracts)) return contracts as Contract[];
if (typeof (contracts as any).results === "object" && Array.isArray((contracts as any).results)) {
return (contracts as any).results as Contract[];
}
return [] as Contract[];
}, [contracts]);
const normalizedDeadlines = useMemo(() => {
if (!deadlines) return [] as UpcomingDeadline[];
if (Array.isArray(deadlines)) return deadlines as UpcomingDeadline[];
if (typeof (deadlines as any).results === "object" && Array.isArray((deadlines as any).results)) {
return (deadlines as any).results as UpcomingDeadline[];
}
return [] as UpcomingDeadline[];
}, [deadlines]);
const activeContracts = useMemo(() => countActiveContracts(normalizedContracts), [normalizedContracts]);
const monthlySpend = useMemo(() => calcMonthlySpend(normalizedContracts), [normalizedContracts]);
const deadlineSeries = useMemo(() => buildDeadlineSeries(normalizedDeadlines), [normalizedDeadlines]);
const { t } = useTranslation();
return (
<>
<PageHeader
title={t("dashboard.title")}
subtitle={t("dashboard.subtitle")}
/>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
{loadingContracts ? (
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
) : errorContracts ? (
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="body2" color="error">
{t("dashboard.contractsError")}
</Typography>
</Paper>
) : (
<StatCard
title={t("dashboard.totalContracts")}
value={contracts?.length ?? 0}
trend={contracts && contracts.length > 0 ? "up" : undefined}
trendLabel={contracts && contracts.length > 0 ? t("dashboard.totalContractsTrend") : undefined}
/>
)}
</Grid>
<Grid item xs={12} md={4}>
{loadingContracts ? (
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
) : errorContracts ? (
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="body2" color="error">
{t("dashboard.contractsError")}
</Typography>
</Paper>
) : (
<StatCard title={t("dashboard.activeContracts") } value={activeContracts} />
)}
</Grid>
<Grid item xs={12} md={4}>
{loadingContracts ? (
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
) : errorContracts ? (
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="body2" color="error">
{t("dashboard.contractsError")}
</Typography>
</Paper>
) : (
<StatCard
title={t("dashboard.monthlySpend")}
value={formatCurrency(monthlySpend)}
trend={monthlySpend > 0 ? "up" : undefined}
trendLabel={monthlySpend > 0 ? t("dashboard.monthlySpendTrend") : undefined}
/>
)}
</Grid>
</Grid>
<Grid container spacing={3} mt={1}>
<Grid item xs={12} md={7}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3, minHeight: 320 }}>
<Typography variant="h6" gutterBottom>
{t("dashboard.deadlineChartTitle")}
</Typography>
{loadingDeadlines ? (
<Stack spacing={2} mt={2}>
<Skeleton variant="rectangular" height={32} />
<Skeleton variant="rectangular" height={32} />
<Skeleton variant="rectangular" height={32} />
</Stack>
) : errorDeadlines ? (
<Typography variant="body2" color="error">
{t("dashboard.deadlinesError")}
</Typography>
) : deadlines && deadlines.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={deadlineSeries}>
<XAxis dataKey="month" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#556cd6" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<Typography variant="body2" color="text.secondary">
{t("dashboard.noDeadlines")}
</Typography>
)}
</Paper>
</Grid>
<Grid item xs={12} md={5}>
{loadingDeadlines ? (
<Stack spacing={2} mt={1}>
<Skeleton variant="rectangular" height={88} />
<Skeleton variant="rectangular" height={88} />
<Skeleton variant="rectangular" height={88} />
</Stack>
) : errorDeadlines ? (
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="body2" color="error">
{t("dashboard.deadlinesError")}
</Typography>
</Paper>
) : (
<DeadlineList deadlines={deadlines ?? []} />
)}
</Grid>
</Grid>
</>
);
}

View File

@@ -0,0 +1,131 @@
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import {
Alert,
Avatar,
Box,
Button,
Container,
Paper,
TextField,
Typography
} from "@mui/material";
import { useMutation } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useAuth } from "../contexts/AuthContext";
type FormValues = {
username: string;
password: string;
};
export default function LoginPage() {
const { login, authEnabled, isAuthenticated } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const schema = useMemo(
() =>
z.object({
username: z.string().min(1, t("login.usernameRequired")),
password: z.string().min(1, t("login.passwordRequired"))
}),
[t]
);
const {
handleSubmit,
register,
formState: { errors }
} = useForm<FormValues>({
resolver: zodResolver(schema)
});
const mutation = useMutation({
mutationFn: ({ username, password }: FormValues) => login(username, password),
onSuccess: () => {
const redirectTo = (location.state as { from?: Location })?.from?.pathname ?? "/dashboard";
navigate(redirectTo, { replace: true });
}
});
useEffect(() => {
if (!authEnabled || isAuthenticated) {
navigate("/dashboard", { replace: true });
}
}, [authEnabled, isAuthenticated, navigate]);
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 12,
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
<Paper elevation={3} sx={{ p: 4, borderRadius: 4, width: "100%" }}>
<Box display="flex" flexDirection="column" alignItems="center" mb={2}>
<Avatar sx={{ m: 1, bgcolor: "primary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5" fontWeight={600}>
{t("login.title")}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("login.welcome")}
</Typography>
</Box>
{mutation.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
{(mutation.error as Error).message || t("login.error")}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit((values) => mutation.mutate(values))}>
<TextField
margin="normal"
fullWidth
label={t("login.username")}
autoComplete="username"
autoFocus
{...register("username")}
error={Boolean(errors.username)}
helperText={errors.username?.message}
/>
<TextField
margin="normal"
fullWidth
label={t("login.password")}
type="password"
autoComplete="current-password"
{...register("password")}
error={Boolean(errors.password)}
helperText={errors.password?.message}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={mutation.isPending}
>
{mutation.isPending ? t("login.checking") : t("login.submit")}
</Button>
{!authEnabled && (
<Alert severity="info">{t("login.disabled")}</Alert>
)}
</Box>
</Paper>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,747 @@
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import RefreshIcon from "@mui/icons-material/Refresh";
import SecurityIcon from "@mui/icons-material/Security";
import SettingsApplicationsIcon from "@mui/icons-material/SettingsApplications";
import StorageIcon from "@mui/icons-material/Storage";
import {
Alert,
Avatar,
Box,
Button,
Card,
CardContent,
CircularProgress,
FormControlLabel,
Grid,
IconButton,
InputAdornment,
List,
ListItem,
ListItemAvatar,
ListItemText,
Paper,
Stack,
Switch,
TextField,
Typography
} from "@mui/material";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
fetchServerConfig,
fetchSettings,
resetIcalSecret,
triggerMailTest,
triggerNtfyTest,
ServerConfig,
SettingsResponse,
updateSettings,
UpdateSettingsPayload
} from "../api/config";
import { request } from "../api/client";
import PageHeader from "../components/PageHeader";
import { useAuth } from "../contexts/AuthContext";
import { useSnackbar } from "../hooks/useSnackbar";
import { useTranslation } from "react-i18next";
interface HealthResponse {
status: string;
}
type FormValues = {
paperlessBaseUrl: string;
paperlessExternalUrl: string;
paperlessToken: string;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
mailServer: string;
mailPort: string;
mailUsername: string;
mailPassword: string;
mailUseTls: boolean;
mailFrom: string;
mailTo: string;
ntfyServerUrl: string;
ntfyTopic: string;
ntfyToken: string;
ntfyPriority: string;
authUsername: string;
authPassword: string;
};
const defaultValues: FormValues = {
paperlessBaseUrl: "",
paperlessExternalUrl: "",
paperlessToken: "",
schedulerIntervalMinutes: 60,
alertDaysBefore: 30,
mailServer: "",
mailPort: "",
mailUsername: "",
mailPassword: "",
mailUseTls: true,
mailFrom: "",
mailTo: "",
ntfyServerUrl: "",
ntfyTopic: "",
ntfyToken: "",
ntfyPriority: "default",
authUsername: "",
authPassword: ""
};
export default function SettingsPage() {
const { authEnabled } = useAuth();
const { showMessage } = useSnackbar();
const { t } = useTranslation();
const { data: health } = useQuery({
queryKey: ["healthz"],
queryFn: () => request<HealthResponse>("/healthz", { method: "GET" })
});
const {
data: serverConfig,
refetch: refetchServerConfig
} = useQuery<ServerConfig>({
queryKey: ["server-config"],
queryFn: fetchServerConfig
});
const {
data: settingsData,
isLoading: loadingSettings,
refetch: refetchSettings
} = useQuery<SettingsResponse>({
queryKey: ["settings"],
queryFn: fetchSettings
});
const initialValuesRef = useRef<FormValues>(defaultValues);
const [removePaperlessToken, setRemovePaperlessToken] = useState(false);
const [removeMailPassword, setRemoveMailPassword] = useState(false);
const [removeNtfyToken, setRemoveNtfyToken] = useState(false);
const [removeAuthPassword, setRemoveAuthPassword] = useState(false);
const {
control,
register,
handleSubmit,
reset,
setValue,
formState: { isSubmitting }
} = useForm<FormValues>({
defaultValues
});
useEffect(() => {
if (!settingsData) {
return;
}
const values: FormValues = {
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
paperlessToken: "",
schedulerIntervalMinutes: settingsData.values.schedulerIntervalMinutes,
alertDaysBefore: settingsData.values.alertDaysBefore,
mailServer: settingsData.values.mailServer ?? "",
mailPort: settingsData.values.mailPort ? String(settingsData.values.mailPort) : "",
mailUsername: settingsData.values.mailUsername ?? "",
mailPassword: "",
mailUseTls: settingsData.values.mailUseTls,
mailFrom: settingsData.values.mailFrom ?? "",
mailTo: settingsData.values.mailTo ?? "",
ntfyServerUrl: settingsData.values.ntfyServerUrl ?? "",
ntfyTopic: settingsData.values.ntfyTopic ?? "",
ntfyToken: "",
ntfyPriority: settingsData.values.ntfyPriority ?? "default",
authUsername: settingsData.values.authUsername ?? "",
authPassword: ""
};
initialValuesRef.current = values;
setRemovePaperlessToken(false);
setRemoveMailPassword(false);
setRemoveNtfyToken(false);
setRemoveAuthPassword(false);
reset(values);
}, [settingsData, reset]);
const updateMutation = useMutation({
mutationFn: (payload: UpdateSettingsPayload) => updateSettings(payload),
onSuccess: async () => {
showMessage(t("settings.saved"), "success");
await Promise.all([refetchSettings(), refetchServerConfig()]);
},
onError: (error: Error) => {
showMessage(error.message ?? t("contractForm.saveError"), "error");
}
});
const resetIcalMutation = useMutation({
mutationFn: () => resetIcalSecret(),
onSuccess: async () => {
showMessage(t("settings.icalTokenRenewed"), "success");
await refetchSettings();
},
onError: (error: Error) => showMessage(error.message ?? t("settings.actionFailed"), "error")
});
const mailTestMutation = useMutation({
mutationFn: () => triggerMailTest(),
onSuccess: () => showMessage(t("settings.mailTestSuccess"), "success"),
onError: (error: Error) => showMessage(error.message ?? t("settings.mailTestError"), "error")
});
const ntfyTestMutation = useMutation({
mutationFn: () => triggerNtfyTest(),
onSuccess: () => showMessage(t("settings.ntfyTestSuccess"), "success"),
onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error")
});
const settings = settingsData;
const icalSecret = settings?.icalSecret ?? null;
const icalUrl = useMemo(() => {
if (!icalSecret) return null;
if (typeof window === "undefined") return null;
const origin = window.location.origin;
return `${origin}/api/calendar/feed.ics?token=${icalSecret}`;
}, [icalSecret]);
const onSubmit = (formValues: FormValues) => {
const initial = initialValuesRef.current;
if (!initial || !settings) {
return;
}
const payload: UpdateSettingsPayload = {};
const trimOrNull = (value: string) => {
const trimmed = value.trim();
return trimmed.length === 0 ? null : trimmed;
};
if (formValues.paperlessBaseUrl !== initial.paperlessBaseUrl) {
payload.paperlessBaseUrl = trimOrNull(formValues.paperlessBaseUrl);
}
if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) {
payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl);
}
if (removePaperlessToken) {
payload.paperlessToken = null;
} else if (formValues.paperlessToken.trim().length > 0) {
payload.paperlessToken = formValues.paperlessToken.trim();
}
if (formValues.schedulerIntervalMinutes !== initial.schedulerIntervalMinutes) {
payload.schedulerIntervalMinutes = formValues.schedulerIntervalMinutes;
}
if (formValues.alertDaysBefore !== initial.alertDaysBefore) {
payload.alertDaysBefore = formValues.alertDaysBefore;
}
if (formValues.mailServer !== initial.mailServer) {
payload.mailServer = trimOrNull(formValues.mailServer);
}
if (formValues.mailPort !== initial.mailPort) {
payload.mailPort = formValues.mailPort.trim()
? Number(formValues.mailPort)
: null;
}
if (formValues.mailUsername !== initial.mailUsername) {
payload.mailUsername = trimOrNull(formValues.mailUsername);
}
if (removeMailPassword) {
payload.mailPassword = null;
} else if (formValues.mailPassword.trim().length > 0) {
payload.mailPassword = formValues.mailPassword.trim();
}
if (formValues.mailUseTls !== initial.mailUseTls) {
payload.mailUseTls = formValues.mailUseTls;
}
if (formValues.mailFrom !== initial.mailFrom) {
payload.mailFrom = trimOrNull(formValues.mailFrom);
}
if (formValues.mailTo !== initial.mailTo) {
payload.mailTo = trimOrNull(formValues.mailTo);
}
if (formValues.ntfyServerUrl !== initial.ntfyServerUrl) {
payload.ntfyServerUrl = trimOrNull(formValues.ntfyServerUrl);
}
if (formValues.ntfyTopic !== initial.ntfyTopic) {
payload.ntfyTopic = trimOrNull(formValues.ntfyTopic);
}
if (removeNtfyToken) {
payload.ntfyToken = null;
} else if (formValues.ntfyToken.trim().length > 0) {
payload.ntfyToken = formValues.ntfyToken.trim();
}
if (formValues.ntfyPriority !== initial.ntfyPriority) {
payload.ntfyPriority = formValues.ntfyPriority === "default" ? null : formValues.ntfyPriority;
}
if (formValues.authUsername !== initial.authUsername) {
payload.authUsername = trimOrNull(formValues.authUsername);
}
if (removeAuthPassword) {
payload.authPassword = null;
} else if (formValues.authPassword.trim().length > 0) {
payload.authPassword = formValues.authPassword.trim();
}
if (Object.keys(payload).length === 0) {
showMessage(t("settings.noChanges"), "info");
return;
}
updateMutation.mutate(payload);
};
const handleCopy = async (text: string) => {
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
showMessage(t("actions.copy"), "success");
} else {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
const successful = document.execCommand("copy");
document.body.removeChild(textarea);
if (successful) {
showMessage(t("actions.copy"), "success");
} else {
showMessage(t("actions.copyUnsupported"), "warning");
}
}
} catch (error) {
showMessage((error as Error).message ?? t("actions.copyFailed"), "error");
}
};
const loading = loadingSettings || !settings;
const paperlessTokenSet = settings?.secrets.paperlessTokenSet ?? false;
const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false;
const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false;
const authPasswordSet = settings?.secrets.authPasswordSet ?? false;
return (
<>
<PageHeader
title={t("settings.title")}
subtitle={t("settings.subtitle")}
/>
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={6}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="h6" gutterBottom>
{t("settings.systemStatus")}
</Typography>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: "success.main" }}>
<StorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={t("settings.apiStatus")}
secondary={health?.status === "ok" ? t("settings.apiOk") : t("settings.apiError")}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: authEnabled ? "primary.main" : "warning.main" }}>
<SecurityIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={t("settings.authStatus")}
secondary={authEnabled ? t("settings.authActive") : t("settings.authInactive")}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: serverConfig?.paperlessConfigured ? "primary.main" : "warning.main" }}>
<SettingsApplicationsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={t("settings.paperlessStatus")}
secondary={
serverConfig?.paperlessConfigured
? t("settings.paperlessActive", {
url: serverConfig.paperlessBaseUrl ?? ""
})
: t("settings.paperlessInactive")
}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: serverConfig?.mailConfigured ? "primary.main" : "warning.main" }}>
<SettingsApplicationsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={t("settings.mailStatus")}
secondary={serverConfig?.mailConfigured ? t("settings.mailActive") : t("settings.mailInactive")}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: serverConfig?.ntfyConfigured ? "primary.main" : "warning.main" }}>
<SettingsApplicationsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={t("settings.ntfyStatus")}
secondary={serverConfig?.ntfyConfigured ? t("settings.ntfyActive") : t("settings.ntfyInactive")}
/>
</ListItem>
</List>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Card variant="outlined" sx={{ borderRadius: 3, height: "100%" }}>
<CardContent>
<Stack spacing={2}>
<Box>
<Typography variant="h6">{t("settings.ical")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("settings.icalDescription")}
</Typography>
</Box>
{icalUrl ? (
<TextField
label={t("settings.icalFeedUrl")}
value={icalUrl}
InputProps={{
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => handleCopy(icalUrl)} edge="end">
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
)
}}
fullWidth
/>
) : (
<Alert severity="warning">{t("settings.icalTokenMissing")}</Alert>
)}
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => resetIcalMutation.mutate()}
disabled={resetIcalMutation.isPending}
>
{t("settings.icalRenew")}
</Button>
{resetIcalMutation.isPending && <CircularProgress size={24} />}
</Stack>
</Stack>
</CardContent>
</Card>
</Grid>
</Grid>
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{t("settings.paperless")}
</Typography>
{loading ? (
<CircularProgress size={24} />
) : (
<Stack spacing={2}>
<TextField
label={t("settings.paperlessApiUrl")}
{...register("paperlessBaseUrl")}
placeholder={t("settings.paperlessExample")}
fullWidth
/>
<TextField
label={t("settings.paperlessExternalUrl")}
{...register("paperlessExternalUrl")}
placeholder={t("settings.paperlessExample")}
fullWidth
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={paperlessTokenSet && !removePaperlessToken ? t("settings.paperlessTokenNew") : t("settings.paperlessToken")}
{...register("paperlessToken")}
type="password"
fullWidth
placeholder={paperlessTokenSet && !removePaperlessToken ? "" : t("settings.paperlessTokenPlaceholder")}
/>
<Button
variant="outlined"
color={removePaperlessToken ? "inherit" : "warning"}
onClick={() => {
if (removePaperlessToken) {
setRemovePaperlessToken(false);
} else {
setRemovePaperlessToken(true);
setValue("paperlessToken", "", { shouldDirty: true });
}
}}
>
{removePaperlessToken ? t("settings.paperlessTokenKeep") : t("settings.paperlessTokenRemove")}
</Button>
</Stack>
{paperlessTokenSet && !removePaperlessToken && (
<Typography variant="caption" color="text.secondary">
{t("settings.paperlessTokenInfo")}
</Typography>
)}
</Stack>
)}
</CardContent>
</Card>
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{t("settings.scheduler")}
</Typography>
{loading ? (
<CircularProgress size={24} />
) : (
<Stack spacing={2}>
<TextField
label={t("settings.interval")}
type="number"
inputProps={{ min: 5, max: 1440 }}
{...register("schedulerIntervalMinutes", { valueAsNumber: true })}
/>
<TextField
label={t("settings.alert")}
type="number"
inputProps={{ min: 1, max: 365 }}
{...register("alertDaysBefore", { valueAsNumber: true })}
/>
</Stack>
)}
</CardContent>
</Card>
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{t("settings.auth")}
</Typography>
{loading ? (
<CircularProgress size={24} />
) : (
<Stack spacing={2}>
<TextField label={t("settings.authUsername")} {...register("authUsername")} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={t("settings.authPassword")}
type="password"
{...register("authPassword")}
fullWidth
placeholder={t("settings.authPasswordPlaceholder")}
/>
<Button
variant="outlined"
color={removeAuthPassword ? "inherit" : "warning"}
onClick={() => {
if (removeAuthPassword) {
setRemoveAuthPassword(false);
} else {
setRemoveAuthPassword(true);
setValue("authPassword", "", { shouldDirty: true });
}
}}
>
{removeAuthPassword ? t("settings.paperlessTokenKeep") : t("settings.authPasswordRemove")}
</Button>
</Stack>
{authPasswordSet && !removeAuthPassword && (
<Typography variant="caption" color="text.secondary">
{t("settings.authPasswordInfo")}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
{t("settings.authHint")}
</Typography>
</Stack>
)}
</CardContent>
</Card>
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{t("settings.mail")}
</Typography>
{loading ? (
<CircularProgress size={24} />
) : (
<Stack spacing={2}>
<TextField label={t("settings.smtpServer")} {...register("mailServer")} fullWidth />
<TextField
label={t("settings.smtpPort")}
type="number"
{...register("mailPort")}
fullWidth
/>
<TextField label={t("settings.smtpUsername")} {...register("mailUsername")} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={mailPasswordSet && !removeMailPassword ? t("settings.smtpPasswordNew") : t("settings.smtpPassword")}
type="password"
{...register("mailPassword")}
fullWidth
/>
<Button
variant="outlined"
color={removeMailPassword ? "inherit" : "warning"}
onClick={() => {
if (removeMailPassword) {
setRemoveMailPassword(false);
} else {
setRemoveMailPassword(true);
setValue("mailPassword", "", { shouldDirty: true });
}
}}
>
{removeMailPassword ? t("settings.paperlessTokenKeep") : t("settings.smtpPasswordRemove")}
</Button>
</Stack>
{mailPasswordSet && !removeMailPassword && (
<Typography variant="caption" color="text.secondary">
{t("settings.smtpPasswordInfo")}
</Typography>
)}
<FormControlLabel
control={
<Controller
name="mailUseTls"
control={control}
render={({ field }) => <Switch {...field} checked={field.value} />}
/>
}
label={t("settings.tls")}
/>
<TextField label={t("settings.mailFrom")} {...register("mailFrom")} fullWidth />
<TextField label={t("settings.mailTo")} {...register("mailTo")} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="outlined"
onClick={() => mailTestMutation.mutate()}
disabled={mailTestMutation.isPending || !serverConfig?.mailConfigured}
>
{t("settings.mailTest")}
</Button>
{mailTestMutation.isPending && <CircularProgress size={24} />}
</Stack>
{mailTestMutation.isError && (
<Alert severity="error">{(mailTestMutation.error as Error).message ?? t("settings.mailTestError")}</Alert>
)}
</Stack>
)}
</CardContent>
</Card>
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{t("settings.ntfy")}
</Typography>
{loading ? (
<CircularProgress size={24} />
) : (
<Stack spacing={2}>
<TextField label={t("settings.ntfyServer")} {...register("ntfyServerUrl")} fullWidth />
<TextField label={t("settings.ntfyTopic")} {...register("ntfyTopic")} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={ntfyTokenSet && !removeNtfyToken ? t("settings.ntfyTokenNew") : t("settings.ntfyToken")}
type="password"
{...register("ntfyToken")}
fullWidth
/>
<Button
variant="outlined"
color={removeNtfyToken ? "inherit" : "warning"}
onClick={() => {
if (removeNtfyToken) {
setRemoveNtfyToken(false);
} else {
setRemoveNtfyToken(true);
setValue("ntfyToken", "", { shouldDirty: true });
}
}}
>
{removeNtfyToken ? t("settings.paperlessTokenKeep") : t("settings.ntfyTokenRemove")}
</Button>
</Stack>
<TextField
label={t("settings.ntfyPriority")}
select
SelectProps={{ native: true }}
{...register("ntfyPriority")}
>
<option value="default">default</option>
<option value="min">min</option>
<option value="low">low</option>
<option value="high">high</option>
<option value="urgent">urgent</option>
</TextField>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="outlined"
onClick={() => ntfyTestMutation.mutate()}
disabled={ntfyTestMutation.isPending || !serverConfig?.ntfyConfigured}
>
{t("settings.ntfyTest")}
</Button>
{ntfyTestMutation.isPending && <CircularProgress size={24} />}
</Stack>
{ntfyTestMutation.isError && (
<Alert severity="error">{(ntfyTestMutation.error as Error).message ?? t("settings.mailTestError")}</Alert>
)}
</Stack>
)}
</CardContent>
</Card>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button
type="submit"
variant="contained"
disabled={isSubmitting || updateMutation.isPending}
>
{updateMutation.isPending ? t("actions.saving") : t("settings.save")}
</Button>
</Stack>
</Stack>
</Box>
</>
);
}