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

19
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contracts Companion</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,24 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /api/ {
proxy_pass http://contract-companion:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
location / {
try_files $uri /index.html;
}
}

35
frontend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "paperless-contract-companion-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.15.15",
"@mui/material": "^5.15.15",
"@tanstack/react-query": "^5.29.2",
"date-fns": "^3.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.50.1",
"react-router-dom": "^6.22.3",
"recharts": "^2.8.0",
"zod": "^3.22.4",
"i18next": "^23.10.1",
"react-i18next": "^13.5.0"
},
"devDependencies": {
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.3.3",
"vite": "^5.1.6"
}
}

59
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { Box, CircularProgress } from "@mui/material";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import Layout from "./components/Layout";
import { useAuth } from "./contexts/AuthContext";
import CalendarView from "./routes/CalendarView";
import ContractDetail from "./routes/ContractDetail";
import ContractForm from "./routes/ContractForm";
import ContractsList from "./routes/ContractsList";
import Dashboard from "./routes/Dashboard";
import LoginPage from "./routes/Login";
import SettingsPage from "./routes/Settings";
function ProtectedRoute({ children }: { children: JSX.Element }) {
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="contracts" element={<ContractsList />} />
<Route path="contracts/new" element={<ContractForm mode="create" />} />
<Route path="contracts/:contractId" element={<ContractDetail />} />
<Route path="contracts/:contractId/edit" element={<ContractForm mode="edit" />} />
<Route path="calendar" element={<CalendarView />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
);
}

36
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,36 @@
import { clearAuthToken, request, setAuthToken } from "./client";
export interface LoginResponse {
token: string;
expiresAt: string;
}
export interface AuthStatus {
enabled: boolean;
}
export async function login(username: string, password: string): Promise<LoginResponse> {
const result = await request<LoginResponse>("/auth/login", {
method: "POST",
body: { username, password },
skipAuth: true
});
setAuthToken(result.token);
return result;
}
export function signOut() {
clearAuthToken();
}
export function setToken(token: string | null) {
if (token) {
setAuthToken(token);
} else {
clearAuthToken();
}
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
return request<AuthStatus>("/auth/status", { method: "GET", skipAuth: true });
}

View File

@@ -0,0 +1,74 @@
function resolveDefaultBaseUrl(): string {
if (typeof window !== "undefined") {
return "/api";
}
return "http://localhost:8000";
}
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? resolveDefaultBaseUrl()).replace(/\/$/, "");
let authToken: string | null = null;
export function setAuthToken(token: string | null) {
authToken = token;
}
export interface ApiError extends Error {
status?: number;
details?: unknown;
}
type ApiRequestInit = Omit<RequestInit, "body"> & {
body?: unknown;
skipAuth?: boolean;
};
export async function request<T = unknown>(path: string, options: ApiRequestInit = {}): Promise<T> {
const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
const { skipAuth, body, headers, ...rest } = options;
const requestHeaders = new Headers(headers ?? {});
requestHeaders.set("Accept", "application/json");
const hasBody = body !== undefined && body !== null;
const rawBody = body as any;
const isFormData = hasBody && typeof FormData !== "undefined" && rawBody instanceof FormData;
if (hasBody && !isFormData) {
requestHeaders.set("Content-Type", "application/json");
}
if (!skipAuth && authToken) {
requestHeaders.set("Authorization", `Bearer ${authToken}`);
}
let requestBody: BodyInit | null | undefined = undefined;
if (hasBody) {
requestBody = isFormData ? (rawBody as FormData) : JSON.stringify(rawBody);
}
const response = await fetch(url, {
...rest,
headers: requestHeaders,
body: requestBody
});
const contentType = response.headers.get("content-type");
const isJson = contentType?.includes("application/json");
const payload = isJson ? await response.json().catch(() => undefined) : await response.text();
if (!response.ok) {
const error: ApiError = new Error(
typeof payload === "string" && payload ? payload : "Request failed"
);
error.status = response.status;
error.details = payload;
throw error;
}
return payload as T;
}
export function clearAuthToken() {
authToken = null;
}

View File

@@ -0,0 +1,91 @@
import { request } from "./client";
export interface ServerConfig {
port: number;
logLevel: string;
databasePath: string;
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
paperlessConfigured: boolean;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
mailConfigured: boolean;
mailServer: string | null;
mailFrom: string | null;
mailUseTls: boolean;
ntfyConfigured: boolean;
authEnabled: boolean;
authTokenExpiresInHours: number;
}
export async function fetchServerConfig(): Promise<ServerConfig> {
return request<ServerConfig>("/config", { method: "GET" });
}
export interface SettingsResponse {
values: {
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
mailServer: string | null;
mailPort: number | null;
mailUsername: string | null;
mailFrom: string | null;
mailTo: string | null;
mailUseTls: boolean;
ntfyServerUrl: string | null;
ntfyTopic: string | null;
ntfyPriority: string | null;
authUsername: string | null;
};
secrets: {
paperlessTokenSet: boolean;
mailPasswordSet: boolean;
ntfyTokenSet: boolean;
authPasswordSet: boolean;
};
icalSecret: string | null;
}
export type UpdateSettingsPayload = Partial<{
paperlessBaseUrl: string | null;
paperlessExternalUrl: string | null;
paperlessToken: string | null;
schedulerIntervalMinutes: number;
alertDaysBefore: number;
mailServer: string | null;
mailPort: number | null;
mailUsername: string | null;
mailPassword: string | null;
mailUseTls: boolean;
mailFrom: string | null;
mailTo: string | null;
ntfyServerUrl: string | null;
ntfyTopic: string | null;
ntfyToken: string | null;
ntfyPriority: string | null;
authUsername: string | null;
authPassword: string | null;
icalSecret: string | null;
}>;
export async function fetchSettings(): Promise<SettingsResponse> {
return request<SettingsResponse>("/settings", { method: "GET" });
}
export async function updateSettings(payload: UpdateSettingsPayload): Promise<SettingsResponse> {
return request<SettingsResponse>("/settings", { method: "PUT", body: payload });
}
export async function resetIcalSecret(): Promise<{ icalSecret: string | null }> {
return request<{ icalSecret: string | null }>("/settings/ical-secret/reset", { method: "POST" });
}
export async function triggerMailTest(): Promise<void> {
await request("/settings/test/mail", { method: "POST" });
}
export async function triggerNtfyTest(): Promise<void> {
await request("/settings/test/ntfy", { method: "POST" });
}

