Files
app/docs/superpowers/plans/2026-06-09-issued-orders.md
BOHA 4093664936 feat(orders): issued-order number sequence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:04:48 +02:00

2415 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Objednávky vydané (Issued Purchase Orders) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a new `objednávky vydané` (issued purchase order) document type — authored with line items, auto-numbered, status-tracked, and exportable to PDF — surfaced as a second tab on the existing Orders page.
**Architecture:** A self-contained vertical slice mirroring the existing `invoices` document (dedicated `issued_orders` + `issued_order_items` tables, a dedicated number sequence, a service returning `{data}`/`{error,status}`, thin routes using the shared helpers, a Puppeteer PDF route, and React Query + MUI frontend). The existing customer-order `orders` module is preserved verbatim and becomes the `Přijaté` tab. No cross-module wiring (warehouse / received-invoice links are a later phase).
**Tech Stack:** Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React Query, Puppeteer, Vitest.
**Source spec:** `docs/superpowers/specs/2026-06-09-issued-orders-design.md`
**Conventions for every task:**
- Branch is `feat/issued-orders` (already created off `master`; the spec commit is the first commit on it).
- All commits end with the trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- Tests live in `src/__tests__/issued-orders.test.ts` and run against the throwaway `app_test` DB via `npx vitest run`. They import service functions directly and clean up in `afterEach` (the established pattern from `src/__tests__/manual-create.test.ts`). Do **not** mock Prisma.
- Quality gates after the relevant tasks: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`, `npm run lint` (0 errors).
- Czech for user-facing strings; English for identifiers.
---
## File Structure
| File | Responsibility |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prisma/schema.prisma` (modify) | Add `issued_orders`, `issued_order_items` models + `issued_orders_status` enum + 2 `company_settings` columns |
| `prisma/migrations/<ts>_issued_orders/` (create) | The generated migration |
| `src/services/numbering.service.ts` (modify) | Add `generateIssuedOrderNumber` / `previewIssuedOrderNumber` / `releaseIssuedOrderNumber` / `isIssuedOrderNumberTaken`; extend `getSettings` + `releaseSequence` |
| `src/schemas/issued-orders.schema.ts` (create) | `CreateIssuedOrderSchema`, `UpdateIssuedOrderSchema`, `IssuedOrderItemSchema` |
| `src/services/issued-orders.service.ts` (create) | List/get/create/update/delete + `computeIssuedOrderTotals` + `VALID_TRANSITIONS` |
| `src/routes/admin/issued-orders.ts` (create) | CRUD + next-number endpoints |
| `src/routes/admin/issued-orders-pdf.ts` (create) | `renderIssuedOrderHtml` (pure) + PDF route |
| `src/server.ts` (modify) | Register both route plugins |
| `src/types/index.ts` (modify) | Add `"issued_order"` to `EntityType` |
| `src/admin/lib/entityTypeLabels.ts` (modify) | Add `issued_order` Czech label |
| `src/admin/lib/queries/issued-orders.ts` (create) | React Query options + TS types |
| `src/admin/pages/OrdersReceived.tsx` (create, from move) | The current Orders page, moved verbatim |
| `src/admin/pages/Orders.tsx` (rewrite) | Tab shell: `Přijaté` \| `Vydané` |
| `src/admin/pages/IssuedOrders.tsx` (create) | The Vydané list |
| `src/admin/pages/IssuedOrderDetail.tsx` (create) | Create/edit form + PDF export |
| `src/admin/AdminApp.tsx` (modify) | Lazy routes for the detail page |
| `src/__tests__/issued-orders.test.ts` (create, grown per task) | All backend tests |
---
## Task 1: Database schema & migration
**Files:**
- Modify: `prisma/schema.prisma` (add two models + one enum after the `orders` model; add two columns to `company_settings`)
- Create: `prisma/migrations/<timestamp>_issued_orders/migration.sql` (generated)
- [ ] **Step 1: Add the two new columns to `company_settings`**
In `prisma/schema.prisma`, inside `model company_settings { … }`, add these two lines next to the other `*_number_pattern` / `*_type_code` fields:
```prisma
issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10)
```
- [ ] **Step 2: Add the two new models + enum**
Add immediately after the `orders` model block:
```prisma
model issued_orders {
id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
customer_id Int?
status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
order_date DateTime? @db.Date
delivery_date DateTime? @db.Date
language String? @default("cs") @db.VarChar(5)
delivery_terms String? @db.VarChar(500)
payment_terms String? @db.VarChar(500)
issued_by String? @db.VarChar(255)
notes String? @db.Text
internal_notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
issued_order_items issued_order_items[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_ibfk_1")
@@index([customer_id], map: "issued_orders_customer_id")
@@index([status, order_date], map: "idx_issued_orders_status_date")
}
model issued_order_items {
id Int @id @default(autoincrement())
issued_order_id Int
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0)
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
@@index([issued_order_id], map: "issued_order_id")
}
enum issued_orders_status {
draft
sent
confirmed
completed
cancelled
}
```
- [ ] **Step 3: Add the back-relation on `customers`**
In `model customers { … }`, add a back-relation line alongside its existing `orders`/`invoices` relations:
```prisma
issued_orders issued_orders[]
```
- [ ] **Step 4: Ask the user to stop the dev server, then create the migration**
Tell the user: "I need to run `prisma migrate dev` — please stop your dev server and confirm." Wait for confirmation. Then run:
```
npx prisma migrate dev --name issued_orders
```
Expected: a new folder `prisma/migrations/<timestamp>_issued_orders/` is created and applied to the dev `app` DB. Open `migration.sql` and confirm it `CREATE TABLE issued_orders`, `CREATE TABLE issued_order_items`, and `ALTER TABLE company_settings ADD COLUMN issued_order_number_pattern …` / `… issued_order_type_code …`.
- [ ] **Step 5: Confirm the migration needs NO manual SQL edits**
Do **not** hand-edit the generated `migration.sql`. The two new `company_settings`
columns are nullable with **no DB default** — exactly like the existing
`order_number_pattern` / `invoice_number_pattern` / `*_type_code` columns. The
baseline values are supplied **in code** by the numbering service (Task 2:
`settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN` and
`settings?.issued_order_type_code || "72"`), so a NULL column resolves correctly
on dev, test, and prod with zero migration surgery.
Editing an **already-applied** migration would change its checksum and make the
next `prisma migrate dev` demand a reset — avoid that entirely. Just verify the
generated `migration.sql` looks sane (two `CREATE TABLE`s, the enum, and two
`ADD COLUMN`s) and move on.
- [ ] **Step 6: Regenerate the Prisma client**
```
npx prisma generate
```
- [ ] **Step 7: Apply the migration to the `app_test` DB so tests can run**
In PowerShell, point `DATABASE_URL` at the `app_test` database (the value is in `.env.test`) and deploy:
```powershell
$env:DATABASE_URL = "<the DATABASE_URL line from .env.test>"
npx prisma migrate deploy
Remove-Item Env:DATABASE_URL
```
Expected: "All migrations have been applied" (including `<timestamp>_issued_orders`).
- [ ] **Step 8: Verify it compiles**
```
npx tsc -b --noEmit
```
Expected: PASS (the generated client now exposes `prisma.issued_orders` / `prisma.issued_order_items`).
- [ ] **Step 9: Commit**
```
git add prisma/schema.prisma prisma/migrations
git commit -m "feat(orders): issued_orders schema + migration"
```
---
## Task 2: Numbering — issued-order number sequence
**Files:**
- Modify: `src/services/numbering.service.ts`
- Test: `src/__tests__/issued-orders.test.ts` (create)
- [ ] **Step 1: Write the failing test**
Create `src/__tests__/issued-orders.test.ts`:
```typescript
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
releaseIssuedOrderNumber,
} from "../services/numbering.service";
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
});
describe("issued-order numbering", () => {
it("generates sequential numbers", async () => {
const year = new Date().getFullYear();
const a = await generateIssuedOrderNumber(year);
const b = await generateIssuedOrderNumber(year);
expect(Number(b.number)).toBe(Number(a.number) + 1);
});
it("preview does not consume the sequence", async () => {
const year = new Date().getFullYear();
const p1 = await previewIssuedOrderNumber(year);
const p2 = await previewIssuedOrderNumber(year);
expect(p1.number).toBe(p2.number);
});
it("release frees only the highest number", async () => {
const year = new Date().getFullYear();
await generateIssuedOrderNumber(year); // seq 1
await generateIssuedOrderNumber(year); // seq 2
const c = await generateIssuedOrderNumber(year); // seq 3 (current highest)
const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4
// Releasing a number that is NOT the current highest is a no-op.
await releaseIssuedOrderNumber(year, "00000000");
expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease);
// Releasing the current highest reclaims it.
await releaseIssuedOrderNumber(year, c.number);
expect((await previewIssuedOrderNumber(year)).number).toBe(c.number);
});
});
```
- [ ] **Step 2: Run it to confirm it fails**
Run: `npx vitest run src/__tests__/issued-orders.test.ts`
Expected: FAIL — `generateIssuedOrderNumber` is not exported.
- [ ] **Step 3: Add the default pattern constant**
In `src/services/numbering.service.ts`, next to the existing `DEFAULT_*` constants (after `DEFAULT_INVOICE_PATTERN`):
```typescript
const DEFAULT_ISSUED_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
```
- [ ] **Step 4: Select the new settings columns in `getSettings()`**
Add these two lines to the `select` object inside `getSettings()`:
```typescript
issued_order_number_pattern: true,
issued_order_type_code: true,
```
- [ ] **Step 5: Teach `releaseSequence` about the new type**
Inside `releaseSequence()`, extend the `pattern` and `code` ternaries to add an `issued_order` branch (insert the new branch before the final `offer` fallback):
```typescript
const pattern =
type === "shared"
? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN
: type === "invoice"
? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN
: type === "issued_order"
? settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN
: settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
const code =
type === "shared"
? settings?.order_type_code || "71"
: type === "invoice"
? settings?.invoice_type_code || "81"
: type === "issued_order"
? settings?.issued_order_type_code || "72"
: "";
```
- [ ] **Step 6: Add the taken-check + the three public functions**
Add near the other `is*NumberTaken` helpers and the invoice number functions:
```typescript
/** Verify an issued-order (PO) number is not already used. */
async function isIssuedOrderNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.issued_orders.findFirst({
where: { po_number: number },
});
return !!existing;
}
export async function generateIssuedOrderNumber(
_year?: number,
tx?: TxClient,
): Promise<{ number: string; next_number: string }> {
const settings = await getSettings();
const pattern =
settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN;
const code = settings?.issued_order_type_code || "72";
const year = _year || new Date().getFullYear();
for (let attempt = 0; attempt < 100; attempt++) {
const seq = await getNextSequence("issued_order", year, tx);
const number = applyPattern(pattern, { year, prefix: "", code, seq });
if (!(await isIssuedOrderNumberTaken(number))) {
return { number, next_number: number };
}
}
throw new Error("Nepodařilo se vygenerovat jedinečné číslo objednávky");
}
export async function previewIssuedOrderNumber(
_year?: number,
): Promise<{ number: string; next_number: string }> {
const settings = await getSettings();
const pattern =
settings?.issued_order_number_pattern || DEFAULT_ISSUED_ORDER_PATTERN;
const code = settings?.issued_order_type_code || "72";
const year = _year || new Date().getFullYear();
const seq = await previewNextSequence("issued_order", year);
const number = applyPattern(pattern, { year, prefix: "", code, seq });
return { number, next_number: number };
}
export async function releaseIssuedOrderNumber(
year?: number,
deletedNumber?: string,
) {
await releaseSequence(
"issued_order",
year || new Date().getFullYear(),
deletedNumber,
);
}
```
> Note: `getNextSequence`, `previewNextSequence`, `releaseSequence`, `applyPattern`, and `TxClient` already exist in this file — do not redefine them.
- [ ] **Step 7: Run the test to confirm it passes**
Run: `npx vitest run src/__tests__/issued-orders.test.ts`
Expected: PASS (3 passing).
- [ ] **Step 8: Commit**
```
git add src/services/numbering.service.ts src/__tests__/issued-orders.test.ts
git commit -m "feat(orders): issued-order number sequence"
```
---
## Task 3: Zod validation schemas
**Files:**
- Create: `src/schemas/issued-orders.schema.ts`
- Test: `src/__tests__/issued-orders.test.ts` (append)
- [ ] **Step 1: Write the failing test**
Append to `src/__tests__/issued-orders.test.ts`:
```typescript
import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema";
describe("CreateIssuedOrderSchema", () => {
it("coerces string form numbers and rejects an out-of-range VAT", () => {
const ok = CreateIssuedOrderSchema.safeParse({
customer_id: "5",
vat_rate: "21",
items: [{ description: "X", quantity: "2", unit_price: "100" }],
});
expect(ok.success).toBe(true);
if (ok.success) expect(ok.data.customer_id).toBe(5);
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
expect(bad.success).toBe(false);
});
});
```
- [ ] **Step 2: Run it to confirm it fails**
Run: `npx vitest run src/__tests__/issued-orders.test.ts`
Expected: FAIL — module `../schemas/issued-orders.schema` not found.
- [ ] **Step 3: Create the schema file**
Create `src/schemas/issued-orders.schema.ts`:
```typescript
import { z } from "zod";
import {
numberInRange,
nonNegativeNumberFromForm,
positiveNumberFromForm,
nullableIntIdFromForm,
booleanFromForm,
isoDateString,
} from "./common";
export const IssuedOrderItemSchema = z.object({
description: z.string().max(500).nullish(),
item_description: z.string().max(5000).nullish(),
quantity: positiveNumberFromForm.optional(),
unit: z.string().max(20).nullish(),
unit_price: nonNegativeNumberFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(),
position: z.number().int().nonnegative().optional(),
});
const ISSUED_ORDER_STATUSES = [
"draft",
"sent",
"confirmed",
"completed",
"cancelled",
] as const;
export const CreateIssuedOrderSchema = z.object({
po_number: z.string().max(50).nullish(),
customer_id: nullableIntIdFromForm.nullish(),
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
currency: z.string().max(10).optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),
exchange_rate: nonNegativeNumberFromForm.optional(),
order_date: isoDateString.nullish(),
delivery_date: isoDateString.nullish(),
language: z.string().max(5).optional(),
delivery_terms: z.string().max(500).nullish(),
payment_terms: z.string().max(500).nullish(),
issued_by: z.string().max(255).nullish(),
notes: z.string().nullish(),
internal_notes: z.string().nullish(),
items: z.array(IssuedOrderItemSchema).optional(),
});
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial();
```
- [ ] **Step 4: Run the test to confirm it passes**
Run: `npx vitest run src/__tests__/issued-orders.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```
git add src/schemas/issued-orders.schema.ts src/__tests__/issued-orders.test.ts
git commit -m "feat(orders): issued-order Zod schemas"
```
---
## Task 4: Service layer
**Files:**
- Create: `src/services/issued-orders.service.ts`
- Test: `src/__tests__/issued-orders.test.ts` (append)
- [ ] **Step 1: Write the failing tests**
Append to `src/__tests__/issued-orders.test.ts`:
```typescript
import {
computeIssuedOrderTotals,
createIssuedOrder,
getIssuedOrder,
updateIssuedOrder,
deleteIssuedOrder,
} from "../services/issued-orders.service";
const createdIds: number[] = [];
const createdCustomerIds: number[] = [];
afterEach(async () => {
for (const id of createdIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of createdCustomerIds)
await prisma.customers.deleteMany({ where: { id } });
createdIds.length = 0;
createdCustomerIds.length = 0;
});
async function makeCustomer() {
const c = await prisma.customers.create({
data: { name: "Dodavatel s.r.o." },
});
createdCustomerIds.push(c.id);
return c;
}
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
it("adds VAT on top of net, rounded per line", () => {
const t = computeIssuedOrderTotals(
[
{ quantity: 2, unit_price: 100, vat_rate: 21 },
{ quantity: 1, unit_price: 50, vat_rate: 12 },
],
true,
21,
);
expect(t.subtotal).toBe(250);
expect(t.vat_amount).toBe(48);
expect(t.total).toBe(298);
});
it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => {
const t = computeIssuedOrderTotals(
[{ quantity: 3, unit_price: 100, vat_rate: 21 }],
false,
21,
);
expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 });
});
it("falls back to the document rate when a line rate is null", () => {
const t = computeIssuedOrderTotals(
[{ quantity: 1, unit_price: 100, vat_rate: null }],
true,
15,
);
expect(t.vat_amount).toBe(15);
});
});
describe("createIssuedOrder", () => {
it("assigns a PO number, defaults to draft, stores items", async () => {
const c = await makeCustomer();
const order = await createIssuedOrder({
customer_id: c.id,
items: [
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
],
});
createdIds.push(order.id);
expect(order.po_number).toBeTruthy();
expect(order.status).toBe("draft");
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
expect(items.length).toBe(1);
expect(Number(items[0].unit_price)).toBe(100);
});
});
describe("updateIssuedOrder status transitions", () => {
it("allows draft → sent and rejects draft → completed", async () => {
const order = await createIssuedOrder({});
createdIds.push(order.id);
const ok = await updateIssuedOrder(order.id, { status: "sent" });
expect("error" in ok).toBe(false);
const bad = await updateIssuedOrder(order.id, { status: "completed" });
expect("error" in bad && bad.error).toBe("invalid_transition");
});
it("locks items once confirmed", async () => {
const order = await createIssuedOrder({
items: [{ description: "A", quantity: 1, unit_price: 10 }],
});
createdIds.push(order.id);
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
await updateIssuedOrder(order.id, {
items: [{ description: "B", quantity: 9, unit_price: 99 }],
});
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
expect(items.length).toBe(1);
expect(items[0].description).toBe("A");
});
});
describe("getIssuedOrder", () => {
it("returns customer_name and valid_transitions", async () => {
const c = await makeCustomer();
const order = await createIssuedOrder({ customer_id: c.id });
createdIds.push(order.id);
const detail = await getIssuedOrder(order.id);
expect(detail?.customer_name).toBe("Dodavatel s.r.o.");
expect(detail?.valid_transitions).toContain("sent");
});
});
describe("deleteIssuedOrder", () => {
it("deletes, cascades items, frees the latest number", async () => {
const order = await createIssuedOrder({
items: [{ description: "X", quantity: 1, unit_price: 1 }],
});
const before = order.po_number;
await deleteIssuedOrder(order.id);
expect(
await prisma.issued_orders.findUnique({ where: { id: order.id } }),
).toBeNull();
expect(
(
await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
})
).length,
).toBe(0);
expect((await previewIssuedOrderNumber()).number).toBe(before);
});
});
```
- [ ] **Step 2: Run it to confirm it fails**
Run: `npx vitest run src/__tests__/issued-orders.test.ts`
Expected: FAIL — module `../services/issued-orders.service` not found.
- [ ] **Step 3: Create the service file**
Create `src/services/issued-orders.service.ts`:
```typescript
import prisma from "../config/database";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
releaseIssuedOrderNumber,
} from "./numbering.service";
export interface IssuedOrderItemInput {
description?: string | null;
item_description?: string | null;
quantity?: number | string | null;
unit?: string | null;
unit_price?: number | string | null;
vat_rate?: number | string | null;
position?: number | null;
}
export interface IssuedOrderInput {
po_number?: string | number | null;
customer_id?: number | string | null;
status?: string;
currency?: string;
vat_rate?: number | string | null;
apply_vat?: boolean | number | string;
exchange_rate?: number | string | null;
order_date?: string | null;
delivery_date?: string | null;
language?: string;
delivery_terms?: string | null;
payment_terms?: string | null;
issued_by?: string | null;
notes?: string | null;
internal_notes?: string | null;
items?: IssuedOrderItemInput[];
[key: string]: unknown;
}
interface ListIssuedOrdersParams {
page: number;
limit: number;
skip: number;
sort: string;
order: string;
search?: string;
status?: string;
customer_id?: number;
}
const VALID_TRANSITIONS: Record<string, string[]> = {
draft: ["sent", "cancelled"],
sent: ["confirmed", "cancelled"],
confirmed: ["completed", "cancelled"],
completed: [],
cancelled: [],
};
const ALLOWED_SORT_FIELDS = [
"id",
"po_number",
"status",
"order_date",
"currency",
];
/** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */
export function computeIssuedOrderTotals(
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
applyVat: boolean | null,
defaultVatRate: unknown,
) {
let subtotal = 0;
let vat = 0;
for (const it of items) {
const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
subtotal += base;
if (applyVat) {
const rate =
it.vat_rate != null && it.vat_rate !== ""
? Number(it.vat_rate)
: defaultVatRate != null
? Number(defaultVatRate)
: 21;
vat += Math.round(base * (rate / 100) * 100) / 100;
}
}
return {
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vat * 100) / 100,
total: Math.round((subtotal + vat) * 100) / 100,
};
}
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
const { page, limit, skip, sort, order, search, status, customer_id } =
params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (search) {
where.OR = [
{ po_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
];
}
// Tiebreak on id so same-day order_date rows sort deterministically.
const orderBy: Record<string, string>[] = [
{ [sortField]: order },
{ id: order },
];
const [rows, total] = await Promise.all([
prisma.issued_orders.findMany({
where,
skip,
take: limit,
orderBy,
include: {
customers: { select: { id: true, name: true } },
issued_order_items: true,
},
}),
prisma.issued_orders.count({ where }),
]);
const enriched = rows.map((o) => {
const totals = computeIssuedOrderTotals(
o.issued_order_items,
o.apply_vat,
o.vat_rate,
);
const { issued_order_items, ...rest } = o;
return {
...rest,
items: issued_order_items,
customer_name: o.customers?.name || null,
...totals,
};
});
return { data: enriched, total, page, limit };
}
export async function getIssuedOrder(id: number) {
const order = await prisma.issued_orders.findUnique({
where: { id },
include: {
customers: true,
issued_order_items: { orderBy: { position: "asc" } },
},
});
if (!order) return null;
const { issued_order_items, ...rest } = order;
return {
...rest,
items: issued_order_items,
customer: order.customers,
customer_name: order.customers?.name || null,
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
};
}
export async function createIssuedOrder(body: IssuedOrderInput) {
return prisma.$transaction(async (tx) => {
const poNumber =
body.po_number !== undefined &&
body.po_number !== null &&
body.po_number !== ""
? String(body.po_number)
: (await generateIssuedOrderNumber(undefined, tx)).number;
const order = await tx.issued_orders.create({
data: {
po_number: poNumber,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status: (body.status ? String(body.status) : "draft") as never,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate:
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
order_date: body.order_date
? new Date(String(body.order_date))
: new Date(),
delivery_date: body.delivery_date
? new Date(String(body.delivery_date))
: null,
language: body.language ? String(body.language) : "cs",
delivery_terms: body.delivery_terms
? String(body.delivery_terms)
: null,
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
issued_by: body.issued_by ? String(body.issued_by) : null,
notes: body.notes ? String(body.notes) : null,
internal_notes: body.internal_notes
? String(body.internal_notes)
: null,
},
});
if (Array.isArray(body.items)) {
await tx.issued_order_items.createMany({
data: body.items.map((item, i) => ({
issued_order_id: order.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
}
return order;
});
}
export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
const existing = await prisma.issued_orders.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const };
const currentStatus = existing.status as string;
if (body.status !== undefined && body.status !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return { error: "invalid_transition" as const, currentStatus, newStatus };
}
}
const editable = currentStatus === "draft" || currentStatus === "sent";
const data: Record<string, unknown> = { modified_at: new Date() };
if (editable) {
const strFields = [
"currency",
"language",
"delivery_terms",
"payment_terms",
"issued_by",
];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined)
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined)
data.apply_vat =
body.apply_vat === true ||
body.apply_vat === 1 ||
body.apply_vat === "1";
if (body.exchange_rate !== undefined)
data.exchange_rate =
body.exchange_rate != null ? Number(body.exchange_rate) : null;
if (body.order_date !== undefined)
data.order_date = body.order_date
? new Date(String(body.order_date))
: null;
if (body.delivery_date !== undefined)
data.delivery_date = body.delivery_date
? new Date(String(body.delivery_date))
: null;
if (body.notes !== undefined)
data.notes = body.notes ? String(body.notes) : null;
if (body.internal_notes !== undefined)
data.internal_notes = body.internal_notes
? String(body.internal_notes)
: null;
}
if (body.status !== undefined) data.status = String(body.status);
await prisma.issued_orders.update({ where: { id }, data });
if (editable && Array.isArray(body.items)) {
await prisma.$transaction(async (tx) => {
await tx.issued_order_items.deleteMany({
where: { issued_order_id: id },
});
await tx.issued_order_items.createMany({
data: body.items!.map((item, i) => ({
issued_order_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
});
}
return { id, po_number: existing.po_number };
}
export async function deleteIssuedOrder(id: number) {
const existing = await prisma.issued_orders.findUnique({ where: { id } });
if (!existing) return null;
await prisma.issued_orders.delete({ where: { id } });
await releaseIssuedOrderNumber(
new Date().getFullYear(),
existing.po_number ?? undefined,
);
return existing;
}
export async function getNextIssuedOrderNumberPreview() {
return previewIssuedOrderNumber();
}
```
> Note on the `status: ... as never` cast: Prisma types `status` as the generated `issued_orders_status` enum; the runtime string is valid but TypeScript needs the cast since the value originates from untyped input. This matches how other services coerce enum-typed columns.
- [ ] **Step 4: Run the tests to confirm they pass**
Run: `npx vitest run src/__tests__/issued-orders.test.ts`
Expected: PASS (all describe blocks).
- [ ] **Step 5: Typecheck**
Run: `npx tsc -b --noEmit`
Expected: PASS.
- [ ] **Step 6: Commit**
```
git add src/services/issued-orders.service.ts src/__tests__/issued-orders.test.ts
git commit -m "feat(orders): issued-orders service (CRUD + totals + transitions)"
```
---
## Task 5: HTTP routes
**Files:**
- Create: `src/routes/admin/issued-orders.ts`
- Modify: `src/server.ts` (register the plugin)
- [ ] **Step 1: Create the routes file**
Create `src/routes/admin/issued-orders.ts`:
```typescript
import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
CreateIssuedOrderSchema,
UpdateIssuedOrderSchema,
} from "../../schemas/issued-orders.schema";
import {
listIssuedOrders,
getIssuedOrder,
createIssuedOrder,
updateIssuedOrder,
deleteIssuedOrder,
getNextIssuedOrderNumberPreview,
} from "../../services/issued-orders.service";
export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
// GET /api/admin/issued-orders
fastify.get(
"/",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query);
const result = await listIssuedOrders({
page,
limit,
skip,
sort: String(query.sort || ""),
order,
search,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, page, limit),
);
},
);
// GET /api/admin/issued-orders/next-number
fastify.get(
"/next-number",
{ preHandler: requirePermission("orders.create") },
async (_request, reply) => {
return success(reply, await getNextIssuedOrderNumberPreview());
},
);
// GET /api/admin/issued-orders/:id
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await getIssuedOrder(id);
if (!order) return error(reply, "Objednávka nenalezena", 404);
return success(reply, order);
},
);
// POST /api/admin/issued-orders
fastify.post(
"/",
{ preHandler: requirePermission("orders.create") },
async (request, reply) => {
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const order = await createIssuedOrder(parsed.data);
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "issued_order",
entityId: order.id,
description: `Vytvořena vydaná objednávka ${order.po_number}`,
});
return success(
reply,
{ id: order.id, po_number: order.po_number },
201,
"Objednávka byla vytvořena",
);
},
);
// PUT /api/admin/issued-orders/:id
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("orders.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateIssuedOrderSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const result = await updateIssuedOrder(id, parsed.data);
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Objednávka nenalezena", 404);
if (result.error === "invalid_transition")
return error(
reply,
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
400,
);
return error(reply, "Neznámá chyba", 500);
}
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "issued_order",
entityId: id,
description: `Upravena vydaná objednávka ${result.po_number}`,
});
return success(reply, { id }, 200, "Objednávka byla aktualizována");
},
);
// DELETE /api/admin/issued-orders/:id
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("orders.delete") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await deleteIssuedOrder(id);
if (!existing) return error(reply, "Objednávka nenalezena", 404);
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "issued_order",
entityId: id,
description: `Smazána vydaná objednávka ${existing.po_number}`,
});
return success(reply, null, 200, "Objednávka smazána");
},
);
}
```
- [ ] **Step 2: Register the plugin in `src/server.ts`**
Find the line that registers the invoices routes (`await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" });`). Add the import next to the other route imports at the top:
```typescript
import issuedOrdersRoutes from "./routes/admin/issued-orders";
```
And add the registration right after the invoices registration:
```typescript
await app.register(issuedOrdersRoutes, { prefix: "/api/admin/issued-orders" });
```
- [ ] **Step 3: Typecheck + build**
Run: `npx tsc -b --noEmit` then `npm run build`
Expected: both PASS. (The route compiles against the `entityType: "issued_order"` value — if `tsc` flags it as not assignable to `EntityType`, complete Task 6 first; you may do Task 6 before this step.)
- [ ] **Step 4: Commit**
```
git add src/routes/admin/issued-orders.ts src/server.ts
git commit -m "feat(orders): issued-orders CRUD routes"
```
---
## Task 6: Audit entity type + label
**Files:**
- Modify: `src/types/index.ts`
- Modify: `src/admin/lib/entityTypeLabels.ts`
- [ ] **Step 1: Extend the server `EntityType` union**
In `src/types/index.ts`, add a member to the `EntityType` union (next to `"order"`):
```typescript
| "issued_order"
```
- [ ] **Step 2: Add the Czech label**
In `src/admin/lib/entityTypeLabels.ts`, add to the `ENTITY_TYPE_LABELS` map (next to `order`):
```typescript
issued_order: "Objednávka vydaná",
```
- [ ] **Step 3: Typecheck**
Run: `npx tsc -b --noEmit`
Expected: PASS (the routes' `entityType: "issued_order"` now type-checks).
- [ ] **Step 4: Commit**
```
git add src/types/index.ts src/admin/lib/entityTypeLabels.ts
git commit -m "feat(orders): audit entity type for issued orders"
```
---
## Task 7: PDF export route
**Files:**
- Create: `src/routes/admin/issued-orders-pdf.ts`
- Modify: `src/server.ts` (register the plugin)
- Test: `src/__tests__/issued-orders.test.ts` (append)
- [ ] **Step 1: Write the failing test**
Append to `src/__tests__/issued-orders.test.ts`:
```typescript
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
describe("renderIssuedOrderHtml", () => {
const order = {
po_number: "26720001",
order_date: new Date("2026-06-09T12:00:00"),
delivery_date: null,
currency: "CZK",
apply_vat: true,
vat_rate: 21,
notes: "<p>Pozn</p><script>alert(1)</script>",
delivery_terms: null,
payment_terms: null,
issued_by: null,
};
const items = [
{
description: "Materiál",
item_description: null,
quantity: 2,
unit: "ks",
unit_price: 100,
vat_rate: 21,
},
];
it("renders the PO number, items, and both party names", () => {
const html = renderIssuedOrderHtml(
order,
items,
{ name: "Dodavatel" },
{ company_name: "Naše firma" },
"cs",
);
expect(html).toContain("26720001");
expect(html).toContain("Materiál");
expect(html).toContain("Dodavatel");
expect(html).toContain("Naše firma");
});
it("strips script tags from notes", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs");
expect(html).not.toContain("<script>");
expect(html).not.toContain("alert(1)");
});
});
```
- [ ] **Step 2: Run it to confirm it fails**
Run: `npx vitest run src/__tests__/issued-orders.test.ts`
Expected: FAIL — module `../routes/admin/issued-orders-pdf` not found.
- [ ] **Step 3: Create the PDF route file**
Create `src/routes/admin/issued-orders-pdf.ts`:
```typescript
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { parseId } from "../../utils/response";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
function escapeHtml(s: unknown): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatNum(n: number, decimals = 2): string {
return new Intl.NumberFormat("cs-CZ", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(n);
}
// Copied from invoices-pdf.ts: strips scripts/handlers/inline styles from Quill HTML.
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'href="#"',
);
s = s.replace(
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'src=""',
);
s = s.replace(/(&nbsp;)/g, " ");
return s;
}
type Lang = "cs" | "en";
const T = {
cs: {
title: "Objednávka",
supplier: "Dodavatel",
recipient: "Odběratel",
desc: "Popis",
qty: "Množství",
unitPrice: "Cena/MJ",
base: "Bez DPH",
vat: "DPH",
lineTotal: "Celkem",
subtotal: "Základ",
vatTotal: "DPH celkem",
total: "Celkem",
orderDate: "Datum vystavení",
deliveryDate: "Požadované dodání",
notes: "Poznámka",
deliveryTerms: "Dodací podmínky",
paymentTerms: "Platební podmínky",
issuedBy: "Vystavil",
},
en: {
title: "Purchase Order",
supplier: "Supplier",
recipient: "Buyer",
desc: "Description",
qty: "Quantity",
unitPrice: "Unit price",
base: "Net",
vat: "VAT",
lineTotal: "Total",
subtotal: "Subtotal",
vatTotal: "VAT total",
total: "Total",
orderDate: "Order date",
deliveryDate: "Requested delivery",
notes: "Note",
deliveryTerms: "Delivery terms",
paymentTerms: "Payment terms",
issuedBy: "Issued by",
},
} as const;
interface IssuedOrderPdfData {
po_number: string | null;
order_date: Date | null;
delivery_date: Date | null;
currency: string | null;
apply_vat: boolean | null;
vat_rate: unknown;
notes: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
}
interface IssuedOrderPdfItem {
description: string | null;
item_description: string | null;
quantity: unknown;
unit: string | null;
unit_price: unknown;
vat_rate: unknown;
}
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
customer: Record<string, unknown> | null,
settings: Record<string, unknown> | null,
lang: Lang,
): string {
const t = T[lang];
const applyVat = order.apply_vat !== false;
const currency = escapeHtml(order.currency || "CZK");
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
let subtotal = 0;
let vatTotal = 0;
const rows = items
.map((it) => {
const qty = Number(it.quantity) || 0;
const unitPrice = Number(it.unit_price) || 0;
const base = qty * unitPrice;
const rate =
it.vat_rate != null && it.vat_rate !== ""
? Number(it.vat_rate)
: docRate;
const lineVat = applyVat
? Math.round(base * (rate / 100) * 100) / 100
: 0;
subtotal += base;
vatTotal += lineVat;
return `<tr>
<td>${escapeHtml(it.description)}${it.item_description ? `<div class="sub">${escapeHtml(it.item_description)}</div>` : ""}</td>
<td class="r">${formatNum(qty, Number.isInteger(qty) ? 0 : 2)}${it.unit ? " " + escapeHtml(it.unit) : ""}</td>
<td class="r">${formatNum(unitPrice)}</td>
<td class="r">${formatNum(base)}</td>
<td class="c">${applyVat ? Math.round(rate) + "%" : "0%"}</td>
<td class="r">${formatNum(lineVat)}</td>
<td class="r">${formatNum(base + lineVat)}</td>
</tr>`;
})
.join("");
subtotal = Math.round(subtotal * 100) / 100;
vatTotal = Math.round(vatTotal * 100) / 100;
const total = Math.round((subtotal + vatTotal) * 100) / 100;
const company = (k: string) => escapeHtml(settings?.[k] ?? "");
const cust = (k: string) => escapeHtml(customer?.[k] ?? "");
const notesRaw = order.notes ?? "";
const notesHtml = notesRaw.replace(/<[^>]*>/g, "").trim()
? `<div class="notes"><div class="label">${escapeHtml(t.notes)}</div><div>${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div></div>`
: "";
return `<!DOCTYPE html><html lang="${lang}"><head><meta charset="utf-8"/>
<title>${escapeHtml(t.title)} ${escapeHtml(order.po_number)}</title>
<style>
@page { size: A4; margin: 14mm; }
body { font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #222; }
h1 { font-size: 20px; margin: 0 0 4px; }
.meta { color: #666; margin-bottom: 12px; }
.parties { display: flex; justify-content: space-between; margin: 14px 0; }
.party { width: 48%; }
.label { font-weight: bold; text-transform: uppercase; font-size: 9px; color: #888; }
table.items { width: 100%; border-collapse: collapse; margin-top: 10px; }
table.items th, table.items td { border-bottom: 1px solid #ddd; padding: 5px 6px; text-align: left; }
table.items th { background: #f4f4f4; font-size: 9px; text-transform: uppercase; }
.r { text-align: right; } .c { text-align: center; }
.sub { color: #888; font-size: 9px; }
.totals { margin-top: 12px; width: 42%; margin-left: auto; border-collapse: collapse; }
.totals td { padding: 3px 6px; }
.totals .grand { font-weight: bold; border-top: 2px solid #333; font-size: 13px; }
.notes { margin-top: 16px; }
.terms { margin-top: 8px; color: #555; }
</style></head><body>
<h1>${escapeHtml(t.title)} ${escapeHtml(order.po_number)}</h1>
<div class="meta">${escapeHtml(t.orderDate)}: ${order.order_date ? localDateCzStr(order.order_date) : "—"}${order.delivery_date ? ` &nbsp;·&nbsp; ${escapeHtml(t.deliveryDate)}: ${localDateCzStr(order.delivery_date)}` : ""}</div>
<div class="parties">
<div class="party">
<div class="label">${escapeHtml(t.supplier)}</div>
<div><strong>${cust("name")}</strong></div>
<div>${cust("street")}</div>
<div>${cust("postal_code")} ${cust("city")}</div>
${cust("company_id") ? `<div>IČO: ${cust("company_id")}</div>` : ""}
${cust("vat_id") ? `<div>DIČ: ${cust("vat_id")}</div>` : ""}
</div>
<div class="party">
<div class="label">${escapeHtml(t.recipient)}</div>
<div><strong>${company("company_name")}</strong></div>
<div>${company("street")}</div>
<div>${company("postal_code")} ${company("city")}</div>
${company("company_id") ? `<div>IČO: ${company("company_id")}</div>` : ""}
${company("vat_id") ? `<div>DIČ: ${company("vat_id")}</div>` : ""}
</div>
</div>
<table class="items">
<thead><tr>
<th>${escapeHtml(t.desc)}</th>
<th class="r">${escapeHtml(t.qty)}</th>
<th class="r">${escapeHtml(t.unitPrice)}</th>
<th class="r">${escapeHtml(t.base)}</th>
<th class="c">${escapeHtml(t.vat)}</th>
<th class="r">${escapeHtml(t.vat)}</th>
<th class="r">${escapeHtml(t.lineTotal)}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
<table class="totals">
<tr><td>${escapeHtml(t.subtotal)}</td><td class="r">${formatNum(subtotal)} ${currency}</td></tr>
<tr><td>${escapeHtml(t.vatTotal)}</td><td class="r">${formatNum(vatTotal)} ${currency}</td></tr>
<tr class="grand"><td>${escapeHtml(t.total)}</td><td class="r">${formatNum(total)} ${currency}</td></tr>
</table>
${notesHtml}
${order.delivery_terms ? `<div class="terms"><span class="label">${escapeHtml(t.deliveryTerms)}:</span> ${escapeHtml(order.delivery_terms)}</div>` : ""}
${order.payment_terms ? `<div class="terms"><span class="label">${escapeHtml(t.paymentTerms)}:</span> ${escapeHtml(order.payment_terms)}</div>` : ""}
${order.issued_by ? `<div class="terms"><span class="label">${escapeHtml(t.issuedBy)}:</span> ${escapeHtml(order.issued_by)}</div>` : ""}
</body></html>`;
}
export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("orders.export") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
const lang: Lang = query.lang === "en" ? "en" : "cs";
try {
const order = await prisma.issued_orders.findUnique({ where: { id } });
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: id },
orderBy: { position: "asc" },
});
const customer = order.customer_id
? ((await prisma.customers.findUnique({
where: { id: order.customer_id },
})) as Record<string, unknown> | null)
: null;
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
const html = renderIssuedOrderHtml(
order,
items,
customer,
settings,
lang,
);
const pdf = await htmlToPdf(html);
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
return reply
.type("application/pdf")
.header("Content-Disposition", `attachment; filename="${filename}"`)
.send(pdf);
} catch (err) {
request.log.error(err, "Issued order PDF generation failed");
return reply
.status(500)
.type("text/html")
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
}
},
);
}
```
- [ ] **Step 4: Register the PDF plugin in `src/server.ts`**
Add the import with the other route imports:
```typescript
import issuedOrdersPdfRoutes from "./routes/admin/issued-orders-pdf";
```
And register it after the issued-orders CRUD registration:
```typescript
await app.register(issuedOrdersPdfRoutes, {
prefix: "/api/admin/issued-orders-pdf",
});
```
- [ ] **Step 5: Run the test to confirm it passes**
Run: `npx vitest run src/__tests__/issued-orders.test.ts`
Expected: PASS (the two `renderIssuedOrderHtml` cases included).
- [ ] **Step 6: Typecheck + build**
Run: `npx tsc -b --noEmit` then `npm run build`
Expected: both PASS.
- [ ] **Step 7: Commit**
```
git add src/routes/admin/issued-orders-pdf.ts src/server.ts src/__tests__/issued-orders.test.ts
git commit -m "feat(orders): issued-order PDF export route"
```
---
## Task 8: Frontend — React Query options + types
**Files:**
- Create: `src/admin/lib/queries/issued-orders.ts`
> Frontend tasks have no component tests in this project (server-side tests only). Verify each with `npx tsc -b --noEmit`, `npm run build`, `npm run lint`, plus the manual smoke test noted at the end.
- [ ] **Step 1: Create the query options file**
Create `src/admin/lib/queries/issued-orders.ts`. **First open `src/admin/lib/queries/invoices.ts` and copy its exact import line(s) for `queryOptions`, `jsonQuery`, and `paginatedJsonQuery`** (paths differ per project layout — match them verbatim). Then add:
```typescript
// import { queryOptions } from "@tanstack/react-query";
// import { jsonQuery, paginatedJsonQuery } from "<same path as invoices.ts>";
export interface IssuedOrder {
id: number;
po_number: string | null;
customer_id: number | null;
customer_name: string | null;
status: string;
currency: string | null;
order_date: string | null;
subtotal: number;
vat_amount: number;
total: number;
}
export interface IssuedOrderItem {
id?: number;
description: string | null;
item_description: string | null;
quantity: number | string | null;
unit: string | null;
unit_price: number | string | null;
vat_rate: number | string | null;
position?: number;
}
export interface IssuedOrderDetail extends IssuedOrder {
apply_vat: boolean | null;
vat_rate: number | string | null;
exchange_rate: number | string | null;
delivery_date: string | null;
language: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
notes: string | null;
internal_notes: string | null;
items: IssuedOrderItem[];
customer: Record<string, unknown> | null;
valid_transitions: string[];
}
export const issuedOrderListOptions = (filters: {
search?: string;
sort?: string;
order?: string;
page?: number;
perPage?: number;
status?: string;
}) =>
queryOptions({
queryKey: ["issued-orders", "list", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.sort) params.set("sort", filters.sort);
if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
const qs = params.toString();
return paginatedJsonQuery<IssuedOrder>(
`/api/admin/issued-orders${qs ? `?${qs}` : ""}`,
);
},
});
export const issuedOrderDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["issued-orders", id],
queryFn: () =>
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
enabled: !!id,
});
```
- [ ] **Step 2: Typecheck**
Run: `npx tsc -b --noEmit`
Expected: PASS.
- [ ] **Step 3: Commit**
```
git add src/admin/lib/queries/issued-orders.ts
git commit -m "feat(orders): issued-order query options + types"
```
---
## Task 9: Frontend — Orders page tabs + Vydané list
**Files:**
- Create: `src/admin/pages/OrdersReceived.tsx` (the current Orders page, moved verbatim)
- Rewrite: `src/admin/pages/Orders.tsx` (tab shell)
- Create: `src/admin/pages/IssuedOrders.tsx` (the Vydané list)
- [ ] **Step 1: Move the current Orders page verbatim into `OrdersReceived.tsx`**
Copy the **entire** current contents of `src/admin/pages/Orders.tsx` into a new file `src/admin/pages/OrdersReceived.tsx`. Change **only** the component function name and the default export from `Orders` (or whatever it is named) to `OrdersReceived`. Change nothing else — preserve every hook, query, mutation, `invalidate` array, permission check, and JSX exactly.
- [ ] **Step 2: Rewrite `Orders.tsx` as the tab shell**
Replace the entire contents of `src/admin/pages/Orders.tsx` with the tab shell below. **Open `src/admin/pages/Invoices.tsx` to confirm the exact import path + props of the `Tabs` component and `useSearchParams`, and match them** (the snippet uses the same `Tabs` component Invoices.tsx uses):
```tsx
import { lazy, Suspense } from "react";
import { Box } from "@mui/material";
import { useSearchParams } from "react-router-dom";
import { Tabs } from "../ui"; // ← match the import path used by Invoices.tsx
import { LoadingState } from "../ui"; // ← match the import used by Invoices.tsx
import OrdersReceived from "./OrdersReceived";
const IssuedOrders = lazy(() => import("./IssuedOrders"));
export default function Orders() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") === "vydane" ? "vydane" : "prijate";
const setActiveTab = (tab: string) =>
setSearchParams({ tab }, { replace: true });
return (
<>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v)}
tabs={[
{ value: "prijate", label: "Přijaté" },
{ value: "vydane", label: "Vydané" },
]}
/>
</Box>
{activeTab === "vydane" ? (
<Suspense fallback={<LoadingState />}>
<IssuedOrders />
</Suspense>
) : (
<OrdersReceived />
)}
</>
);
}
```
> If `Tabs`/`LoadingState` are imported from specific files (not a barrel) in Invoices.tsx, use those exact paths.
- [ ] **Step 3: Create the Vydané list `IssuedOrders.tsx`**
Create `src/admin/pages/IssuedOrders.tsx`. Match the kit-component imports (`PageHeader`, `Button`, `DataTable`, `FilterBar`, `Select`, `StatusChip`, `ConfirmDialog`, `PageEnter`, `EmptyState`) to how `src/admin/pages/Invoices.tsx` / the existing `OrdersReceived.tsx` import them. The status-label map and columns are below:
```tsx
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useApiMutation } from "../hooks/useApiMutation"; // ← match path used by InvoiceDetail.tsx
import {
PageEnter,
PageHeader,
Button,
DataTable,
FilterBar,
Select,
StatusChip,
ConfirmDialog,
EmptyState,
} from "../ui"; // ← match the import paths used elsewhere
import {
issuedOrderListOptions,
type IssuedOrder,
} from "../lib/queries/issued-orders";
const STATUS_LABELS: Record<string, string> = {
draft: "Koncept",
sent: "Odeslaná",
confirmed: "Potvrzená",
completed: "Dokončená",
cancelled: "Stornovaná",
};
const STATUS_COLORS: Record<string, string> = {
draft: "default",
sent: "info",
confirmed: "warning",
completed: "success",
cancelled: "error",
};
const API_BASE = "/api/admin";
export default function IssuedOrders() {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canCreate = hasPermission("orders.create");
const canEdit = hasPermission("orders.edit");
const canDelete = hasPermission("orders.delete");
const canExport = hasPermission("orders.export");
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [toDelete, setToDelete] = useState<IssuedOrder | null>(null);
const listQuery = useQuery(
issuedOrderListOptions({ search, status, page, perPage: 20 }),
);
const deleteMutation = useApiMutation<unknown, unknown>({
url: (o: IssuedOrder) => `${API_BASE}/issued-orders/${o.id}`,
method: () => "DELETE",
invalidate: ["issued-orders"],
onSuccess: () => setToDelete(null),
});
const rows = listQuery.data?.data ?? [];
const columns = useMemo(
() => [
{ key: "po_number", label: "Číslo", sortable: true },
{ key: "customer_name", label: "Dodavatel" },
{
key: "status",
label: "Stav",
render: (o: IssuedOrder) => (
<StatusChip
label={STATUS_LABELS[o.status] ?? o.status}
color={STATUS_COLORS[o.status] ?? "default"}
/>
),
},
{ key: "order_date", label: "Datum", sortable: true },
{
key: "total",
label: "Celkem",
align: "right" as const,
render: (o: IssuedOrder) =>
`${o.total.toLocaleString("cs-CZ")} ${o.currency ?? "CZK"}`,
},
],
[],
);
return (
<PageEnter>
<PageHeader
title="Objednávky vydané"
actions={
canCreate ? (
<Button onClick={() => navigate("/orders/issued/new")}>
Vytvořit objednávku vydanou
</Button>
) : null
}
/>
<FilterBar>
<input
placeholder="Hledat…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
<Select
value={status}
onChange={(v) => {
setStatus(v);
setPage(1);
}}
options={[
{ value: "", label: "Všechny stavy" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({
value,
label,
})),
]}
/>
</FilterBar>
{rows.length === 0 && !listQuery.isLoading ? (
<EmptyState text="Zatím žádné vydané objednávky. Vytvořte první tlačítkem „Vytvořit objednávku vydanou“." />
) : (
<DataTable
columns={columns}
rows={rows}
loading={listQuery.isLoading}
onRowClick={(o: IssuedOrder) => navigate(`/orders/issued/${o.id}`)}
rowActions={(o: IssuedOrder) =>
[
canExport && {
label: "PDF",
onClick: () =>
window.open(
`${API_BASE}/issued-orders-pdf/${o.id}?lang=cs`,
"_blank",
),
},
canEdit && {
label: "Upravit",
onClick: () => navigate(`/orders/issued/${o.id}`),
},
canDelete && {
label: "Smazat",
danger: true,
onClick: () => setToDelete(o),
},
].filter(Boolean)
}
/>
)}
<ConfirmDialog
open={!!toDelete}
title="Smazat objednávku?"
onCancel={() => setToDelete(null)}
onConfirm={() => toDelete && deleteMutation.mutate(toDelete)}
>
{toDelete ? `Opravdu smazat objednávku ${toDelete.po_number}?` : ""}
</ConfirmDialog>
</PageEnter>
);
}
```
> The exact prop names of `DataTable` (`columns`/`rows`/`rowActions`/`onRowClick`), `Select`, `StatusChip`, `FilterBar`, and `useAuth().hasPermission` must match the existing kit — open `OrdersReceived.tsx` (which you just created from the current Orders page) and reuse its exact patterns for the table, filter bar, status chip, and permission checks. Adapt the snippet's prop names if they differ.
- [ ] **Step 4: Typecheck + lint + build**
Run: `npx tsc -b --noEmit` then `npm run lint` then `npm run build`
Expected: all PASS, 0 lint errors. (All hooks are above any early return — keep it that way.)
- [ ] **Step 5: Commit**
```
git add src/admin/pages/OrdersReceived.tsx src/admin/pages/Orders.tsx src/admin/pages/IssuedOrders.tsx
git commit -m "feat(orders): Orders page Přijaté|Vydané tabs + issued list"
```
---
## Task 10: Frontend — IssuedOrderDetail create/edit form
**Files:**
- Create: `src/admin/pages/IssuedOrderDetail.tsx`
- Modify: `src/admin/AdminApp.tsx` (lazy routes)
- [ ] **Step 1: Add the lazy routes in `AdminApp.tsx`**
Add the lazy import next to the other page imports:
```tsx
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
```
Add two routes next to the existing `orders` route (which already renders `<Orders />`):
```tsx
<Route path="orders/issued/new" element={<IssuedOrderDetail />} />
<Route path="orders/issued/:id" element={<IssuedOrderDetail />} />
```
> Ensure these are registered **before** any `orders/:id` catch-all if one exists, so `/orders/issued/new` is not captured as an order id.
- [ ] **Step 2: Create the detail/create form `IssuedOrderDetail.tsx`**
Create `src/admin/pages/IssuedOrderDetail.tsx`. Match all kit-component imports and the `apiFetch`/`jsonQuery`/`useApiMutation` paths to how `src/admin/pages/InvoiceDetail.tsx` imports them. The component:
```tsx
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useApiMutation } from "../hooks/useApiMutation"; // ← match InvoiceDetail.tsx
import { apiFetch } from "../utils/api"; // ← match InvoiceDetail.tsx
import { jsonQuery } from "../lib/queries/_helpers"; // ← match InvoiceDetail.tsx import of jsonQuery
import {
PageEnter,
PageHeader,
Button,
TextField,
DateField,
Select,
CheckboxField,
RichEditor,
Card,
} from "../ui"; // ← match existing import paths
import {
issuedOrderDetailOptions,
type IssuedOrderDetail as IssuedOrderDetailType,
type IssuedOrderItem,
} from "../lib/queries/issued-orders";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
draft: "Koncept",
sent: "Odeslaná",
confirmed: "Potvrzená",
completed: "Dokončená",
cancelled: "Stornovaná",
};
interface CustomerOption {
id: number;
name: string;
}
function emptyItem(): IssuedOrderItem & { _key: string } {
return {
_key: Math.random().toString(36).slice(2),
description: "",
item_description: "",
quantity: 1,
unit: "",
unit_price: 0,
vat_rate: 21,
};
}
function todayStr(): string {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
export default function IssuedOrderDetail() {
const { id } = useParams();
const isEdit = !!id;
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canExport = hasPermission("orders.export");
const detailQuery = useQuery(issuedOrderDetailOptions(id));
const customersQuery = useQuery({
queryKey: ["customers", "options"],
queryFn: () =>
jsonQuery<CustomerOption[]>(`${API_BASE}/customers?per_page=1000`),
});
const nextNumberQuery = useQuery({
queryKey: ["issued-orders", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
`${API_BASE}/issued-orders/next-number`,
).then((d) => d?.next_number || d?.number || ""),
enabled: !isEdit,
});
const loaded = detailQuery.data;
const [form, setForm] = useState({
customer_id: "",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: todayStr(),
delivery_date: "",
language: "cs",
delivery_terms: "",
payment_terms: "",
issued_by: "",
notes: "",
internal_notes: "",
status: "draft",
});
const [items, setItems] = useState<Array<IssuedOrderItem & { _key: string }>>(
[emptyItem()],
);
const [hydrated, setHydrated] = useState(false);
// Hydrate the form once from the loaded order (edit mode).
if (isEdit && loaded && !hydrated) {
setForm({
customer_id: loaded.customer_id ? String(loaded.customer_id) : "",
currency: loaded.currency ?? "CZK",
vat_rate: Number(loaded.vat_rate ?? 21),
apply_vat: loaded.apply_vat !== false,
order_date: (loaded.order_date ?? "").slice(0, 10),
delivery_date: (loaded.delivery_date ?? "").slice(0, 10),
language: loaded.language ?? "cs",
delivery_terms: loaded.delivery_terms ?? "",
payment_terms: loaded.payment_terms ?? "",
issued_by: loaded.issued_by ?? "",
notes: loaded.notes ?? "",
internal_notes: loaded.internal_notes ?? "",
status: loaded.status,
});
setItems(
(loaded.items.length ? loaded.items : [emptyItem()]).map((it) => ({
...it,
_key: Math.random().toString(36).slice(2),
})),
);
setHydrated(true);
}
const editable = !isEdit || form.status === "draft" || form.status === "sent";
const totals = useMemo(() => {
let subtotal = 0;
let vat = 0;
for (const it of items) {
const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
subtotal += base;
if (form.apply_vat) {
const rate = Number(it.vat_rate) || 0;
vat += Math.round(base * (rate / 100) * 100) / 100;
}
}
return {
subtotal: Math.round(subtotal * 100) / 100,
vat: Math.round(vat * 100) / 100,
total: Math.round((subtotal + vat) * 100) / 100,
};
}, [items, form.apply_vat]);
const saveMutation = useApiMutation<Record<string, unknown>, { id: number }>({
url: () =>
isEdit ? `${API_BASE}/issued-orders/${id}` : `${API_BASE}/issued-orders`,
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["issued-orders"],
onSuccess: () => navigate("/orders?tab=vydane"),
});
const addItem = () => setItems((prev) => [...prev, emptyItem()]);
const removeItem = (index: number) => {
if (items.length <= 1) return;
setItems((prev) => prev.filter((_, i) => i !== index));
};
const updateItem = (
index: number,
field: keyof IssuedOrderItem,
value: unknown,
) =>
setItems((prev) =>
prev.map((it, i) => (i === index ? { ...it, [field]: value } : it)),
);
const submit = () => {
saveMutation.mutate({
...form,
customer_id: form.customer_id || null,
delivery_date: form.delivery_date || null,
items: items.map((it, i) => ({
description: it.description,
item_description: it.item_description,
quantity: it.quantity,
unit: it.unit,
unit_price: it.unit_price,
vat_rate: it.vat_rate,
position: i,
})),
});
};
const transition = (status: string) => {
if (!isEdit) return;
saveMutation.mutate({ status });
};
const customers = customersQuery.data ?? [];
const headingNumber = isEdit
? (loaded?.po_number ?? "")
: (nextNumberQuery.data ?? "");
return (
<PageEnter>
<PageHeader
title={`Objednávka vydaná ${headingNumber}`}
actions={
isEdit && canExport ? (
<Button
variant="secondary"
onClick={() =>
window.open(
`${API_BASE}/issued-orders-pdf/${id}?lang=${form.language}`,
"_blank",
)
}
>
Export PDF
</Button>
) : null
}
/>
<Card>
<Select
label="Dodavatel"
value={form.customer_id}
onChange={(v) => setForm((f) => ({ ...f, customer_id: v }))}
disabled={!editable}
options={[
{ value: "", label: "— vyberte —" },
...customers.map((c) => ({ value: String(c.id), label: c.name })),
]}
/>
<DateField
label="Datum vystavení"
value={form.order_date}
onChange={(v) => setForm((f) => ({ ...f, order_date: v }))}
disabled={!editable}
/>
<DateField
label="Požadované dodání"
value={form.delivery_date}
onChange={(v) => setForm((f) => ({ ...f, delivery_date: v }))}
disabled={!editable}
/>
<TextField
label="Měna"
value={form.currency}
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))}
disabled={!editable}
/>
<TextField
label="Sazba DPH %"
type="number"
value={String(form.vat_rate)}
onChange={(e) =>
setForm((f) => ({ ...f, vat_rate: Number(e.target.value) }))
}
disabled={!editable}
/>
<CheckboxField
label="Uplatnit DPH"
checked={form.apply_vat}
onChange={(v) => setForm((f) => ({ ...f, apply_vat: v }))}
disabled={!editable}
/>
</Card>
<Card>
{items.map((it, i) => (
<div key={it._key} style={{ display: "flex", gap: 8 }}>
<TextField
placeholder="Popis položky…"
value={it.description ?? ""}
onChange={(e) => updateItem(i, "description", e.target.value)}
disabled={!editable}
/>
<TextField
placeholder="Množství"
type="number"
value={String(it.quantity ?? "")}
onChange={(e) => updateItem(i, "quantity", e.target.value)}
disabled={!editable}
/>
<TextField
placeholder="MJ"
value={it.unit ?? ""}
onChange={(e) => updateItem(i, "unit", e.target.value)}
disabled={!editable}
/>
<TextField
placeholder="Cena/MJ (bez DPH)"
type="number"
value={String(it.unit_price ?? "")}
onChange={(e) => updateItem(i, "unit_price", e.target.value)}
disabled={!editable}
/>
<TextField
placeholder="DPH %"
type="number"
value={String(it.vat_rate ?? "")}
onChange={(e) => updateItem(i, "vat_rate", e.target.value)}
disabled={!editable}
/>
{editable && (
<Button variant="ghost" onClick={() => removeItem(i)}>
×
</Button>
)}
</div>
))}
{editable && (
<Button variant="secondary" onClick={addItem}>
Přidat položku
</Button>
)}
<div style={{ textAlign: "right", marginTop: 8 }}>
Základ: {totals.subtotal.toLocaleString("cs-CZ")} {form.currency} ·
DPH: {totals.vat.toLocaleString("cs-CZ")} {form.currency} ·{" "}
<strong>
Celkem: {totals.total.toLocaleString("cs-CZ")} {form.currency}
</strong>
</div>
</Card>
<Card>
<RichEditor
value={form.notes}
onChange={(v) => setForm((f) => ({ ...f, notes: v }))}
readOnly={!editable}
/>
<TextField
label="Interní poznámka"
value={form.internal_notes}
onChange={(e) =>
setForm((f) => ({ ...f, internal_notes: e.target.value }))
}
disabled={!editable}
/>
<TextField
label="Dodací podmínky"
value={form.delivery_terms}
onChange={(e) =>
setForm((f) => ({ ...f, delivery_terms: e.target.value }))
}
disabled={!editable}
/>
<TextField
label="Platební podmínky"
value={form.payment_terms}
onChange={(e) =>
setForm((f) => ({ ...f, payment_terms: e.target.value }))
}
disabled={!editable}
/>
<TextField
label="Vystavil"
value={form.issued_by}
onChange={(e) =>
setForm((f) => ({ ...f, issued_by: e.target.value }))
}
disabled={!editable}
/>
</Card>
<div style={{ display: "flex", gap: 8 }}>
{editable && (
<Button onClick={submit} disabled={saveMutation.isPending}>
Uložit
</Button>
)}
{isEdit &&
(loaded?.valid_transitions ?? []).map((s) => (
<Button key={s} variant="secondary" onClick={() => transition(s)}>
{STATUS_LABELS[s] ?? s}
</Button>
))}
<Button variant="ghost" onClick={() => navigate("/orders?tab=vydane")}>
Zpět
</Button>
</div>
</PageEnter>
);
}
```
> The "hydrate once" pattern (`if (isEdit && loaded && !hydrated) { … }`) sets state during render guarded by a flag — acceptable and lint-clean. If `OrdersReceived.tsx` / `InvoiceDetail.tsx` use a different hydration idiom (e.g. a `key` prop or `useEffect`), prefer matching that. All `useState`/`useQuery`/`useMemo` calls stay above any early return.
> Prop-name reality check: `DateField`, `Select`, `CheckboxField`, `RichEditor`, `Button` (`variant`), and `useApiMutation` (`url`/`method`/`invalidate`/`onSuccess`) must match the existing kit. Open `InvoiceDetail.tsx` and adjust prop names where they differ — do not invent props.
- [ ] **Step 3: Typecheck + lint + build**
Run: `npx tsc -b --noEmit` then `npm run lint` then `npm run build`
Expected: all PASS, 0 lint errors.
- [ ] **Step 4: Commit**
```
git add src/admin/pages/IssuedOrderDetail.tsx src/admin/AdminApp.tsx
git commit -m "feat(orders): issued-order create/edit form + PDF export button"
```
---
## Task 11: Full verification & manual smoke test
**Files:** none (verification only)
- [ ] **Step 1: Run every quality gate**
```
npx tsc -b --noEmit
npm run build
npx vitest run
npm run lint
```
Expected: typecheck PASS; build PASS; **all** tests pass (the existing suite + the new `issued-orders.test.ts`); lint 0 errors.
- [ ] **Step 2: Manual smoke test (ask the user to exercise the running app)**
Since this project's dev server is user-managed and there are no component tests, ask the user to verify in the browser:
1. Open **Objednávky** → the **Přijaté** tab shows the existing orders unchanged; the **Vydané** tab loads.
2. **Vytvořit objednávku vydanou** → pick a supplier (customer), add a couple of line items, save → it appears in the Vydané list with the right total.
3. Open it → **Export PDF** downloads a PDF showing your company as recipient, the supplier block, the items, and NET+VAT totals.
4. Move it `draft → sent → confirmed`; confirm the line items lock after `confirmed`.
5. Delete a draft → it disappears and its number is freed.
- [ ] **Step 3: Final commit (if any cleanup) and finish the branch**
If the smoke test surfaced fixes, commit them. Then use **superpowers:finishing-a-development-branch** to wrap up `feat/issued-orders` (review the full diff, confirm the gates, and merge/PR per the user's preference). Do not push or merge without the user's go-ahead.
---
## Self-review notes (for the author — checked against the spec)
- **Spec coverage:** data model (T1), numbering (T2), schemas (T3), service + totals + transitions (T4), routes + permissions (T5), audit entity type (T6), PDF (T7), queries (T8), tabs + list (T9), detail form + routes (T10), tests throughout, migration (T1), gates (T11). Future-phase items (warehouse/received-invoice links, NAS PDF persistence, dedicated `purchase-orders.*` perms) are intentionally **not** tasked.
- **Permissions:** reuse `orders.view/create/edit/delete/export` (verified to exist in `seed.ts`) — no permission migration, matching the spec.
- **VAT regime:** NET + VAT-on-top in `computeIssuedOrderTotals`, the PDF, and the form's live totals — consistent across all three, and deliberately opposite to `received_invoices` (gross).
- **Naming consistency:** `issued_orders` / `issued_order_items` / `po_number` / `issued_orders_status` / `generateIssuedOrderNumber` / `issuedOrderListOptions` / `entityType: "issued_order"` are used identically in every task.