Files
app/src/admin/pages/Projects.tsx

624 lines
17 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
projectListOptions,
projectNextNumberOptions,
} from "../lib/queries/projects";
import { offerCustomersOptions } from "../lib/queries/offers";
import { userListOptions } from "../lib/queries/users";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Pagination,
Modal,
ConfirmDialog,
Field,
FilterBar,
TextField,
Select,
DateField,
StatusChip,
CheckboxField,
LoadingState,
PageEnter,
Tabs,
type TabDef,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: "Aktivní",
dokonceny: "Dokončený",
zruseny: "Zrušený",
};
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
aktivni: "success",
dokonceny: "info",
zruseny: "default",
};
// Status filter tabs (mirrors the offers page): "" = all.
const STATUS_TABS: TabDef[] = [
{ value: "", label: "Všechny" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
];
interface Project {
id: number;
project_number: string;
name: string;
customer_name: string;
responsible_user_name: string;
status: string;
start_date: string;
end_date: string;
order_id?: number;
order_number?: string;
}
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
export default function Projects() {
const alert = useAlert();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { sort, order, handleSort } = useTableSort("project_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false);
const deleteMutation = useApiMutation<
{ id: number; delete_files: boolean },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/projects/${id}`,
method: () => "DELETE",
invalidate: [
"projects",
"warehouse",
"orders",
"offers",
"invoices",
"attendance",
],
onSuccess: (data) => {
alert.success(data?.message || "Projekt byl smazán");
setDeleteTarget(null);
setDeleteFiles(false);
},
});
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
name: "",
customer_id: "",
responsible_user_id: "",
status: "aktivni",
start_date: "",
end_date: "",
notes: "",
});
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
const [creating, setCreating] = useState(false);
const customersQuery = useQuery({
...offerCustomersOptions(),
enabled: showCreate,
});
// Responsible-person picker: only users whose role can view projects
// (projects.view, filtered server-side) and who are active.
const usersQuery = useQuery({
...userListOptions("projects.view"),
enabled: showCreate,
});
const nextNumberQuery = useQuery({
...projectNextNumberOptions(),
enabled: showCreate,
});
const createMutation = useApiMutation<
Omit<typeof createForm, "start_date" | "end_date"> & {
start_date: string | null;
end_date: string | null;
},
{ id: number }
>({
url: () => `${API_BASE}/projects`,
method: () => "POST",
invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
onSuccess: () => {
setShowCreate(false);
alert.success("Projekt byl vytvořen");
},
});
const openCreate = () => {
setCreateForm({
name: "",
customer_id: "",
responsible_user_id: "",
status: "aktivni",
start_date: "",
end_date: "",
notes: "",
});
setCreateErrors({});
setShowCreate(true);
};
const handleCreate = async () => {
const errs: Record<string, string> = {};
if (!createForm.name.trim()) errs.name = "Zadejte název projektu";
setCreateErrors(errs);
if (Object.keys(errs).length > 0) return;
setCreating(true);
try {
// Server's isoDateString.nullish() accepts null but rejects "" —
// send empty date fields as null so the create doesn't 400.
await createMutation.mutateAsync({
...createForm,
start_date: createForm.start_date || null,
end_date: createForm.end_date || null,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setCreating(false);
}
};
const {
items: projects,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<Project>(
projectListOptions({
search: debouncedSearch,
sort,
order,
page,
status: statusFilter || undefined,
}),
);
if (!hasPermission("projects.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync({
id: deleteTarget.id,
delete_files: deleteFiles,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
setDeleteTarget(null);
setDeleteFiles(false);
}
};
if (isPending) {
return <LoadingState />;
}
const columns: DataColumn<Project>[] = [
{
key: "project_number",
header: "Číslo",
width: "12%",
sortKey: "project_number",
mono: true,
render: (p) => (
<Box
component={RouterLink}
to={`/projects/${p.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{p.project_number}
</Box>
),
},
{
key: "name",
header: "Název",
width: "16%",
sortKey: "name",
bold: true,
render: (p) => p.name || "—",
},
{
key: "customer",
header: "Zákazník",
width: "13%",
render: (p) => p.customer_name || "—",
},
{
key: "responsible",
header: "Zodpovědná osoba",
width: "14%",
render: (p) => p.responsible_user_name || "—",
},
{
key: "status",
header: "Stav",
width: "10%",
sortKey: "status",
render: (p) => (
<StatusChip
label={STATUS_LABELS[p.status] || p.status}
color={STATUS_COLORS[p.status] || "default"}
/>
),
},
{
key: "order",
header: "Objednávka",
width: "12%",
render: (p) =>
p.order_id ? (
<Box
component={RouterLink}
to={`/orders/${p.order_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{p.order_number}
</Box>
) : (
"—"
),
},
{
key: "start_date",
header: "Začátek",
width: "8%",
mono: true,
render: (p) => formatDate(p.start_date),
},
{
key: "end_date",
header: "Konec",
width: "8%",
mono: true,
render: (p) => formatDate(p.end_date),
},
{
key: "actions",
header: "Akce",
width: "7%",
align: "right",
render: (p) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => navigate(`/projects/${p.id}`)}
aria-label="Upravit"
title="Upravit"
>
{EditIcon}
</IconButton>
{hasPermission("projects.delete") && !p.order_id && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteTarget(p)}
aria-label="Smazat"
title="Smazat projekt"
>
{DeleteIcon}
</IconButton>
)}
</Box>
),
},
];
// Per-status row tints (mirrors the offers list): LOW-ALPHA washes via the
// palette channel vars in the badge's color — never solid `.light` fills
// (invisible white text in dark mode). Cancelled rows are faded/muted like
// invalidated offers.
const rowSx = (p: Project) => {
if (p.status === "dokonceny") {
return {
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.12)",
"&:hover": {
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.18)",
},
};
}
if (p.status === "zruseny") {
return {
opacity: 0.6,
"& td": { color: "var(--mui-palette-text-secondary)" },
};
}
return {};
};
// Search/status filter active → empty means "nothing matches" (no CTA).
const isFiltered = !!debouncedSearch || !!statusFilter;
return (
<PageEnter>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
<Typography variant="h4">Projekty</Typography>
{hasPermission("projects.create") && (
<Button startIcon={PlusIcon} onClick={openCreate}>
Přidat projekt
</Button>
)}
</Box>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<Tabs
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
tabs={STATUS_TABS}
/>
</Box>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
fullWidth
/>
</Box>
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<Project>
columns={columns}
rows={projects}
rowKey={(p) => p.id}
rowSx={rowSx}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
isFiltered ? (
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Žádné projekty neodpovídají filtru.
</Typography>
<Typography variant="body2" color="text.secondary">
Zkuste změnit filtr nebo hledaný výraz.
</Typography>
</Box>
) : (
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nejsou žádné projekty.
</Typography>
<Typography variant="body2" color="text.secondary">
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
tlačítkem Přidat projekt".
</Typography>
</Box>
)
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => {
setDeleteTarget(null);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
deleteTarget
? `Opravdu chcete smazat projekt ${deleteTarget.project_number}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
>
<CheckboxField
label="Smazat i soubory na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
</ConfirmDialog>
<Modal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
title="Přidat projekt"
subtitle={`Číslo projektu: ${
nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
""
} (přiděleno automaticky)`}
submitText="Vytvořit"
loading={creating}
>
<Field label="Název" required error={createErrors.name}>
<TextField
value={createForm.name}
placeholder="Název projektu"
error={!!createErrors.name}
onChange={(e) => {
setCreateForm({ ...createForm, name: e.target.value });
setCreateErrors((p) => ({ ...p, name: "" }));
}}
/>
</Field>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Zákazník">
<Select
value={createForm.customer_id}
onChange={(value) =>
setCreateForm({ ...createForm, customer_id: value })
}
options={[
{ value: "", label: "Vyberte zákaznika" },
...(customersQuery.data ?? []).map((c) => ({
value: String(c.id),
label: c.name,
})),
]}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Zodpovědná osoba">
<Select
value={createForm.responsible_user_id}
onChange={(value) =>
setCreateForm({ ...createForm, responsible_user_id: value })
}
options={[
{ value: "", label: "Vyberte zaměstnance" },
...(usersQuery.data ?? [])
.filter((u) => u.is_active)
.map((u) => ({
value: String(u.id),
label: `${u.first_name} ${u.last_name}`,
})),
]}
/>
</Field>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Začátek">
<DateField
value={createForm.start_date}
onChange={(val) =>
setCreateForm({ ...createForm, start_date: val })
}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Konec">
<DateField
value={createForm.end_date}
onChange={(val) =>
setCreateForm({ ...createForm, end_date: val })
}
/>
</Field>
</Box>
</Box>
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
/>
</Field>
</Modal>
</PageEnter>
);
}