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>
This commit is contained in:
BOHA
2026-07-04 04:33:58 +02:00
parent 3ec512faf1
commit 5683912b76
7 changed files with 551 additions and 31 deletions

View File

@@ -40,6 +40,12 @@ import {
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;
@@ -47,7 +53,6 @@ interface User {
interface ProjectForm {
name: string;
status: string;
start_date: string;
end_date: string;
responsible_user_id: string;
@@ -76,11 +81,15 @@ export default function ProjectDetail() {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProjectForm>({
name: "",
status: "aktivni",
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);
@@ -121,7 +130,6 @@ export default function ProjectDetail() {
if (project && !formInitialized.current) {
setForm({
name: project.name || "",
status: project.status || "aktivni",
start_date: (project.start_date || "").substring(0, 10),
end_date: (project.end_date || "").substring(0, 10),
responsible_user_id: project.responsible_user_id || "",
@@ -141,7 +149,6 @@ export default function ProjectDetail() {
const projectSaveMutation = useApiMutation<
{
name: string;
status: string;
start_date: string | null;
end_date: string | null;
responsible_user_id: string | null;
@@ -153,6 +160,14 @@ export default function ProjectDetail() {
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
@@ -189,7 +204,6 @@ export default function ProjectDetail() {
try {
await projectSaveMutation.mutateAsync({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null,
@@ -202,6 +216,51 @@ export default function ProjectDetail() {
}
};
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 {
@@ -333,9 +392,24 @@ export default function ProjectDetail() {
</Box>
{canEdit && (
<Box sx={headerActionsSx}>
<Button onClick={handleSave} disabled={saving}>
<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"
@@ -399,23 +473,10 @@ export default function ProjectDetail() {
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Stav">
<Select
value={form.status}
onChange={(v) => updateForm("status", v)}
disabled={!canEdit}
>
{Object.entries(PROJECT_STATUS).map(([value, s]) => (
<MenuItem key={value} value={value}>
{s.label}
</MenuItem>
))}
</Select>
</Field>
<Field label="Datum zahájení">
<DateField
value={form.start_date}
@@ -609,6 +670,22 @@ export default function ProjectDetail() {
</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={() => {