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:
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user