initial
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user