Files
Paperless-Contracts/frontend/src/routes/ContractsList.tsx
2025-10-11 13:01:18 +02:00

260 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
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 [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
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 handleDeleteConfirm = () => {
if (!contractToDelete) return;
deleteMutation.mutate(contractToDelete.id);
setContractToDelete(null);
};
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
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/contracts/${contract.id}`)}
>
<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.edit")}>
<IconButton
onClick={(event) => {
event.stopPropagation();
navigate(`/contracts/${contract.id}/edit`);
}}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("actions.delete")}>
<IconButton
color="error"
onClick={(event) => {
event.stopPropagation();
setContractToDelete(contract);
}}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
<Dialog
open={Boolean(contractToDelete)}
onClose={() => setContractToDelete(null)}
aria-labelledby="delete-contract-title"
>
<DialogTitle id="delete-contract-title">{t("contracts.deleteTitle")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("contracts.deleteConfirm", { title: contractToDelete?.title ?? "" })}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setContractToDelete(null)}>{t("actions.cancel")}</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteMutation.isPending}
>
{t("actions.delete")}
</Button>
</DialogActions>
</Dialog>
</>
);
}