Compare commits

...

4 Commits

Author SHA1 Message Date
BOHA
d859b5bbf7 chore(release): v2.4.42 — status quick actions + bidirectional project-order sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:34:57 +02:00
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
BOHA
3ec512faf1 feat(projects/orders): project status machine + bidirectional project-order sync; order reopen
- projects.service: VALID_TRANSITIONS (aktivni->dokonceny/zruseny,
  terminal->aktivni reopen), tolerant of legacy statuses; getProject returns
  valid_transitions; updateProject validates transitions (invalid_transition
  token -> Czech 400 in the route).
- NEW project->order cascade, transactional with the project update: finishing/
  cancelling a project completes/cancels its linked received order — only while
  the order is still open (never resurrects storno, never cancels completed);
  reopen never cascades. Direct tx write, no service recursion; cascaded order
  change gets its own audit row.
- orders.service: reopen edges (dokoncena/stornovana -> v_realizaci);
  syncProjectStatus is reopen-aware (reopen skips project sync) and returns
  the cascaded projects for per-project audit rows in the route.
- orders.schema: fix phantom 'zrusena' enum token -> canonical 'stornovana'
  (every storno PUT through the route 400ed with a raw English Zod message;
  latent until now because no UI sent it) + route-level regression test.
- NEW project-order-status-sync.test.ts: 15 tests — both cascade directions,
  guards, reopen-no-cascade, legacy tolerance, forward-sync regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:33:58 +02:00
BOHA
9f9f359acb docs(spec): status quick actions + bidirectional project-order sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:49:46 +02:00
17 changed files with 1198 additions and 51 deletions

View File