View File

@@ -0,0 +1,88 @@
import { request } from "./client";
import {
Contract,
ContractPayload,
PaperlessDocument,
PaperlessSearchResponse,
UpcomingDeadline
} from "../types";
export interface ContractsQuery {
skip?: number;
limit?: number;
}
export async function fetchContracts(query: ContractsQuery = {}): Promise<Contract[]> {
const params = new URLSearchParams();
if (query.skip !== undefined) params.set("skip", String(query.skip));
if (query.limit !== undefined) params.set("limit", String(query.limit));
const suffix = params.toString() ? `?${params.toString()}` : "";
const result = await request<unknown>(`/contracts${suffix}`, { method: "GET" });
if (!Array.isArray(result)) {
throw new Error("Unexpected response format for contracts");
}
return result as Contract[];
}
export async function fetchContract(contractId: number): Promise<Contract> {
const result = await request<unknown>(`/contracts/${contractId}`, { method: "GET" });
if (!result || typeof result !== "object") {
throw new Error("Unexpected response format for contract");
}
return result as Contract;
}
export async function createContract(payload: ContractPayload): Promise<Contract> {
return request<Contract>("/contracts", {
method: "POST",
body: payload
});
}
export async function updateContract(
contractId: number,
payload: Partial<ContractPayload>
): Promise<Contract> {
return request<Contract>(`/contracts/${contractId}`, {
method: "PUT",
body: payload
});
}
export async function removeContract(contractId: number): Promise<void> {
await request(`/contracts/${contractId}`, { method: "DELETE" });
}
export async function fetchUpcomingDeadlines(days?: number): Promise<UpcomingDeadline[]> {
const params = new URLSearchParams();
if (days !== undefined) params.set("days", String(days));
const suffix = params.toString() ? `?${params.toString()}` : "";
const result = await request<unknown>(`/reports/upcoming${suffix}`, { method: "GET" });
if (!Array.isArray(result)) {
throw new Error("Unexpected response format for deadlines");
}
return result as UpcomingDeadline[];
}
export async function fetchPaperlessDocument(
contractId: number
): Promise<PaperlessDocument | null> {
try {
return await request<PaperlessDocument | null>(`/contracts/${contractId}/paperless-document`, {
method: "GET"
});
} catch (error) {
if ((error as { status?: number }).status === 404) {
return null;
}
throw error;
}
}
export async function searchPaperlessDocuments(query: string, page = 1): Promise<PaperlessSearchResponse> {
const params = new URLSearchParams({ q: query, page: String(page) });
return request<PaperlessSearchResponse>(`/integrations/paperless/search?${params.toString()}`, {
method: "GET"
});
}

View File

