Compare commits
12 Commits
593dfc356d
...
v2.4.46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3530dd247c | ||
|
|
996012e3d3 | ||
|
|
3cd7cfcc34 | ||
|
|
68d9c224bc | ||
|
|
200aa0e1a8 | ||
|
|
e3b414d22c | ||
|
|
89656ac2c6 | ||
|
|
94030230e7 | ||
|
|
d859b5bbf7 | ||
|
|
5683912b76 | ||
|
|
3ec512faf1 | ||
|
|
9f9f359acb |
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.41",
|
"version": "2.4.46",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.41",
|
"version": "2.4.46",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.41",
|
"version": "2.4.46",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
||||||
<span class="summary-value">16,00h</span>
|
<span class="summary-value">8,00h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Dovolená:</span>
|
<span class="summary-label">Dovolená:</span>
|
||||||
@@ -345,7 +345,11 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Přesčas:</span>
|
<span class="summary-label">Přesčas:</span>
|
||||||
<span class="summary-value">8,00h</span>
|
<span class="summary-value">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row summary-alert">
|
||||||
|
<span class="summary-label">Chybějící hodiny:</span>
|
||||||
|
<span class="summary-value">152,00h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Svátek:</span>
|
<span class="summary-label">Svátek:</span>
|
||||||
|
|||||||
284
src/__tests__/attendance-mzda-summary.test.ts
Normal file
284
src/__tests__/attendance-mzda-summary.test.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
computeMzdaSummary,
|
||||||
|
formatHoursDecimal1,
|
||||||
|
} from "../admin/utils/attendanceHelpers";
|
||||||
|
import { buildUserSectionHtml } from "../admin/hooks/useAttendanceAdmin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BINDING payroll-recap formulas (owner spec 2026-07, back-computed against
|
||||||
|
* the real payslip of Dominik Vesecký 5/2026 — the dataset below is the
|
||||||
|
* built-in acceptance test and MUST match exactly, not approximately):
|
||||||
|
*
|
||||||
|
* fond = (Po–Pá dny, které NEJSOU svátek) × 8
|
||||||
|
* odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8, paušálně
|
||||||
|
* svátek = čisté odpracované hodiny ve dnech svátku
|
||||||
|
* dovolená = Σ leave_hours (evidence)
|
||||||
|
* přesčas = max(0, (worked_total + dovolená) − fond) [měsíční]
|
||||||
|
* vč. svátků = odpracováno + přesčas
|
||||||
|
* so/ne = čisté hodiny směn začínajících v So/Ne (celá směna)
|
||||||
|
* noc = průnik s 22:00–06:00 minus pauzy v pásmu, ke dni začátku
|
||||||
|
*
|
||||||
|
* Expected 5/2026 (holidays 1.5. + 8.5.): fond 152 · odpracováno 144 ·
|
||||||
|
* svátek 8 · dovolená 24 · přesčas 17,25 (=17,3 na desetiny) · vč. 161,25
|
||||||
|
* (=161,3) · So/Ne 57,25 · noc 36,75 (payslip said 31 — accountant's own
|
||||||
|
* error, she missed the 31.5.→1.6. overnight shift; 36,75 is CORRECT).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const W = (d: string, from: string, to: string, brk?: [string, string]) => ({
|
||||||
|
shift_date: `${d}T02:00:00`,
|
||||||
|
arrival_time: from.includes(" ") ? from.replace(" ", "T") : `${d}T${from}:00`,
|
||||||
|
departure_time: to.includes(" ") ? to.replace(" ", "T") : `${d}T${to}:00`,
|
||||||
|
break_start: brk
|
||||||
|
? brk[0].includes(" ")
|
||||||
|
? brk[0].replace(" ", "T")
|
||||||
|
: `${d}T${brk[0]}:00`
|
||||||
|
: null,
|
||||||
|
break_end: brk
|
||||||
|
? brk[1].includes(" ")
|
||||||
|
? brk[1].replace(" ", "T")
|
||||||
|
: `${d}T${brk[1]}:00`
|
||||||
|
: null,
|
||||||
|
leave_type: "work",
|
||||||
|
});
|
||||||
|
const V = (d: string, h: number) => ({
|
||||||
|
shift_date: `${d}T02:00:00`,
|
||||||
|
leave_type: "vacation",
|
||||||
|
leave_hours: h,
|
||||||
|
arrival_time: null,
|
||||||
|
departure_time: null,
|
||||||
|
break_start: null,
|
||||||
|
break_end: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The exact dataset from the spec (times with a space carry a full date). */
|
||||||
|
const DOMINIK_MAY_2026 = [
|
||||||
|
W("2026-05-01", "08:15", "17:15", ["12:30", "13:30"]),
|
||||||
|
W("2026-05-03", "21:30", "2026-05-04 06:15:00", [
|
||||||
|
"2026-05-04 01:52:00",
|
||||||
|
"2026-05-04 02:22:00",
|
||||||
|
]),
|
||||||
|
V("2026-05-05", 8),
|
||||||
|
V("2026-05-06", 8),
|
||||||
|
V("2026-05-07", 8),
|
||||||
|
W("2026-05-10", "21:15", "2026-05-11 06:30:00", [
|
||||||
|
"2026-05-11 01:52:00",
|
||||||
|
"2026-05-11 02:22:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-12", "07:45", "16:00", ["11:52", "12:22"]),
|
||||||
|
W("2026-05-13", "08:00", "16:15", ["12:07", "12:37"]),
|
||||||
|
W("2026-05-14", "08:00", "16:15", ["12:07", "12:37"]),
|
||||||
|
W("2026-05-15", "08:00", "16:00", ["12:00", "12:30"]),
|
||||||
|
W("2026-05-16", "10:00", "23:30", ["16:45", "17:45"]),
|
||||||
|
W("2026-05-17", "21:30", "2026-05-18 06:30:00", [
|
||||||
|
"2026-05-18 02:00:00",
|
||||||
|
"2026-05-18 02:30:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-19", "09:30", "17:00", ["13:15", "13:45"]),
|
||||||
|
W("2026-05-20", "08:30", "16:30", ["12:30", "13:00"]),
|
||||||
|
W("2026-05-21", "09:45", "15:15"),
|
||||||
|
W("2026-05-22", "10:45", "15:30"),
|
||||||
|
W("2026-05-24", "17:45", "2026-05-25 06:00:00", [
|
||||||
|
"23:45",
|
||||||
|
"2026-05-25 00:45:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-26", "09:00", "14:30"),
|
||||||
|
W("2026-05-27", "10:00", "15:30"),
|
||||||
|
W("2026-05-28", "08:00", "15:45", ["11:45", "12:15"]),
|
||||||
|
W("2026-05-29", "08:15", "15:00", ["11:30", "12:00"]),
|
||||||
|
W("2026-05-31", "19:45", "2026-06-01 04:15:00", [
|
||||||
|
"2026-06-01 00:00:00",
|
||||||
|
"2026-06-01 00:30:00",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("computeMzdaSummary — built-in acceptance dataset (Dominik 5/2026)", () => {
|
||||||
|
const s = computeMzdaSummary(DOMINIK_MAY_2026 as never, "2026-05");
|
||||||
|
|
||||||
|
it("Fond měsíce = 152 (19 Po–Pá dnů mimo svátky 1.5. a 8.5.)", () => {
|
||||||
|
expect(s.businessDays).toBe(19);
|
||||||
|
expect(s.fund).toBe(152);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Odpracováno = 144 (18 pracovních dnů mimo svátek × 8, paušálně)", () => {
|
||||||
|
expect(s.odpracovano).toBe(144);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Svátek = 8 (skutečné hodiny odpracované 1. 5.)", () => {
|
||||||
|
expect(s.svatekHours).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Dovolená = 24", () => {
|
||||||
|
expect(s.vacationHours).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("worked_total = 145,25 (145:15 čistého času)", () => {
|
||||||
|
expect(s.workedHoursRaw).toBe(145.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Přesčas = 17,25 → zobrazeno 17,3 ((145,25 + 24) − 152)", () => {
|
||||||
|
expect(s.prescas).toBe(17.25);
|
||||||
|
expect(formatHoursDecimal1(s.prescas * 60)).toBe("17,3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Odpracováno včetně svátků a přesčasů = 161,25 → zobrazeno 161,3 (144 + 17,25)", () => {
|
||||||
|
expect(s.vcetneSvatku).toBe(161.25);
|
||||||
|
expect(formatHoursDecimal1(s.vcetneSvatku * 60)).toBe("161,3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("So/Ne = 57,25 (6 směn začínajících v So/Ne, celé směny)", () => {
|
||||||
|
expect(s.weekendHours).toBe(57.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Práce v noci = 36,75 — VČETNĚ směny 31.5.→1.6. (výplatnice s 31 h se mýlí)", () => {
|
||||||
|
expect(s.nightMinutes / 60).toBe(36.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Chybějící hodiny = 0 (145,25 + 24 pokrývá fond 152)", () => {
|
||||||
|
expect(s.manko).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeMzdaSummary — Chybějící hodiny (Dominik 6/2026, reálná data)", () => {
|
||||||
|
// Real production month exposing the gap the paušál hides: 21 attended days
|
||||||
|
// × 8 = 168 + 8h dovolená = 176 = fond exactly (weekend shifts nominally
|
||||||
|
// compensate 3 missing weekdays 1.6./11.6./15.6.), yet REAL worked time is
|
||||||
|
// only 136,5h → 31,5h below the fond. Přesčas must stay 0 (formulas are
|
||||||
|
// binding), manko must surface the 31,5.
|
||||||
|
const JUNE = [
|
||||||
|
W("2026-06-02", "06:15", "14:00", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-03", "06:30", "13:30", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-04", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-05", "06:15", "13:45", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-07", "07:15", "13:00", ["09:30", "10:00"]), // Sunday
|
||||||
|
W("2026-06-08", "07:15", "13:30", ["10:15", "10:45"]),
|
||||||
|
W("2026-06-09", "06:00", "11:15", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-10", "06:00", "11:45", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-12", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-14", "17:30", "2026-06-15 04:15:00", ["22:45", "23:15"]), // Sun→Mon
|
||||||
|
W("2026-06-16", "07:30", "13:30", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-17", "10:45", "13:15"),
|
||||||
|
W("2026-06-18", "06:00", "13:15", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-19", "07:00", "13:15", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-22", "06:00", "13:30", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-23", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-24", "05:30", "13:45", ["09:30", "10:00"]),
|
||||||
|
V("2026-06-25", 8),
|
||||||
|
W("2026-06-26", "05:45", "14:45", ["10:15", "10:45"]),
|
||||||
|
W("2026-06-27", "05:45", "17:00", ["11:15", "11:45"]), // Saturday
|
||||||
|
W("2026-06-29", "11:45", "14:30"),
|
||||||
|
W("2026-06-30", "06:30", "09:15"),
|
||||||
|
W("2026-06-30", "10:45", "13:30"),
|
||||||
|
];
|
||||||
|
const s = computeMzdaSummary(JUNE as never, "2026-06");
|
||||||
|
|
||||||
|
it("verified rows stay exactly as before (fond 176, odpracováno 168, přesčas 0)", () => {
|
||||||
|
expect(s.fund).toBe(176);
|
||||||
|
expect(s.businessDays).toBe(22);
|
||||||
|
expect(s.odpracovano).toBe(168); // 21 days × 8 flat
|
||||||
|
expect(s.vacationHours).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(136.5);
|
||||||
|
expect(s.prescas).toBe(0); // (136,5 + 8) − 176 < 0 — unchanged formula
|
||||||
|
expect(s.vcetneSvatku).toBe(168);
|
||||||
|
expect(s.svatekHours).toBe(0);
|
||||||
|
expect(s.weekendHours).toBe(26.25); // shifts starting 7.6., 14.6., 27.6.
|
||||||
|
// 14.6. shift (22:00–04:15 minus break = 5:45) + early starts before
|
||||||
|
// 06:00: 24.6. 05:30 (0:30), 26.6. 05:45 (0:15), 27.6. 05:45 (0:15).
|
||||||
|
expect(s.nightMinutes / 60).toBe(6.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Chybějící hodiny = 31,5 — the gap the paušál nominally hides", () => {
|
||||||
|
expect(s.manko).toBe(31.5); // 176 − (136,5 + 8)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nemoc a neplacené volno kryjí fond stejně jako dovolená", () => {
|
||||||
|
const withSick = [...JUNE, { ...V("2026-06-01", 8), leave_type: "sick" }];
|
||||||
|
const s2 = computeMzdaSummary(withSick as never, "2026-06");
|
||||||
|
expect(s2.sickHours).toBe(8);
|
||||||
|
expect(s2.manko).toBe(23.5); // 31,5 − 8
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeMzdaSummary — formula edge cases", () => {
|
||||||
|
it("Odpracováno is a flat 8h per day: a lone 4:45 day counts as 8", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[W("2026-06-02", "10:45", "15:30")] as never,
|
||||||
|
"2026-06",
|
||||||
|
);
|
||||||
|
expect(s.odpracovano).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(4.75);
|
||||||
|
expect(s.prescas).toBe(0); // (4,75 + 0) − 176 < 0
|
||||||
|
expect(s.vcetneSvatku).toBe(8); // odpracováno + přesčas
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two shifts on one day count that day ONCE (8h, not 16h)", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[
|
||||||
|
W("2026-06-02", "06:00", "10:00"),
|
||||||
|
W("2026-06-02", "14:00", "18:00"),
|
||||||
|
] as never,
|
||||||
|
"2026-06",
|
||||||
|
);
|
||||||
|
expect(s.odpracovano).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dovolená covers the fond in Přesčas", () => {
|
||||||
|
// 176h fond (June 2026): 152h worked + 30h vacation = 182 → +6 přesčas.
|
||||||
|
const recs: unknown[] = [];
|
||||||
|
const days = [
|
||||||
|
"01",
|
||||||
|
"02",
|
||||||
|
"03",
|
||||||
|
"04",
|
||||||
|
"05",
|
||||||
|
"08",
|
||||||
|
"09",
|
||||||
|
"10",
|
||||||
|
"11",
|
||||||
|
"12",
|
||||||
|
"15",
|
||||||
|
"16",
|
||||||
|
"17",
|
||||||
|
"18",
|
||||||
|
"19",
|
||||||
|
"22",
|
||||||
|
"23",
|
||||||
|
"24",
|
||||||
|
"25",
|
||||||
|
].map((d) => `2026-06-${d}`);
|
||||||
|
for (const d of days) recs.push(W(d, "08:00", "16:30", ["12:00", "12:30"])); // 19×8=152
|
||||||
|
recs.push(V("2026-06-29", 8), V("2026-06-30", 8), V("2026-06-26", 14));
|
||||||
|
const s = computeMzdaSummary(recs as never, "2026-06");
|
||||||
|
expect(s.vacationHours).toBe(30);
|
||||||
|
expect(s.prescas).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a shift STARTING on a holiday goes to Svátek, not to Odpracováno days", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[W("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])] as never, // 6.7. holiday, 10h
|
||||||
|
"2026-07",
|
||||||
|
);
|
||||||
|
expect(s.svatekHours).toBe(10);
|
||||||
|
expect(s.odpracovano).toBe(0);
|
||||||
|
expect(s.fund).toBe(176); // 23 Mon–Fri − 6.7. holiday = 22 × 8
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("print day table — multiple records on one day", () => {
|
||||||
|
it("renders one row per record (two shifts are both visible)", () => {
|
||||||
|
const records = [
|
||||||
|
W("2026-06-02", "06:00", "10:00"),
|
||||||
|
W("2026-06-02", "14:00", "18:00"),
|
||||||
|
];
|
||||||
|
const html = buildUserSectionHtml(
|
||||||
|
"1",
|
||||||
|
{ name: "Test", minutes: 480, vacation_hours: 0, records } as never,
|
||||||
|
{ month: "2026-06", month_name: "Červen 2026", user_totals: {} } as never,
|
||||||
|
);
|
||||||
|
expect(html).toContain("06:00");
|
||||||
|
expect(html).toContain("14:00");
|
||||||
|
const rows = html.match(/<td>2\. 6\. 2026<\/td>/g) || [];
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/__tests__/attendance-print-tz.test.ts
Normal file
77
src/__tests__/attendance-print-tz.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
getCzechWeekday,
|
||||||
|
isWeekendDate,
|
||||||
|
formatTimeOrDatetimePrint,
|
||||||
|
} from "../admin/utils/attendanceHelpers";
|
||||||
|
import { formatDate } from "../admin/utils/formatters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: the attendance admin print builds its day rows from date-ONLY
|
||||||
|
* strings ("2026-06-01"). `new Date("YYYY-MM-DD")` is UTC midnight per the
|
||||||
|
* ECMAScript spec, so formatting it with local getters rendered the PREVIOUS
|
||||||
|
* day for viewers west of UTC — a June print opened with "31. 5. 2026 Neděle"
|
||||||
|
* (a May row) and every weekday name was shifted by one. The helpers now
|
||||||
|
* parse the calendar day from the string parts, which is timezone-proof by
|
||||||
|
* construction: these assertions hold in EVERY zone (on a machine west of
|
||||||
|
* UTC the old implementation fails them).
|
||||||
|
*/
|
||||||
|
describe("attendance print — timezone-proof date rendering", () => {
|
||||||
|
it("formatDate renders a date-only string as that exact calendar day", () => {
|
||||||
|
expect(formatDate("2026-06-01")).toBe("1. 6. 2026");
|
||||||
|
expect(formatDate("2026-06-30")).toBe("30. 6. 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatDate keeps naive-datetime (wire format) behavior", () => {
|
||||||
|
expect(formatDate("2026-06-01T02:00:00")).toBe("1. 6. 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCzechWeekday names the string's own calendar day", () => {
|
||||||
|
expect(getCzechWeekday("2026-06-01")).toBe("Pondělí");
|
||||||
|
expect(getCzechWeekday("2026-06-06")).toBe("Sobota");
|
||||||
|
expect(getCzechWeekday("2026-06-07")).toBe("Neděle");
|
||||||
|
// naive-datetime wire format resolves to the same day
|
||||||
|
expect(getCzechWeekday("2026-06-01T02:00:00")).toBe("Pondělí");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isWeekendDate flags the string's own calendar day", () => {
|
||||||
|
expect(isWeekendDate("2026-06-06")).toBe(true); // Saturday
|
||||||
|
expect(isWeekendDate("2026-06-07")).toBe(true); // Sunday
|
||||||
|
expect(isWeekendDate("2026-06-01")).toBe(false); // Monday
|
||||||
|
expect(isWeekendDate("2026-06-06T02:00:00")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees with a locally-constructed date in the current zone (guards a regression to UTC parsing)", () => {
|
||||||
|
// Local construction is the ground truth for "calendar day" in any zone.
|
||||||
|
const local = new Date(2026, 5, 1, 12).toLocaleDateString("cs-CZ", {
|
||||||
|
weekday: "long",
|
||||||
|
});
|
||||||
|
const expected = local.charAt(0).toUpperCase() + local.slice(1);
|
||||||
|
expect(getCzechWeekday("2026-06-01")).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attendance print — overnight date prefix on time cells", () => {
|
||||||
|
// shift_date arrives as a naive datetime on the wire ("2026-06-01T02:00:00");
|
||||||
|
// the guard used to compare it raw against the extracted date part, so the
|
||||||
|
// overnight-only "d.m." prefix fired on EVERY cell ("1.6. 08:00").
|
||||||
|
const wireShiftDate = "2026-06-01T02:00:00";
|
||||||
|
|
||||||
|
it("same-day punch renders time only", () => {
|
||||||
|
expect(
|
||||||
|
formatTimeOrDatetimePrint("2026-06-01T08:00:00", wireShiftDate),
|
||||||
|
).toBe("08:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overnight punch keeps the date prefix", () => {
|
||||||
|
expect(
|
||||||
|
formatTimeOrDatetimePrint("2026-06-02T01:30:00", wireShiftDate),
|
||||||
|
).toBe("2.6. 01:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("date-only shiftDate also matches", () => {
|
||||||
|
expect(formatTimeOrDatetimePrint("2026-06-01T08:00:00", "2026-06-01")).toBe(
|
||||||
|
"08:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}` };
|
||||||
|
|||||||
301
src/__tests__/project-order-status-sync.test.ts
Normal file
301
src/__tests__/project-order-status-sync.test.ts
Normal 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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
148
src/admin/components/StatusChipMenu.tsx
Normal file
148
src/admin/components/StatusChipMenu.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,10 +18,8 @@ import {
|
|||||||
getDaysInMonth,
|
getDaysInMonth,
|
||||||
shiftDateForMonth,
|
shiftDateForMonth,
|
||||||
formatHoursDecimal,
|
formatHoursDecimal,
|
||||||
calculateNightMinutes,
|
|
||||||
normalizeDateStr,
|
normalizeDateStr,
|
||||||
holidaysInMonth,
|
computeMzdaSummary,
|
||||||
calculateFreeHolidayHours,
|
|
||||||
getCzechWeekday,
|
getCzechWeekday,
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
import type {
|
import type {
|
||||||
@@ -99,6 +97,7 @@ interface UserTotal {
|
|||||||
weekend_hours?: number;
|
weekend_hours?: number;
|
||||||
night_minutes?: number;
|
night_minutes?: number;
|
||||||
worked_holiday_hours?: number; // sum of hours worked ON a holiday
|
worked_holiday_hours?: number; // sum of hours worked ON a holiday
|
||||||
|
manko?: number; // Chybějící hodiny: real clock time below the fond
|
||||||
records?: AttendanceRecord[];
|
records?: AttendanceRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,116 +215,36 @@ function computeUserTotals(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add fund data per user (matching PHP addFundDataToUserTotals)
|
// Rekapitulace per user — the SAME shared computation the print uses
|
||||||
const [yearStr, monthStr] = month.split("-");
|
// (computeMzdaSummary), so the screen and the printed sheet can never
|
||||||
const yr = parseInt(yearStr, 10);
|
// disagree. Accountant spec 2026-07: Odpracováno = worked minus
|
||||||
const mo = parseInt(monthStr, 10) - 1;
|
// holiday-worked; Vč. svátků = ALL worked; Přesčas = max(0, worked − fond);
|
||||||
|
// Svátek = hours actually worked on holidays.
|
||||||
// Count Mon-Fri business days in month (INCLUDING holidays).
|
|
||||||
// The fund is rawBizDays × 8 — holidays stay in the count so the
|
|
||||||
// user is paid for them via the fund. Svátek (free holiday hours) is
|
|
||||||
// computed separately as "8h per holiday the user did not work".
|
|
||||||
let rawBizDays = 0;
|
|
||||||
const cur = new Date(yr, mo, 1);
|
|
||||||
while (cur.getMonth() === mo) {
|
|
||||||
const dow = cur.getDay();
|
|
||||||
if (dow !== 0 && dow !== 6) rawBizDays++;
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const holidayDates = holidaysInMonth(yr, mo + 1);
|
|
||||||
|
|
||||||
for (const uid of Object.keys(totals)) {
|
for (const uid of Object.keys(totals)) {
|
||||||
const t = totals[uid];
|
const t = totals[uid];
|
||||||
const userRecords = recordsByUser.get(Number(uid)) || [];
|
const userRecords = recordsByUser.get(Number(uid)) || [];
|
||||||
|
const s = computeMzdaSummary(userRecords, month);
|
||||||
// Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
|
|
||||||
const workedHoursRaw = t.minutes / 60;
|
|
||||||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
|
||||||
|
|
||||||
// So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
|
|
||||||
const weekendHours = userRecords
|
|
||||||
.filter(
|
|
||||||
(r) =>
|
|
||||||
(r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
|
|
||||||
)
|
|
||||||
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
|
|
||||||
|
|
||||||
// Práce v noci: minutes in 22:00-06:00 across all work records.
|
|
||||||
const nightMinutes = userRecords
|
|
||||||
.filter((r) => (r.leave_type || "work") === "work")
|
|
||||||
.reduce((sum, r) => sum + calculateNightMinutes(r), 0);
|
|
||||||
|
|
||||||
// Svátek (free): 8h per holiday the user did not work.
|
|
||||||
const freeHolidayHours = calculateFreeHolidayHours(
|
|
||||||
userRecords,
|
|
||||||
holidayDates,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Worked holidays: holidays the user DID work (counted toward
|
|
||||||
// Odpracováno, but their 8h must not flow into Přesčas).
|
|
||||||
const workedDatesSet = new Set(
|
|
||||||
userRecords
|
|
||||||
.filter((r) => (r.leave_type || "work") === "work")
|
|
||||||
.map((r) => normalizeDateStr(r.shift_date))
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
let workedHolidayCount = 0;
|
|
||||||
let workedHolidayHours = 0;
|
|
||||||
for (const hd of holidayDates) {
|
|
||||||
if (workedDatesSet.has(hd)) workedHolidayCount++;
|
|
||||||
}
|
|
||||||
// Sum the actual hours worked on holiday dates (for the KPI card's
|
|
||||||
// "fond used" total = real_work + vacation + worked_holiday + free_holiday).
|
|
||||||
for (const r of userRecords) {
|
|
||||||
if ((r.leave_type || "work") !== "work") continue;
|
|
||||||
const d = normalizeDateStr(r.shift_date);
|
|
||||||
if (d && holidayDates.includes(d)) {
|
|
||||||
workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
|
|
||||||
const fund = rawBizDays * 8;
|
|
||||||
|
|
||||||
// Včetně svátků a přesčasů (mzda formula):
|
|
||||||
// odpracovano + vacation + remainder − min(freeHols, workedHols × 8)
|
|
||||||
//
|
|
||||||
// Two-way logic matching the print:
|
|
||||||
// • If the user worked at least one holiday, that worked holiday's
|
|
||||||
// 8h is in Odpracováno and shouldn't also count as Přesčas. We
|
|
||||||
// subtract one 8h per worked holiday (capped at the total free
|
|
||||||
// holiday hours — can't go negative).
|
|
||||||
// • If the user did NOT work any holiday, no adjustment is needed:
|
|
||||||
// all unworked holidays are already paid via the Svátek line and
|
|
||||||
// don't flow into Přesčas, so vcetne_svatku just equals the
|
|
||||||
// work + vacation + remainder.
|
|
||||||
const remainder = workedHoursRaw - odpracovano;
|
|
||||||
const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
|
|
||||||
const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
|
|
||||||
|
|
||||||
// Přesčas = vcetneSv − odpracovano (derived).
|
|
||||||
const prescas = vcetneSv - odpracovano;
|
|
||||||
|
|
||||||
// Legacy fields (kept so existing UI doesn't break).
|
// Legacy fields (kept so existing UI doesn't break).
|
||||||
const workedHours = Math.round(workedHoursRaw * 10) / 10;
|
const workedHours = Math.round(s.workedHoursRaw * 10) / 10;
|
||||||
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
|
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
|
||||||
|
|
||||||
t.fund = fund;
|
t.fund = s.fund;
|
||||||
t.business_days = rawBizDays;
|
t.business_days = s.businessDays;
|
||||||
t.worked_hours = workedHours;
|
t.worked_hours = workedHours;
|
||||||
t.covered = covered;
|
t.covered = covered;
|
||||||
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
|
t.missing = Math.max(0, Math.round((s.fund - covered) * 10) / 10);
|
||||||
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
|
t.overtime = Math.max(0, Math.round((covered - s.fund) * 10) / 10);
|
||||||
|
|
||||||
t.worked_hours_raw = workedHoursRaw;
|
t.worked_hours_raw = s.workedHoursRaw;
|
||||||
t.odpracovano = odpracovano;
|
t.odpracovano = s.odpracovano;
|
||||||
t.vcetne_svatku = vcetneSv;
|
t.vcetne_svatku = s.vcetneSvatku;
|
||||||
t.prescas = prescas;
|
t.prescas = s.prescas;
|
||||||
t.svatek = freeHolidayHours;
|
t.svatek = s.svatekHours;
|
||||||
t.weekend_hours = weekendHours;
|
t.weekend_hours = s.weekendHours;
|
||||||
t.night_minutes = nightMinutes;
|
t.night_minutes = s.nightMinutes;
|
||||||
t.worked_holiday_hours = workedHolidayHours;
|
t.worked_holiday_hours = s.svatekHours;
|
||||||
|
t.manko = s.manko;
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals;
|
return totals;
|
||||||
@@ -390,10 +309,23 @@ export function buildUserSectionHtml(
|
|||||||
userData: UserTotal,
|
userData: UserTotal,
|
||||||
printData: PrintData,
|
printData: PrintData,
|
||||||
): string {
|
): string {
|
||||||
// Build a date-keyed lookup of the user's records.
|
// Build a date-keyed lookup of the user's records. A day may hold MULTIPLE
|
||||||
const recordsByDate = new Map<string, AttendanceRecord>();
|
// records (two shifts, or work + partial-day vacation) — keep them ALL; a
|
||||||
|
// single-record map silently dropped every shift but the last one.
|
||||||
|
const recordsByDate = new Map<string, AttendanceRecord[]>();
|
||||||
for (const r of userData.records || []) {
|
for (const r of userData.records || []) {
|
||||||
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
const key = normalizeDateStr(r.shift_date);
|
||||||
|
const list = recordsByDate.get(key);
|
||||||
|
if (list) list.push(r);
|
||||||
|
else recordsByDate.set(key, [r]);
|
||||||
|
}
|
||||||
|
// Stable in-day order: by arrival time (leave records without times last).
|
||||||
|
for (const list of recordsByDate.values()) {
|
||||||
|
list.sort((a, b) =>
|
||||||
|
String(a.arrival_time || "9999").localeCompare(
|
||||||
|
String(b.arrival_time || "9999"),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterate one row per day of the month.
|
// Iterate one row per day of the month.
|
||||||
@@ -407,19 +339,14 @@ export function buildUserSectionHtml(
|
|||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
const dateStr = shiftDateForMonth(yr, mo, d);
|
const dateStr = shiftDateForMonth(yr, mo, d);
|
||||||
const record = recordsByDate.get(dateStr);
|
const dayRecords = recordsByDate.get(dateStr);
|
||||||
const weekend = isWeekendDate(dateStr);
|
const weekend = isWeekendDate(dateStr);
|
||||||
const holiday = isHoliday(dateStr);
|
const holiday = isHoliday(dateStr);
|
||||||
const leaveType = (record?.leave_type as string) || "work";
|
|
||||||
const rowClasses = [
|
|
||||||
weekend && "weekend",
|
|
||||||
holiday && "holiday",
|
|
||||||
leaveType === "vacation" && "vacation",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
if (!record) {
|
if (!dayRecords || dayRecords.length === 0) {
|
||||||
|
const rowClasses = [weekend && "weekend", holiday && "holiday"]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
rows.push(`<tr class="${rowClasses}">
|
rows.push(`<tr class="${rowClasses}">
|
||||||
<td>${escapeHtml(formatDate(dateStr))}</td>
|
<td>${escapeHtml(formatDate(dateStr))}</td>
|
||||||
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
||||||
@@ -434,25 +361,37 @@ export function buildUserSectionHtml(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLeave = leaveType !== "work";
|
// One row PER RECORD — a day can hold two shifts, or work + partial-day
|
||||||
const workMinutes = calculateWorkMinutesPrint(record);
|
// vacation. (A single row per day silently dropped the extra shifts.)
|
||||||
const hours = Math.floor(workMinutes / 60);
|
for (const record of dayRecords) {
|
||||||
const mins = workMinutes % 60;
|
const leaveType = (record.leave_type as string) || "work";
|
||||||
const breakCell =
|
const rowClasses = [
|
||||||
isLeave || !record.break_start || !record.break_end
|
weekend && "weekend",
|
||||||
? "—"
|
holiday && "holiday",
|
||||||
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
|
leaveType === "vacation" && "vacation",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
let hoursCell: string;
|
const isLeave = leaveType !== "work";
|
||||||
if (workMinutes > 0 && !isLeave) {
|
const workMinutes = calculateWorkMinutesPrint(record);
|
||||||
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
|
const hours = Math.floor(workMinutes / 60);
|
||||||
} else if (isLeave) {
|
const mins = workMinutes % 60;
|
||||||
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
|
const breakCell =
|
||||||
} else {
|
isLeave || !record.break_start || !record.break_end
|
||||||
hoursCell = "—";
|
? "—"
|
||||||
}
|
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
|
||||||
|
|
||||||
rows.push(`<tr class="${rowClasses}">
|
let hoursCell: string;
|
||||||
|
if (workMinutes > 0 && !isLeave) {
|
||||||
|
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
|
||||||
|
} else if (isLeave) {
|
||||||
|
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
|
||||||
|
} else {
|
||||||
|
hoursCell = "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(`<tr class="${rowClasses}">
|
||||||
<td>${escapeHtml(formatDate(dateStr))}</td>
|
<td>${escapeHtml(formatDate(dateStr))}</td>
|
||||||
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
||||||
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
|
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
|
||||||
@@ -463,110 +402,52 @@ export function buildUserSectionHtml(
|
|||||||
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
||||||
<td>${escapeHtml(record.notes || "")}</td>
|
<td>${escapeHtml(record.notes || "")}</td>
|
||||||
</tr>`);
|
</tr>`);
|
||||||
}
|
|
||||||
|
|
||||||
// ----- mzda-style summary numbers (computed here, not from userData,
|
|
||||||
// because the backend's getPrintData only returns the legacy fields). -----
|
|
||||||
|
|
||||||
// Worked minutes: sum of calculateWorkMinutesPrint across work records.
|
|
||||||
let workedMinutes = 0;
|
|
||||||
let weekendHoursRaw = 0;
|
|
||||||
let nightMinutes = 0;
|
|
||||||
for (const r of userRecords) {
|
|
||||||
const leaveType = r.leave_type || "work";
|
|
||||||
if (leaveType !== "work") continue;
|
|
||||||
const m = calculateWorkMinutesPrint(r);
|
|
||||||
workedMinutes += m;
|
|
||||||
if (isWeekendDate(r.shift_date)) {
|
|
||||||
weekendHoursRaw += m / 60;
|
|
||||||
}
|
}
|
||||||
nightMinutes += calculateNightMinutes(r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
|
// Rekapitulace — single shared implementation (accountant spec 2026-07):
|
||||||
// "holiday" leave_type is auto-computed from Czech holidays further down
|
// Odpracováno = all worked hours EXCEPT hours worked on holidays
|
||||||
// and no longer selectable in the UI, so it's deliberately omitted here.
|
// Vč. svátků a přesč. = ALL worked hours (line2 − line1 = Svátek)
|
||||||
let vacationHours = 0;
|
// Přesčas = max(0, worked − fond)
|
||||||
for (const r of userRecords) {
|
// Svátek = hours actually worked on holiday dates
|
||||||
const lt = r.leave_type || "work";
|
const s = computeMzdaSummary(userRecords, printData.month);
|
||||||
if (lt === "work") continue;
|
|
||||||
const h = Number(r.leave_hours) || 8;
|
|
||||||
if (lt === "vacation") vacationHours += h;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Odpracováno: floor(worked / 8) × 8.
|
|
||||||
const workedHoursRaw = workedMinutes / 60;
|
|
||||||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
|
||||||
|
|
||||||
// Free Svátek: 8h per holiday date the user did not work.
|
|
||||||
const holidayDates = holidaysInMonth(yr, mo);
|
|
||||||
const workedDates = new Set(
|
|
||||||
userRecords
|
|
||||||
.filter((r) => (r.leave_type || "work") === "work")
|
|
||||||
.map((r) => normalizeDateStr(r.shift_date))
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
let freeHolidayHours = 0;
|
|
||||||
let workedHolidayCount = 0;
|
|
||||||
for (const hd of holidayDates) {
|
|
||||||
if (workedDates.has(hd)) workedHolidayCount++;
|
|
||||||
else freeHolidayHours += 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fund: count Mon-Fri (incl. holidays) × 8h.
|
|
||||||
let rawBizDays = 0;
|
|
||||||
const cur = new Date(yr, mo - 1, 1);
|
|
||||||
while (cur.getMonth() === mo - 1) {
|
|
||||||
const dow = cur.getDay();
|
|
||||||
if (dow !== 0 && dow !== 6) rawBizDays++;
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
const businessDays = rawBizDays;
|
|
||||||
const fund = businessDays * 8;
|
|
||||||
|
|
||||||
// Včetně svátků a přesčasů: floor(worked/8)*8 − worked_holiday*8 + vacation + remainder.
|
|
||||||
// (A worked holiday counts toward Odpracováno, but its 8h should not flow
|
|
||||||
// into Přesčas — so we subtract it here. Unworked holidays are paid via
|
|
||||||
// the fund and don't affect this line.)
|
|
||||||
const remainder = workedHoursRaw - odpracovano;
|
|
||||||
const vcetneSv =
|
|
||||||
odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
|
|
||||||
|
|
||||||
// Přesčas = #2 - #1.
|
|
||||||
const prescas = vcetneSv - odpracovano;
|
|
||||||
|
|
||||||
const summaryHtml = `<div class="user-summary">
|
const summaryHtml = `<div class="user-summary">
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Odpracováno:</span>
|
<span class="summary-label">Odpracováno:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(odpracovano * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.odpracovano * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(vcetneSv * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.vcetneSvatku * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Dovolená:</span>
|
<span class="summary-label">Dovolená:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.vacationHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Přesčas:</span>
|
<span class="summary-label">Přesčas:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row${s.manko > 0 ? " summary-alert" : ""}">
|
||||||
|
<span class="summary-label">Chybějící hodiny:</span>
|
||||||
|
<span class="summary-value">${formatHoursDecimal(s.manko * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Svátek:</span>
|
<span class="summary-label">Svátek:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">So/Ne:</span>
|
<span class="summary-label">So/Ne:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.weekendHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Práce v noci:</span>
|
<span class="summary-label">Práce v noci:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.nightMinutes)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row summary-fund">
|
<div class="summary-row summary-fund">
|
||||||
<span class="summary-label">Fond měsíce:</span>
|
<span class="summary-label">Fond měsíce:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)</span>
|
<span class="summary-value">${formatHoursDecimal(s.fund * 60)}h (${s.businessDays} dnů)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
|
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
|
||||||
@@ -672,6 +553,9 @@ export function buildPrintHtml(
|
|||||||
}
|
}
|
||||||
.user-summary .summary-label { color: #555; }
|
.user-summary .summary-label { color: #555; }
|
||||||
.user-summary .summary-value { font-weight: 600; }
|
.user-summary .summary-value { font-weight: 600; }
|
||||||
|
/* Chybějící hodiny > 0 — must jump out of the sheet, not hide in it. */
|
||||||
|
.user-summary .summary-alert .summary-label,
|
||||||
|
.user-summary .summary-alert .summary-value { color: #b00020; font-weight: 700; }
|
||||||
.user-summary .summary-fund {
|
.user-summary .summary-fund {
|
||||||
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface UserTotalData {
|
|||||||
prescas?: number;
|
prescas?: number;
|
||||||
svatek?: number;
|
svatek?: number;
|
||||||
worked_holiday_hours?: number;
|
worked_holiday_hours?: number;
|
||||||
|
/** Chybějící hodiny: real clock time below the fond (approved absences count). */
|
||||||
|
manko?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fond "used" total mirrors the Fond footer line below:
|
/** Fond "used" total mirrors the Fond footer line below:
|
||||||
@@ -293,6 +295,13 @@ export default function AttendanceAdmin() {
|
|||||||
label={`Nep: ${ut.unpaid_hours}h`}
|
label={`Nep: ${ut.unpaid_hours}h`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{ut.manko !== undefined && ut.manko > 0 && (
|
||||||
|
<StatusChip
|
||||||
|
color="error"
|
||||||
|
label={`Chybí: ${String(Math.round(ut.manko * 100) / 100).replace(".", ",")}h`}
|
||||||
|
title="Skutečně odpracované hodiny + schválené absence nedosahují fondu měsíce"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Fond usage */}
|
{/* Fond usage */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -173,7 +173,10 @@ export const formatTimeOrDatetimePrint = (
|
|||||||
const timeDate = dateMatch
|
const timeDate = dateMatch
|
||||||
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
|
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
|
||||||
: "";
|
: "";
|
||||||
if (timeDate && timeDate !== shiftDate) {
|
// Compare DATE PARTS: shiftDate arrives as a naive datetime off the wire
|
||||||
|
// ("2026-06-01T02:00:00"), so a raw string compare never matched and the
|
||||||
|
// overnight-only date prefix fired on every single time cell.
|
||||||
|
if (timeDate && timeDate !== normalizeDateStr(shiftDate)) {
|
||||||
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`;
|
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`;
|
||||||
return `${datePart} ${extractTime(datetime)}`;
|
return `${datePart} ${extractTime(datetime)}`;
|
||||||
}
|
}
|
||||||
@@ -203,9 +206,23 @@ export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
|
|||||||
// Print template helpers (mzda-style counting)
|
// Print template helpers (mzda-style counting)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the calendar day of a date-only or naive-datetime string as a LOCAL
|
||||||
|
* Date. `new Date("YYYY-MM-DD")` is UTC midnight per the ECMAScript spec —
|
||||||
|
* in timezones west of UTC it renders as the PREVIOUS day, so a US viewer's
|
||||||
|
* June print started with "31. 5. Neděle" and every weekday name was shifted.
|
||||||
|
* Building the Date from its string parts keeps the calendar day identical
|
||||||
|
* in every timezone.
|
||||||
|
*/
|
||||||
|
const localDayFromString = (dateStr: string): Date => {
|
||||||
|
const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||||
|
return new Date(dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
/** Returns the Czech weekday name with the first letter capitalized. */
|
/** Returns the Czech weekday name with the first letter capitalized. */
|
||||||
export const getCzechWeekday = (dateStr: string): string => {
|
export const getCzechWeekday = (dateStr: string): string => {
|
||||||
const wd = new Date(dateStr).toLocaleDateString("cs-CZ", {
|
const wd = localDayFromString(dateStr).toLocaleDateString("cs-CZ", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
});
|
});
|
||||||
return wd.charAt(0).toUpperCase() + wd.slice(1);
|
return wd.charAt(0).toUpperCase() + wd.slice(1);
|
||||||
@@ -213,7 +230,7 @@ export const getCzechWeekday = (dateStr: string): string => {
|
|||||||
|
|
||||||
/** True if the date is a Saturday or Sunday. */
|
/** True if the date is a Saturday or Sunday. */
|
||||||
export const isWeekendDate = (dateStr: string): boolean => {
|
export const isWeekendDate = (dateStr: string): boolean => {
|
||||||
const dow = new Date(dateStr).getDay();
|
const dow = localDayFromString(dateStr).getDay();
|
||||||
return dow === 0 || dow === 6;
|
return dow === 0 || dow === 6;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -353,3 +370,142 @@ export const calculateFreeHolidayHours = (
|
|||||||
}
|
}
|
||||||
return free;
|
return free;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mzda rekapitulace (single source — the print AND the admin screen consume
|
||||||
|
// this, so the two can never drift again). Formulas are BINDING per the
|
||||||
|
// accountant-verified spec (2026-07, back-computed to the tenth of an hour
|
||||||
|
// against a real payslip — Dominik Vesecký 5/2026; the built-in dataset test
|
||||||
|
// in attendance-mzda-summary.test.ts pins every line). Do not "improve" them:
|
||||||
|
// fond = (Po–Pá dny, které NEJSOU svátek) × 8
|
||||||
|
// odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8 — FLAT
|
||||||
|
// 8 h per day regardless of the shift's real length; real
|
||||||
|
// deviations settle monthly via Přesčas
|
||||||
|
// svátek = skutečné čisté hodiny odpracované ve dnech svátku
|
||||||
|
// dovolená = Σ leave_hours (evidence only, never from timestamps)
|
||||||
|
// přesčas = max(0, (worked_total + dovolená) − fond) [monthly]
|
||||||
|
// vč. svátků = odpracováno + přesčas (plain sum, NOT from timestamps)
|
||||||
|
// so/ne = čisté hodiny směn ZAČÍNAJÍCÍCH v So/Ne (celá směna)
|
||||||
|
// noc = průnik směny s 22:00–06:00 minus pauzy v pásmu; celá směna
|
||||||
|
// patří dni svého ZAČÁTKU (i přes hranici měsíce)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MzdaSummary {
|
||||||
|
/** worked_total: all net worked hours (arrival→departure minus breaks). */
|
||||||
|
workedHoursRaw: number;
|
||||||
|
/** Odpracováno: non-holiday calendar days with a work record × 8 h flat. */
|
||||||
|
odpracovano: number;
|
||||||
|
/** Odpracováno včetně svátků a přesčasů = odpracovano + prescas. */
|
||||||
|
vcetneSvatku: number;
|
||||||
|
/** Dovolená: sum of vacation leave_hours (hour-based, partial days allowed). */
|
||||||
|
vacationHours: number;
|
||||||
|
/** Přesčas: max(0, (worked_total + dovolená) − fond) — monthly, never daily. */
|
||||||
|
prescas: number;
|
||||||
|
/** Svátek: net hours actually WORKED on public-holiday dates. */
|
||||||
|
svatekHours: number;
|
||||||
|
/** So/Ne: net hours of shifts whose START day is Sat/Sun (whole shift). */
|
||||||
|
weekendHours: number;
|
||||||
|
/** Práce v noci: minutes in 22:00–06:00 minus breaks in the window. */
|
||||||
|
nightMinutes: number;
|
||||||
|
/** Fond měsíce: Mon–Fri days that are NOT public holidays × 8 h. */
|
||||||
|
fund: number;
|
||||||
|
/** Mon–Fri non-holiday day count backing the fond. */
|
||||||
|
businessDays: number;
|
||||||
|
/** Nemoc: sum of sick leave_hours (evidence records). */
|
||||||
|
sickHours: number;
|
||||||
|
/** Neplacené volno: sum of unpaid leave_hours (evidence records). */
|
||||||
|
unpaidHours: number;
|
||||||
|
/**
|
||||||
|
* Chybějící hodiny (manko):
|
||||||
|
* max(0, fond − (worked_total + dovolená + nemoc + neplacené volno))
|
||||||
|
* — REAL clock hours below the fond. A supplement next to Přesčas, never a
|
||||||
|
* replacement: the paušál Odpracováno can nominally cover the fond (weekend
|
||||||
|
* work compensating missing weekdays) while dozens of real hours are
|
||||||
|
* missing — e.g. Dominik 6/2026: paušál 168 + 8 dovolená = fond 176, yet
|
||||||
|
* real worked was 136,5 → manko 31,5. This line makes that visible.
|
||||||
|
*/
|
||||||
|
manko: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute the payroll recap for one user's month of records. */
|
||||||
|
export const computeMzdaSummary = (
|
||||||
|
records: AttendanceRecord[],
|
||||||
|
monthStr: string,
|
||||||
|
): MzdaSummary => {
|
||||||
|
const [yearStr, monthNumStr] = monthStr.split("-");
|
||||||
|
const yr = parseInt(yearStr, 10);
|
||||||
|
const mo = parseInt(monthNumStr, 10);
|
||||||
|
|
||||||
|
const holidaySet = new Set(holidaysInMonth(yr, mo));
|
||||||
|
|
||||||
|
let workedMinutes = 0;
|
||||||
|
let svatekMinutes = 0;
|
||||||
|
let weekendMinutes = 0;
|
||||||
|
let nightMinutes = 0;
|
||||||
|
let vacationHours = 0;
|
||||||
|
let sickHours = 0;
|
||||||
|
let unpaidHours = 0;
|
||||||
|
// Distinct non-holiday calendar days (start day) with a "Práce" record —
|
||||||
|
// two shifts on one day still count that day once.
|
||||||
|
const workDays = new Set<string>();
|
||||||
|
|
||||||
|
for (const r of records) {
|
||||||
|
const lt = r.leave_type || "work";
|
||||||
|
if (lt !== "work") {
|
||||||
|
// "holiday" placeholder records are evidence-only — skip everywhere.
|
||||||
|
if (lt === "vacation") vacationHours += Number(r.leave_hours) || 8;
|
||||||
|
else if (lt === "sick") sickHours += Number(r.leave_hours) || 8;
|
||||||
|
else if (lt === "unpaid") unpaidHours += Number(r.leave_hours) || 8;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = calculateWorkMinutesPrint(r);
|
||||||
|
workedMinutes += m;
|
||||||
|
const day = normalizeDateStr(r.shift_date);
|
||||||
|
if (holidaySet.has(day)) {
|
||||||
|
svatekMinutes += m;
|
||||||
|
} else if (day) {
|
||||||
|
workDays.add(day);
|
||||||
|
}
|
||||||
|
if (isWeekendDate(day)) weekendMinutes += m;
|
||||||
|
nightMinutes += calculateNightMinutes(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fond: Mon–Fri days that are NOT public holidays × 8 h.
|
||||||
|
let businessDays = 0;
|
||||||
|
const cur = new Date(yr, mo - 1, 1);
|
||||||
|
while (cur.getMonth() === mo - 1) {
|
||||||
|
const dow = cur.getDay();
|
||||||
|
if (dow !== 0 && dow !== 6) {
|
||||||
|
const dayStr = `${yr}-${String(mo).padStart(2, "0")}-${String(cur.getDate()).padStart(2, "0")}`;
|
||||||
|
if (!holidaySet.has(dayStr)) businessDays++;
|
||||||
|
}
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
const fund = businessDays * 8;
|
||||||
|
|
||||||
|
const workedHoursRaw = workedMinutes / 60;
|
||||||
|
const odpracovano = workDays.size * 8;
|
||||||
|
const prescas = Math.max(0, workedHoursRaw + vacationHours - fund);
|
||||||
|
// Chybějící hodiny: REAL clock time (not the paušál) vs the fond — approved
|
||||||
|
// absences (dovolená/nemoc/neplacené volno) cover the fond.
|
||||||
|
const manko = Math.max(
|
||||||
|
0,
|
||||||
|
fund - (workedHoursRaw + vacationHours + sickHours + unpaidHours),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workedHoursRaw,
|
||||||
|
odpracovano,
|
||||||
|
vcetneSvatku: odpracovano + prescas,
|
||||||
|
vacationHours,
|
||||||
|
prescas,
|
||||||
|
svatekHours: svatekMinutes / 60,
|
||||||
|
weekendHours: weekendMinutes / 60,
|
||||||
|
nightMinutes,
|
||||||
|
fund,
|
||||||
|
businessDays,
|
||||||
|
sickHours,
|
||||||
|
unpaidHours,
|
||||||
|
manko,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ export function formatMultiCurrency(
|
|||||||
|
|
||||||
export function formatDate(dateStr: string | null | undefined): string {
|
export function formatDate(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
const d = new Date(dateStr);
|
// Date-ONLY strings ("2026-06-01") parse as UTC midnight per the spec, which
|
||||||
|
// renders as the PREVIOUS day in timezones west of UTC — a US viewer's June
|
||||||
|
// attendance print started with 31. 5. Parse the parts instead so the
|
||||||
|
// calendar day is identical in every timezone. Datetime strings keep the
|
||||||
|
// local-parse behavior (naive wire times are already local).
|
||||||
|
const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
const d = m
|
||||||
|
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||||
|
: new Date(dateStr);
|
||||||
return d.toLocaleDateString("cs-CZ");
|
return d.toLocaleDateString("cs-CZ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user