@@ -0,0 +1,96 @@
# Status quick actions + project⇄order sync
**Date:** 2026-07-04 · **Status:** Approved (interactive design w/ owner) · **Scope:** offers, received orders, projects
## Problem
Status changes are inconsistent: the Invoices list has a quick chip action (click → confirm → paid),
but Offers/ReceivedOrders/Projects tables have inert chips — every change needs the detail page.
ProjectDetail edits status via a free combobox in the form (no transitions, no confirm), unlike
OfferDetail/OrderDetail's transition buttons. Projects have **no status machine at all** (free-text
column). Order→project completion sync exists one-way (`syncProjectStatus`), but finishing a
**project** leaves its linked order untouched.
## Decisions (owner)
1. **Chip opens a menu** of valid next states (not single-click-advance) → ConfirmDialog per pick,
danger styling + cascade notes on destructive/irreversible ones.
2. **Reopen allowed**: `dokonceny/zruseny → aktivni` (projects), `dokoncena/stornovana → v_realizaci`
(received orders). **Reopening never cascades** to the linked record.
3. **Sync scope: finish + cancel, both directions.** Project `dokonceny` ⇄ order `dokoncena`;
project `zruseny` ⇄ order `stornovana`.
4. Offers quick menu: `Aktivovat` (draft — same number-assignment + PDF-archive semantics as the
detail), `Zneplatnit` (danger), and **"Vytvořit objednávku…" which opens the existing
create-order modal** (never a raw label flip to `ordered` — that state is owned by the
create-order flow).
## Design
### Backend
**Projects gain a status machine** (`src/services/projects.service.ts`):
`VALID_TRANSITIONS = { aktivni: [dokonceny, zruseny], dokonceny: [aktivni], zruseny: [aktivni] }`.
`updateProject` validates status changes (token `invalid_transition` → Czech 400 in the route);
a legacy/unknown current status may transition to any canonical value (tolerant). `getProject`
returns `valid_transitions` (same contract as offers/orders).
**Project → order cascade** (new), inside one transaction with the project update:
- project → `dokonceny` ⇒ linked order → `dokoncena` **iff** order status ∈ {prijata, v_realizaci}
- project → `zruseny` ⇒ linked order → `stornovana` **iff** order status ∈ {prijata, v_realizaci}
- reopen ⇒ no order change; completed/cancelled orders are never resurrected by a cascade
- direct `tx` write (no service recursion); sibling projects of the same order are NOT touched
(multi-project orders are a rare schema possibility, not a flow; avoid surprise mass updates)
- the service returns what cascaded so the route writes an audit row for the order change too
**Received orders** (`src/services/orders.service.ts`):
- `VALID_TRANSITIONS` gains reopen: `dokoncena: [v_realizaci]`, `stornovana: [v_realizaci]`
- `syncProjectStatus` becomes reopen-aware: given the previous status, a transition OUT of a
terminal state (reopen) skips project sync entirely (decision 2). Forward transitions keep the
existing mapping (v_realizaci→aktivni no-op in practice, dokoncena→dokonceny,
stornovana→zruseny), and the cascaded project change now gets an audit row.
- item/section edit guards stay status-based, so a reopened order is editable again (intended).
No DB migration (status columns are strings already).
### Frontend
**Shared `StatusChipMenu`** (`src/admin/components/StatusChipMenu.tsx`): renders a `StatusChip`;
click opens a small MUI `Menu` of actions; each action either opens a `ConfirmDialog`
(danger variant + `loading` during the request; cascade note in the message) or runs a plain
`onClick` (the modal-opening offer item). Hidden affordance fixed via `title` tooltip. Chip is
plain (non-clickable) when the user lacks the edit permission or no actions exist.
**Offers list**: draft → `Aktivovat` (confirm notes the number assignment; after success the same
fire-and-forget PDF-archive call the detail does); active → `Vytvořit objednávku…` (opens the
existing modal) + `Zneplatnit` (danger); ordered → `Zneplatnit` (danger).
**ReceivedOrders list**: prijata → `Zahájit realizaci`, `Stornovat` (danger, note: linked project
will be cancelled); v_realizaci → `Dokončit` (note: linked project will be completed),
`Stornovat` (danger); dokoncena/stornovana → `Obnovit` (reopen, no cascade note).
**Projects list**: aktivni → `Dokončit` (note when `order_id` present: linked order will be
completed), `Zrušit` (danger, order-storno note); dokonceny/zruseny → `Obnovit`.
**ProjectDetail**: status `Select` removed from the form (and status removed from the save
payload); header gains transition buttons rendered from `valid_transitions`
(`Dokončit projekt` / `Zrušit projekt` danger / `Obnovit projekt`), each via ConfirmDialog with
cascade notes, as a status-only PUT with its own mutation
(invalidate: projects, orders, offers, invoices, warehouse). OrderDetail keeps its pattern;
its transition label shows `Obnovit` when reopening from a terminal state.
Invalidation for all new status mutations: `["orders","offers","projects","invoices"]`
(+`["warehouse"]` on project mutations, matching the page's existing set).
### Tests
Service-level (real `app_test` DB): project transition validation (all legal/illegal edges incl.
legacy-status tolerance), project→order cascade both mappings + the guard (no cascade onto
dokoncena/stornovana orders), reopen-no-cascade both directions, order reopen transitions, and
regression: order→project sync still fires on forward transitions.
## Out of scope
Offer machine changes (unchanged: draft→active→ordered/invalidated), issued orders, sibling-project
propagation, ReceivedOrders detail-page button changes beyond the automatic reopen button.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.41", "version": "2.4.42",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.41", "version": "2.4.42",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.41", "version": "2.4.42",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -196,6 +196,31 @@ describe("order attachment payload hygiene", () => {
expect(withoutDetail!.attachment_name).toBeNull(); expect(withoutDetail!.attachment_name).toBeNull();
}); });
it("PUT /:id accepts the canonical storno token through the Zod layer", async () => {
// Regression: UpdateOrderSchema carried a phantom "zrusena" enum member
// until 2026-07, so { status: "stornovana" } 400ed at parseBody with a
// raw English Zod message. Service-level tests bypass the schema — this
// must stay a route-level test.
const customer = await makeCustomer();
const res = await createOrder({
...baseOrder,
customer_id: customer.id,
create_project: false,
});
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const put = await app.inject({
method: "PUT",
url: `/api/admin/orders/${res.id}`,
headers: { authorization: `Bearer ${adminToken}` },
payload: { status: "stornovana" },
});
expect(put.statusCode).toBe(200);
const row = await prisma.orders.findUnique({ where: { id: res.id! } });
expect(row!.status).toBe("stornovana");
});
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => { it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
const { withId, withoutId } = await makeOrderPair(); const { withId, withoutId } = await makeOrderPair();
const headers = { authorization: `Bearer ${adminToken}` }; const headers = { authorization: `Bearer ${adminToken}` };

View File

@@ -0,0 +1,301 @@
import { describe, it, expect, afterEach, afterAll } from "vitest";
import prisma from "../config/database";
import {
updateProject,
getProject,
VALID_TRANSITIONS as PROJECT_VALID_TRANSITIONS,
} from "../services/projects.service";
import { updateOrder } from "../services/orders.service";
/**
* Bidirectional project⇄order status sync (spec 2026-07-04):
* - projects gain a status machine (aktivni ⇄ dokonceny/zruseny) with
* legacy-status tolerance,
* - finishing/cancelling a project cascades onto its linked OPEN order
* (prijata/v_realizaci) — terminal orders are never touched,
* - reopen (project → aktivni, order → v_realizaci) is now a legal
* transition and NEVER cascades,
* - regression: the existing order→project forward sync stays intact.
*
* Real app_test DB via the service layer (suite convention). Fixtures use a
* unique prefix; projects are created without project_number so getProject's
* NAS folder probe is skipped.
*/
const N = "posync_";
const createdProjectIds: number[] = [];
const createdOrderIds: number[] = [];
let seq = 0;
async function mkOrder(status: string) {
const order = await prisma.orders.create({
data: { order_number: `${N}${Date.now()}_${seq++}`, status },
});
createdOrderIds.push(order.id);
return order;
}
async function mkProject(status: string, orderId?: number) {
const project = await prisma.projects.create({
data: { name: `${N}project_${seq++}`, status, order_id: orderId ?? null },
});
createdProjectIds.push(project.id);
return project;
}
// FK-safe order: projects reference orders (Restrict), so projects go first.
afterEach(async () => {
await prisma.projects.deleteMany({
where: { id: { in: createdProjectIds } },
});
await prisma.orders.deleteMany({ where: { id: { in: createdOrderIds } } });
createdProjectIds.length = 0;
createdOrderIds.length = 0;
});
afterAll(async () => {
await prisma.$disconnect();
});
function assertOk<T>(
res: T,
): asserts res is Exclude<T, null | { error: unknown }> {
if (!res || (typeof res === "object" && "error" in res)) {
throw new Error(`expected success, got ${JSON.stringify(res)}`);
}
}
describe("project → order cascade (updateProject)", () => {
it("aktivni→dokonceny completes a linked v_realizaci order and returns synced_order", async () => {
const order = await mkOrder("v_realizaci");
const project = await mkProject("aktivni", order.id);
const res = await updateProject(project.id, { status: "dokonceny" });
assertOk(res);
expect(res.synced_order).toEqual({
id: order.id,
order_number: order.order_number,
from: "v_realizaci",
to: "dokoncena",
});
expect(res.old_status).toBe("aktivni");
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("dokoncena");
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("dokonceny");
});
it("aktivni→zruseny cancels a linked prijata order", async () => {
const order = await mkOrder("prijata");
const project = await mkProject("aktivni", order.id);
const res = await updateProject(project.id, { status: "zruseny" });
assertOk(res);
expect(res.synced_order).toEqual({
id: order.id,
order_number: order.order_number,
from: "prijata",
to: "stornovana",
});
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("stornovana");
});
it("guard: never resurrects a terminal order (stornovana stays stornovana)", async () => {
const order = await mkOrder("stornovana");
const project = await mkProject("aktivni", order.id);
const res = await updateProject(project.id, { status: "dokonceny" });
assertOk(res);
expect(res.synced_order).toBeNull();
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("stornovana");
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("dokonceny");
});
it("reopen (dokonceny→aktivni) never touches the linked order", async () => {
const order = await mkOrder("dokoncena");
const project = await mkProject("dokonceny", order.id);
const res = await updateProject(project.id, { status: "aktivni" });
assertOk(res);
expect(res.synced_order).toBeNull();
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("dokoncena");
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("aktivni");
});
it("no linked order → no cascade, synced_order null", async () => {
const project = await mkProject("aktivni");
const res = await updateProject(project.id, { status: "dokonceny" });
assertOk(res);
expect(res.synced_order).toBeNull();
});
});
describe("project status machine (updateProject validation)", () => {
it("rejects dokonceny→zruseny with the invalid_transition token", async () => {
const project = await mkProject("dokonceny");
const res = await updateProject(project.id, { status: "zruseny" });
expect(res && "error" in res && res.error).toBe("invalid_transition");
expect(res).toMatchObject({
currentStatus: "dokonceny",
newStatus: "zruseny",
});
// Nothing was written.
const fresh = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(fresh?.status).toBe("dokonceny");
});
it("legacy tolerance: an unknown current status may move to any canonical target", async () => {
const project = await mkProject("stary");
const res = await updateProject(project.id, { status: "dokonceny" });
assertOk(res);
const fresh = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(fresh?.status).toBe("dokonceny");
});
it("legacy tolerance still rejects a non-canonical target", async () => {
const project = await mkProject("stary");
const res = await updateProject(project.id, { status: "nesmysl" });
expect(res && "error" in res && res.error).toBe("invalid_transition");
});
it("status-unchanged payloads pass without transition validation", async () => {
const project = await mkProject("dokonceny");
const res = await updateProject(project.id, {
status: "dokonceny",
notes: `${N}note`,
});
assertOk(res);
});
});
describe("order reopen + order → project sync (updateOrder)", () => {
it("dokoncena→v_realizaci is now valid and skips project sync (reopen)", async () => {
const order = await mkOrder("dokoncena");
const project = await mkProject("dokonceny", order.id);
const res = await updateOrder(order.id, { status: "v_realizaci" });
expect("error" in res).toBe(false);
if ("error" in res) throw new Error(res.error);
expect(res.synced_projects).toEqual([]);
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("v_realizaci");
// Reopen never cascades — the project keeps its terminal status.
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("dokonceny");
});
it("stornovana→v_realizaci is valid too; other exits from terminal stay rejected", async () => {
const order = await mkOrder("stornovana");
const res = await updateOrder(order.id, { status: "v_realizaci" });
expect("error" in res).toBe(false);
const back = await mkOrder("dokoncena");
const rejected = await updateOrder(back.id, { status: "prijata" });
expect("error" in rejected).toBe(true);
if ("error" in rejected) {
expect(rejected.status).toBe(400);
expect(rejected.error).toContain("Neplatný přechod stavu");
}
});
it("regression: v_realizaci→dokoncena still completes the linked project and reports it", async () => {
const order = await mkOrder("v_realizaci");
const project = await mkProject("aktivni", order.id);
const res = await updateOrder(order.id, { status: "dokoncena" });
expect("error" in res).toBe(false);
if ("error" in res) throw new Error(res.error);
expect(res.synced_projects).toEqual([
{
id: project.id,
project_number: null,
from: "aktivni",
to: "dokonceny",
},
]);
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("dokonceny");
});
it("regression: prijata→stornovana still cancels the linked project", async () => {
const order = await mkOrder("prijata");
const project = await mkProject("aktivni", order.id);
const res = await updateOrder(order.id, { status: "stornovana" });
expect("error" in res).toBe(false);
if ("error" in res) throw new Error(res.error);
expect(res.synced_projects).toHaveLength(1);
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("zruseny");
});
});
describe("getProject valid_transitions", () => {
it("matches the machine for canonical statuses", async () => {
const active = await mkProject("aktivni");
expect((await getProject(active.id))?.valid_transitions).toEqual(
PROJECT_VALID_TRANSITIONS["aktivni"],
);
expect((await getProject(active.id))?.valid_transitions).toEqual([
"dokonceny",
"zruseny",
]);
const done = await mkProject("dokonceny");
expect((await getProject(done.id))?.valid_transitions).toEqual(["aktivni"]);
const cancelled = await mkProject("zruseny");
expect((await getProject(cancelled.id))?.valid_transitions).toEqual([
"aktivni",
]);
});
it("offers all canonical targets for a legacy/unknown status", async () => {
const legacy = await mkProject("stary");
expect((await getProject(legacy.id))?.valid_transitions).toEqual([
"aktivni",
"dokonceny",
"zruseny",
]);
});
});

View File

@@ -0,0 +1,148 @@
import { useState, type ComponentProps } from "react";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { StatusChip, ConfirmDialog } from "../ui";
/** One entry in a {@link StatusChipMenu}. */
export interface StatusChipAction {
/** Stable identifier, e.g. the target status. */
key: string;
/** Menu item text, e.g. "Dokončit". */
label: string;
/** Red menu-item text + danger ConfirmDialog variant. */
danger?: boolean;
/**
* Confirmation dialog content. When omitted the action fires directly from
* the menu without confirmation (used by "Vytvořit objednávku…", which
* opens its own modal).
*/
confirm?: { title: string; message: string; confirmText: string };
/**
* Executed on pick (after confirmation when `confirm` is set). Awaited:
* while pending the ConfirmDialog shows its loading state and cannot be
* closed; on resolve the dialog closes; on rejection it stays open (the
* caller is responsible for toasting the error).
*/
onAction: () => void | Promise<void>;
}
interface StatusChipMenuProps {
/** Chip text (current status label). */
label: string;
/** Chip color, same palette as {@link StatusChip}. */
color: ComponentProps<typeof StatusChip>["color"];
/** Quick actions. Empty/undefined renders a plain non-clickable chip. */
actions?: StatusChipAction[];
/** Force a plain chip (e.g. the user lacks the edit permission). */
disabled?: boolean;
/** Tooltip on the clickable chip. */
title?: string;
}
/**
* Status chip with a quick-action menu, shared by the list pages
* (offers / received orders / projects).
*
* Clicking the chip (stopPropagation, so row navigation never fires) opens a
* dense MUI Menu of the valid next actions. Picking one closes the menu and
* either fires `onAction` directly (no `confirm` given) or opens the single
* shared ConfirmDialog instance — danger variant for destructive actions,
* `loading` while the awaited `onAction` is pending, staying open when it
* rejects per app convention (the caller toasts errors).
*
* With no actions — or with `disabled` — it renders a plain non-clickable
* StatusChip without a tooltip.
*/
export default function StatusChipMenu({
label,
color,
actions,
disabled = false,
title = "Změnit stav",
}: StatusChipMenuProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [pending, setPending] = useState<StatusChipAction | null>(null);
const [loading, setLoading] = useState(false);
if (disabled || !actions || actions.length === 0) {
return <StatusChip label={label} color={color} />;
}
const handlePick = (action: StatusChipAction) => {
setAnchorEl(null);
if (action.confirm) {
setPending(action);
} else {
void (async () => {
try {
await action.onAction();
} catch {
// Expected: the caller toasts its own errors; nothing to close here.
}
})();
}
};
const handleConfirm = async () => {
if (!pending) return;
setLoading(true);
try {
await pending.onAction();
// Success → close; ConfirmDialog freezes its content through the fade.
setPending(null);
} catch {
// Expected: rejection keeps the dialog open; the caller toasts the error.
} finally {
setLoading(false);
}
};
return (
<>
<StatusChip
label={label}
color={color}
title={title}
aria-haspopup="menu"
aria-expanded={Boolean(anchorEl)}
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{ list: { dense: true } }}
>
{actions.map((action) => (
<MenuItem
key={action.key}
// stopPropagation: MUI portals re-bubble synthetic events to
// React-tree ancestors — a future onRowClick row must not fire.
onClick={(e) => {
e.stopPropagation();
handlePick(action);
}}
sx={action.danger ? { color: "error.main" } : undefined}
>
{action.label}
</MenuItem>
))}
</Menu>
<ConfirmDialog
isOpen={pending !== null}
onClose={() => setPending(null)}
onConfirm={() => void handleConfirm()}
title={pending?.confirm?.title ?? ""}
message={pending?.confirm?.message ?? ""}
confirmText={pending?.confirm?.confirmText}
confirmVariant={pending?.danger ? "danger" : "primary"}
loading={loading}
/>
</>
);
}

