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