initial
This commit is contained in:
147
frontend/src/routes/CalendarView.tsx
Normal file
147
frontend/src/routes/CalendarView.tsx
Normal 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);
|
||||
}
|
||||
189
frontend/src/routes/ContractDetail.tsx
Normal file
189
frontend/src/routes/ContractDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
401
frontend/src/routes/ContractForm.tsx
Normal file
401
frontend/src/routes/ContractForm.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
219
frontend/src/routes/ContractsList.tsx
Normal file
219
frontend/src/routes/ContractsList.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
197
frontend/src/routes/Dashboard.tsx
Normal file
197
frontend/src/routes/Dashboard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
frontend/src/routes/Login.tsx
Normal file
131
frontend/src/routes/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
747
frontend/src/routes/Settings.tsx
Normal file
747
frontend/src/routes/Settings.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user