View File

@@ -28,6 +28,8 @@ export interface ProjectData {
quotation_number?: string; quotation_number?: string;
has_nas_folder?: boolean; has_nas_folder?: boolean;
project_notes?: ProjectNote[]; project_notes?: ProjectNote[];
/** Valid status-machine targets from the current status (server-computed). */
valid_transitions?: string[];
} }
export interface Project { export interface Project {

View File

@@ -11,6 +11,9 @@ import ListItemText from "@mui/material/ListItemText";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { import {
@@ -41,7 +44,6 @@ import {
Field, Field,
TextField, TextField,
Select, Select,
StatusChip,
FileUpload, FileUpload,
EmptyState, EmptyState,
FilterBar, FilterBar,
@@ -413,6 +415,21 @@ export default function Offers() {
}, },
}); });
// Quick-action finalize (draft → active) from the status chip menu. A
// status-only PUT deliberately passes the editable-state guard; the number
// is assigned in-transaction server-side (same semantics as the detail).
const activateMutation = useApiMutation<
{ id: number; status: "active" },
{ id: number }
>({
url: (input) => `${API_BASE}/offers/${input.id}`,
method: () => "PUT",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: () => {
alert.success("Nabídka byla aktivována");
},
});
if (!hasPermission("offers.view")) return <Forbidden />; if (!hasPermission("offers.view")) return <Forbidden />;
const handleDuplicate = async (quotation: Quotation) => { const handleDuplicate = async (quotation: Quotation) => {
@@ -489,6 +506,89 @@ export default function Offers() {
} }
}; };
const handleActivate = async (q: Quotation) => {
try {
await activateMutation.mutateAsync({ id: q.id, status: "active" });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
// Re-throw so the chip's ConfirmDialog stays open (app convention).
throw e;
}
// A finalized offer now has its official number — archive the PDF on the
// NAS, exactly like OfferDetail after finalize. Fire-and-forget: never
// block the toast/refresh — but a failure is surfaced, not swallowed.
apiFetch(`${API_BASE}/offers-pdf/${q.id}?save=1`)
.then((res) => {
if (!res.ok) alert.error("Nepodařilo se archivovat PDF nabídky na NAS");
})
.catch(() => alert.error("Nepodařilo se archivovat PDF nabídky na NAS"));
};
// "Zneplatnit" chip-menu entry — same wording as the page's existing
// invalidate ConfirmDialog and the same row mutation underneath.
const invalidateChipAction = (q: Quotation): StatusChipAction => ({
key: "invalidated",
label: "Zneplatnit",
danger: true,
confirm: {
title: "Zneplatnit nabídku",
message: `Opravdu chcete zneplatnit nabídku „${documentNumberLabel(q.quotation_number)}“? Nabídka bude pouze pro čtení a nepůjde upravovat.`,
confirmText: "Zneplatnit",
},
onAction: async () => {
try {
await invalidateMutation.mutateAsync(q.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
// Re-throw so the chip's ConfirmDialog stays open (app convention).
throw e;
}
},
});
// Quick actions for the status chip menu, by offer status. Only rendered
// when the user holds offers.edit (the page's mutating-action permission);
// "Vytvořit objednávku…" additionally mirrors the create-order icon's gates
// (active + no existing order + orders.create) and opens the same modal.
const statusQuickActions = (q: Quotation): StatusChipAction[] => {
switch (q.status) {
case "draft":
return [
{
key: "active",
label: "Aktivovat",
confirm: {
title: "Aktivovat nabídku",
message: `Nabídce „${documentNumberLabel(q.quotation_number)}“ bude přiděleno oficiální číslo. Aktivovat?`,
confirmText: "Aktivovat",
},
onAction: () => handleActivate(q),
},
];
case "active": {
const actions: StatusChipAction[] = [];
if (!q.order_id && hasPermission("orders.create")) {
actions.push({
key: "create-order",
label: "Vytvořit objednávku…",
onAction: () => {
setCustomerOrderNumber("");
setOrderAttachment(null);
setOrderModal({ show: true, quotation: q });
},
});
}
actions.push(invalidateChipAction(q));
return actions;
}
case "ordered":
return [invalidateChipAction(q)];
default:
// `invalidated` (and any unknown status): plain chip, no actions.
return [];
}
};
// Only show the full-page skeleton on the very first load; on subsequent // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/customer/tab/page change) keep the table visible (the // refetches (filter/customer/tab/page change) keep the table visible (the
// Card dims via isFetching) so it doesn't flash. // Card dims via isFetching) so it doesn't flash.
@@ -572,14 +672,19 @@ export default function Offers() {
// The offer's own status is `active`/`ordered`/`invalidated`. When its // The offer's own status is `active`/`ordered`/`invalidated`. When its
// linked order is completed, surface that as "Dokončená" (success) — // linked order is completed, surface that as "Dokončená" (success) —
// matching the OfferDetail header chip and the completed row tint. // matching the OfferDetail header chip and the completed row tint.
// Completed rows are read-only everywhere on this page, so the chip
// stays plain (no quick actions) in that derived state.
const completed = const completed =
q.status !== "invalidated" && q.order_status === "dokoncena"; q.status !== "invalidated" && q.order_status === "dokoncena";
return completed ? ( return completed ? (
<StatusChip label="Dokončená" color="success" /> <StatusChipMenu label="Dokončená" color="success" />
) : ( ) : (
<StatusChip <StatusChipMenu
label={statusLabel(OFFER_STATUS, q.status)} label={statusLabel(OFFER_STATUS, q.status)}
color={statusColor(OFFER_STATUS, q.status)} color={statusColor(OFFER_STATUS, q.status)}
actions={
hasPermission("offers.edit") ? statusQuickActions(q) : undefined
}
/> />
); );
}, },

