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

View File

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

View File

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

View File

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

View File

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

View File

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