Files
app/src/admin/pages/ProjectDetail.tsx
BOHA 5683912b76 feat(ui): quick status-action chip menus on offers/orders/projects; ProjectDetail transition buttons
- NEW shared StatusChipMenu: clickable status chip opens a dense menu of valid
  next states; picks confirm via the shared ConfirmDialog (danger variant,
  loading, stays open on failure); plain chip without edit permission;
  row-click-safe (stopPropagation both directions).
- Offers list: draft -> Aktivovat (number assignment noted, PDF archived after
  finalize like the detail); active -> 'Vytvořit objednávku…' (opens the
  existing create-order modal — 'ordered' stays owned by that flow) +
  Zneplatnit; ordered -> Zneplatnit.
- ReceivedOrders list: Zahájit realizaci / Dokončit / Stornovat / Obnovit with
  cascade notes; Czech quote pairs fixed („…“); OrderDetail transition button
  says 'Obnovit' when reopening.
- Projects list + ProjectDetail: status combobox replaced by transition
  buttons (Dokončit/Zrušit/Obnovit projekt) rendered from valid_transitions;
  cascade notes only when a linked order will actually change; save no longer
  carries status; busy states symmetric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:33:58 +02:00

713 lines
21 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 { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import MenuItem from "@mui/material/MenuItem";
import {
projectDetailOptions,
type ProjectNote,
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden";
import ProjectFileManager from "../components/ProjectFileManager";
import NoteCard from "../components/NoteCard";
import {
Button,
Card,
Field,
TextField,
Select,
DateField,
StatusChip,
CheckboxField,
ConfirmDialog,
LoadingState,
PageEnter,
headerActionsSx,
} from "../ui";
import {
PROJECT_STATUS,
ORDER_STATUS,
statusLabel,
statusColor,
} from "../lib/documentStatus";
const API_BASE = "/api/admin";
const TRANSITION_LABELS: Record<string, string> = {
dokonceny: "Dokončit projekt",
zruseny: "Zrušit projekt",
aktivni: "Obnovit projekt",
};
interface User {
id: number;
name: string;
}
interface ProjectForm {
name: string;
start_date: string;
end_date: string;
responsible_user_id: string;
}
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
export default function ProjectDetail() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission, isAdmin, user } = useAuth();
const navigate = useNavigate();
const qc = useQueryClient();
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProjectForm>({
name: "",
start_date: "",
end_date: "",
responsible_user_id: "",
});
const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{
show: boolean;
status: string | null;
}>({ show: false, status: null });
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
// Dynamic notes
const [newNote, setNewNote] = useState("");
const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const projectQuery = useQuery(projectDetailOptions(id));
const project = projectQuery.data;
const isPending = projectQuery.isPending;
const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view"));
// Only active users with projects.view — but keep the project's CURRENT
// assignee in the list even if they're now inactive, so the select still
// reflects the saved value (marked "(neaktivní)" so it's clear).
const assignedUserId = project?.responsible_user_id || "";
const users: User[] = (usersData ?? [])
.filter((u: ApiUser) => u.is_active || String(u.id) === assignedUserId)
.map((u: ApiUser) => ({
id: u.id,
name:
(`${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username) +
(u.is_active ? "" : " (neaktivní)"),
}));
// Reset form sync when navigating to a different project
const formInitialized = useRef(false);
useEffect(() => {
formInitialized.current = false;
}, [id]);
// Sync project data to local form state on first load
useEffect(() => {
if (project && !formInitialized.current) {
setForm({
name: project.name || "",
start_date: (project.start_date || "").substring(0, 10),
end_date: (project.end_date || "").substring(0, 10),
responsible_user_id: project.responsible_user_id || "",
});
formInitialized.current = true;
}
}, [project]);
// Navigate away on fetch error
useEffect(() => {
if (projectQuery.error) {
alert.error("Nepodařilo se načíst projekt");
navigate("/projects");
}
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
const projectSaveMutation = useApiMutation<
{
name: string;
start_date: string | null;
end_date: string | null;
responsible_user_id: string | null;
},
unknown
>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "warehouse"],
});
// Status transitions (header buttons). A project transition may cascade to
// the linked order (and its documents), so invalidate broadly.
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "orders", "offers", "invoices", "warehouse"],
});
const projectDeleteMutation = useApiMutation<
{ delete_files: boolean },
unknown
>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "DELETE",
invalidate: ["projects", "warehouse"],
});
const projectAddNoteMutation = useApiMutation<{ content: string }, unknown>({
url: () => `${API_BASE}/projects/${id}/notes`,
method: () => "POST",
invalidate: ["projects", "warehouse"],
});
const projectDeleteNoteMutation = useApiMutation<number, unknown>({
url: (noteId) => `${API_BASE}/projects/${id}/notes/${noteId}`,
method: () => "DELETE",
invalidate: ["projects", "warehouse"],
});
if (!hasPermission("projects.view")) return <Forbidden />;
const updateForm = (field: keyof ProjectForm, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }));
const handleSave = async () => {
if (!form.name.trim()) {
alert.error("Název projektu je povinný");
return;
}
setSaving(true);
try {
await projectSaveMutation.mutateAsync({
name: form.name,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null,
});
alert.success("Projekt byl aktualizován");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
};
// Confirm message with the cascade note — the linked-order cascade only
// exists when the project actually has a linked order; reopening never
// cascades.
const statusConfirmMessage = (status: string | null): string => {
if (!status || !project) return "";
if (status === "aktivni") {
return (
'Projekt se vrátí do stavu "Aktivní".' +
(project.order_id ? " Propojená objednávka zůstane beze změny." : "")
);
}
const base = `Označit projekt "${project.project_number} ${project.name}" jako ${
status === "zruseny" ? "zrušený" : "dokončený"
}?`;
// The cascade only fires while the order is still open (prijata /
// v_realizaci) — a terminal order (e.g. after a project reopen) stays
// untouched, so don't promise a change that won't happen.
const orderCascades =
project.order_id &&
(project.order_status === "prijata" ||
project.order_status === "v_realizaci");
if (!orderCascades) return base;
return (
base +
(status === "zruseny"
? " Propojená objednávka bude automaticky stornována."
: " Propojená objednávka bude automaticky dokončena.")
);
};
const handleDelete = async () => {
setDeleting(true);
try {
await projectDeleteMutation.mutateAsync({ delete_files: deleteFiles });
navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
};
const handleAddNote = async () => {
if (!newNote.trim()) return;
setAddingNote(true);
try {
await projectAddNoteMutation.mutateAsync({ content: newNote.trim() });
setNewNote("");
alert.success("Poznámka byla přidána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setAddingNote(false);
}
};
const handleDeleteNote = async (noteId: number) => {
setDeletingNoteId(noteId);
try {
await projectDeleteNoteMutation.mutateAsync(noteId);
alert.success("Poznámka byla smazána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeletingNoteId(null);
}
};
// Throws on failure so NoteCard keeps its editor open with the draft.
const saveNoteEdit = async (noteId: number, content: string) => {
// Raw apiFetch: the body must carry ONLY content (strict schema), the
// note id lives in the URL — useApiMutation sends its whole input.
let res: Response;
try {
res = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
} catch {
alert.error("Chyba připojení");
throw new Error("network");
}
const body = await res.json().catch(() => null);
if (!res.ok) {
alert.error(body?.error || "Uložení selhalo");
throw new Error(body?.error || "Uložení selhalo");
}
qc.invalidateQueries({ queryKey: ["projects"] });
alert.success("Poznámka upravena");
};
if (isPending) {
return <LoadingState />;
}
if (!project) return null;
const canEdit = hasPermission("projects.edit");
return (
<PageEnter>
{/* Header */}
<Box>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
{/* flexWrap: the title must drop below the Zpět button on phones
instead of being squeezed beside it (mirrors Offer/Invoice
detail headers). */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/projects"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
Projekt
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{project.project_number}
</Box>
</Typography>
<StatusChip
label={statusLabel(PROJECT_STATUS, project.status)}
color={statusColor(PROJECT_STATUS, project.status)}
/>
</Box>
</Box>
{canEdit && (
<Box sx={headerActionsSx}>
<Button
onClick={handleSave}
disabled={saving || statusChanging !== null}
>
{saving ? "Ukládání..." : "Uložit"}
</Button>
{(project.valid_transitions ?? []).map((status) => (
<Button
key={status}
color={status === "zruseny" ? "error" : "primary"}
onClick={() => setStatusConfirm({ show: true, status })}
disabled={saving || statusChanging !== null}
>
{statusChanging === status
? "Zpracovávám…"
: TRANSITION_LABELS[status] || status}
</Button>
))}
{!project.order_id && (
<Button
variant="outlined"
color="error"
onClick={() => setDeleteConfirm(true)}
>
Smazat
</Button>
)}
</Box>
)}
</Box>
</Box>
{/* Basic info form */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Číslo projektu">
<TextField value={project.project_number} fullWidth disabled />
</Field>
<Field label="Název">
<TextField
value={form.name}
onChange={(e) => updateForm("name", e.target.value)}
placeholder="Název projektu"
fullWidth
disabled={!canEdit}
/>
</Field>
<Field label="Zákazník">
<TextField
value={project.customer_name || "—"}
fullWidth
disabled
/>
</Field>
<Field label="Zodpovědná osoba">
<Select
value={form.responsible_user_id}
onChange={(v) => updateForm("responsible_user_id", v)}
disabled={!canEdit}
>
<MenuItem value=""> Nevybráno </MenuItem>
{users.map((u) => (
<MenuItem key={u.id} value={String(u.id)}>
{u.name}
</MenuItem>
))}
</Select>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Datum zahájení">
<DateField
value={form.start_date}
onChange={(val) => updateForm("start_date", val)}
disabled={!canEdit}
/>
</Field>
<Field label="Datum ukončení">
<DateField
value={form.end_date}
onChange={(val) => updateForm("end_date", val)}
disabled={!canEdit}
/>
</Field>
</Box>
</Card>
{/* Notes */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Poznámky
</Typography>
{/* Add note */}
<Box sx={{ mb: 3 }}>
<TextField
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
multiline
minRows={2}
placeholder="Napište poznámku..."
fullWidth
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
handleAddNote();
}
}}
/>
<Box sx={{ mt: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleAddNote}
disabled={addingNote || !newNote.trim()}
>
{addingNote ? "Přidávání..." : "Přidat poznámku"}
</Button>
</Box>
</Box>
{/* Legacy notes (read-only) */}
{project.notes && (
<Box
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
mb: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
Starší poznámka (před zavedením systému)
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ whiteSpace: "pre-wrap" }}
>
{project.notes}
</Typography>
</Box>
)}
{/* Notes list */}
{notes.length === 0 && !project.notes && (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center", py: 2 }}
>
Zatím žádné poznámky
</Typography>
)}
{(notes.length > 0 || project.notes) && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{notes.map((note) => (
<NoteCard
key={note.id}
authorName={note.user_name}
createdAt={note.created_at}
editedAt={note.edited_at}
content={note.content || ""}
canEdit={user?.id === note.user_id}
canDelete={isAdmin}
deleting={deletingNoteId === note.id}
onSave={(content) => saveNoteEdit(note.id, content)}
onDelete={() => handleDeleteNote(note.id)}
/>
))}
</Box>
)}
</Card>
{/* Project File Manager */}
<Box sx={{ mb: 2 }}>
<ProjectFileManager
projectId={project.id}
projectNumber={project.project_number}
hasPermission={hasPermission}
hasNasFolder={project.has_nas_folder ?? false}
/>
</Box>
{/* Links */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Propojení
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Objednávka
</Typography>
<Typography
variant="body2"
component="div"
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
{project.order_id ? (
<>
<Box
component={RouterLink}
to={`/orders/${project.order_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.order_number}
</Box>
{project.order_status && (
<StatusChip
label={statusLabel(ORDER_STATUS, project.order_status)}
color={statusColor(ORDER_STATUS, project.order_status)}
/>
)}
</>
) : (
"—"
)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Nabídka
</Typography>
<Typography variant="body2">
{project.quotation_id ? (
<Box
component={RouterLink}
to={`/offers/${project.quotation_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.quotation_number}
</Box>
) : (
"—"
)}
</Typography>
</Box>
</Box>
</Card>
{/* Status change confirmation */}
<ConfirmDialog
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav projektu"
message={statusConfirmMessage(statusConfirm.status)}
confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
}
confirmVariant={
statusConfirm.status === "zruseny" ? "danger" : "primary"
}
cancelText="Zrušit"
/>
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={`Opravdu chcete smazat projekt „${project.project_number} ${project.name}"? Tato akce je nevratná.`}
confirmText="Smazat"
confirmVariant="danger"
loading={deleting}
>
{project.has_nas_folder && (
<CheckboxField
label="Smazat i soubory na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
)}
</ConfirmDialog>
</PageEnter>
);
}