@@ -0,0 +1,85 @@
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { Box, Card, CardContent, Chip, List, ListItem, ListItemButton, ListItemText, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UpcomingDeadline } from "../types";
import { formatDeadlineDate } from "../utils/date";
interface Props {
deadlines: UpcomingDeadline[];
}
export default function DeadlineList({ deadlines }: Props) {
const navigate = useNavigate();
const { t } = useTranslation();
if (deadlines.length === 0) {
return (
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="subtitle1">{t("deadlineList.none")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("deadlineList.info")}
</Typography>
</CardContent>
</Card>
);
}
return (
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="subtitle1" gutterBottom>
{t("dashboard.upcomingList")}
</Typography>
<List>
{deadlines.map((deadline) => (
<ListItem
key={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={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="body1" fontWeight={600}>
{deadline.title}
</Typography>
<ArrowForwardIcon fontSize="small" color="primary" />
</Box>
}
secondary={
<Typography variant="body2" color="text.secondary">
{t("deadlineList.terminateBy", { date: formatDeadlineDate(deadline.terminationDeadline) })}
{deadline.contractEndDate
? `${t("deadlineList.contractEnds", {
date: formatDeadlineDate(deadline.contractEndDate)
})}`
: ""}
</Typography>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,164 @@
import MenuIcon from "@mui/icons-material/Menu";
import LogoutIcon from "@mui/icons-material/Logout";
import CalendarMonthIcon from "@mui/icons-material/CalendarMonth";
import DashboardIcon from "@mui/icons-material/Dashboard";
import DescriptionIcon from "@mui/icons-material/Description";
import SettingsIcon from "@mui/icons-material/Settings";
import {
AppBar,
Box,
Divider,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
MenuItem,
Select,
Toolbar,
Typography,
useMediaQuery,
useTheme
} from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { useSnackbar } from "../hooks/useSnackbar";
const drawerWidth = 240;
const navItems = [
{ key: "nav.dashboard", icon: <DashboardIcon />, path: "/dashboard" },
{ key: "nav.contracts", icon: <DescriptionIcon />, path: "/contracts" },
{ key: "nav.calendar", icon: <CalendarMonthIcon />, path: "/calendar" },
{ key: "nav.settings", icon: <SettingsIcon />, path: "/settings" }
];
export default function Layout() {
const [mobileOpen, setMobileOpen] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuth();
const { showMessage } = useSnackbar();
const { t, i18n } = useTranslation();
const handleDrawerToggle = () => {
setMobileOpen((prev) => !prev);
};
const handleNavigate = (path: string) => {
navigate(path);
if (isMobile) {
setMobileOpen(false);
}
};
const drawer = (
<div>
<Toolbar>
<Typography variant="h6" noWrap component="div">
Contracts Companion
</Typography>
</Toolbar>
<Divider />
<List>
{navItems.map((item) => (
<ListItem key={item.path} disablePadding>
<ListItemButton
selected={location.pathname.startsWith(item.path)}
onClick={() => handleNavigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={t(item.key)} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<List>
<ListItem disablePadding>
<ListItemButton
onClick={() => {
logout();
showMessage(t("messages.signedOut"), "info");
}}
>
<ListItemIcon>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary={t("nav.logout") } />
</ListItemButton>
</ListItem>
</List>
</div>
);
return (
<Box sx={{ display: "flex" }}>
<AppBar position="fixed" sx={{ zIndex: theme.zIndex.drawer + 1, backdropFilter: "blur(10px)" }}>
<Toolbar>
{isMobile && (
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
)}
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{t("layout.title")}
</Typography>
<Select
size="small"
value={i18n.language.startsWith("de") ? "de" : "en"}
onChange={(event) => i18n.changeLanguage(event.target.value)}
sx={{ color: "inherit", borderColor: "inherit", minWidth: 80 }}
>
<MenuItem value="de">DE</MenuItem>
<MenuItem value="en">EN</MenuItem>
</Select>
</Toolbar>
</AppBar>
<Box component="nav" sx={{ width: { md: drawerWidth }, flexShrink: { md: 0 } }} aria-label="navigation">
<Drawer
variant={isMobile ? "temporary" : "permanent"}
open={isMobile ? mobileOpen : true}
onClose={handleDrawerToggle}
ModalProps={{ keepMounted: true }}
sx={{
"& .MuiDrawer-paper": {
boxSizing: "border-box",
width: drawerWidth
}
}}
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { md: `calc(100% - ${drawerWidth}px)` },
minHeight: "100vh",
backgroundColor: (theme) => theme.palette.background.default
}}
>
<Toolbar />
<Outlet />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,26 @@
import { Box, Typography } from "@mui/material";
import { ReactNode } from "react";
interface Props {
title: string;
subtitle?: string;
action?: ReactNode;
}
export default function PageHeader({ title, subtitle, action }: Props) {
return (
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3} flexWrap="wrap" gap={2}>
<Box>
<Typography variant="h4" fontWeight={600}>
{title}
</Typography>
{subtitle && (
<Typography variant="subtitle1" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
{action}
</Box>
);
}

View File

@@ -0,0 +1,134 @@
import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
InputAdornment,
List,
ListItem,
ListItemButton,
ListItemText,
Stack,
TextField,
Typography
} from "@mui/material";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { searchPaperlessDocuments } from "../api/contracts";
import { useSnackbar } from "../hooks/useSnackbar";
import { PaperlessDocument, PaperlessSearchResponse } from "../types";
interface Props {
open: boolean;
onClose: () => void;
onSelect: (document: PaperlessDocument) => void;
}
export default function PaperlessSearchDialog({ open, onClose, onSelect }: Props) {
const { showMessage } = useSnackbar();
const [query, setQuery] = useState("");
const [results, setResults] = useState<PaperlessSearchResponse | null>(null);
const { t } = useTranslation();
const searchMutation = useMutation({
mutationFn: (term: string) => searchPaperlessDocuments(term),
onSuccess: (data) => setResults(data),
onError: (error: Error) => showMessage(error.message ?? t("paperlessDialog.error"), "error")
});
useEffect(() => {
if (!open) {
setQuery("");
setResults(null);
}
}, [open]);
const handleSearch = () => {
if (query.trim().length < 2) {
showMessage(t("paperlessDialog.minChars"), "warning");
return;
}
searchMutation.mutate(query.trim());
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>
{t("paperlessDialog.title")}
<IconButton
aria-label="close"
onClick={onClose}
sx={{ position: "absolute", right: 12, top: 12 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<TextField
label={t("contracts.searchLabel")}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleSearch();
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={handleSearch} edge="end">
<SearchIcon />
</IconButton>
</InputAdornment>
)
}}
placeholder={t("paperlessDialog.searchPlaceholder")}
fullWidth
autoFocus
/>
{searchMutation.isPending && <Typography>{t("paperlessDialog.searching")}</Typography>}
{results && results.results.length === 0 && !searchMutation.isPending && (
<Typography color="text.secondary">{t("paperlessDialog.noResults")}</Typography>
)}
{results && results.results.length > 0 && (
<List>
{results.results.map((doc) => (
<ListItem key={doc.id} disablePadding>
<ListItemButton
onClick={() => {
onSelect(doc);
onClose();
}}
>
<ListItemText
primary={doc.title ?? `Dokument #${doc.id}`}
secondary={
<Box component="span" sx={{ display: "block", color: "text.secondary" }}>
{(doc as Record<string, unknown>).correspondent
? `${t("paperlessDialog.correspondent")}: ${(doc as Record<string, unknown>).correspondent}`
: ""}
</Box>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("paperlessDialog.cancel")}</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,31 @@
import TrendingDownIcon from "@mui/icons-material/TrendingDown";
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
import { Box, Card, CardContent, Typography } from "@mui/material";
interface Props {
title: string;
value: string | number;
trend?: "up" | "down";
trendLabel?: string;
}
export default function StatCard({ title, value, trend, trendLabel }: Props) {
return (
<Card elevation={0} sx={{ borderRadius: 3, background: "linear-gradient(135deg,#ffffff,#f0f4ff)" }}>
<CardContent>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
{title}
</Typography>
<Typography variant="h4" fontWeight={600}>
{value}
</Typography>
{trend && trendLabel && (
<Box display="flex" alignItems="center" gap={0.5} mt={1} color={trend === "up" ? "success.main" : "error.main"}>
{trend === "up" ? <TrendingUpIcon fontSize="small" /> : <TrendingDownIcon fontSize="small" />}
<Typography variant="body2">{trendLabel}</Typography>
</Box>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,108 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { fetchAuthStatus, login as apiLogin, setToken as setClientToken, signOut } from "../api/auth";
interface AuthContextValue {
token: string | null;
expiresAt: string | null;
authEnabled: boolean;
isAuthenticated: boolean;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const TOKEN_KEY = "contract-companion.token";
const EXPIRY_KEY = "contract-companion.tokenExpiry";
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setTokenState] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [authEnabled, setAuthEnabled] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
async function init() {
try {
const status = await fetchAuthStatus();
setAuthEnabled(status.enabled);
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedExpiry = localStorage.getItem(EXPIRY_KEY);
if (!status.enabled) {
setTokenState(null);
setExpiresAt(null);
setClientToken(null);
} else if (storedToken && storedExpiry && new Date(storedExpiry) > new Date()) {
setTokenState(storedToken);
setExpiresAt(storedExpiry);
setClientToken(storedToken);
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXPIRY_KEY);
setClientToken(null);
}
} catch {
// assume auth is required if status endpoint fails
setAuthEnabled(true);
} finally {
setLoading(false);
}
}
init();
}, []);
const setToken = useCallback((newToken: string | null, expiry: string | null) => {
setTokenState(newToken);
setExpiresAt(expiry);
setClientToken(newToken);
if (newToken && expiry) {
localStorage.setItem(TOKEN_KEY, newToken);
localStorage.setItem(EXPIRY_KEY, expiry);
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXPIRY_KEY);
}
}, []);
const login = useCallback(
async (username: string, password: string) => {
const result = await apiLogin(username, password);
setToken(result.token, result.expiresAt);
},
[setToken]
);
const logout = useCallback(() => {
signOut();
setToken(null, null);
}, [setToken]);
const value = useMemo<AuthContextValue>(
() => ({
token,
expiresAt,
authEnabled,
isAuthenticated: !authEnabled || Boolean(token),
loading,
login,
logout
}),
[authEnabled, loading, login, logout, token, expiresAt]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,54 @@
import { Alert, AlertColor, Snackbar } from "@mui/material";
import { createContext, useContext, useMemo, useState } from "react";
interface SnackbarState {
open: boolean;
message: string;
severity: AlertColor;
}
interface SnackbarContextValue {
showMessage: (message: string, severity?: AlertColor) => void;
}
const SnackbarContext = createContext<SnackbarContextValue | undefined>(undefined);
export function SnackbarProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<SnackbarState>({
open: false,
message: "",
severity: "info"
});
const showMessage = (message: string, severity: AlertColor = "info") => {
setState({ open: true, message, severity });
};
const handleClose = () => setState((prev) => ({ ...prev, open: false }));
const value = useMemo(() => ({ showMessage }), []);
return (
<SnackbarContext.Provider value={value}>
{children}
<Snackbar
open={state.open}
autoHideDuration={4000}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={handleClose} severity={state.severity} variant="filled" sx={{ width: "100%" }}>
{state.message}
</Alert>
</Snackbar>
</SnackbarContext.Provider>
);
}
export function useSnackbar(): SnackbarContextValue {
const context = useContext(SnackbarContext);
if (!context) {
throw new Error("useSnackbar must be used within SnackbarProvider");
}
return context;
}

68
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,68 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en/common.json";
import de from "./locales/de/common.json";
const LANGUAGE_STORAGE_KEY = "contract-companion.language";
const SUPPORTED_LANGUAGES = ["en", "de"] as const;
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
function normalizeLanguage(value?: string | null): SupportedLanguage {
if (!value) return "en";
const lower = value.toLowerCase();
const match = SUPPORTED_LANGUAGES.find((lang) => lower.startsWith(lang));
return match ?? "en";
}
function detectInitialLanguage(): SupportedLanguage {
if (typeof window === "undefined") {
return "en";
}
try {
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (stored) {
return normalizeLanguage(stored);
}
} catch {
// Access to localStorage might be blocked (e.g., privacy mode); ignore and fall back to navigator.
}
return normalizeLanguage(window.navigator.language);
}
const initialLanguage = detectInitialLanguage();
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
de: { translation: de }
},
lng: initialLanguage,
fallbackLng: "en",
supportedLngs: ["en", "de"],
interpolation: {
escapeValue: false
}
});
if (typeof window !== "undefined") {
const applyLanguageSideEffects = (lang: string) => {
const normalized = normalizeLanguage(lang);
try {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, normalized);
} catch {
// Ignore storage write failures (private mode, etc.)
}
if (typeof document !== "undefined") {
document.documentElement.setAttribute("lang", normalized);
}
};
applyLanguageSideEffects(initialLanguage);
i18n.on("languageChanged", applyLanguageSideEffects);
}
export default i18n;

View File

@@ -0,0 +1,250 @@
{
"nav": {
"dashboard": "Dashboard",
"contracts": "Verträge",
"calendar": "Kalender",
"settings": "Einstellungen",
"logout": "Abmelden"
},
"layout": {
"title": "Vertragsübersicht",
"language": "Sprache"
},
"actions": {
"save": "Speichern",
"saving": "Speichern…",
"cancel": "Abbrechen",
"delete": "Löschen",
"search": "Suchen",
"test": "Test senden",
"testMail": "Testmail senden",
"testNtfy": "Test senden",
"copy": "In Zwischenablage kopiert",
"copyUnsupported": "Kopieren wird nicht unterstützt",
"copyFailed": "Kopieren fehlgeschlagen"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Alle wichtigen Vertragskennzahlen auf einen Blick.",
"totalContracts": "Gesamtverträge",
"activeContracts": "Aktive Verträge",
"monthlySpend": "Monatliche Kosten (Summe)",
"deadlineChartTitle": "Kündigungsfristen pro Monat",
"noDeadlines": "Derzeit stehen keine Deadlines an.",
"upcomingList": "Nächste Kündigungsfristen",
"loading": "Lade Daten…",
"contractsError": "Verträge konnten nicht geladen werden.",
"deadlinesError": "Deadlines konnten nicht geladen werden.",
"totalContractsTrend": "+100% erfasst",
"monthlySpendTrend": "Budget im Blick"
},
"deadlineList": {
"none": "Keine anstehenden Deadlines",
"info": "Du bist auf Stand es stehen aktuell keine Kündigungsfristen an.",
"daysLabel_one": "{{count}} Tag",
"daysLabel_other": "{{count}} Tage",
"terminateBy": "Kündigen bis spätestens {{date}}",
"contractEnds": "Vertrag bis {{date}}"
},
"calendar": {
"title": "Kalender & Deadlines",
"subtitle": "Plane Kündigungsfristen langfristig und behalte Verlängerungen im Blick.",
"loading": "Lade Deadlines…",
"none": "Keine Deadlines innerhalb der nächsten 12 Monate.",
"deadlineCount_one": "{{count}} Deadline",
"deadlineCount_other": "{{count}} Deadlines",
"unknownMonth": "Unbekannt"
},
"contracts": {
"title": "Verträge",
"subtitle": "Alle laufenden und vergangenen Verträge im Überblick.",
"new": "Neuer Vertrag",
"searchPlaceholder": "z.B. Anbieter, Titel, Schlagwort",
"searchLabel": "Suche",
"filterAll": "Alle Kategorien",
"columns": {
"title": "Titel",
"provider": "Anbieter",
"category": "Kategorie",
"price": "Preis",
"end": "Ende",
"tags": "Tags",
"actions": "Aktionen"
},
"loading": "Lade Verträge…",
"empty": "Keine Verträge gefunden.",
"deleteConfirm": "Vertrag \"{{title}}\" wirklich löschen?",
"details": "Details anzeigen",
"edit": "Bearbeiten",
"deleted": "Vertrag gelöscht",
"deleteError": "Löschen fehlgeschlagen"
},
"contractForm": {
"createTitle": "Neuen Vertrag anlegen",
"createSubtitle": "Erfasse alle wichtigen Vertragsdetails.",
"editSubtitle": "Bearbeite die Daten von \"{{title}}\".",
"fields": {
"title": "Titel",
"provider": "Anbieter",
"category": "Kategorie",
"paperlessId": "Paperless Dokument-ID",
"contractStart": "Vertragsbeginn",
"contractEnd": "Vertragsende",
"terminationNotice": "Kündigungsfrist (Tage)",
"renewalPeriod": "Verlängerung (Monate)",
"autoRenew": "Automatische Verlängerung",
"price": "Preis",
"currency": "Währung",
"notes": "Notizen",
"tags": "Tags (durch Komma getrennt)",
"searchButton": "Suchen"
},
"tagsPlaceholder": "z.B. strom, wohnung",
"paperlessNotConfigured": "Paperless ist nicht konfiguriert. Hinterlege die API-URL und das Token in den Einstellungen.",
"paperlessLinked": "Verknüpft mit: {{title}}",
"saving": "Speichere…",
"save": "Speichern",
"saved": "Vertrag \"{{title}}\" gespeichert",
"loadError": "Ungültige Vertrags-ID",
"loading": "Lade Vertrag…",
"saveError": "Speichern fehlgeschlagen",
"invalidNumber": "Bitte eine gültige Zahl eingeben"
},
"contractDetail": {
"edit": "Vertrag bearbeiten",
"details": "Vertragsdetails",
"start": "Vertragsbeginn",
"end": "Vertragsende",
"notice": "Kündigungsfrist",
"renewal": "Verlängerung",
"price": "Preis",
"category": "Kategorie",
"notes": "Notizen",
"tags": "Tags",
"noTags": "Keine Tags vergeben.",
"noNotes": "Keine Notizen",
"document": "Verknüpftes Dokument",
"documentFallback": "Dokument",
"documentError": "Paperless-Dokument konnte nicht geladen werden: {{error}}",
"documentMissing": "Kein Dokument verknüpft oder Dokument nicht gefunden.",
"openInPaperless": "In paperless öffnen",
"configurePaperless": "Direkt-Öffnen konfigurieren, indem du die Paperless-URL in den Einstellungen hinterlegst.",
"metadata": "Metadaten",
"id": "ID",
"created": "Erstellt",
"updated": "Aktualisiert",
"monthsLabel_one": "{{count}} Monat",
"monthsLabel_other": "{{count}} Monate"
},
"settings": {
"title": "Einstellungen",
"subtitle": "Systemstatus, Benachrichtigungen und Integrationen verwalten.",
"systemStatus": "Systemstatus",
"apiStatus": "API-Erreichbarkeit",
"authStatus": "Authentifizierung",
"paperlessStatus": "Paperless-Integration",
"mailStatus": "Mail-Benachrichtigungen",
"ntfyStatus": "ntfy Push",
"apiOk": "API antwortet korrekt",
"apiError": "Keine Antwort erhalten",
"authActive": "Login per Token ist aktiv.",
"authInactive": "Kein Login erforderlich bitte nur im geschützten Netz betreiben.",
"paperlessActive": "Aktiv ({{url}})",
"paperlessInactive": "Nicht konfiguriert",
"mailActive": "Aktiv",
"mailInactive": "Nicht konfiguriert",
"ntfyActive": "Aktiv",
"ntfyInactive": "Nicht konfiguriert",
"auth": "Authentifizierung",
"paperless": "Paperless",
"scheduler": "Fristen & Benachrichtigungen",
"mail": "E-Mail",
"ntfy": "ntfy Push",
"ical": "iCal-Abo",
"icalFeedUrl": "Feed-URL",
"paperlessApiUrl": "Paperless API URL",
"paperlessExternalUrl": "Paperless externe URL (für Direktlink)",
"paperlessExample": "https://paperless.example.com",
"paperlessToken": "Paperless Token",
"paperlessTokenNew": "Neuen Token hinterlegen",
"paperlessTokenPlaceholder": "Token eingeben",
"paperlessTokenKeep": "Behalten",
"paperlessTokenRemove": "Token löschen",
"paperlessTokenInfo": "Ein Token ist hinterlegt. Lasse das Feld leer, um ihn unverändert zu lassen.",
"paperlessSearch": "Suchen",
"paperlessNotConfigured": "Paperless ist nicht konfiguriert. Hinterlege URL & Token in den Einstellungen.",
"paperlessLinked": "Verknüpft mit: {{title}}",
"interval": "Prüfintervall (Minuten)",
"alert": "Warnung vor Deadline (Tage)",
"smtpServer": "SMTP-Server",
"smtpPort": "Port",
"smtpUsername": "Benutzername",
"smtpPassword": "Passwort",
"smtpPasswordNew": "Neues Passwort",
"smtpPasswordInfo": "Ein Passwort ist hinterlegt. Lasse das Feld leer, um es unverändert zu lassen.",
"smtpPasswordRemove": "Passwort löschen",
"tls": "TLS verwenden",
"mailFrom": "Absender",
"mailTo": "Empfänger",
"ntfyServer": "Server URL",
"ntfyTopic": "Topic",
"ntfyToken": "Token",
"ntfyTokenNew": "Neuer Token",
"ntfyTokenRemove": "Token löschen",
"ntfyPriority": "Priorität",
"authUsername": "Benutzername",
"authPassword": "Passwort",
"authPasswordInfo": "Ein Passwort ist hinterlegt. Lasse das Feld leer, um es unverändert zu lassen.",
"authPasswordRemove": "Passwort löschen",
"authHint": "Lässt du Benutzername oder Passwort leer, wird der Login deaktiviert.",
"authPasswordPlaceholder": "Neues Passwort setzen",
"icalDescription": "Abonniere die Kündigungsfristen in deinem Kalender. Bewahre das Token vertraulich auf.",
"icalTokenMissing": "Kein iCal-Token vorhanden.",
"icalRenew": "Token erneuern",
"icalTokenRenewed": "iCal-Token erneuert",
"copy": "In Zwischenablage kopiert",
"copyUnsupported": "Kopieren wird nicht unterstützt",
"saving": "Speichern…",
"save": "Änderungen speichern",
"noChanges": "Keine Änderungen erkannt",
"saved": "Einstellungen gespeichert",
"mailTest": "Testmail senden",
"ntfyTest": "Test senden",
"mailTestSuccess": "Testmail versendet",
"ntfyTestSuccess": "ntfy-Test gesendet",
"mailTestError": "Test fehlgeschlagen",
"ntfyTestError": "ntfy-Test fehlgeschlagen",
"mailConfigMissing": "E-Mail-Konfiguration unvollständig",
"ntfyConfigMissing": "ntfy-Konfiguration unvollständig",
"actionFailed": "Aktion fehlgeschlagen"
},
"paperlessDialog": {
"title": "Paperless-Dokument verknüpfen",
"searchPlaceholder": "Titel, Notizen, Tags…",
"minChars": "Bitte mindestens zwei Zeichen eingeben",
"searching": "Suche läuft…",
"noResults": "Keine Treffer gefunden.",
"error": "Suche fehlgeschlagen",
"correspondent": "Korrespondent",
"cancel": "Abbrechen"
},
"login": {
"title": "Anmeldung",
"welcome": "Willkommen zurück! Bitte melde dich an.",
"username": "Benutzername",
"password": "Passwort",
"submit": "Anmelden",
"checking": "Wird geprüft…",
"disabled": "Hinweis: Die API ist momentan ohne Login erreichbar. Du wirst automatisch weitergeleitet.",
"usernameRequired": "Benutzername erforderlich",
"passwordRequired": "Passwort erforderlich",
"error": "Login fehlgeschlagen"
},
"messages": {
"loading": "Lade Daten…",
"deleteConfirm": "Vertrag \"{{title}}\" wirklich löschen?",
"deleteSuccess": "Vertrag gelöscht",
"deleteError": "Löschen fehlgeschlagen",
"signedOut": "Abgemeldet"
}
}

View File

@@ -0,0 +1,250 @@
{
"nav": {
"dashboard": "Dashboard",
"contracts": "Contracts",
"calendar": "Calendar",
"settings": "Settings",
"logout": "Sign out"
},
"layout": {
"title": "Contracts Overview",
"language": "Language"
},
"actions": {
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel",
"delete": "Delete",
"search": "Search",
"test": "Send test",
"testMail": "Send test mail",
"testNtfy": "Send test",
"copy": "Copied to clipboard",
"copyUnsupported": "Copying not supported",
"copyFailed": "Copy failed"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Keep an eye on all key contract metrics.",
"totalContracts": "Total contracts",
"activeContracts": "Active contracts",
"monthlySpend": "Monthly spend (total)",
"deadlineChartTitle": "Termination deadlines per month",
"noDeadlines": "No upcoming deadlines.",
"upcomingList": "Next termination deadlines",
"loading": "Loading data…",
"contractsError": "Unable to load contracts.",
"deadlinesError": "Unable to load deadlines.",
"totalContractsTrend": "+100% captured",
"monthlySpendTrend": "Budget under control"
},
"deadlineList": {
"none": "No upcoming deadlines",
"info": "You're all set there are no termination deadlines currently due.",
"daysLabel_one": "{{count}} day",
"daysLabel_other": "{{count}} days",
"terminateBy": "Cancel by {{date}}",
"contractEnds": "Contract ends {{date}}"
},
"calendar": {
"title": "Calendar & Deadlines",
"subtitle": "Plan cancellations ahead and stay on top of renewals.",
"loading": "Loading deadlines…",
"none": "No deadlines within the next 12 months.",
"deadlineCount_one": "{{count}} deadline",
"deadlineCount_other": "{{count}} deadlines",
"unknownMonth": "Unknown"
},
"contracts": {
"title": "Contracts",
"subtitle": "Overview of all current and past contracts.",
"new": "New contract",
"searchPlaceholder": "e.g. provider, title, keyword",
"searchLabel": "Search",
"filterAll": "All categories",
"columns": {
"title": "Title",
"provider": "Provider",
"category": "Category",
"price": "Price",
"end": "End date",
"tags": "Tags",
"actions": "Actions"
},
"loading": "Loading contracts…",
"empty": "No contracts found.",
"deleteConfirm": "Do you really want to delete \"{{title}}\"?",
"details": "Show details",
"edit": "Edit",
"deleted": "Contract deleted",
"deleteError": "Failed to delete"
},
"contractForm": {
"createTitle": "Create contract",
"createSubtitle": "Capture all important contract details.",
"editSubtitle": "Edit \"{{title}}\".",
"fields": {
"title": "Title",
"provider": "Provider",
"category": "Category",
"paperlessId": "Paperless document ID",
"contractStart": "Contract start",
"contractEnd": "Contract end",
"terminationNotice": "Termination notice (days)",
"renewalPeriod": "Renewal period (months)",
"autoRenew": "Auto renewal",
"price": "Price",
"currency": "Currency",
"notes": "Notes",
"tags": "Tags (comma-separated)",
"searchButton": "Search"
},
"tagsPlaceholder": "e.g. energy, apartment",
"paperlessNotConfigured": "Paperless is not configured. Provide the API URL and token in settings.",
"paperlessLinked": "Linked to: {{title}}",
"saving": "Saving…",
"save": "Save",
"saved": "Contract \"{{title}}\" saved",
"loadError": "Invalid contract ID",
"loading": "Loading contract…",
"saveError": "Failed to save",
"invalidNumber": "Please enter a valid number"
},
"contractDetail": {
"edit": "Edit contract",
"details": "Contract details",
"start": "Contract start",
"end": "Contract end",
"notice": "Termination notice",
"renewal": "Renewal",
"price": "Price",
"category": "Category",
"notes": "Notes",
"tags": "Tags",
"noTags": "No tags assigned.",
"noNotes": "No notes",
"document": "Linked document",
"documentFallback": "Document",
"documentError": "Unable to load paperless document: {{error}}",
"documentMissing": "No document linked or not found.",
"openInPaperless": "Open in paperless",
"configurePaperless": "Configure the paperless URL in settings to enable direct links.",
"metadata": "Metadata",
"id": "ID",
"created": "Created",
"updated": "Updated",
"monthsLabel_one": "{{count}} month",
"monthsLabel_other": "{{count}} months"
},
"settings": {
"title": "Settings",
"subtitle": "Manage system status, notifications, and integrations.",
"systemStatus": "System status",
"apiStatus": "API reachability",
"authStatus": "Authentication",
"paperlessStatus": "Paperless integration",
"mailStatus": "Mail notifications",
"ntfyStatus": "ntfy push",
"apiOk": "API is responding",
"apiError": "No response received",
"authActive": "Token-based login is active.",
"authInactive": "Login not required only use on trusted networks.",
"paperlessActive": "Active ({{url}})",
"paperlessInactive": "Not configured",
"mailActive": "Active",
"mailInactive": "Not configured",
"ntfyActive": "Active",
"ntfyInactive": "Not configured",
"auth": "Authentication",
"paperless": "Paperless",
"scheduler": "Deadlines & notifications",
"mail": "E-mail",
"ntfy": "ntfy push",
"ical": "iCal subscription",
"icalFeedUrl": "Feed URL",
"paperlessApiUrl": "Paperless API URL",
"paperlessExternalUrl": "Paperless external URL (for direct link)",
"paperlessExample": "https://paperless.example.com",
"paperlessToken": "Paperless token",
"paperlessTokenNew": "Provide new token",
"paperlessTokenPlaceholder": "Enter token",
"paperlessTokenKeep": "Keep",
"paperlessTokenRemove": "Remove token",
"paperlessTokenInfo": "A token is stored. Leave empty to keep it.",
"paperlessSearch": "Search",
"paperlessNotConfigured": "Paperless is not configured. Provide URL & token in settings.",
"paperlessLinked": "Linked to: {{title}}",
"interval": "Check interval (minutes)",
"alert": "Warning before deadline (days)",
"smtpServer": "SMTP server",
"smtpPort": "Port",
"smtpUsername": "Username",
"smtpPassword": "Password",
"smtpPasswordNew": "New password",
"smtpPasswordInfo": "A password is stored. Leave empty to keep it.",
"smtpPasswordRemove": "Remove password",
"tls": "Use TLS",
"mailFrom": "Sender",
"mailTo": "Recipient",
"ntfyServer": "Server URL",
"ntfyTopic": "Topic",
"ntfyToken": "Token",
"ntfyTokenNew": "New token",
"ntfyTokenRemove": "Remove token",
"ntfyPriority": "Priority",
"authUsername": "Username",
"authPassword": "Password",
"authPasswordInfo": "A password is stored. Leave empty to keep it.",
"authPasswordRemove": "Remove password",
"authHint": "Leave username or password empty to disable login.",
"authPasswordPlaceholder": "Set new password",
"icalDescription": "Subscribe to deadlines in your calendar. Keep the token secret.",
"icalTokenMissing": "No iCal token available.",
"icalRenew": "Renew token",
"icalTokenRenewed": "iCal token refreshed",
"copy": "Copied to clipboard",
"copyUnsupported": "Copying not supported",
"saving": "Saving…",
"save": "Save changes",
"noChanges": "No changes detected",
"saved": "Settings saved",
"mailTest": "Send test mail",
"ntfyTest": "Send test",
"mailTestSuccess": "Test mail sent",
"ntfyTestSuccess": "ntfy test sent",
"mailTestError": "Test failed",
"ntfyTestError": "ntfy test failed",
"mailConfigMissing": "E-mail configuration incomplete",
"ntfyConfigMissing": "ntfy configuration incomplete",
"actionFailed": "Action failed"
},
"paperlessDialog": {
"title": "Link paperless document",
"searchPlaceholder": "Title, notes, tags…",
"minChars": "Please enter at least two characters",
"searching": "Searching…",
"noResults": "No results found.",
"error": "Search failed",
"correspondent": "Correspondent",
"cancel": "Cancel"
},
"login": {
"title": "Sign in",
"welcome": "Welcome back! Please sign in.",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"checking": "Checking…",
"disabled": "Note: The API is currently accessible without authentication. You will be redirected automatically.",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"error": "Login failed"
},
"messages": {
"loading": "Loading data…",
"deleteConfirm": "Do you really want to delete \"{{title}}\"?",
"deleteSuccess": "Contract deleted",
"deleteError": "Failed to delete",
"signedOut": "Signed out"
}
}

27
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { CssBaseline, ThemeProvider } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { AuthProvider } from "./contexts/AuthContext";
import { SnackbarProvider } from "./hooks/useSnackbar";
import { lightTheme } from "./theme";
import "./i18n";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<SnackbarProvider>
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<App />
</ThemeProvider>
</SnackbarProvider>
</AuthProvider>
</QueryClientProvider>
</React.StrictMode>
);

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

38
frontend/src/theme.ts Normal file
View File

@@ -0,0 +1,38 @@
import { createTheme } from "@mui/material/styles";
export const lightTheme = createTheme({
palette: {
mode: "light",
primary: {
main: "#556cd6"
},
secondary: {
main: "#19857b"
},
background: {
default: "#f4f6fb"
}
},
typography: {
fontFamily: [
"Inter",
"-apple-system",
"BlinkMacSystemFont",
"'Segoe UI'",
"Roboto",
"'Helvetica Neue'",
"Arial",
"sans-serif"
].join(",")
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 10,
textTransform: "none"
}
}
}
}
});

46
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,46 @@
export interface ContractPayload {
title: string;
paperlessDocumentId?: number | null;
provider?: string | null;
category?: string | null;
contractStartDate?: string | null;
contractEndDate?: string | null;
terminationNoticeDays?: number | null;
renewalPeriodMonths?: number | null;
autoRenew?: boolean;
price?: number | null;
currency?: string;
notes?: string | null;
tags?: string[];
}
export interface Contract extends ContractPayload {
id: number;
createdAt: string;
updatedAt: string;
}
export interface UpcomingDeadline {
id: number;
title: string;
provider?: string | null;
paperlessDocumentId?: number | null;
contractEndDate?: string | null;
terminationDeadline?: string | null;
daysUntilDeadline?: number | null;
}
export type PaperlessDocument = Record<string, unknown> & {
id?: number;
title?: string;
created?: string;
modified?: string;
notes?: string;
};
export interface PaperlessSearchResponse {
count: number;
next?: string | null;
previous?: string | null;
results: PaperlessDocument[];
}

View File

@@ -0,0 +1,22 @@
import { format, parseISO } from "date-fns";
import { de } from "date-fns/locale";
export function formatDate(date: string | null | undefined, pattern = "dd.MM.yyyy"): string {
if (!date) return "";
try {
return format(parseISO(date), pattern, { locale: de });
} catch {
return date;
}
}
export function formatDeadlineDate(date: string | null | undefined): string {
return formatDate(date);
}
export function formatCurrency(amount: number | null | undefined, currency = "EUR"): string {
if (amount === null || amount === undefined) {
return "";
}
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(amount);
}

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
secure: false
}
}
},
preview: {
port: 4173
}
});