initial
This commit is contained in:
219
frontend/src/routes/ContractsList.tsx
Normal file
219
frontend/src/routes/ContractsList.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user