initial
This commit is contained in:
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
24
frontend/nginx.conf
Normal 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
35
frontend/package.json
Normal 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
59
frontend/src/App.tsx
Normal 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
36
frontend/src/api/auth.ts
Normal 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 });
|
||||
}
|
||||
74
frontend/src/api/client.ts
Normal file
74
frontend/src/api/client.ts
Normal 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;
|
||||
}
|
||||
91
frontend/src/api/config.ts
Normal file
91
frontend/src/api/config.ts
Normal 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" });
|
||||
}
|
||||
88
frontend/src/api/contracts.ts
Normal file
88
frontend/src/api/contracts.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
85
frontend/src/components/DeadlineList.tsx
Normal file
85
frontend/src/components/DeadlineList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/components/Layout.tsx
Normal file
164
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/PageHeader.tsx
Normal file
26
frontend/src/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/PaperlessSearchDialog.tsx
Normal file
134
frontend/src/components/PaperlessSearchDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/StatCard.tsx
Normal file
31
frontend/src/components/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/contexts/AuthContext.tsx
Normal file
108
frontend/src/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
54
frontend/src/hooks/useSnackbar.tsx
Normal file
54
frontend/src/hooks/useSnackbar.tsx
Normal 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
68
frontend/src/i18n.ts
Normal 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;
|
||||
250
frontend/src/locales/de/common.json
Normal file
250
frontend/src/locales/de/common.json
Normal 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"
|
||||
}
|
||||
}
|
||||
250
frontend/src/locales/en/common.json
Normal file
250
frontend/src/locales/en/common.json
Normal 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
27
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
147
frontend/src/routes/CalendarView.tsx
Normal file
147
frontend/src/routes/CalendarView.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import EventIcon from "@mui/icons-material/Event";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { fetchUpcomingDeadlines } from "../api/contracts";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { UpcomingDeadline } from "../types";
|
||||
import { formatDate } from "../utils/date";
|
||||
|
||||
const UNKNOWN_MONTH_KEY = "__unknown__";
|
||||
|
||||
function groupByMonth(deadlines: UpcomingDeadline[], unknownKey: string) {
|
||||
const groups = new Map<string, UpcomingDeadline[]>();
|
||||
deadlines.forEach((deadline) => {
|
||||
const month = deadline.terminationDeadline?.slice(0, 7) ?? unknownKey;
|
||||
if (!groups.has(month)) {
|
||||
groups.set(month, []);
|
||||
}
|
||||
groups.get(month)!.push(deadline);
|
||||
});
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, items]) => ({
|
||||
month,
|
||||
items: items.sort((a, b) => (a.terminationDeadline ?? "").localeCompare(b.terminationDeadline ?? ""))
|
||||
}));
|
||||
}
|
||||
|
||||
export default function CalendarView() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["deadlines", "calendar"],
|
||||
queryFn: () => fetchUpcomingDeadlines(365)
|
||||
});
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!data || !Array.isArray(data)) return [] as ReturnType<typeof groupByMonth>;
|
||||
return groupByMonth(data, UNKNOWN_MONTH_KEY);
|
||||
}, [data]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t("calendar.title")}
|
||||
subtitle={t("calendar.subtitle")}
|
||||
/>
|
||||
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
{isLoading ? (
|
||||
<Typography sx={{ p: 3 }}>{t("calendar.loading")}</Typography>
|
||||
) : groups.length === 0 ? (
|
||||
<Typography sx={{ p: 3 }} color="text.secondary">
|
||||
{t("calendar.none")}
|
||||
</Typography>
|
||||
) : (
|
||||
groups.map(({ month, items }) => (
|
||||
<Accordion key={month} defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight={600} display="flex" alignItems="center" gap={1}>
|
||||
<EventIcon fontSize="small" />
|
||||
{formatMonth(month, i18n.language, t("calendar.unknownMonth"))}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={t("calendar.deadlineCount", { count: items.length })}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ ml: 2 }}
|
||||
/>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<List>
|
||||
{items.map((deadline) => (
|
||||
<ListItem
|
||||
key={`${month}-${deadline.id}`}
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
deadline.daysUntilDeadline != null ? (
|
||||
<Chip
|
||||
label={t("deadlineList.daysLabel", { count: deadline.daysUntilDeadline })}
|
||||
color={
|
||||
deadline.daysUntilDeadline <= 7
|
||||
? "error"
|
||||
: deadline.daysUntilDeadline <= 21
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ListItemButton onClick={() => navigate(`/contracts/${deadline.id}`)}>
|
||||
<ListItemText
|
||||
primary={deadline.title}
|
||||
secondary={
|
||||
<>
|
||||
{t("deadlineList.terminateBy", {
|
||||
date: formatDate(deadline.terminationDeadline)
|
||||
})}
|
||||
{deadline.contractEndDate
|
||||
? ` • ${t("deadlineList.contractEnds", {
|
||||
date: formatDate(deadline.contractEndDate)
|
||||
})}`
|
||||
: ""}
|
||||
</>
|
||||
}
|
||||
primaryTypographyProps={{ fontWeight: 600 }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMonth(month: string, locale: string, unknownLabel: string): string {
|
||||
if (month === UNKNOWN_MONTH_KEY) return unknownLabel;
|
||||
const [year, monthNumber] = month.split("-");
|
||||
const date = new Date(Number(year), Number(monthNumber) - 1);
|
||||
return new Intl.DateTimeFormat(locale.startsWith("de") ? "de-DE" : "en-US", {
|
||||
month: "long",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
189
frontend/src/routes/ContractDetail.tsx
Normal file
189
frontend/src/routes/ContractDetail.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
|
||||
import { fetchContract, fetchPaperlessDocument } from "../api/contracts";
|
||||
import { fetchServerConfig, ServerConfig } from "../api/config";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { formatCurrency, formatDate } from "../utils/date";
|
||||
|
||||
export default function ContractDetail() {
|
||||
const { contractId } = useParams<{ contractId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const id = Number(contractId);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: contract, isLoading } = useQuery({
|
||||
queryKey: ["contracts", id],
|
||||
queryFn: () => fetchContract(id),
|
||||
enabled: Number.isFinite(id)
|
||||
});
|
||||
|
||||
const { data: serverConfig } = useQuery<ServerConfig>({
|
||||
queryKey: ["server-config"],
|
||||
queryFn: fetchServerConfig
|
||||
});
|
||||
|
||||
const {
|
||||
data: paperlessDoc,
|
||||
error: paperlessError
|
||||
} = useQuery({
|
||||
queryKey: ["contracts", id, "paperless"],
|
||||
queryFn: () => fetchPaperlessDocument(id),
|
||||
enabled: Number.isFinite(id)
|
||||
});
|
||||
|
||||
const paperlessAppUrl = serverConfig?.paperlessExternalUrl ?? serverConfig?.paperlessBaseUrl ?? null;
|
||||
const terminationValue =
|
||||
contract?.terminationNoticeDays !== undefined && contract?.terminationNoticeDays !== null
|
||||
? t("deadlineList.daysLabel", { count: contract.terminationNoticeDays })
|
||||
: "–";
|
||||
const renewalValue =
|
||||
contract?.renewalPeriodMonths
|
||||
? `${t("contractDetail.monthsLabel", { count: contract.renewalPeriodMonths })}${contract.autoRenew ? `, ${t("contractForm.fields.autoRenew")}` : ""}`
|
||||
: contract?.autoRenew
|
||||
? t("contractForm.fields.autoRenew")
|
||||
: "–";
|
||||
const notesValue = contract?.notes ?? t("contractDetail.noNotes");
|
||||
|
||||
if (!Number.isFinite(id)) {
|
||||
return <Typography>{t("contractForm.loadError")}</Typography>;
|
||||
}
|
||||
|
||||
if (isLoading || !contract) {
|
||||
return <Typography>{t("contractForm.loading")}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={contract.title}
|
||||
subtitle={contract.provider ?? ""}
|
||||
action={
|
||||
<Button variant="contained" onClick={() => navigate(`/contracts/${contract.id}/edit`)}>
|
||||
{t("contractDetail.edit")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("contractDetail.details")}
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<Detail label={t("contractDetail.start")} value={formatDate(contract.contractStartDate)} />
|
||||
<Detail label={t("contractDetail.end")} value={formatDate(contract.contractEndDate)} />
|
||||
<Detail label={t("contractDetail.notice")} value={terminationValue} />
|
||||
<Detail label={t("contractDetail.renewal")} value={renewalValue} />
|
||||
<Detail label={t("contractDetail.price")} value={formatCurrency(contract.price, contract.currency ?? "EUR")} />
|
||||
<Detail label={t("contractDetail.category")} value={contract.category ?? "–"} />
|
||||
<Detail label={t("contractDetail.notes")} value={notesValue} />
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t("contractDetail.tags")}
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{(contract.tags ?? []).length > 0 ? (
|
||||
contract.tags!.map((tag) => <Chip key={tag} label={tag} />)
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("contractDetail.noTags")}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("contractDetail.document")}
|
||||
</Typography>
|
||||
{paperlessError ? (
|
||||
<Typography variant="body2" color="error">
|
||||
{t("contractDetail.documentError", { error: (paperlessError as Error).message })}
|
||||
</Typography>
|
||||
) : paperlessDoc ? (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">
|
||||
{String(paperlessDoc.title ?? t("contractDetail.documentFallback"))}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("contractDetail.created")}: {paperlessDoc.created ? formatDate(String(paperlessDoc.created)) : "–"}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("contractDetail.updated")}: {paperlessDoc.modified ? formatDate(String(paperlessDoc.modified)) : "–"}
|
||||
</Typography>
|
||||
{paperlessDoc.notes && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{String(paperlessDoc.notes)}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<LaunchIcon />}
|
||||
sx={{ alignSelf: "flex-start", mt: 1 }}
|
||||
disabled={!serverConfig || !paperlessAppUrl || !contract.paperlessDocumentId}
|
||||
onClick={() => {
|
||||
if (!paperlessAppUrl || !contract.paperlessDocumentId) return;
|
||||
const url = `${paperlessAppUrl.replace(/\/$/, "")}/documents/${contract.paperlessDocumentId}`;
|
||||
window.open(url, "_blank", "noopener");
|
||||
}}
|
||||
>
|
||||
{t("contractDetail.openInPaperless")}
|
||||
</Button>
|
||||
{!paperlessAppUrl && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("contractDetail.configurePaperless")}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("contractDetail.documentMissing")}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("contractDetail.metadata")}
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<Detail label={t("contractDetail.id")} value={`#${contract.id}`} />
|
||||
<Detail label={t("contractDetail.created")} value={formatDate(contract.createdAt)} />
|
||||
<Detail label={t("contractDetail.updated")} value={formatDate(contract.updatedAt)} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body1">{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
401
frontend/src/routes/ContractForm.tsx
Normal file
401
frontend/src/routes/ContractForm.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createContract,
|
||||
fetchContract,
|
||||
fetchPaperlessDocument,
|
||||
updateContract
|
||||
} from "../api/contracts";
|
||||
import { fetchServerConfig } from "../api/config";
|
||||
import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { ContractPayload, PaperlessDocument } from "../types";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, "Titel erforderlich"),
|
||||
provider: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
paperlessDocumentId: z.string().optional(),
|
||||
contractStartDate: z.string().optional(),
|
||||
contractEndDate: z.string().optional(),
|
||||
terminationNoticeDays: z.string().optional(),
|
||||
renewalPeriodMonths: z.string().optional(),
|
||||
autoRenew: z.boolean().optional(),
|
||||
price: z.string().optional(),
|
||||
currency: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
tags: z.string().optional()
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface Props {
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
function parseInteger(input?: string | null): number | null {
|
||||
if (!input) return null;
|
||||
const value = Number(input);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error("Invalid number");
|
||||
}
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function parseDecimal(input?: string | null): number | null {
|
||||
if (!input) return null;
|
||||
const normalized = input.replace(",", ".").trim();
|
||||
const value = Number(normalized);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error("Invalid number");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseTags(input?: string | null): string[] {
|
||||
if (!input) return [];
|
||||
return input
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function ContractForm({ mode }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { contractId } = useParams<{ contractId: string }>();
|
||||
const id = contractId ? Number(contractId) : null;
|
||||
const queryClient = useQueryClient();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
setValue,
|
||||
watch
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
provider: "",
|
||||
category: "",
|
||||
contractStartDate: "",
|
||||
contractEndDate: "",
|
||||
terminationNoticeDays: "",
|
||||
renewalPeriodMonths: "",
|
||||
autoRenew: false,
|
||||
price: "",
|
||||
currency: "EUR",
|
||||
notes: "",
|
||||
tags: ""
|
||||
}
|
||||
});
|
||||
|
||||
const { data: contract, isLoading } = useQuery({
|
||||
queryKey: ["contracts", id],
|
||||
queryFn: () => fetchContract(id ?? 0),
|
||||
enabled: mode === "edit" && id !== null
|
||||
});
|
||||
|
||||
const { data: serverConfig } = useQuery({
|
||||
queryKey: ["server-config"],
|
||||
queryFn: fetchServerConfig
|
||||
});
|
||||
|
||||
const { data: linkedPaperlessDoc } = useQuery({
|
||||
queryKey: ["contracts", id, "paperless"],
|
||||
queryFn: () => fetchPaperlessDocument(id ?? 0),
|
||||
enabled: mode === "edit" && id !== null
|
||||
});
|
||||
|
||||
const [searchDialogOpen, setSearchDialogOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<PaperlessDocument | null>(null);
|
||||
const paperlessDocumentId = watch("paperlessDocumentId");
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && contract) {
|
||||
reset({
|
||||
title: contract.title,
|
||||
provider: contract.provider ?? "",
|
||||
category: contract.category ?? "",
|
||||
contractStartDate: contract.contractStartDate ?? "",
|
||||
contractEndDate: contract.contractEndDate ?? "",
|
||||
terminationNoticeDays: contract.terminationNoticeDays
|
||||
? String(contract.terminationNoticeDays)
|
||||
: "",
|
||||
renewalPeriodMonths: contract.renewalPeriodMonths
|
||||
? String(contract.renewalPeriodMonths)
|
||||
: "",
|
||||
autoRenew: contract.autoRenew ?? false,
|
||||
price: contract.price ? String(contract.price) : "",
|
||||
currency: contract.currency ?? "EUR",
|
||||
notes: contract.notes ?? "",
|
||||
paperlessDocumentId: contract.paperlessDocumentId
|
||||
? String(contract.paperlessDocumentId)
|
||||
: "",
|
||||
tags: contract.tags?.join(", ") ?? ""
|
||||
});
|
||||
}
|
||||
}, [contract, mode, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (linkedPaperlessDoc) {
|
||||
setSelectedDocument(linkedPaperlessDoc);
|
||||
}
|
||||
}, [linkedPaperlessDoc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paperlessDocumentId) {
|
||||
setSelectedDocument(null);
|
||||
return;
|
||||
}
|
||||
if (selectedDocument?.id && String(selectedDocument.id) === paperlessDocumentId) {
|
||||
return;
|
||||
}
|
||||
if (selectedDocument && String(selectedDocument.id ?? "") !== paperlessDocumentId) {
|
||||
setSelectedDocument(null);
|
||||
}
|
||||
}, [paperlessDocumentId, selectedDocument]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
const payload: ContractPayload = {
|
||||
title: values.title,
|
||||
provider: values.provider?.trim() || null,
|
||||
category: values.category?.trim() || null,
|
||||
contractStartDate: values.contractStartDate || null,
|
||||
contractEndDate: values.contractEndDate || null,
|
||||
terminationNoticeDays: parseInteger(values.terminationNoticeDays ?? undefined),
|
||||
renewalPeriodMonths: parseInteger(values.renewalPeriodMonths ?? undefined),
|
||||
autoRenew: values.autoRenew ?? false,
|
||||
price: parseDecimal(values.price ?? undefined),
|
||||
currency: values.currency?.trim() || "EUR",
|
||||
notes: values.notes?.trim() || null,
|
||||
paperlessDocumentId: parseInteger(values.paperlessDocumentId ?? undefined),
|
||||
tags: parseTags(values.tags ?? "")
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
return createContract(payload);
|
||||
}
|
||||
if (!id) {
|
||||
throw new Error("Invalid contract ID");
|
||||
}
|
||||
return updateContract(id, payload);
|
||||
},
|
||||
onSuccess: (updated) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["contracts"] });
|
||||
showMessage(t("contractForm.saved", { title: updated.title }), "success");
|
||||
navigate(`/contracts/${updated.id}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const message = error.message === "Invalid number" ? t("contractForm.invalidNumber") : error.message ?? t("contractForm.saveError");
|
||||
showMessage(message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === "edit" && (isLoading || !contract)) {
|
||||
return <Typography>{t("contractForm.loading")}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={mode === "create" ? t("contractForm.createTitle") : t("contractDetail.edit")}
|
||||
subtitle={
|
||||
mode === "create"
|
||||
? t("contractForm.createSubtitle")
|
||||
: t("contractForm.editSubtitle", { title: contract?.title ?? "" })
|
||||
}
|
||||
/>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3 }}>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit((values) => mutation.mutate(values))}
|
||||
noValidate
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.title")}
|
||||
fullWidth
|
||||
required
|
||||
{...register("title")}
|
||||
error={Boolean(errors.title)}
|
||||
helperText={errors.title?.message}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField label={t("contractForm.fields.provider")} fullWidth {...register("provider")} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField label={t("contractForm.fields.category")} fullWidth {...register("category")} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="flex-start">
|
||||
<TextField
|
||||
label={t("contractForm.fields.paperlessId")}
|
||||
fullWidth
|
||||
{...register("paperlessDocumentId")}
|
||||
error={Boolean(errors.paperlessDocumentId)}
|
||||
helperText={errors.paperlessDocumentId?.message}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setSearchDialogOpen(true)}
|
||||
disabled={!serverConfig?.paperlessConfigured}
|
||||
>
|
||||
{t("contractForm.fields.searchButton")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{!serverConfig?.paperlessConfigured && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("contractForm.paperlessNotConfigured")}
|
||||
</Typography>
|
||||
)}
|
||||
{selectedDocument && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
|
||||
{t("contractForm.paperlessLinked", {
|
||||
title: selectedDocument.title ?? `#${selectedDocument.id}`
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.contractStart")}
|
||||
type="date"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
{...register("contractStartDate")}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.contractEnd")}
|
||||
type="date"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
{...register("contractEndDate")}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.terminationNotice")}
|
||||
fullWidth
|
||||
{...register("terminationNoticeDays")}
|
||||
error={Boolean(errors.terminationNoticeDays)}
|
||||
helperText={errors.terminationNoticeDays?.message}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.renewalPeriod")}
|
||||
fullWidth
|
||||
{...register("renewalPeriodMonths")}
|
||||
error={Boolean(errors.renewalPeriodMonths)}
|
||||
helperText={errors.renewalPeriodMonths?.message}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="autoRenew"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
label={t("contractForm.fields.autoRenew")}
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onChange={(event) => field.onChange(event.target.checked)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.price")}
|
||||
fullWidth
|
||||
{...register("price")}
|
||||
error={Boolean(errors.price)}
|
||||
helperText={errors.price?.message}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.currency")}
|
||||
fullWidth
|
||||
{...register("currency")}
|
||||
error={Boolean(errors.currency)}
|
||||
helperText={errors.currency?.message}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.notes")}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
{...register("notes")}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label={t("contractForm.fields.tags")}
|
||||
fullWidth
|
||||
placeholder={t("contractForm.tagsPlaceholder")}
|
||||
{...register("tags")}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box display="flex" justifyContent="flex-end" mt={4}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Speichere..." : "Speichern"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<PaperlessSearchDialog
|
||||
open={searchDialogOpen}
|
||||
onClose={() => setSearchDialogOpen(false)}
|
||||
onSelect={(doc) => {
|
||||
const idValue = doc.id ? String(doc.id) : "";
|
||||
setValue("paperlessDocumentId", idValue, { shouldDirty: true });
|
||||
setSelectedDocument(doc);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
219
frontend/src/routes/ContractsList.tsx
Normal file
219
frontend/src/routes/ContractsList.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { fetchContracts, removeContract } from "../api/contracts";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { Contract } from "../types";
|
||||
import { formatCurrency, formatDate } from "../utils/date";
|
||||
|
||||
export default function ContractsList() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: contracts,
|
||||
isLoading,
|
||||
isError
|
||||
} = useQuery({
|
||||
queryKey: ["contracts", "list"],
|
||||
queryFn: () => fetchContracts({ limit: 500 })
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const values = new Set<string>();
|
||||
contracts?.forEach((contract) => {
|
||||
if (contract.category) values.add(contract.category);
|
||||
});
|
||||
return Array.from(values).sort();
|
||||
}, [contracts]);
|
||||
|
||||
const normalizedContracts = useMemo(() => {
|
||||
if (!contracts) return [] as Contract[];
|
||||
if (Array.isArray(contracts)) return contracts as Contract[];
|
||||
if (typeof (contracts as any).results === "object" && Array.isArray((contracts as any).results)) {
|
||||
return (contracts as any).results as Contract[];
|
||||
}
|
||||
return [] as Contract[];
|
||||
}, [contracts]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return normalizedContracts.filter((contract) => {
|
||||
const searchMatch =
|
||||
!search ||
|
||||
[contract.title, contract.provider, contract.notes, contract.category]
|
||||
.filter(Boolean)
|
||||
.some((field) => field!.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
const categoryMatch = category === "all" || contract.category === category;
|
||||
return searchMatch && categoryMatch;
|
||||
});
|
||||
}, [contracts, search, category]);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (contractId: number) => removeContract(contractId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["contracts"] });
|
||||
showMessage(t("contracts.deleted"), "success");
|
||||
},
|
||||
onError: (error: Error) => showMessage(error.message ?? t("contracts.deleteError"), "error")
|
||||
});
|
||||
|
||||
const handleDelete = (contract: Contract) => {
|
||||
if (window.confirm(t("contracts.deleteConfirm", { title: contract.title }))) {
|
||||
deleteMutation.mutate(contract.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t("contracts.title")}
|
||||
subtitle={t("contracts.subtitle")}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate("/contracts/new")}>
|
||||
{t("contracts.new")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
|
||||
<Box display="flex" flexWrap="wrap" gap={2} mb={2}>
|
||||
<TextField
|
||||
label={t("contracts.searchLabel")}
|
||||
placeholder={t("contracts.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
sx={{ flex: { xs: "1 1 100%", md: "1 1 320px" } }}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start">🔍</InputAdornment>
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label={t("contracts.columns.category")}
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
sx={{ width: 200 }}
|
||||
>
|
||||
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
|
||||
{categories.map((item) => (
|
||||
<MenuItem key={item} value={item}>
|
||||
{item}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Box>
|
||||
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("contracts.columns.title")}</TableCell>
|
||||
<TableCell>{t("contracts.columns.provider")}</TableCell>
|
||||
<TableCell>{t("contracts.columns.category")}</TableCell>
|
||||
<TableCell>{t("contracts.columns.price")}</TableCell>
|
||||
<TableCell>{t("contracts.columns.end")}</TableCell>
|
||||
<TableCell>{t("contracts.columns.tags")}</TableCell>
|
||||
<TableCell align="right">{t("contracts.columns.actions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("contracts.loading")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{isError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography variant="body2" color="error">
|
||||
{t("dashboard.contractsError")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && !isError && filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("contracts.empty")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filtered.map((contract) => (
|
||||
<TableRow key={contract.id} hover>
|
||||
<TableCell>
|
||||
<Typography fontWeight={600}>{contract.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
#{contract.id}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{contract.provider ?? "–"}</TableCell>
|
||||
<TableCell>{contract.category ?? "–"}</TableCell>
|
||||
<TableCell>{formatCurrency(contract.price, contract.currency ?? "EUR")}</TableCell>
|
||||
<TableCell>{formatDate(contract.contractEndDate)}</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{(contract.tags ?? []).map((tag) => (
|
||||
<Chip key={tag} label={tag} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t("contracts.details")}>
|
||||
<IconButton onClick={() => navigate(`/contracts/${contract.id}`)}>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("contracts.edit")}>
|
||||
<IconButton onClick={() => navigate(`/contracts/${contract.id}/edit`)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("actions.delete")}>
|
||||
<IconButton color="error" onClick={() => handleDelete(contract)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
197
frontend/src/routes/Dashboard.tsx
Normal file
197
frontend/src/routes/Dashboard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Grid, Paper, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ResponsiveContainer, BarChart, Bar, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { fetchContracts, fetchUpcomingDeadlines } from "../api/contracts";
|
||||
import DeadlineList from "../components/DeadlineList";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import StatCard from "../components/StatCard";
|
||||
import { Contract, UpcomingDeadline } from "../types";
|
||||
import { formatCurrency } from "../utils/date";
|
||||
|
||||
function buildDeadlineSeries(deadlines: UpcomingDeadline[]) {
|
||||
const grouped = new Map<string, number>();
|
||||
deadlines.forEach((deadline) => {
|
||||
if (!deadline.terminationDeadline) return;
|
||||
const month = deadline.terminationDeadline.slice(0, 7);
|
||||
grouped.set(month, (grouped.get(month) ?? 0) + 1);
|
||||
});
|
||||
return Array.from(grouped.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, count]) => ({ month, count }));
|
||||
}
|
||||
|
||||
function countActiveContracts(contracts: Contract[]): number {
|
||||
const now = new Date();
|
||||
return contracts.filter((contract) => {
|
||||
if (!contract.contractEndDate) {
|
||||
return true;
|
||||
}
|
||||
const end = new Date(`${contract.contractEndDate}T00:00:00Z`);
|
||||
return end >= now;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function calcMonthlySpend(contracts: Contract[]): number {
|
||||
return contracts.reduce((total, contract) => {
|
||||
if (!contract.price) return total;
|
||||
return total + contract.price;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const {
|
||||
data: contracts,
|
||||
isLoading: loadingContracts,
|
||||
isError: errorContracts
|
||||
} = useQuery({
|
||||
queryKey: ["contracts", "dashboard"],
|
||||
queryFn: () => fetchContracts({ limit: 200 })
|
||||
});
|
||||
|
||||
const {
|
||||
data: deadlines,
|
||||
isLoading: loadingDeadlines,
|
||||
isError: errorDeadlines
|
||||
} = useQuery({
|
||||
queryKey: ["deadlines", 60],
|
||||
queryFn: () => fetchUpcomingDeadlines(60)
|
||||
});
|
||||
|
||||
const normalizedContracts = useMemo(() => {
|
||||
if (!contracts) return [] as Contract[];
|
||||
if (Array.isArray(contracts)) return contracts as Contract[];
|
||||
if (typeof (contracts as any).results === "object" && Array.isArray((contracts as any).results)) {
|
||||
return (contracts as any).results as Contract[];
|
||||
}
|
||||
return [] as Contract[];
|
||||
}, [contracts]);
|
||||
|
||||
const normalizedDeadlines = useMemo(() => {
|
||||
if (!deadlines) return [] as UpcomingDeadline[];
|
||||
if (Array.isArray(deadlines)) return deadlines as UpcomingDeadline[];
|
||||
if (typeof (deadlines as any).results === "object" && Array.isArray((deadlines as any).results)) {
|
||||
return (deadlines as any).results as UpcomingDeadline[];
|
||||
}
|
||||
return [] as UpcomingDeadline[];
|
||||
}, [deadlines]);
|
||||
|
||||
const activeContracts = useMemo(() => countActiveContracts(normalizedContracts), [normalizedContracts]);
|
||||
const monthlySpend = useMemo(() => calcMonthlySpend(normalizedContracts), [normalizedContracts]);
|
||||
const deadlineSeries = useMemo(() => buildDeadlineSeries(normalizedDeadlines), [normalizedDeadlines]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t("dashboard.title")}
|
||||
subtitle={t("dashboard.subtitle")}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
{loadingContracts ? (
|
||||
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
|
||||
) : errorContracts ? (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Typography variant="body2" color="error">
|
||||
{t("dashboard.contractsError")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<StatCard
|
||||
title={t("dashboard.totalContracts")}
|
||||
value={contracts?.length ?? 0}
|
||||
trend={contracts && contracts.length > 0 ? "up" : undefined}
|
||||
trendLabel={contracts && contracts.length > 0 ? t("dashboard.totalContractsTrend") : undefined}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
{loadingContracts ? (
|
||||
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
|
||||
) : errorContracts ? (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Typography variant="body2" color="error">
|
||||
{t("dashboard.contractsError")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<StatCard title={t("dashboard.activeContracts") } value={activeContracts} />
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
{loadingContracts ? (
|
||||
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
|
||||
) : errorContracts ? (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Typography variant="body2" color="error">
|
||||
{t("dashboard.contractsError")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<StatCard
|
||||
title={t("dashboard.monthlySpend")}
|
||||
value={formatCurrency(monthlySpend)}
|
||||
trend={monthlySpend > 0 ? "up" : undefined}
|
||||
trendLabel={monthlySpend > 0 ? t("dashboard.monthlySpendTrend") : undefined}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={3} mt={1}>
|
||||
<Grid item xs={12} md={7}>
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3, minHeight: 320 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("dashboard.deadlineChartTitle")}
|
||||
</Typography>
|
||||
{loadingDeadlines ? (
|
||||
<Stack spacing={2} mt={2}>
|
||||
<Skeleton variant="rectangular" height={32} />
|
||||
<Skeleton variant="rectangular" height={32} />
|
||||
<Skeleton variant="rectangular" height={32} />
|
||||
</Stack>
|
||||
) : errorDeadlines ? (
|
||||
<Typography variant="body2" color="error">
|
||||
{t("dashboard.deadlinesError")}
|
||||
</Typography>
|
||||
) : deadlines && deadlines.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={deadlineSeries}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#556cd6" radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("dashboard.noDeadlines")}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={5}>
|
||||
{loadingDeadlines ? (
|
||||
<Stack spacing={2} mt={1}>
|
||||
<Skeleton variant="rectangular" height={88} />
|
||||
<Skeleton variant="rectangular" height={88} />
|
||||
<Skeleton variant="rectangular" height={88} />
|
||||
</Stack>
|
||||
) : errorDeadlines ? (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Typography variant="body2" color="error">
|
||||
{t("dashboard.deadlinesError")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<DeadlineList deadlines={deadlines ?? []} />
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
frontend/src/routes/Login.tsx
Normal file
131
frontend/src/routes/Login.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
type FormValues = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, authEnabled, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
username: z.string().min(1, t("login.usernameRequired")),
|
||||
password: z.string().min(1, t("login.passwordRequired"))
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors }
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ username, password }: FormValues) => login(username, password),
|
||||
onSuccess: () => {
|
||||
const redirectTo = (location.state as { from?: Location })?.from?.pathname ?? "/dashboard";
|
||||
navigate(redirectTo, { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authEnabled || isAuthenticated) {
|
||||
navigate("/dashboard", { replace: true });
|
||||
}
|
||||
}, [authEnabled, isAuthenticated, navigate]);
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Paper elevation={3} sx={{ p: 4, borderRadius: 4, width: "100%" }}>
|
||||
<Box display="flex" flexDirection="column" alignItems="center" mb={2}>
|
||||
<Avatar sx={{ m: 1, bgcolor: "primary.main" }}>
|
||||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5" fontWeight={600}>
|
||||
{t("login.title")}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("login.welcome")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{mutation.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{(mutation.error as Error).message || t("login.error")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit((values) => mutation.mutate(values))}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
fullWidth
|
||||
label={t("login.username")}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
{...register("username")}
|
||||
error={Boolean(errors.username)}
|
||||
helperText={errors.username?.message}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
fullWidth
|
||||
label={t("login.password")}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...register("password")}
|
||||
error={Boolean(errors.password)}
|
||||
helperText={errors.password?.message}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? t("login.checking") : t("login.submit")}
|
||||
</Button>
|
||||
{!authEnabled && (
|
||||
<Alert severity="info">{t("login.disabled")}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
747
frontend/src/routes/Settings.tsx
Normal file
747
frontend/src/routes/Settings.tsx
Normal file
@@ -0,0 +1,747 @@
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import SecurityIcon from "@mui/icons-material/Security";
|
||||
import SettingsApplicationsIcon from "@mui/icons-material/SettingsApplications";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
fetchServerConfig,
|
||||
fetchSettings,
|
||||
resetIcalSecret,
|
||||
triggerMailTest,
|
||||
triggerNtfyTest,
|
||||
ServerConfig,
|
||||
SettingsResponse,
|
||||
updateSettings,
|
||||
UpdateSettingsPayload
|
||||
} from "../api/config";
|
||||
import { request } from "../api/client";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
paperlessBaseUrl: string;
|
||||
paperlessExternalUrl: string;
|
||||
paperlessToken: string;
|
||||
schedulerIntervalMinutes: number;
|
||||
alertDaysBefore: number;
|
||||
mailServer: string;
|
||||
mailPort: string;
|
||||
mailUsername: string;
|
||||
mailPassword: string;
|
||||
mailUseTls: boolean;
|
||||
mailFrom: string;
|
||||
mailTo: string;
|
||||
ntfyServerUrl: string;
|
||||
ntfyTopic: string;
|
||||
ntfyToken: string;
|
||||
ntfyPriority: string;
|
||||
authUsername: string;
|
||||
authPassword: string;
|
||||
};
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
paperlessBaseUrl: "",
|
||||
paperlessExternalUrl: "",
|
||||
paperlessToken: "",
|
||||
schedulerIntervalMinutes: 60,
|
||||
alertDaysBefore: 30,
|
||||
mailServer: "",
|
||||
mailPort: "",
|
||||
mailUsername: "",
|
||||
mailPassword: "",
|
||||
mailUseTls: true,
|
||||
mailFrom: "",
|
||||
mailTo: "",
|
||||
ntfyServerUrl: "",
|
||||
ntfyTopic: "",
|
||||
ntfyToken: "",
|
||||
ntfyPriority: "default",
|
||||
authUsername: "",
|
||||
authPassword: ""
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { authEnabled } = useAuth();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ["healthz"],
|
||||
queryFn: () => request<HealthResponse>("/healthz", { method: "GET" })
|
||||
});
|
||||
|
||||
const {
|
||||
data: serverConfig,
|
||||
refetch: refetchServerConfig
|
||||
} = useQuery<ServerConfig>({
|
||||
queryKey: ["server-config"],
|
||||
queryFn: fetchServerConfig
|
||||
});
|
||||
|
||||
const {
|
||||
data: settingsData,
|
||||
isLoading: loadingSettings,
|
||||
refetch: refetchSettings
|
||||
} = useQuery<SettingsResponse>({
|
||||
queryKey: ["settings"],
|
||||
queryFn: fetchSettings
|
||||
});
|
||||
|
||||
const initialValuesRef = useRef<FormValues>(defaultValues);
|
||||
const [removePaperlessToken, setRemovePaperlessToken] = useState(false);
|
||||
const [removeMailPassword, setRemoveMailPassword] = useState(false);
|
||||
const [removeNtfyToken, setRemoveNtfyToken] = useState(false);
|
||||
const [removeAuthPassword, setRemoveAuthPassword] = useState(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormValues>({
|
||||
defaultValues
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsData) {
|
||||
return;
|
||||
}
|
||||
const values: FormValues = {
|
||||
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
|
||||
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
|
||||
paperlessToken: "",
|
||||
schedulerIntervalMinutes: settingsData.values.schedulerIntervalMinutes,
|
||||
alertDaysBefore: settingsData.values.alertDaysBefore,
|
||||
mailServer: settingsData.values.mailServer ?? "",
|
||||
mailPort: settingsData.values.mailPort ? String(settingsData.values.mailPort) : "",
|
||||
mailUsername: settingsData.values.mailUsername ?? "",
|
||||
mailPassword: "",
|
||||
mailUseTls: settingsData.values.mailUseTls,
|
||||
mailFrom: settingsData.values.mailFrom ?? "",
|
||||
mailTo: settingsData.values.mailTo ?? "",
|
||||
ntfyServerUrl: settingsData.values.ntfyServerUrl ?? "",
|
||||
ntfyTopic: settingsData.values.ntfyTopic ?? "",
|
||||
ntfyToken: "",
|
||||
ntfyPriority: settingsData.values.ntfyPriority ?? "default",
|
||||
authUsername: settingsData.values.authUsername ?? "",
|
||||
authPassword: ""
|
||||
};
|
||||
|
||||
initialValuesRef.current = values;
|
||||
setRemovePaperlessToken(false);
|
||||
setRemoveMailPassword(false);
|
||||
setRemoveNtfyToken(false);
|
||||
setRemoveAuthPassword(false);
|
||||
reset(values);
|
||||
}, [settingsData, reset]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: UpdateSettingsPayload) => updateSettings(payload),
|
||||
onSuccess: async () => {
|
||||
showMessage(t("settings.saved"), "success");
|
||||
await Promise.all([refetchSettings(), refetchServerConfig()]);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showMessage(error.message ?? t("contractForm.saveError"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const resetIcalMutation = useMutation({
|
||||
mutationFn: () => resetIcalSecret(),
|
||||
onSuccess: async () => {
|
||||
showMessage(t("settings.icalTokenRenewed"), "success");
|
||||
await refetchSettings();
|
||||
},
|
||||
onError: (error: Error) => showMessage(error.message ?? t("settings.actionFailed"), "error")
|
||||
});
|
||||
|
||||
const mailTestMutation = useMutation({
|
||||
mutationFn: () => triggerMailTest(),
|
||||
onSuccess: () => showMessage(t("settings.mailTestSuccess"), "success"),
|
||||
onError: (error: Error) => showMessage(error.message ?? t("settings.mailTestError"), "error")
|
||||
});
|
||||
|
||||
const ntfyTestMutation = useMutation({
|
||||
mutationFn: () => triggerNtfyTest(),
|
||||
onSuccess: () => showMessage(t("settings.ntfyTestSuccess"), "success"),
|
||||
onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error")
|
||||
});
|
||||
|
||||
const settings = settingsData;
|
||||
const icalSecret = settings?.icalSecret ?? null;
|
||||
const icalUrl = useMemo(() => {
|
||||
if (!icalSecret) return null;
|
||||
if (typeof window === "undefined") return null;
|
||||
const origin = window.location.origin;
|
||||
return `${origin}/api/calendar/feed.ics?token=${icalSecret}`;
|
||||
}, [icalSecret]);
|
||||
|
||||
const onSubmit = (formValues: FormValues) => {
|
||||
const initial = initialValuesRef.current;
|
||||
if (!initial || !settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: UpdateSettingsPayload = {};
|
||||
const trimOrNull = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 ? null : trimmed;
|
||||
};
|
||||
|
||||
if (formValues.paperlessBaseUrl !== initial.paperlessBaseUrl) {
|
||||
payload.paperlessBaseUrl = trimOrNull(formValues.paperlessBaseUrl);
|
||||
}
|
||||
if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) {
|
||||
payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl);
|
||||
}
|
||||
|
||||
if (removePaperlessToken) {
|
||||
payload.paperlessToken = null;
|
||||
} else if (formValues.paperlessToken.trim().length > 0) {
|
||||
payload.paperlessToken = formValues.paperlessToken.trim();
|
||||
}
|
||||
|
||||
if (formValues.schedulerIntervalMinutes !== initial.schedulerIntervalMinutes) {
|
||||
payload.schedulerIntervalMinutes = formValues.schedulerIntervalMinutes;
|
||||
}
|
||||
if (formValues.alertDaysBefore !== initial.alertDaysBefore) {
|
||||
payload.alertDaysBefore = formValues.alertDaysBefore;
|
||||
}
|
||||
|
||||
if (formValues.mailServer !== initial.mailServer) {
|
||||
payload.mailServer = trimOrNull(formValues.mailServer);
|
||||
}
|
||||
|
||||
if (formValues.mailPort !== initial.mailPort) {
|
||||
payload.mailPort = formValues.mailPort.trim()
|
||||
? Number(formValues.mailPort)
|
||||
: null;
|
||||
}
|
||||
|
||||
if (formValues.mailUsername !== initial.mailUsername) {
|
||||
payload.mailUsername = trimOrNull(formValues.mailUsername);
|
||||
}
|
||||
|
||||
if (removeMailPassword) {
|
||||
payload.mailPassword = null;
|
||||
} else if (formValues.mailPassword.trim().length > 0) {
|
||||
payload.mailPassword = formValues.mailPassword.trim();
|
||||
}
|
||||
|
||||
if (formValues.mailUseTls !== initial.mailUseTls) {
|
||||
payload.mailUseTls = formValues.mailUseTls;
|
||||
}
|
||||
|
||||
if (formValues.mailFrom !== initial.mailFrom) {
|
||||
payload.mailFrom = trimOrNull(formValues.mailFrom);
|
||||
}
|
||||
|
||||
if (formValues.mailTo !== initial.mailTo) {
|
||||
payload.mailTo = trimOrNull(formValues.mailTo);
|
||||
}
|
||||
|
||||
if (formValues.ntfyServerUrl !== initial.ntfyServerUrl) {
|
||||
payload.ntfyServerUrl = trimOrNull(formValues.ntfyServerUrl);
|
||||
}
|
||||
|
||||
if (formValues.ntfyTopic !== initial.ntfyTopic) {
|
||||
payload.ntfyTopic = trimOrNull(formValues.ntfyTopic);
|
||||
}
|
||||
|
||||
if (removeNtfyToken) {
|
||||
payload.ntfyToken = null;
|
||||
} else if (formValues.ntfyToken.trim().length > 0) {
|
||||
payload.ntfyToken = formValues.ntfyToken.trim();
|
||||
}
|
||||
|
||||
if (formValues.ntfyPriority !== initial.ntfyPriority) {
|
||||
payload.ntfyPriority = formValues.ntfyPriority === "default" ? null : formValues.ntfyPriority;
|
||||
}
|
||||
|
||||
if (formValues.authUsername !== initial.authUsername) {
|
||||
payload.authUsername = trimOrNull(formValues.authUsername);
|
||||
}
|
||||
|
||||
if (removeAuthPassword) {
|
||||
payload.authPassword = null;
|
||||
} else if (formValues.authPassword.trim().length > 0) {
|
||||
payload.authPassword = formValues.authPassword.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
showMessage(t("settings.noChanges"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showMessage(t("actions.copy"), "success");
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "absolute";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const successful = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
if (successful) {
|
||||
showMessage(t("actions.copy"), "success");
|
||||
} else {
|
||||
showMessage(t("actions.copyUnsupported"), "warning");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage((error as Error).message ?? t("actions.copyFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const loading = loadingSettings || !settings;
|
||||
const paperlessTokenSet = settings?.secrets.paperlessTokenSet ?? false;
|
||||
const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false;
|
||||
const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false;
|
||||
const authPasswordSet = settings?.secrets.authPasswordSet ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t("settings.title")}
|
||||
subtitle={t("settings.subtitle")}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("settings.systemStatus")}
|
||||
</Typography>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: "success.main" }}>
|
||||
<StorageIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={t("settings.apiStatus")}
|
||||
secondary={health?.status === "ok" ? t("settings.apiOk") : t("settings.apiError")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: authEnabled ? "primary.main" : "warning.main" }}>
|
||||
<SecurityIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={t("settings.authStatus")}
|
||||
secondary={authEnabled ? t("settings.authActive") : t("settings.authInactive")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: serverConfig?.paperlessConfigured ? "primary.main" : "warning.main" }}>
|
||||
<SettingsApplicationsIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={t("settings.paperlessStatus")}
|
||||
secondary={
|
||||
serverConfig?.paperlessConfigured
|
||||
? t("settings.paperlessActive", {
|
||||
url: serverConfig.paperlessBaseUrl ?? ""
|
||||
})
|
||||
: t("settings.paperlessInactive")
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: serverConfig?.mailConfigured ? "primary.main" : "warning.main" }}>
|
||||
<SettingsApplicationsIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={t("settings.mailStatus")}
|
||||
secondary={serverConfig?.mailConfigured ? t("settings.mailActive") : t("settings.mailInactive")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: serverConfig?.ntfyConfigured ? "primary.main" : "warning.main" }}>
|
||||
<SettingsApplicationsIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={t("settings.ntfyStatus")}
|
||||
secondary={serverConfig?.ntfyConfigured ? t("settings.ntfyActive") : t("settings.ntfyInactive")}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined" sx={{ borderRadius: 3, height: "100%" }}>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h6">{t("settings.ical")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("settings.icalDescription")}
|
||||
</Typography>
|
||||
</Box>
|
||||
{icalUrl ? (
|
||||
<TextField
|
||||
label={t("settings.icalFeedUrl")}
|
||||
value={icalUrl}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => handleCopy(icalUrl)} edge="end">
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
) : (
|
||||
<Alert severity="warning">{t("settings.icalTokenMissing")}</Alert>
|
||||
)}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => resetIcalMutation.mutate()}
|
||||
disabled={resetIcalMutation.isPending}
|
||||
>
|
||||
{t("settings.icalRenew")}
|
||||
</Button>
|
||||
{resetIcalMutation.isPending && <CircularProgress size={24} />}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={3}>
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("settings.paperless")}
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label={t("settings.paperlessApiUrl")}
|
||||
{...register("paperlessBaseUrl")}
|
||||
placeholder={t("settings.paperlessExample")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("settings.paperlessExternalUrl")}
|
||||
{...register("paperlessExternalUrl")}
|
||||
placeholder={t("settings.paperlessExample")}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<TextField
|
||||
label={paperlessTokenSet && !removePaperlessToken ? t("settings.paperlessTokenNew") : t("settings.paperlessToken")}
|
||||
{...register("paperlessToken")}
|
||||
type="password"
|
||||
fullWidth
|
||||
placeholder={paperlessTokenSet && !removePaperlessToken ? "" : t("settings.paperlessTokenPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={removePaperlessToken ? "inherit" : "warning"}
|
||||
onClick={() => {
|
||||
if (removePaperlessToken) {
|
||||
setRemovePaperlessToken(false);
|
||||
} else {
|
||||
setRemovePaperlessToken(true);
|
||||
setValue("paperlessToken", "", { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removePaperlessToken ? t("settings.paperlessTokenKeep") : t("settings.paperlessTokenRemove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{paperlessTokenSet && !removePaperlessToken && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("settings.paperlessTokenInfo")}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("settings.scheduler")}
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label={t("settings.interval")}
|
||||
type="number"
|
||||
inputProps={{ min: 5, max: 1440 }}
|
||||
{...register("schedulerIntervalMinutes", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextField
|
||||
label={t("settings.alert")}
|
||||
type="number"
|
||||
inputProps={{ min: 1, max: 365 }}
|
||||
{...register("alertDaysBefore", { valueAsNumber: true })}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("settings.auth")}
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<TextField label={t("settings.authUsername")} {...register("authUsername")} fullWidth />
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<TextField
|
||||
label={t("settings.authPassword")}
|
||||
type="password"
|
||||
{...register("authPassword")}
|
||||
fullWidth
|
||||
placeholder={t("settings.authPasswordPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={removeAuthPassword ? "inherit" : "warning"}
|
||||
onClick={() => {
|
||||
if (removeAuthPassword) {
|
||||
setRemoveAuthPassword(false);
|
||||
} else {
|
||||
setRemoveAuthPassword(true);
|
||||
setValue("authPassword", "", { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removeAuthPassword ? t("settings.paperlessTokenKeep") : t("settings.authPasswordRemove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{authPasswordSet && !removeAuthPassword && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("settings.authPasswordInfo")}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("settings.authHint")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("settings.mail")}
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<TextField label={t("settings.smtpServer")} {...register("mailServer")} fullWidth />
|
||||
<TextField
|
||||
label={t("settings.smtpPort")}
|
||||
type="number"
|
||||
{...register("mailPort")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField label={t("settings.smtpUsername")} {...register("mailUsername")} fullWidth />
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<TextField
|
||||
label={mailPasswordSet && !removeMailPassword ? t("settings.smtpPasswordNew") : t("settings.smtpPassword")}
|
||||
type="password"
|
||||
{...register("mailPassword")}
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={removeMailPassword ? "inherit" : "warning"}
|
||||
onClick={() => {
|
||||
if (removeMailPassword) {
|
||||
setRemoveMailPassword(false);
|
||||
} else {
|
||||
setRemoveMailPassword(true);
|
||||
setValue("mailPassword", "", { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removeMailPassword ? t("settings.paperlessTokenKeep") : t("settings.smtpPasswordRemove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{mailPasswordSet && !removeMailPassword && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("settings.smtpPasswordInfo")}
|
||||
</Typography>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Controller
|
||||
name="mailUseTls"
|
||||
control={control}
|
||||
render={({ field }) => <Switch {...field} checked={field.value} />}
|
||||
/>
|
||||
}
|
||||
label={t("settings.tls")}
|
||||
/>
|
||||
<TextField label={t("settings.mailFrom")} {...register("mailFrom")} fullWidth />
|
||||
<TextField label={t("settings.mailTo")} {...register("mailTo")} fullWidth />
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => mailTestMutation.mutate()}
|
||||
disabled={mailTestMutation.isPending || !serverConfig?.mailConfigured}
|
||||
>
|
||||
{t("settings.mailTest")}
|
||||
</Button>
|
||||
{mailTestMutation.isPending && <CircularProgress size={24} />}
|
||||
</Stack>
|
||||
{mailTestMutation.isError && (
|
||||
<Alert severity="error">{(mailTestMutation.error as Error).message ?? t("settings.mailTestError")}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("settings.ntfy")}
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<TextField label={t("settings.ntfyServer")} {...register("ntfyServerUrl")} fullWidth />
|
||||
<TextField label={t("settings.ntfyTopic")} {...register("ntfyTopic")} fullWidth />
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<TextField
|
||||
label={ntfyTokenSet && !removeNtfyToken ? t("settings.ntfyTokenNew") : t("settings.ntfyToken")}
|
||||
type="password"
|
||||
{...register("ntfyToken")}
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={removeNtfyToken ? "inherit" : "warning"}
|
||||
onClick={() => {
|
||||
if (removeNtfyToken) {
|
||||
setRemoveNtfyToken(false);
|
||||
} else {
|
||||
setRemoveNtfyToken(true);
|
||||
setValue("ntfyToken", "", { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removeNtfyToken ? t("settings.paperlessTokenKeep") : t("settings.ntfyTokenRemove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<TextField
|
||||
label={t("settings.ntfyPriority")}
|
||||
select
|
||||
SelectProps={{ native: true }}
|
||||
{...register("ntfyPriority")}
|
||||
>
|
||||
<option value="default">default</option>
|
||||
<option value="min">min</option>
|
||||
<option value="low">low</option>
|
||||
<option value="high">high</option>
|
||||
<option value="urgent">urgent</option>
|
||||
</TextField>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => ntfyTestMutation.mutate()}
|
||||
disabled={ntfyTestMutation.isPending || !serverConfig?.ntfyConfigured}
|
||||
>
|
||||
{t("settings.ntfyTest")}
|
||||
</Button>
|
||||
{ntfyTestMutation.isPending && <CircularProgress size={24} />}
|
||||
</Stack>
|
||||
{ntfyTestMutation.isError && (
|
||||
<Alert severity="error">{(ntfyTestMutation.error as Error).message ?? t("settings.mailTestError")}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? t("actions.saving") : t("settings.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
frontend/src/theme.ts
Normal file
38
frontend/src/theme.ts
Normal 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
46
frontend/src/types.ts
Normal 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[];
|
||||
}
|
||||
22
frontend/src/utils/date.ts
Normal file
22
frontend/src/utils/date.ts
Normal 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
22
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal 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
19
frontend/vite.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user