260 lines
8.8 KiB
TypeScript
260 lines
8.8 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|