624 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|