View File

@@ -292,6 +292,11 @@ export default function OrderDetail() {
if (!order) return null; if (!order) return null;
// From a terminal state the backend offers v_realizaci as a REOPEN — label
// it "Obnovit" (not "Zahájit realizaci") and note the no-cascade semantics.
const isReopen =
order.status === "dokoncena" || order.status === "stornovana";
const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({ const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({
...item, ...item,
_index: index, _index: index,
@@ -475,7 +480,9 @@ export default function OrderDetail() {
> >
{statusChanging === status {statusChanging === status
? "Zpracovávám…" ? "Zpracovávám…"
: TRANSITION_LABELS[status] || status} : isReopen && status === "v_realizaci"
? "Obnovit"
: TRANSITION_LABELS[status] || status}
</Button> </Button>
))} ))}
{hasPermission("orders.delete") && ( {hasPermission("orders.delete") && (
@@ -782,9 +789,15 @@ export default function OrderDetail() {
onClose={() => setStatusConfirm({ show: false, status: null })} onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange} onConfirm={handleStatusChange}
title="Změnit stav objednávky" title="Změnit stav objednávky"
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`} message={
isReopen && statusConfirm.status === "v_realizaci"
? `Opravdu chcete obnovit objednávku "${order.order_number}"? Objednávka se vrátí do stavu "V realizaci". Propojený projekt zůstane beze změny.`
: `Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`
}
confirmText={ confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit" isReopen && statusConfirm.status === "v_realizaci"
? "Obnovit"
: TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
} }
cancelText="Zrušit" cancelText="Zrušit"
/> />

View File

@@ -40,6 +40,12 @@ import {
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
const TRANSITION_LABELS: Record<string, string> = {
dokonceny: "Dokončit projekt",
zruseny: "Zrušit projekt",
aktivni: "Obnovit projekt",
};
interface User { interface User {
id: number; id: number;
name: string; name: string;
@@ -47,7 +53,6 @@ interface User {
interface ProjectForm { interface ProjectForm {
name: string; name: string;
status: string;
start_date: string; start_date: string;
end_date: string; end_date: string;
responsible_user_id: string; responsible_user_id: string;
@@ -76,11 +81,15 @@ export default function ProjectDetail() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProjectForm>({ const [form, setForm] = useState<ProjectForm>({
name: "", name: "",
status: "aktivni",
start_date: "", start_date: "",
end_date: "", end_date: "",
responsible_user_id: "", 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 [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -121,7 +130,6 @@ export default function ProjectDetail() {
if (project && !formInitialized.current) { if (project && !formInitialized.current) {
setForm({ setForm({
name: project.name || "", name: project.name || "",
status: project.status || "aktivni",
start_date: (project.start_date || "").substring(0, 10), start_date: (project.start_date || "").substring(0, 10),
end_date: (project.end_date || "").substring(0, 10), end_date: (project.end_date || "").substring(0, 10),
responsible_user_id: project.responsible_user_id || "", responsible_user_id: project.responsible_user_id || "",
@@ -141,7 +149,6 @@ export default function ProjectDetail() {
const projectSaveMutation = useApiMutation< const projectSaveMutation = useApiMutation<
{ {
name: string; name: string;
status: string;
start_date: string | null; start_date: string | null;
end_date: string | null; end_date: string | null;
responsible_user_id: string | null; responsible_user_id: string | null;
@@ -153,6 +160,14 @@ export default function ProjectDetail() {
invalidate: ["projects", "warehouse"], 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< const projectDeleteMutation = useApiMutation<
{ delete_files: boolean }, { delete_files: boolean },
unknown unknown
@@ -189,7 +204,6 @@ export default function ProjectDetail() {
try { try {
await projectSaveMutation.mutateAsync({ await projectSaveMutation.mutateAsync({
name: form.name, name: form.name,
status: form.status,
start_date: form.start_date || null, start_date: form.start_date || null,
end_date: form.end_date || null, end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || 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 () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true);
try { try {
@@ -333,9 +392,24 @@ export default function ProjectDetail() {
</Box> </Box>
{canEdit && ( {canEdit && (
<Box sx={headerActionsSx}> <Box sx={headerActionsSx}>
<Button onClick={handleSave} disabled={saving}> <Button
onClick={handleSave}
disabled={saving || statusChanging !== null}
>
{saving ? "Ukládání..." : "Uložit"} {saving ? "Ukládání..." : "Uložit"}
</Button> </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 && ( {!project.order_id && (
<Button <Button
variant="outlined" variant="outlined"
@@ -399,23 +473,10 @@ export default function ProjectDetail() {
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" }, gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2, 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í"> <Field label="Datum zahájení">
<DateField <DateField
value={form.start_date} value={form.start_date}
@@ -609,6 +670,22 @@ export default function ProjectDetail() {
</Box> </Box>
</Card> </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 <ConfirmDialog
isOpen={deleteConfirm} isOpen={deleteConfirm}
onClose={() => { onClose={() => {

View File

@@ -7,6 +7,9 @@ import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import { formatDate } from "../utils/formatters"; import { formatDate } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
@@ -30,7 +33,6 @@ import {
TextField, TextField,
Select, Select,
DateField, DateField,
StatusChip,
CheckboxField, CheckboxField,
LoadingState, LoadingState,
PageEnter, PageEnter,
@@ -144,6 +146,17 @@ export default function Projects() {
}, },
}); });
// Quick status change from the list chip menu. A project transition may
// cascade to the linked order (and its documents), so invalidate broadly.
const statusMutation = useApiMutation<
{ id: number; status: string },
unknown
>({
url: ({ id }) => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "orders", "offers", "invoices", "warehouse"],
});
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
name: "", name: "",
@@ -259,6 +272,78 @@ export default function Projects() {
return <LoadingState />; return <LoadingState />;
} }
const canEditStatus = hasPermission("projects.edit");
// "Číslo Název" reference for confirm messages (name may be empty on
// legacy rows).
const projectRef = (p: Project) =>
p.name ? `${p.project_number} ${p.name}` : p.project_number;
const changeStatus = async (p: Project, status: string) => {
try {
await statusMutation.mutateAsync({ id: p.id, status });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
throw e; // rejection keeps the ConfirmDialog open (app convention)
}
};
// Quick actions per current status; unknown/legacy statuses get none
// (plain chip) — those are resolved on the detail page.
const statusActions = (p: Project): StatusChipAction[] => {
if (p.status === "aktivni") {
return [
{
key: "dokonceny",
label: "Dokončit",
confirm: {
title: "Dokončit projekt",
message:
`Označit projekt "${projectRef(p)}" jako dokončený?` +
(p.order_id
? " Propojená objednávka bude automaticky dokončena."
: ""),
confirmText: "Dokončit",
},
onAction: () => changeStatus(p, "dokonceny"),
},
{
key: "zruseny",
label: "Zrušit",
danger: true,
confirm: {
title: "Zrušit projekt",
message:
`Označit projekt "${projectRef(p)}" jako zrušený?` +
(p.order_id
? " Propojená objednávka bude automaticky stornována."
: ""),
confirmText: "Zrušit projekt",
},
onAction: () => changeStatus(p, "zruseny"),
},
];
}
if (p.status === "dokonceny" || p.status === "zruseny") {
return [
{
key: "aktivni",
label: "Obnovit",
confirm: {
title: "Obnovit projekt",
message:
'Projekt se vrátí do stavu "Aktivní".' +
(p.order_id ? " Propojená objednávka zůstane beze změny." : ""),
confirmText: "Obnovit",
},
onAction: () => changeStatus(p, "aktivni"),
},
];
}
return [];
};
const columns: DataColumn<Project>[] = [ const columns: DataColumn<Project>[] = [
{ {
key: "project_number", key: "project_number",
@@ -306,9 +391,11 @@ export default function Projects() {
width: "10%", width: "10%",
sortKey: "status", sortKey: "status",
render: (p) => ( render: (p) => (
<StatusChip <StatusChipMenu
label={statusLabel(PROJECT_STATUS, p.status)} label={statusLabel(PROJECT_STATUS, p.status)}
color={statusColor(PROJECT_STATUS, p.status)} color={statusColor(PROJECT_STATUS, p.status)}
actions={statusActions(p)}
disabled={!canEditStatus}
/> />
), ),
}, },

View File

@@ -6,6 +6,9 @@ import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { import {
formatCurrency, formatCurrency,
@@ -32,7 +35,6 @@ import {
Field, Field,
TextField, TextField,
Select, Select,
StatusChip,
CheckboxField, CheckboxField,
FileUpload, FileUpload,
FilterBar, FilterBar,
@@ -180,6 +182,17 @@ export default function OrdersReceived({
}, },
}); });
// Quick status change from the table chip (StatusChipMenu). The `id` rides
// along in the input only to build the URL; UpdateOrderSchema strips it.
const statusMutation = useApiMutation<
{ id: number; status: string },
unknown
>({
url: ({ id }) => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "offers", "projects", "invoices"],
});
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
customer_id: "", customer_id: "",
customer_order_number: "", customer_order_number: "",
@@ -352,6 +365,79 @@ export default function OrdersReceived({
return <LoadingState />; return <LoadingState />;
} }
// Quick actions for the status chip menu, mirroring the order status
// machine (VALID_TRANSITIONS incl. the reopen edges). Rejections propagate
// so the ConfirmDialog stays open per app convention; we toast here.
const statusActions = (o: Order): StatusChipAction[] => {
const changeStatus = (status: string) => async () => {
try {
await statusMutation.mutateAsync({ id: o.id, status });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
throw e;
}
};
const stornovat: StatusChipAction = {
key: "stornovana",
label: "Stornovat",
danger: true,
confirm: {
title: "Stornovat objednávku",
message: `Opravdu chcete stornovat objednávku „${o.order_number}“? Propojený projekt bude automaticky zrušen.`,
confirmText: "Stornovat",
},
onAction: changeStatus("stornovana"),
};
switch (o.status) {
case "prijata":
return [
{
key: "v_realizaci",
label: "Zahájit realizaci",
confirm: {
title: "Zahájit realizaci",
message: `Opravdu chcete zahájit realizaci objednávky „${o.order_number}“?`,
confirmText: "Zahájit realizaci",
},
onAction: changeStatus("v_realizaci"),
},
stornovat,
];
case "v_realizaci":
return [
{
key: "dokoncena",
label: "Dokončit",
confirm: {
title: "Dokončit objednávku",
message: `Opravdu chcete dokončit objednávku „${o.order_number}“? Propojený projekt bude automaticky dokončen.`,
confirmText: "Dokončit",
},
onAction: changeStatus("dokoncena"),
},
stornovat,
];
case "dokoncena":
case "stornovana":
// Reopen — deliberately no cascade to the linked project.
return [
{
key: "v_realizaci",
label: "Obnovit",
confirm: {
title: "Obnovit objednávku",
message: `Opravdu chcete obnovit objednávku „${o.order_number}“? Objednávka se vrátí do stavu "V realizaci". Propojený projekt zůstane beze změny.`,
confirmText: "Obnovit",
},
onAction: changeStatus("v_realizaci"),
},
];
default:
return [];
}
};
const columns: DataColumn<Order>[] = [ const columns: DataColumn<Order>[] = [
{ {
key: "order_number", key: "order_number",
@@ -403,9 +489,11 @@ export default function OrdersReceived({
width: "13%", width: "13%",
sortKey: "status", sortKey: "status",
render: (o) => ( render: (o) => (
<StatusChip <StatusChipMenu
label={statusLabel(ORDER_STATUS, o.status)} label={statusLabel(ORDER_STATUS, o.status)}
color={statusColor(ORDER_STATUS, o.status)} color={statusColor(ORDER_STATUS, o.status)}
actions={statusActions(o)}
disabled={!hasPermission("orders.edit")}
/> />
), ),
}, },
@@ -589,7 +677,7 @@ export default function OrdersReceived({
title="Smazat objednávku" title="Smazat objednávku"
message={ message={
deleteConfirm.order deleteConfirm.order
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.` ? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}? Bude smazán i přidružený projekt. Tato akce je nevratná.`
: "" : ""
} }
confirmText="Smazat" confirmText="Smazat"

View File

@@ -331,6 +331,26 @@ export default async function ordersRoutes(
entityId: id, entityId: id,
description: `Upravena objednávka ${result.data.order_number}`, description: `Upravena objednávka ${result.data.order_number}`,
}); });
// The order→project cascade updated linked project(s) — give each its
// own audit trail entry.
for (const p of result.synced_projects ?? []) {
const verb =
p.to === "dokonceny"
? "dokončen"
: p.to === "zruseny"
? "zrušen"
: "aktivován";
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "project",
entityId: p.id,
description: `Projekt ${p.project_number ?? `#${p.id}`} automaticky ${verb} (synchronizace s objednávkou)`,
oldValues: { status: p.from },
newValues: { status: p.to },
});
}
return success(reply, { id }, 200, "Objednávka byla uložena"); return success(reply, { id }, 200, "Objednávka byla uložena");
}, },
); );

View File

@@ -131,6 +131,12 @@ export default async function projectsRoutes(
const result = await updateProject(id, parsed.data); const result = await updateProject(id, parsed.data);
if (!result) return error(reply, "Projekt nenalezen", 404); if (!result) return error(reply, "Projekt nenalezen", 404);
if ("error" in result) { if ("error" in result) {
if (result.error === "invalid_transition" && "currentStatus" in result)
return error(
reply,
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
400,
);
return error( return error(
reply, reply,
result.error, result.error,
@@ -138,6 +144,7 @@ export default async function projectsRoutes(
); );
} }
const statusChanged = result.old_status !== result.status;
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
@@ -145,7 +152,26 @@ export default async function projectsRoutes(
entityType: "project", entityType: "project",
entityId: id, entityId: id,
description: `Upraven projekt ${result.name}`, description: `Upraven projekt ${result.name}`,
oldValues: statusChanged ? { status: result.old_status } : undefined,
newValues: statusChanged ? { status: result.status } : undefined,
}); });
// The project→order cascade updated the linked order too — give the
// order its own audit trail entry.
if (result.synced_order) {
const so = result.synced_order;
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "order",
entityId: so.id,
description: `Objednávka ${so.order_number ?? `#${so.id}`} automaticky ${
so.to === "dokoncena" ? "dokončena" : "stornována"
} (synchronizace s projektem)`,
oldValues: { status: so.from },
newValues: { status: so.to },
});
}
return success(reply, { id }, 200, "Projekt byl uložen"); return success(reply, { id }, 200, "Projekt byl uložen");
}, },
); );

View File

@@ -36,7 +36,7 @@ export const CreateOrderSchema = z.object({
quotation_id: nullableIntIdFromForm.nullish(), quotation_id: nullableIntIdFromForm.nullish(),
customer_id: nullableIntIdFromForm.nullish(), customer_id: nullableIntIdFromForm.nullish(),
status: z status: z
.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]) .enum(["prijata", "v_realizaci", "dokoncena", "stornovana"])
.optional() .optional()
.default("prijata"), .default("prijata"),
currency: z.string().max(10).optional().default("CZK"), currency: z.string().max(10).optional().default("CZK"),
@@ -52,7 +52,9 @@ export const CreateOrderSchema = z.object({
export const UpdateOrderSchema = z.object({ export const UpdateOrderSchema = z.object({
customer_order_number: z.string().max(100).nullish(), customer_order_number: z.string().max(100).nullish(),
status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(), status: z
.enum(["prijata", "v_realizaci", "dokoncena", "stornovana"])
.optional(),
currency: z.string().max(10).optional(), currency: z.string().max(10).optional(),
language: z.string().max(5).optional(), language: z.string().max(5).optional(),
scope_title: z.string().max(255).nullish(), scope_title: z.string().max(255).nullish(),

View File

@@ -31,12 +31,13 @@ interface OrderSectionInput {
position?: number; position?: number;
} }
// Status transition rules matching PHP // Status transition rules matching PHP, plus the deliberate reopen edges
// dokoncena/stornovana → v_realizaci (spec 2026-07-04 — status quick actions).
export const VALID_TRANSITIONS: Record<string, string[]> = { export const VALID_TRANSITIONS: Record<string, string[]> = {
prijata: ["v_realizaci", "stornovana"], prijata: ["v_realizaci", "stornovana"],
v_realizaci: ["dokoncena", "stornovana"], v_realizaci: ["dokoncena", "stornovana"],
dokoncena: [], dokoncena: ["v_realizaci"],
stornovana: [], stornovana: ["v_realizaci"],
}; };
const ORDER_ALLOWED_SORT_FIELDS = [ const ORDER_ALLOWED_SORT_FIELDS = [
@@ -54,23 +55,53 @@ const ORDER_TO_PROJECT_STATUS: Record<string, string> = {
stornovana: "zruseny", stornovana: "zruseny",
}; };
// Order statuses that count as terminal — a transition OUT of one is a REOPEN,
// which deliberately never cascades to the linked project(s) (spec 2026-07-04).
const TERMINAL_ORDER_STATUSES = ["dokoncena", "stornovana"];
/** Project change performed by syncProjectStatus, returned for auditing. */
export interface SyncedProject {
id: number;
project_number: string | null;
from: string | null;
to: string;
}
/** /**
* Propagate an order status change onto its linked project(s). No-op when the * Propagate an order status change onto its linked project(s). No-op when the
* new status has no project-status mapping. Accepts a Prisma client (the tx * new status has no project-status mapping, or when the change is a reopen
* client inside a transaction, or the base client otherwise) so both update * (previous status terminal — reopening never cascades, spec 2026-07-04).
* branches share one implementation. * Accepts a Prisma client (the tx client inside a transaction, or the base
* client otherwise) so both update branches share one implementation.
* Returns the projects it actually changed so the route can audit them.
*/ */
async function syncProjectStatus( async function syncProjectStatus(
client: Prisma.TransactionClient, client: Prisma.TransactionClient,
orderId: number, orderId: number,
previousStatus: string,
newStatus: string, newStatus: string,
): Promise<void> { ): Promise<SyncedProject[]> {
if (TERMINAL_ORDER_STATUSES.includes(previousStatus)) return [];
const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus]; const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus];
if (!projectStatus) return; if (!projectStatus) return [];
await client.projects.updateMany({ const linked = await client.projects.findMany({
where: { order_id: orderId }, where: { order_id: orderId },
select: { id: true, project_number: true, status: true },
});
// Filter in JS (not via `status: { not: … }`) so NULL-status projects are
// still picked up — SQL `<>` would exclude them.
const changed = linked.filter((p) => p.status !== projectStatus);
if (changed.length === 0) return [];
await client.projects.updateMany({
where: { id: { in: changed.map((p) => p.id) } },
data: { status: projectStatus }, data: { status: projectStatus },
}); });
return changed.map((p) => ({
id: p.id,
project_number: p.project_number,
from: p.status,
to: projectStatus,
}));
} }
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item // ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item
@@ -640,6 +671,10 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
} }
} }
// Projects cascaded onto by a status change — surfaced to the route for
// per-project audit rows.
let syncedProjects: SyncedProject[] = [];
const data: Record<string, unknown> = { modified_at: new Date() }; const data: Record<string, unknown> = { modified_at: new Date() };
const strFields = [ const strFields = [
"customer_order_number", "customer_order_number",
@@ -678,7 +713,12 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
// Sync project status when order status changes (matching PHP) // Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) { if (body.status !== undefined && String(body.status) !== currentStatus) {
await syncProjectStatus(tx, id, String(body.status)); syncedProjects = await syncProjectStatus(
tx,
id,
currentStatus,
String(body.status),
);
} }
if (Array.isArray(body.items)) { if (Array.isArray(body.items)) {
@@ -714,11 +754,19 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
// Sync project status when order status changes (matching PHP) // Sync project status when order status changes (matching PHP)
if (body.status !== undefined && String(body.status) !== currentStatus) { if (body.status !== undefined && String(body.status) !== currentStatus) {
await syncProjectStatus(prisma, id, String(body.status)); syncedProjects = await syncProjectStatus(
prisma,
id,
currentStatus,
String(body.status),
);
} }
} }
return { data: { id, order_number: existing.order_number } }; return {
data: { id, order_number: existing.order_number },
synced_projects: syncedProjects,
};
} }
export async function deleteOrder(id: number, deleteFiles = false) { export async function deleteOrder(id: number, deleteFiles = false) {

View File

@@ -6,6 +6,7 @@ import {
} from "./numbering.service"; } from "./numbering.service";
import { NasFileManager } from "./nas-file-manager"; import { NasFileManager } from "./nas-file-manager";
import type { CreateProjectInput } from "../schemas/projects.schema"; import type { CreateProjectInput } from "../schemas/projects.schema";
import type { projects } from "../types";
const nasFileManager = new NasFileManager(); const nasFileManager = new NasFileManager();
@@ -17,6 +18,29 @@ const ALLOWED_SORT_FIELDS = [
"created_at", "created_at",
]; ];
// Status transition rules (spec 2026-07-04 — projects gain a status machine;
// dokonceny/zruseny → aktivni is a deliberate reopen edge).
export const VALID_TRANSITIONS: Record<string, string[]> = {
aktivni: ["dokonceny", "zruseny"],
dokonceny: ["aktivni"],
zruseny: ["aktivni"],
};
const CANONICAL_STATUSES = Object.keys(VALID_TRANSITIONS);
/**
* Legal next statuses for a project. The status column is a legacy free-text
* string, so an unknown/legacy current status may move to any canonical value
* (minus itself) — tolerant, matching the updateProject validation.
*/
function validTransitionsFor(status: string | null): string[] {
const current = status ?? "";
if (Object.prototype.hasOwnProperty.call(VALID_TRANSITIONS, current)) {
return VALID_TRANSITIONS[current];
}
return CANONICAL_STATUSES.filter((s) => s !== current);
}
interface ListProjectsParams { interface ListProjectsParams {
page: number; page: number;
limit: number; limit: number;
@@ -91,13 +115,39 @@ export async function getProject(id: number) {
order_number: orders?.order_number ?? null, order_number: orders?.order_number ?? null,
order_status: orders?.status ?? null, order_status: orders?.status ?? null,
quotation_number: quotations?.quotation_number ?? null, quotation_number: quotations?.quotation_number ?? null,
// Computed server-side so the frontend renders only legal status buttons
// (same contract as offers/orders/issued orders).
valid_transitions: validTransitionsFor(project.status),
has_nas_folder: project.project_number has_nas_folder: project.project_number
? nasFileManager.projectFolderExists(project.project_number) ? nasFileManager.projectFolderExists(project.project_number)
: false, : false,
}; };
} }
export async function updateProject(id: number, body: Record<string, unknown>) { /** Order change performed by the project→order cascade, returned for auditing. */
export interface SyncedOrder {
id: number;
order_number: string | null;
from: string;
to: string;
}
// Explicit union — inference would normalize the members with `?: undefined`
// props (and narrow synced_order to `null`, its value at the return
// statement), breaking the route's `"error" in result` narrowing.
type UpdateProjectResult =
| null
| { error: string; status: number }
| { error: "invalid_transition"; currentStatus: string; newStatus: string }
| (projects & {
old_status: string | null;
synced_order: SyncedOrder | null;
});
export async function updateProject(
id: number,
body: Record<string, unknown>,
): Promise<UpdateProjectResult> {
const existing = await prisma.projects.findUnique({ where: { id } }); const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return null; if (!existing) return null;
@@ -108,6 +158,25 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
return { error: "Číslo projektu nelze změnit", status: 400 }; return { error: "Číslo projektu nelze změnit", status: 400 };
} }
// Status changes must follow the transition table (offers/orders parity).
// Legacy tolerance: the column is historic free text, so an unknown current
// status may move to any canonical value.
const currentStatus = existing.status ?? "";
const statusChanges =
body.status !== undefined && String(body.status) !== currentStatus;
const newStatus = statusChanges ? String(body.status) : null;
if (statusChanges && newStatus !== null) {
const allowed = Object.prototype.hasOwnProperty.call(
VALID_TRANSITIONS,
currentStatus,
)
? VALID_TRANSITIONS[currentStatus]
: CANONICAL_STATUSES;
if (!allowed.includes(newStatus)) {
return { error: "invalid_transition" as const, currentStatus, newStatus };
}
}
// FK pre-validation (mirrors createProject): a dangling id would raise // FK pre-validation (mirrors createProject): a dangling id would raise
// P2003 at Prisma and surface as a generic 500 — return the same Czech // P2003 at Prisma and surface as a generic 500 — return the same Czech
// 400s as the create path. null still clears the FK. // 400s as the create path. null still clears the FK.
@@ -164,7 +233,45 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
if (body.end_date !== undefined) if (body.end_date !== undefined)
data.end_date = body.end_date ? new Date(String(body.end_date)) : null; data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
const updated = await prisma.projects.update({ where: { id }, data }); // Project → order cascade (spec 2026-07-04): finishing/cancelling a project
// completes/cancels its linked order iff the order is still open
// (prijata/v_realizaci). Reopen (→aktivni) never touches the order, and
// completed/cancelled orders are never resurrected. Direct tx write — no
// updateOrder recursion — in the SAME transaction as the project update.
let syncedOrder: SyncedOrder | null = null;
const wantsCascade =
statusChanges &&
(newStatus === "dokonceny" || newStatus === "zruseny") &&
existing.order_id != null;
const updated = wantsCascade
? await prisma.$transaction(async (tx) => {
const row = await tx.projects.update({ where: { id }, data });
const order = await tx.orders.findUnique({
where: { id: existing.order_id! },
select: { id: true, order_number: true, status: true },
});
const orderStatus = order?.status ?? "";
if (
order &&
(orderStatus === "prijata" || orderStatus === "v_realizaci")
) {
const to = newStatus === "dokonceny" ? "dokoncena" : "stornovana";
await tx.orders.update({
where: { id: order.id },
data: { status: to, modified_at: new Date() },
});
syncedOrder = {
id: order.id,
order_number: order.order_number,
from: orderStatus,
to,
};
}
return row;
})
: await prisma.projects.update({ where: { id }, data });
if ( if (
body.name !== undefined && body.name !== undefined &&
@@ -184,7 +291,9 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
} }
} }
return updated; // old_status + synced_order let the route audit the status change and the
// cascaded order update.
return { ...updated, old_status: existing.status, synced_order: syncedOrder };
} }
/** /**