Merge feat/issued-orders: issued purchase orders (objednávky vydané)

New issued-purchase-order document type (schema, numbering, service, routes, PDF, frontend Vydané tab) plus the orders/invoices consistency pass (Vydané-first tabs, shared month filter, shared red-accent PDF template). Dependency upgrades (React 19, Prisma 7, file-type 22) were landed separately on master first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 13:41:46 +02:00
24 changed files with 7349 additions and 642 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
# Objednávky vydané (Issued Purchase Orders) — Design Spec
**Date:** 2026-06-09
**Status:** Approved (brainstorm) → ready for implementation plan
**Author:** brainstorm session
---
## Goal
Add a new document type — **objednávky vydané** (purchase orders you issue to a
supplier) — alongside the existing customer orders. You author the PO with line
items, it gets an auto-generated number, you track its status, and you export it
to PDF to send to the supplier. The existing orders module becomes the
**přijaté** (customer-order) side of a two-tab UI, mirroring how the Invoices
page already splits **Vydané | Přijaté**.
## Non-goals (v1)
- **No cross-module wiring.** A PO does **not** link to warehouse goods receipts
(`sklad_receipts`) or to received invoices (`faktury přijaté`). The full
procurement loop (ordered → received → invoiced) is a deliberate later phase.
- **No new supplier master.** POs reuse the existing `customers` table as generic
business partners.
- **No NAS persistence of the PO PDF** in v1 (generated on demand only).
- **No "scope sections"** (the CZ/EN rich-text blocks orders/quotations carry).
- **No AI/Odin authoring.** POs are authored by hand; Odin extraction is a
received-document concept and does not apply to documents you create.
---
## Decisions log (resolved during brainstorm)
1. **Direction mapping.** Today's `orders` (status default `prijata`, flowing
quotation → order → project → invoice) stay as the **Přijaté** side, largely
unchanged — only surfaced under a new tab. The new **Vydané** side is a
brand-new document type. _(Not a `direction` flag on the existing table.)_
2. **Supplier source.** A PO references the existing **`customers`** table (FK),
used as a generic business partner. No new supplier/vendor master.
3. **Linkage.** **Standalone document** in v1. No automatic links to warehouse or
received invoices.
4. **UI placement.** **Tabs on the Orders page** (`Přijaté | Vydané`), mirroring
the Invoices page.
5. **Model shape.** A **dedicated `issued_orders` + `issued_order_items`** pair,
mirroring `invoices`/`invoice_items`_not_ an extension of `orders` and _not_
an upload-only record like `received_invoices`.
6. **VAT regime.** **NET base + VAT-on-top** (the _issued-invoice_ convention),
the **opposite** of `received_invoices` (which are gross/VAT-inclusive). Totals
use the same per-line-rounded math as `computeInvoiceTotals`.
7. **Numbering.** A **dedicated** `number_sequences` type (`"issued_order"`),
separate from the `"shared"` orders/projects pool and from `"invoice"`. Number
assigned **at creation** (even for a `draft`), with release-on-delete.
8. **Permissions.** **Reuse the existing `orders.*` set** (`view`/`create`/`edit`/
`delete`) plus `orders.export` for the PDF — mirrors invoices sharing one
permission set across both tabs; **no permission migration needed**.
---
## Architecture
A self-contained vertical slice following the codebase's established document
pattern (the same shape `invoices` uses):
```
prisma/schema.prisma → models issued_orders, issued_order_items + enum
src/schemas/issued-orders.schema.ts
src/services/issued-orders.service.ts
src/services/numbering.service.ts (extend: issued-order number fns)
src/routes/admin/issued-orders.ts (register in routes/admin/index.ts)
src/routes/admin/issued-orders-pdf.ts (PDF route, modeled on invoices-pdf.ts)
src/admin/pages/Orders.tsx (add Přijaté | Vydané tabs)
src/admin/pages/IssuedOrderDetail.tsx (create/edit form + PDF export)
src/admin/lib/queries/issued-orders.ts
src/admin/lib/entityTypeLabels.ts (add "issued_order" Czech label)
src/admin/AdminApp.tsx (lazy routes for detail/new)
src/__tests__/issued-orders.test.ts
```
**Tech stack:** Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React
Query, Puppeteer (PDF), Vitest. All new code follows the audited conventions
(success/error helpers, `parseBody`/`parseId`, shared Zod coercers, broad query
invalidation, deterministic ordering with an `id` tiebreak, locked
read-modify-write for numbering).
---
## Data model
### `issued_orders` (parallels `invoices`)
| Field | Type | Notes |
| ---------------- | -------------------------------------------------- | ------------------------------------------- |
| `id` | Int PK autoincrement | |
| `po_number` | VARCHAR(50) unique | auto-assigned (see Numbering) |
| `customer_id` | Int FK → `customers` (onDelete Restrict, nullable) | the **supplier/partner** being ordered from |
| `status` | enum `issued_orders_status` | default `draft` |
| `currency` | VARCHAR(10) default `"CZK"` | |
| `vat_rate` | Decimal(5,2) default `21.00` | doc-level default rate |
| `apply_vat` | Boolean default `true` | |
| `exchange_rate` | Decimal(12,4) nullable | foreign-currency, like orders |
| `order_date` | Date | date the PO is issued |
| `delivery_date` | Date nullable | requested delivery / required-by |
| `language` | VARCHAR(5) default `"cs"` | drives PDF language |
| `delivery_terms` | VARCHAR(500) nullable | optional, shown on PDF |
| `payment_terms` | VARCHAR(500) nullable | optional, shown on PDF |
| `issued_by` | VARCHAR(255) nullable | signatory name |
| `notes` | Text nullable | rich text, **rendered on PDF** (sanitized) |
| `internal_notes` | Text nullable | private, **not** on PDF |
| `created_at` | DateTime default now | |
| `modified_at` | DateTime @updatedAt | |
**Relations:** `issued_order_items[]`, `customers?`.
**Indexes:** `(customer_id)`, `(status, order_date)`.
### `issued_order_items` (parallels `invoice_items`)
| Field | Type | Notes |
| ------------------ | ------------------------------------------- | ------------------------------- |
| `id` | Int PK | |
| `issued_order_id` | Int FK → `issued_orders` (onDelete Cascade) | |
| `description` | VARCHAR(500) | main line text |
| `item_description` | Text nullable | optional long detail |
| `quantity` | Decimal(12,3) default `1.000` | |
| `unit` | VARCHAR(20) nullable | e.g. ks, hod, kg |
| `unit_price` | Decimal(12,2) default `0.00` | **NET** unit price |
| `vat_rate` | Decimal(5,2) default `21.00` | per-line; overrides doc default |
| `position` | Int default `0` | ordering |
**Index:** `(issued_order_id)`.
### `issued_orders_status` enum
`draft`, `sent`, `confirmed`, `completed`, `cancelled`
(Czech labels: _Koncept · Odeslaná · Potvrzená · Dokončená · Stornovaná_.)
---
## Numbering
A dedicated sequence, exactly like invoices:
- New `number_sequences` type string `"issued_order"` (unique on `(type, year)`,
as the table already enforces). Separate from `"shared"` and `"invoice"`.
- New `company_settings` columns:
- `issued_order_number_pattern` VARCHAR default `"{YY}{CODE}{NNNN}"`
- `issued_order_type_code` VARCHAR default `"72"` (orders use `71`, invoices
`81`; `72` is free and configurable).
- New functions in `src/services/numbering.service.ts`, reusing the existing
`applyPattern` + `getNextSequence` (`SELECT … FOR UPDATE` lock, P2002
first-of-year retry):
- `generateIssuedOrderNumber(tx)` — atomically consumes the next number.
- `previewIssuedOrderNumber()` — non-consuming peek (for the create form).
- `releaseIssuedOrderNumber(year, number)` — decrement only if the deleted PO
held the current highest number that year (gap prevention), mirroring
`releaseInvoiceNumber`.
- The number is **assigned at creation**, including for a `draft`. Deleting a PO
releases the number per the rule above.
---
## Backend API
### Schema — `src/schemas/issued-orders.schema.ts`
`CreateIssuedOrderSchema`, `UpdateIssuedOrderSchema`, `IssuedOrderItemSchema`,
built **only** from the shared coercers in `src/schemas/common.ts`:
- `customer_id``nullableIntIdFromForm`
- `quantity``positiveNumberFromForm`; `unit_price``nonNegativeNumberFromForm`
- `vat_rate``numberInRange(0, 100)`
- `order_date` / `delivery_date``isoDateString` (lenient)
- text fields → `z.string().max(...)`; optional email-like none here
- Czech user-facing messages; English identifiers.
**No** raw `z.union([z.number(), z.string()]).transform(Number)` — that idiom is
banned (silent `NaN`).
### Service — `src/services/issued-orders.service.ts`
Plain exported async functions returning `{ data }` or `{ error, status }`:
- `listIssuedOrders(params)` — paginated; filters `status`, `customer_id`,
`search` (po_number / supplier name); whitelist sort
(`id`/`po_number`/`status`/`order_date`/`created_at`) with a deterministic
**`{ id }` tiebreak**; each row enriched via `computeIssuedOrderTotals`.
- `getIssuedOrder(id)` — full detail with items, supplier name, and
`valid_transitions`.
- `createIssuedOrder(body)` — inside `prisma.$transaction`:
`generateIssuedOrderNumber(tx)` → insert header → batch-insert items.
- `updateIssuedOrder(id, body)` — validates the status transition against
`VALID_TRANSITIONS`; replaces items (delete-all + recreate) only while the doc
is editable (`draft`/`sent`); blocks any `po_number` change.
- `deleteIssuedOrder(id)` — delete (items cascade) then
`releaseIssuedOrderNumber(year, po_number)`.
- `computeIssuedOrderTotals(items, applyVat, docRate)` — **NET + VAT-on-top,
rounded per line before accumulation** (same shape as `computeInvoiceTotals`):
`base = qty × unit_price`; `lineVat = round(base × rate/100)` when
`applyVat`; `subtotal = Σ base`, `vat = Σ lineVat`, `total = subtotal + vat`.
Falls back line `vat_rate` → doc `vat_rate` → 21.
- `getNextIssuedOrderNumber()` — proxies `previewIssuedOrderNumber()`.
`VALID_TRANSITIONS`:
```
draft → [sent, cancelled]
sent → [confirmed, cancelled]
confirmed → [completed, cancelled]
completed → [] // terminal
cancelled → [] // terminal
```
### Routes — `src/routes/admin/issued-orders.ts`
Registered in `src/routes/admin/index.ts`. Every handler uses `success()`/
`error()`, `parseBody(Schema, …)`, `parseId((request.params as any).id, reply)`,
and `logAudit` with a **new** entity type `"issued_order"` (create/update/delete,
with `oldValues`/`newValues`). Adding this entity type requires also registering
its Czech label in `src/admin/lib/entityTypeLabels.ts` (keyed to the server's
`EntityType` value), per the single-source-of-truth convention.
| Method · Path | Guard | Purpose |
| ------------------------------------------ | --------------- | -------------------------- |
| GET `/api/admin/issued-orders` | `orders.view` | list (paginated, filtered) |
| GET `/api/admin/issued-orders/next-number` | `orders.create` | preview next PO number |
| GET `/api/admin/issued-orders/:id` | `orders.view` | detail |
| POST `/api/admin/issued-orders` | `orders.create` | create |
| PUT `/api/admin/issued-orders/:id` | `orders.edit` | update |
| DELETE `/api/admin/issued-orders/:id` | `orders.delete` | delete |
| GET `/api/admin/issued-orders/:id/pdf` | `orders.export` | render + stream PDF |
---
## PDF export
New route + template `src/routes/admin/issued-orders-pdf.ts`, modeled on
`invoices-pdf.ts`, reusing `htmlToPdf` (Puppeteer) from `src/utils/pdf.ts`:
- **Sender block** = your company (`company_settings`: name, IČO, DIČ, address,
logo as base64). **Recipient block** = the selected supplier (the `customers`
record: name, IČO/`company_id`, DIČ/`vat_id`, address).
- Body: items table → VAT recap grouped by rate → **NET total + VAT + gross**.
- `cs` / `en` strings selected by `language`. Czech date/currency via the
existing formatters.
- `notes` passed through the **same** `cleanQuillHtml` + DOMPurify sanitization
used by invoices/orders before going into the template (mandatory for any new
rich-text-on-PDF field).
- **v1 behavior:** generate on demand and stream `application/pdf` with
`Content-Disposition` filename `{po_number}.pdf`. **No NAS write.** (A future
`?save=1 → Objednávky vydané/YYYY/MM/{po_number}.pdf` add-on is a small,
isolated follow-up.)
---
## Frontend
- **`src/admin/pages/Orders.tsx` — add `Přijaté | Vydané` tabs** using the same
MUI Tabs pattern as `Invoices.tsx`. The current orders list moves **verbatim**
into the **Přijaté** tab — every hook, mutation, `invalidate` array, validation,
and permission check preserved unchanged; only presentation is reorganized. The
**Vydané** tab renders the new list.
- **Vydané list** — `DataTable` columns: `po_number`, supplier (customer name),
status chip (color per status), `order_date`, total (currency), actions (open,
**Export PDF**, delete). `FilterBar` with status + customer `Select`s and search
at standard widths; pagination + sortable headers; an "Vytvořit objednávku
vydanou" button gated on `orders.create`.
- **`src/admin/pages/IssuedOrderDetail.tsx`** (lazy routes `/orders/issued/new`
and `/orders/issued/:id` in `AdminApp.tsx`): supplier `Select`, `order_date` /
`delivery_date` `DateField`s, currency / VAT rate / `apply_vat`, a **line-items
table** (add/remove rows + `@dnd-kit` reorder, like `InvoiceDetail`), `RichEditor`
notes, internal notes, `delivery_terms` / `payment_terms`, `issued_by`, **live
totals** (net / VAT / gross), status-transition buttons, and an **Export PDF**
button gated on `orders.export`. Top-level sections wrapped in `<PageEnter>`;
imports come from the `ui/` kit.
- **Queries** `src/admin/lib/queries/issued-orders.ts``issuedOrderListOptions`,
`issuedOrderDetailOptions`, `issuedOrderNextNumberOptions`. All mutations
invalidate the **broad `["issued-orders"]`** domain key.
- **Rules of Hooks:** all hooks run before any early permission `return`
(`<Forbidden/>`/`<Navigate/>` go after every hook) — `npm run lint` enforces
`react-hooks/rules-of-hooks` as an error.
- Sidebar nav unchanged — one "Objednávky" item; the split lives in the tabs.
---
## Permissions
Reuse the existing `orders.*` set for the Vydané tab:
- `orders.view` — list + detail + PDF view
- `orders.create` — create + next-number preview
- `orders.edit` — update
- `orders.delete` — delete
- `orders.export` — PDF export (already seeded)
This mirrors invoices (issued + received share one `invoices.*` set) and needs
**no permission migration**. _(Future option, out of scope for v1: a dedicated
`purchase-orders._` set via a seed + migration if purchasing must be locked down
separately from sales orders.)\*
---
## Status lifecycle
`draft → sent → confirmed → completed`, plus any non-terminal → `cancelled`.
- **Editable** (header + items): `draft`, `sent`.
- **Read-only except a permitted status change**: `confirmed`, `completed`,
`cancelled`.
- Enforced server-side by the `VALID_TRANSITIONS` guard in the service; the detail
page only renders buttons for valid next states.
---
## Testing
`src/__tests__/issued-orders.test.ts`, against the real **`app_test`** DB via
`buildApp()` (no Prisma mocks):
- **Numbering:** sequential allocation; release-only-if-highest on delete; the
generated number matches the configured pattern.
- **Create + totals:** create with multiple items, assert NET+VAT-on-top totals
with **per-line rounding pinned to exact 2-decimal values**; mixed per-line VAT
rates; `apply_vat=false` zeroes VAT but preserves the NET subtotal.
- **Status transitions:** every valid transition succeeds; invalid transitions
return the proper error; items become read-only after `confirmed`.
- **Permissions:** each endpoint rejects a caller lacking the required `orders.*`
permission (not bare auth).
- **PDF:** `GET /:id/pdf` returns `200` with `content-type: application/pdf`.
---
## Migration
A single `prisma migrate dev --name issued_orders`:
1. Creates `issued_orders` and `issued_order_items` tables + the
`issued_orders_status` enum.
2. Adds `issued_order_number_pattern` and `issued_order_type_code` columns to
`company_settings` with SQL defaults (`"{YY}{CODE}{NNNN}"`, `"72"`).
Per project rules: **ask the user to stop the dev server first**; commit both
`schema.prisma` and the generated migration folder; run `prisma generate`. No raw
SQL on prod — the column defaults ship inside the migration's `migration.sql`.
**Quality gates before "done":** `npx tsc -b --noEmit`, `npm run build`,
`npx vitest run`, `npm run lint` (0 errors).
---
## Future phases (explicitly out of scope for v1)
- **Procurement links:** PO ↔ warehouse goods receipt (`sklad_receipts`) and
PO ↔ received invoice matching (ordered → received → invoiced rollups), with
Odin pre-filling a received invoice from a PO.
- **NAS persistence** of the generated PO PDF (`?save=1`).
- **Dedicated `purchase-orders.*` permission set.**
- **Scope sections** (CZ/EN rich-text blocks) if POs ever need narrative content.

View File

@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE `ai_chat_messages` DROP FOREIGN KEY `fk_ai_chat_conv`;
-- AlterTable
ALTER TABLE `quotations` MODIFY `project_code` VARCHAR(100) NULL;
-- AddForeignKey
ALTER TABLE `ai_chat_messages` ADD CONSTRAINT `ai_chat_messages_conversation_id_fkey` FOREIGN KEY (`conversation_id`) REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,52 @@
-- AlterTable
ALTER TABLE `company_settings` ADD COLUMN `issued_order_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `issued_order_type_code` VARCHAR(10) NULL;
-- CreateTable
CREATE TABLE `issued_orders` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`po_number` VARCHAR(50) NULL,
`customer_id` INTEGER NULL,
`status` ENUM('draft', 'sent', 'confirmed', 'completed', 'cancelled') NOT NULL DEFAULT 'draft',
`currency` VARCHAR(10) NULL DEFAULT 'CZK',
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
`apply_vat` BOOLEAN NULL DEFAULT true,
`exchange_rate` DECIMAL(10, 4) NULL DEFAULT 1.0000,
`order_date` DATE NULL,
`delivery_date` DATE NULL,
`language` VARCHAR(5) NULL DEFAULT 'cs',
`delivery_terms` VARCHAR(500) NULL,
`payment_terms` VARCHAR(500) NULL,
`issued_by` VARCHAR(255) NULL,
`notes` TEXT NULL,
`internal_notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_issued_orders_number_unique`(`po_number`),
INDEX `issued_orders_customer_id`(`customer_id`),
INDEX `idx_issued_orders_status_date`(`status`, `order_date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `issued_order_items` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issued_order_id` INTEGER NOT NULL,
`description` VARCHAR(500) NULL,
`item_description` TEXT NULL,
`quantity` DECIMAL(12, 3) NULL DEFAULT 1.000,
`unit` VARCHAR(20) NULL,
`unit_price` DECIMAL(12, 2) NULL DEFAULT 0.00,
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
`position` INTEGER NULL DEFAULT 0,
INDEX `issued_order_id`(`issued_order_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `issued_order_items` ADD CONSTRAINT `issued_order_items_ibfk_1` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;

View File

@@ -164,6 +164,8 @@ model company_settings {
offer_number_pattern String? @db.VarChar(100) offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100) order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100) invoice_number_pattern String? @db.VarChar(100)
issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10)
warehouse_receipt_prefix String? @db.VarChar(20) warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100) warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20) warehouse_issue_prefix String? @db.VarChar(20)
@@ -189,6 +191,7 @@ model customers {
sync_version Int? @default(0) sync_version Int? @default(0)
invoices invoices[] invoices invoices[]
orders orders[] orders orders[]
issued_orders issued_orders[]
projects projects[] projects projects[]
quotations quotations[] quotations quotations[]
} }
@@ -362,6 +365,55 @@ model orders {
@@index([quotation_id], map: "quotation_id") @@index([quotation_id], map: "quotation_id")
} }
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
}
model permissions { model permissions {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique(map: "name") @db.VarChar(50) name String @unique(map: "name") @db.VarChar(50)

View File

@@ -0,0 +1,292 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
releaseIssuedOrderNumber,
} from "../services/numbering.service";
import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema";
import {
computeIssuedOrderTotals,
createIssuedOrder,
getIssuedOrder,
listIssuedOrders,
updateIssuedOrder,
deleteIssuedOrder,
} from "../services/issued-orders.service";
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
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);
});
});
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);
});
});
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);
});
it("releases the number against the order's creation year, not the current year", async () => {
const priorYear = new Date().getFullYear() - 1;
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
// so the PO number matches the prior year's current highest.
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
const order = await createIssuedOrder({ po_number: poNumber });
createdIds.push(order.id);
// Backdate creation into the prior year so delete derives that year.
await prisma.issued_orders.update({
where: { id: order.id },
data: { created_at: new Date(priorYear, 5, 1, 12, 0, 0) },
});
await deleteIssuedOrder(order.id);
// The prior-year sequence must be decremented (1 -> 0). With the old
// current-year bug it would stay at 1 (no current-year row to match).
const seq = await prisma.number_sequences.findFirst({
where: { type: "issued_order", year: priorYear },
});
expect(seq?.last_number).toBe(0);
});
});
describe("listIssuedOrders month filter", () => {
it("returns only orders whose order_date is in the given month", async () => {
const inMonth = await createIssuedOrder({ order_date: "2026-03-15" });
const outMonth = await createIssuedOrder({ order_date: "2026-04-15" });
createdIds.push(inMonth.id, outMonth.id);
const res = await listIssuedOrders({
page: 1,
limit: 50,
skip: 0,
sort: "id",
order: "desc",
month: 3,
year: 2026,
});
const ids = res.data.map((o) => o.id);
expect(ids).toContain(inMonth.id);
expect(ids).not.toContain(outMonth.id);
});
});
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: "Detailní popis položky",
quantity: 2,
unit: "ks",
unit_price: 100,
vat_rate: 21,
},
];
it("renders the PO number, items, and both party names (PO direction)", () => {
const html = renderIssuedOrderHtml(
order,
items,
{ name: "Dodavatel s.r.o." },
{ company_name: "Naše firma" },
"cs",
);
expect(html).toContain("26720001");
expect(html).toContain("Materiál");
// Optional item sub-line.
expect(html).toContain("Detailní popis položky");
// PO direction: the customer record is the Dodavatel (supplier), our
// company is the Odběratel (buyer).
expect(html).toContain("Dodavatel s.r.o.");
expect(html).toContain("Naše firma");
expect(html).toContain("Dodavatel");
expect(html).toContain("Odběratel");
});
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)");
});
});

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createOrder, listOrders } from "../services/orders.service";
const createdOrderIds: number[] = [];
const createdCustomerIds: number[] = [];
afterEach(async () => {
for (const id of createdOrderIds)
await prisma.orders.deleteMany({ where: { id } });
for (const id of createdCustomerIds)
await prisma.customers.deleteMany({ where: { id } });
createdOrderIds.length = 0;
createdCustomerIds.length = 0;
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};
function failResult(res: unknown): never {
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
}
async function makeCustomer() {
const c = await prisma.customers.create({
data: { name: "Odběratel a.s." },
});
createdCustomerIds.push(c.id);
return c;
}
const baseListParams = {
page: 1,
limit: 50,
skip: 0,
sort: "id",
order: "desc" as const,
};
describe("listOrders search filter", () => {
it("returns the order when search matches its order_number", async () => {
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 list = await listOrders({
...baseListParams,
search: res.order_number!,
});
expect(list.data.map((o) => o.id)).toContain(res.id);
});
it("excludes the order when search matches nothing", async () => {
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 list = await listOrders({
...baseListParams,
search: "ZZZ-NO-SUCH-ORDER-ZZZ",
});
expect(list.data.map((o) => o.id)).not.toContain(res.id);
});
});
describe("listOrders month filter", () => {
it("returns only orders whose created_at is in the given month", async () => {
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!);
// Pin created_at to a known month so the range filter is deterministic.
await prisma.orders.update({
where: { id: res.id },
data: { created_at: new Date(2026, 2, 15, 12, 0, 0) },
});
const inMonth = await listOrders({
...baseListParams,
month: 3,
year: 2026,
});
expect(inMonth.data.map((o) => o.id)).toContain(res.id);
const otherMonth = await listOrders({
...baseListParams,
month: 4,
year: 2026,
});
expect(otherMonth.data.map((o) => o.id)).not.toContain(res.id);
});
});

View File

@@ -33,6 +33,7 @@ const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
const OffersTemplates = lazy(() => import("./pages/OffersTemplates")); const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const Orders = lazy(() => import("./pages/Orders")); const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail")); const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
const Projects = lazy(() => import("./pages/Projects")); const Projects = lazy(() => import("./pages/Projects"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import("./pages/Invoices")); const Invoices = lazy(() => import("./pages/Invoices"));
@@ -140,6 +141,14 @@ export default function AdminApp() {
element={<OffersTemplates />} element={<OffersTemplates />}
/> />
<Route path="orders" element={<Orders />} /> <Route path="orders" element={<Orders />} />
<Route
path="orders/issued/new"
element={<IssuedOrderDetail />}
/>
<Route
path="orders/issued/:id"
element={<IssuedOrderDetail />}
/>
<Route path="orders/:id" element={<OrderDetail />} /> <Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} /> <Route path="projects" element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} /> <Route path="projects/:id" element={<ProjectDetail />} />

View File

@@ -16,6 +16,7 @@ export const ENTITY_TYPE_LABELS: Record<string, string> = {
invoice: "Faktura", invoice: "Faktura",
quotation: "Nabídka", quotation: "Nabídka",
order: "Objednávka", order: "Objednávka",
issued_order: "Objednávka vydaná",
project: "Projekt", project: "Projekt",
project_file: "Soubor projektu", project_file: "Soubor projektu",
customer: "Zákazník", customer: "Zákazník",

View File

@@ -0,0 +1,79 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
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;
month?: number;
year?: number;
}) =>
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);
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
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,
});

View File

@@ -61,6 +61,8 @@ export const orderListOptions = (filters: {
order?: string; order?: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
month?: number;
year?: number;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["orders", "list", filters], queryKey: ["orders", "list", filters],
@@ -71,6 +73,8 @@ export const orderListOptions = (filters: {
if (filters.order) params.set("order", filters.order); if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page)); if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage)); if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
const qs = params.toString(); const qs = params.toString();
return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`); return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`);
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,359 @@
import { useState } from "react";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatCurrency, formatDate } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import {
issuedOrderListOptions,
type IssuedOrder,
} from "../lib/queries/issued-orders";
import {
Card,
DataTable,
Pagination,
ConfirmDialog,
TextField,
Select,
StatusChip,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
draft: "Koncept",
sent: "Odeslaná",
confirmed: "Potvrzená",
completed: "Dokončená",
cancelled: "Stornovaná",
};
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
draft: "default",
sent: "info",
confirmed: "warning",
completed: "success",
cancelled: "error",
};
const STATUS_OPTIONS = [
{ value: "", label: "Všechny stavy" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
];
const ViewIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
const PdfIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
interface IssuedOrdersProps {
month: number;
year: number;
}
export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const { sort, order, handleSort } = useTableSort("po_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
order: IssuedOrder | null;
}>({ show: false, order: null });
const deleteMutation = useApiMutation<
{ id: number },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/issued-orders/${id}`,
method: () => "DELETE",
invalidate: ["issued-orders"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, order: null });
alert.success(data?.message || "Objednávka byla smazána");
},
});
const {
items: orders,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<IssuedOrder>(
issuedOrderListOptions({
search: debouncedSearch,
sort,
order,
page,
perPage: 20,
status: status || undefined,
month,
year,
}),
);
if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.order) return;
try {
await deleteMutation.mutateAsync({ id: deleteConfirm.order.id });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleExportPdf = async (o: IssuedOrder) => {
// Authenticated open: apiFetch attaches the access token, then we hand the
// resulting PDF blob to a window. A raw window.open(url) would be an
// unauthenticated top-level navigation and 401 on the token-guarded route.
const newWindow = window.open("", "_blank");
try {
const response = await apiFetch(
`${API_BASE}/issued-orders-pdf/${o.id}?lang=cs`,
);
if (!response.ok) {
newWindow?.close();
alert.error("Nepodařilo se vygenerovat PDF");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch {
newWindow?.close();
alert.error("Chyba při generování PDF");
}
};
if (isPending) {
return <LoadingState />;
}
const columns: DataColumn<IssuedOrder>[] = [
{
key: "po_number",
header: "Číslo",
width: "16%",
sortKey: "po_number",
mono: true,
render: (o) => (
<Box
component={RouterLink}
to={`/orders/issued/${o.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{o.po_number || "—"}
</Box>
),
},
{
key: "customer_name",
header: "Dodavatel",
width: "24%",
render: (o) => o.customer_name || "—",
},
{
key: "status",
header: "Stav",
width: "14%",
sortKey: "status",
render: (o) => (
<StatusChip
label={STATUS_LABELS[o.status] || o.status}
color={STATUS_COLORS[o.status] || "default"}
/>
),
},
{
key: "order_date",
header: "Datum",
width: "13%",
sortKey: "order_date",
mono: true,
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
},
{
key: "total",
header: "Celkem",
width: "14%",
align: "right",
mono: true,
bold: true,
render: (o) => formatCurrency(o.total, o.currency ?? "CZK"),
},
{
key: "actions",
header: "Akce",
width: "14%",
align: "right",
render: (o) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => navigate(`/orders/issued/${o.id}`)}
aria-label={hasPermission("orders.edit") ? "Upravit" : "Detail"}
title={hasPermission("orders.edit") ? "Upravit" : "Detail"}
>
{ViewIcon}
</IconButton>
{hasPermission("orders.export") && (
<IconButton
size="small"
onClick={() => handleExportPdf(o)}
aria-label="PDF"
title="PDF"
>
{PdfIcon}
</IconButton>
)}
{hasPermission("orders.delete") && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, order: o })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
)}
</Box>
),
},
];
return (
<>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat podle čísla nebo dodavatele..."
fullWidth
/>
</Box>
<Box sx={{ flex: "0 1 200px" }}>
<Select
value={status}
onChange={(value) => {
setStatus(value);
setPage(1);
}}
options={STATUS_OPTIONS}
/>
</Box>
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<IssuedOrder>
columns={columns}
rows={orders}
rowKey={(o) => o.id}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
<EmptyState
title="Zatím žádné vydané objednávky."
description={
hasPermission("orders.create")
? "Vytvořte první tlačítkem „Vytvořit objednávku vydanou“."
: undefined
}
/>
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, order: null })}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
deleteConfirm.order
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.po_number || ""}"? Tato akce je nevratná.`
: ""
}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
</>
);
}

View File

@@ -1,74 +1,29 @@
import { useState } from "react"; import { useState, lazy, Suspense } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
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 apiFetch from "../utils/api"; import { Button, Tabs, PageHeader, PageEnter, LoadingState } from "../ui";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; import OrdersReceived from "./OrdersReceived";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
orderListOptions,
orderNextNumberOptions,
} from "../lib/queries/orders";
import { offerCustomersOptions } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Pagination,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
StatusChip,
CheckboxField,
FileUpload,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin"; const IssuedOrders = lazy(() => import("./IssuedOrders"));
const STATUS_LABELS: Record<string, string> = { const MONTH_NAMES = [
prijata: "Přijatá", "leden",
v_realizaci: "V realizaci", "únor",
dokoncena: "Dokončená", "březen",
stornovana: "Stornována", "duben",
}; "květen",
"červen",
const STATUS_COLORS: Record< "červenec",
string, "srpen",
"default" | "success" | "error" | "warning" | "info" "září",
> = { "říjen",
prijata: "info", "listopad",
v_realizaci: "warning", "prosinec",
dokoncena: "success", ];
stornovana: "error",
};
interface Order {
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
customer_name: string;
status: string;
created_at: string;
total: number;
currency: string;
invoice_id?: number;
}
const PlusIcon = ( const PlusIcon = (
<svg <svg
@@ -83,622 +38,150 @@ const PlusIcon = (
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
); );
const ViewIcon = ( const ChevronLeftIcon = (
<svg <svg
width="18" width="16"
height="18" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2.5"
> >
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <polyline points="15 18 9 12 15 6" />
<circle cx="12" cy="12" r="3" />
</svg> </svg>
); );
const InvoiceViewIcon = ( const ChevronRightIcon = (
<svg <svg
width="18" width="16"
height="18" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2.5"
> >
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> <polyline points="9 18 15 12 9 6" />
<polyline points="14 2 14 8 20 8" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
F
</text>
</svg>
);
const InvoiceCreateIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg> </svg>
); );
export default function Orders() { export default function Orders() {
const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const { sort, order, handleSort } = useTableSort("order_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
order: Order | null;
}>({ show: false, order: null });
const [deleteFiles, setDeleteFiles] = useState(false);
const deleteMutation = useApiMutation<
{ id: number; delete_files: boolean },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/orders/${id}`,
method: () => "DELETE",
invalidate: ["orders", "offers", "projects", "invoices"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(data?.message || "Objednávka byla smazána");
},
});
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
create_project: true,
price: "",
quantity: "1",
});
const [creating, setCreating] = useState(false);
const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const customersQuery = useQuery({ const [searchParams, setSearchParams] = useSearchParams();
...offerCustomersOptions(), const activeTab =
enabled: showCreate, searchParams.get("tab") === "prijate" ? "prijate" : "vydane";
}); const setActiveTab = (tab: string) =>
const nextNumberQuery = useQuery({ setSearchParams({ tab }, { replace: true });
...orderNextNumberOptions(),
enabled: showCreate,
});
const openCreate = () => { const [createOpen, setCreateOpen] = useState(false);
setCreateForm({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
create_project: true,
price: "",
quantity: "1",
});
setOrderAttachment(null);
setShowCreate(true);
};
const handleCreate = async () => { const now = new Date();
setCreating(true); const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
try { const [statsYear, setStatsYear] = useState(now.getFullYear());
const priceNum = createForm.price ? Number(createForm.price) : 0;
const qtyNum = createForm.quantity ? Number(createForm.quantity) : 1;
const items =
priceNum > 0
? [
{
description: createForm.scope_title || "Položka",
quantity: qtyNum,
unit_price: priceNum,
is_included_in_total: true,
},
]
: undefined;
let fetchOptions: RequestInit; const isCurrentMonth =
if (orderAttachment) { statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const fd = new FormData(); const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
if (createForm.customer_id)
fd.append("customer_id", createForm.customer_id); const prevMonth = () => {
fd.append("customer_order_number", createForm.customer_order_number); if (statsMonth === 1) {
fd.append("currency", createForm.currency); setStatsMonth(12);
fd.append("vat_rate", createForm.vat_rate); setStatsYear((y) => y - 1);
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
fd.append("scope_title", createForm.scope_title);
fd.append("scope_description", createForm.scope_description);
fd.append("notes", createForm.notes);
fd.append("create_project", createForm.create_project ? "1" : "0");
if (items) fd.append("items", JSON.stringify(items));
fd.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: fd };
} else { } else {
fetchOptions = { setStatsMonth((m) => m - 1);
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer_id: createForm.customer_id
? Number(createForm.customer_id)
: null,
customer_order_number: createForm.customer_order_number,
currency: createForm.currency,
vat_rate: createForm.vat_rate,
apply_vat: createForm.apply_vat,
scope_title: createForm.scope_title,
scope_description: createForm.scope_description,
notes: createForm.notes,
create_project: createForm.create_project,
...(items ? { items } : {}),
}),
};
} }
};
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions); const nextMonth = () => {
const result = await response.json(); if (isCurrentMonth) return;
if (result.success) { if (statsMonth === 12) {
setShowCreate(false); setStatsMonth(1);
alert.success(result.message || "Objednávka byla vytvořena"); setStatsYear((y) => y + 1);
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
if (result.data?.id) navigate(`/orders/${result.data.id}`);
} else { } else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku"); setStatsMonth((m) => m + 1);
}
} catch {
alert.error("Chyba připojení");
} finally {
setCreating(false);
} }
}; };
const {
items: orders,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<Order>(
orderListOptions({ search: debouncedSearch, sort, order, page }),
);
if (!hasPermission("orders.view")) return <Forbidden />; if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.order) return;
try {
await deleteMutation.mutateAsync({
id: deleteConfirm.order.id,
delete_files: deleteFiles,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (isPending) {
return <LoadingState />;
}
const total = pagination?.total ?? orders.length;
const subtitle = `${total} ${czechPlural(
total,
"objednávka",
"objednávky",
"objednávek",
)}`;
const columns: DataColumn<Order>[] = [
{
key: "order_number",
header: "Číslo",
width: "14%",
sortKey: "order_number",
mono: true,
render: (o) => (
<Box
component={RouterLink}
to={`/orders/${o.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{o.order_number}
</Box>
),
},
{
key: "quotation",
header: "Nabídka",
width: "14%",
render: (o) => (
<Box
component={RouterLink}
to={`/offers/${o.quotation_id}`}
sx={{
color: "text.secondary",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{o.quotation_number}
</Box>
),
},
{
key: "customer",
header: "Zákazník",
width: "20%",
render: (o) => o.customer_name || "—",
},
{
key: "status",
header: "Stav",
width: "13%",
sortKey: "status",
render: (o) => (
<StatusChip
label={STATUS_LABELS[o.status] || o.status}
color={STATUS_COLORS[o.status] || "default"}
/>
),
},
{
key: "created_at",
header: "Datum",
width: "12%",
sortKey: "created_at",
mono: true,
render: (o) => formatDate(o.created_at),
},
{
key: "total",
header: "Celkem",
width: "13%",
align: "right",
mono: true,
bold: true,
render: (o) => formatCurrency(o.total, o.currency),
},
{
key: "actions",
header: "Akce",
width: "14%",
align: "right",
render: (o) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => navigate(`/orders/${o.id}`)}
aria-label="Detail"
title="Detail"
>
{ViewIcon}
</IconButton>
{o.invoice_id ? (
<IconButton
size="small"
color="primary"
onClick={() => navigate(`/invoices/${o.invoice_id}`)}
aria-label="Zobrazit fakturu"
title="Zobrazit fakturu"
>
{InvoiceViewIcon}
</IconButton>
) : (
hasPermission("invoices.create") && (
<IconButton
size="small"
onClick={() => navigate(`/invoices/new?fromOrder=${o.id}`)}
aria-label="Vytvořit fakturu"
title="Vytvořit fakturu"
>
{InvoiceCreateIcon}
</IconButton>
)
)}
{hasPermission("orders.delete") && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, order: o })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
)}
</Box>
),
},
];
return ( return (
<PageEnter> <PageEnter>
<PageHeader <PageHeader
title="Objednávky" title="Objednávky"
subtitle={subtitle}
actions={ actions={
hasPermission("orders.create") ? ( hasPermission("orders.create") ? (
<Button startIcon={PlusIcon} onClick={openCreate}> activeTab === "vydane" ? (
<Button
startIcon={PlusIcon}
onClick={() => navigate("/orders/issued/new")}
>
Vytvořit objednávku vydanou
</Button>
) : (
<Button startIcon={PlusIcon} onClick={() => setCreateOpen(true)}>
Vytvořit objednávku Vytvořit objednávku
</Button> </Button>
)
) : undefined ) : undefined
} }
/> />
<FilterBar> <Box>
<Box sx={{ flex: "1 1 320px" }}> <Box
<TextField sx={{
value={search} display: "flex",
onChange={(e) => { alignItems: "center",
setSearch(e.target.value); justifyContent: "center",
setPage(1); gap: 1,
mb: 2,
}} }}
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..." >
fullWidth <IconButton
/> size="small"
onClick={prevMonth}
aria-label="Předchozí měsíc"
>
{ChevronLeftIcon}
</IconButton>
<Typography
sx={{ minWidth: 140, textAlign: "center", fontWeight: 600 }}
>
{monthLabel}
</Typography>
<IconButton
size="small"
onClick={nextMonth}
disabled={isCurrentMonth}
aria-label="Následující měsíc"
>
{ChevronRightIcon}
</IconButton>
</Box> </Box>
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}> <Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<DataTable<Order> <Tabs
columns={columns} value={activeTab}
rows={orders} onChange={(v) => setActiveTab(v)}
rowKey={(o) => o.id} tabs={[
sortBy={sort} { value: "vydane", label: "Vydané" },
sortDir={order} { value: "prijate", label: "Přijaté" },
onSort={handleSort}
empty={
<EmptyState
title="Zatím nejsou žádné objednávky."
description="Objednávky se vytvářejí z nabídek."
/>
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
deleteConfirm.order
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
>
<CheckboxField
label="Smazat i soubory projektu na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
</ConfirmDialog>
<Modal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
title="Vytvořit objednávku bez nabídky"
subtitle={`Číslo objednávky: ${
nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
"…"
} (přiděleno automaticky)`}
submitText="Vytvořit"
loading={creating}
>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Zákazník">
<Select
value={createForm.customer_id}
onChange={(value) =>
setCreateForm({ ...createForm, customer_id: value })
}
options={[
{ value: "", label: "— bez zákazníka —" },
...(customersQuery.data ?? []).map((c) => ({
value: String(c.id),
label: c.name,
})),
]} ]}
/> />
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Číslo objednávky zákazníka">
<TextField
value={createForm.customer_order_number}
onChange={(e) =>
setCreateForm({
...createForm,
customer_order_number: e.target.value,
})
}
/>
</Field>
</Box> </Box>
</Box> </Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}> {activeTab === "vydane" ? (
<Box sx={{ flex: "1 1 200px" }}> <Suspense fallback={<LoadingState />}>
<Field label="Měna"> <IssuedOrders month={statsMonth} year={statsYear} />
<Select </Suspense>
value={createForm.currency} ) : (
onChange={(value) => <OrdersReceived
setCreateForm({ ...createForm, currency: value }) month={statsMonth}
} year={statsYear}
options={[ createOpen={createOpen}
{ value: "CZK", label: "CZK" }, setCreateOpen={setCreateOpen}
{ value: "EUR", label: "EUR" },
{ value: "USD", label: "USD" },
{ value: "GBP", label: "GBP" },
]}
/> />
</Field> )}
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Sazba DPH (%)">
<TextField
type="number"
inputMode="numeric"
value={createForm.vat_rate}
onChange={(e) =>
setCreateForm({ ...createForm, vat_rate: e.target.value })
}
slotProps={{ htmlInput: { min: 0 } }}
/>
</Field>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Cena za jednotku (bez DPH)">
<TextField
type="number"
inputMode="decimal"
value={createForm.price}
onChange={(e) =>
setCreateForm({ ...createForm, price: e.target.value })
}
placeholder="0"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Množství">
<TextField
type="number"
inputMode="decimal"
value={createForm.quantity}
onChange={(e) =>
setCreateForm({ ...createForm, quantity: e.target.value })
}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
/>
</Field>
</Box>
</Box>
<Field label="Předmět (scope)">
<TextField
value={createForm.scope_title}
onChange={(e) =>
setCreateForm({ ...createForm, scope_title: e.target.value })
}
/>
</Field>
<Field label="Popis">
<TextField
multiline
minRows={3}
value={createForm.scope_description}
onChange={(e) =>
setCreateForm({
...createForm,
scope_description: e.target.value,
})
}
/>
</Field>
<Field label="Poznámka">
<TextField
multiline
minRows={2}
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
/>
</Field>
<Field label="Příloha (PO zákazníka, PDF)">
<FileUpload
files={orderAttachment ? [orderAttachment] : []}
onFilesChange={(files) => setOrderAttachment(files[0] ?? null)}
accept="application/pdf"
multiple={false}
/>
</Field>
<CheckboxField
label="Účtovat DPH"
checked={createForm.apply_vat}
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
/>
<CheckboxField
label="Vytvořit propojený projekt"
checked={createForm.create_project}
onChange={(v) => setCreateForm({ ...createForm, create_project: v })}
/>
</Modal>
</PageEnter> </PageEnter>
); );
} }

View File

@@ -0,0 +1,686 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
orderListOptions,
orderNextNumberOptions,
} from "../lib/queries/orders";
import { offerCustomersOptions } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import {
Card,
DataTable,
Pagination,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
StatusChip,
CheckboxField,
FileUpload,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
prijata: "Přijatá",
v_realizaci: "V realizaci",
dokoncena: "Dokončená",
stornovana: "Stornována",
};
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
prijata: "info",
v_realizaci: "warning",
dokoncena: "success",
stornovana: "error",
};
interface Order {
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
customer_name: string;
status: string;
created_at: string;
total: number;
currency: string;
invoice_id?: number;
}
const ViewIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
const InvoiceViewIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
F
</text>
</svg>
);
const InvoiceCreateIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
interface OrdersReceivedProps {
month: number;
year: number;
createOpen: boolean;
setCreateOpen: (open: boolean) => void;
}
export default function OrdersReceived({
month,
year,
createOpen,
setCreateOpen,
}: OrdersReceivedProps) {
const alert = useAlert();
const { hasPermission } = useAuth();
const { sort, order, handleSort } = useTableSort("order_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
order: Order | null;
}>({ show: false, order: null });
const [deleteFiles, setDeleteFiles] = useState(false);
const deleteMutation = useApiMutation<
{ id: number; delete_files: boolean },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/orders/${id}`,
method: () => "DELETE",
invalidate: ["orders", "offers", "projects", "invoices"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(data?.message || "Objednávka byla smazána");
},
});
const [createForm, setCreateForm] = useState({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
create_project: true,
price: "",
quantity: "1",
});
const [creating, setCreating] = useState(false);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const customersQuery = useQuery({
...offerCustomersOptions(),
enabled: createOpen,
});
const nextNumberQuery = useQuery({
...orderNextNumberOptions(),
enabled: createOpen,
});
const closeCreate = () => {
setCreateOpen(false);
setCreateForm({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
create_project: true,
price: "",
quantity: "1",
});
setOrderAttachment(null);
};
const handleCreate = async () => {
setCreating(true);
try {
const priceNum = createForm.price ? Number(createForm.price) : 0;
const qtyNum = createForm.quantity ? Number(createForm.quantity) : 1;
const items =
priceNum > 0
? [
{
description: createForm.scope_title || "Položka",
quantity: qtyNum,
unit_price: priceNum,
is_included_in_total: true,
},
]
: undefined;
let fetchOptions: RequestInit;
if (orderAttachment) {
const fd = new FormData();
if (createForm.customer_id)
fd.append("customer_id", createForm.customer_id);
fd.append("customer_order_number", createForm.customer_order_number);
fd.append("currency", createForm.currency);
fd.append("vat_rate", createForm.vat_rate);
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
fd.append("scope_title", createForm.scope_title);
fd.append("scope_description", createForm.scope_description);
fd.append("notes", createForm.notes);
fd.append("create_project", createForm.create_project ? "1" : "0");
if (items) fd.append("items", JSON.stringify(items));
fd.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: fd };
} else {
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer_id: createForm.customer_id
? Number(createForm.customer_id)
: null,
customer_order_number: createForm.customer_order_number,
currency: createForm.currency,
vat_rate: createForm.vat_rate,
apply_vat: createForm.apply_vat,
scope_title: createForm.scope_title,
scope_description: createForm.scope_description,
notes: createForm.notes,
create_project: createForm.create_project,
...(items ? { items } : {}),
}),
};
}
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
const result = await response.json();
if (result.success) {
closeCreate();
alert.success(result.message || "Objednávka byla vytvořena");
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
if (result.data?.id) navigate(`/orders/${result.data.id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setCreating(false);
}
};
const {
items: orders,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<Order>(
orderListOptions({
search: debouncedSearch,
sort,
order,
page,
month,
year,
}),
);
if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.order) return;
try {
await deleteMutation.mutateAsync({
id: deleteConfirm.order.id,
delete_files: deleteFiles,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (isPending) {
return <LoadingState />;
}
const columns: DataColumn<Order>[] = [
{
key: "order_number",
header: "Číslo",
width: "14%",
sortKey: "order_number",
mono: true,
render: (o) => (
<Box
component={RouterLink}
to={`/orders/${o.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{o.order_number}
</Box>
),
},
{
key: "quotation",
header: "Nabídka",
width: "14%",
render: (o) => (
<Box
component={RouterLink}
to={`/offers/${o.quotation_id}`}
sx={{
color: "text.secondary",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{o.quotation_number}
</Box>
),
},
{
key: "customer",
header: "Zákazník",
width: "20%",
render: (o) => o.customer_name || "—",
},
{
key: "status",
header: "Stav",
width: "13%",
sortKey: "status",
render: (o) => (
<StatusChip
label={STATUS_LABELS[o.status] || o.status}
color={STATUS_COLORS[o.status] || "default"}
/>
),
},
{
key: "created_at",
header: "Datum",
width: "12%",
sortKey: "created_at",
mono: true,
render: (o) => formatDate(o.created_at),
},
{
key: "total",
header: "Celkem",
width: "13%",
align: "right",
mono: true,
bold: true,
render: (o) => formatCurrency(o.total, o.currency),
},
{
key: "actions",
header: "Akce",
width: "14%",
align: "right",
render: (o) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => navigate(`/orders/${o.id}`)}
aria-label="Detail"
title="Detail"
>
{ViewIcon}
</IconButton>
{o.invoice_id ? (
<IconButton
size="small"
color="primary"
onClick={() => navigate(`/invoices/${o.invoice_id}`)}
aria-label="Zobrazit fakturu"
title="Zobrazit fakturu"
>
{InvoiceViewIcon}
</IconButton>
) : (
hasPermission("invoices.create") && (
<IconButton
size="small"
onClick={() => navigate(`/invoices/new?fromOrder=${o.id}`)}
aria-label="Vytvořit fakturu"
title="Vytvořit fakturu"
>
{InvoiceCreateIcon}
</IconButton>
)
)}
{hasPermission("orders.delete") && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, order: o })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
)}
</Box>
),
},
];
return (
<>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
fullWidth
/>
</Box>
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<Order>
columns={columns}
rows={orders}
rowKey={(o) => o.id}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
<EmptyState
title="Zatím nejsou žádné objednávky."
description="Objednávky se vytvářejí z nabídek."
/>
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
deleteConfirm.order
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
>
<CheckboxField
label="Smazat i soubory projektu na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
</ConfirmDialog>
<Modal
isOpen={createOpen}
onClose={closeCreate}
onSubmit={handleCreate}
title="Vytvořit objednávku bez nabídky"
subtitle={`Číslo objednávky: ${
nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
"…"
} (přiděleno automaticky)`}
submitText="Vytvořit"
loading={creating}
>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Zákazník">
<Select
value={createForm.customer_id}
onChange={(value) =>
setCreateForm({ ...createForm, customer_id: value })
}
options={[
{ value: "", label: "— bez zákazníka —" },
...(customersQuery.data ?? []).map((c) => ({
value: String(c.id),
label: c.name,
})),
]}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Číslo objednávky zákazníka">
<TextField
value={createForm.customer_order_number}
onChange={(e) =>
setCreateForm({
...createForm,
customer_order_number: e.target.value,
})
}
/>
</Field>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Měna">
<Select
value={createForm.currency}
onChange={(value) =>
setCreateForm({ ...createForm, currency: value })
}
options={[
{ value: "CZK", label: "CZK" },
{ value: "EUR", label: "EUR" },
{ value: "USD", label: "USD" },
{ value: "GBP", label: "GBP" },
]}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Sazba DPH (%)">
<TextField
type="number"
inputMode="numeric"
value={createForm.vat_rate}
onChange={(e) =>
setCreateForm({ ...createForm, vat_rate: e.target.value })
}
slotProps={{ htmlInput: { min: 0 } }}
/>
</Field>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Cena za jednotku (bez DPH)">
<TextField
type="number"
inputMode="decimal"
value={createForm.price}
onChange={(e) =>
setCreateForm({ ...createForm, price: e.target.value })
}
placeholder="0"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Množství">
<TextField
type="number"
inputMode="decimal"
value={createForm.quantity}
onChange={(e) =>
setCreateForm({ ...createForm, quantity: e.target.value })
}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
/>
</Field>
</Box>
</Box>
<Field label="Předmět (scope)">
<TextField
value={createForm.scope_title}
onChange={(e) =>
setCreateForm({ ...createForm, scope_title: e.target.value })
}
/>
</Field>
<Field label="Popis">
<TextField
multiline
minRows={3}
value={createForm.scope_description}
onChange={(e) =>
setCreateForm({
...createForm,
scope_description: e.target.value,
})
}
/>
</Field>
<Field label="Poznámka">
<TextField
multiline
minRows={2}
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
/>
</Field>
<Field label="Příloha (PO zákazníka, PDF)">
<FileUpload
files={orderAttachment ? [orderAttachment] : []}
onFilesChange={(files) => setOrderAttachment(files[0] ?? null)}
accept="application/pdf"
multiple={false}
/>
</Field>
<CheckboxField
label="Účtovat DPH"
checked={createForm.apply_vat}
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
/>
<CheckboxField
label="Vytvořit propojený projekt"
checked={createForm.create_project}
onChange={(v) => setCreateForm({ ...createForm, create_project: v })}
/>
</Modal>
</>
);
}

View File

@@ -0,0 +1,874 @@
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);
/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return localDateCzStr(d);
}
function formatNum(n: number, decimals = 2): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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\s*:[^"'>\s]*/gi, 'href="#"');
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = "";
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
}
return s;
}
interface AddressResult {
name: string;
lines: string[];
}
function buildAddressLines(
entity: Record<string, unknown> | null,
isCompany: boolean,
tObj: Record<string, string>,
): AddressResult {
if (!entity) return { name: "", lines: [] };
const nameKey = isCompany ? "company_name" : "name";
const name = String(entity[nameKey] || "");
let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> =
[];
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
if (raw) {
let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) {
cfData =
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) {
cfData = parsed;
}
}
}
if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = {
Name: "name",
CompanyName: "company_name",
Street: "street",
CityPostal: "city_postal",
Country: "country",
CompanyId: "company_id",
VatId: "vat_id",
};
fieldOrder = fieldOrder.map((k) => legacyMap[k] || k);
}
const fieldMap: Record<string, string> = {};
if (name) fieldMap[nameKey] = name;
if (entity.street) fieldMap.street = String(entity.street);
const cityParts = [entity.city || "", entity.postal_code || ""]
.filter(Boolean)
.map(String);
const cityPostal = cityParts.join(" ").trim();
if (cityPostal) fieldMap.city_postal = cityPostal;
if (entity.country) fieldMap.country = String(entity.country);
if (entity.company_id)
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
cfData.forEach((cf, i) => {
const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false;
if (cfValue) {
fieldMap[`custom_${i}`] =
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
}
});
const lines: string[] = [];
if (Array.isArray(fieldOrder) && fieldOrder.length) {
for (const key of fieldOrder) {
if (key === nameKey) continue;
if (fieldMap[key]) lines.push(fieldMap[key]);
}
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
if (!fieldOrder!.includes(key)) lines.push(line);
}
} else {
for (const [key, line] of Object.entries(fieldMap)) {
if (key === nameKey) continue;
lines.push(line);
}
}
return { name, lines };
}
/* ── Translations ────────────────────────────────────────────────── */
type Lang = "cs" | "en";
const translations: Record<Lang, Record<string, string>> = {
cs: {
title: "OBJEDNÁVKA",
heading: "OBJEDNÁVKA č.",
// PO direction: WE (company) are the buyer (Odběratel), the
// selected customer record is the supplier (Dodavatel).
supplier: "Dodavatel",
buyer: "Odběratel",
issue_date: "Datum vystavení:",
delivery_date: "Požadované dodání:",
billing: "Objednáváme u Vás:",
col_no: "Č.",
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem",
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
delivery_terms: "Dodací podmínky:",
payment_terms: "Platební podmínky:",
issued_by: "Vystavil:",
approved_by: "Schválil:",
ico: "IČ: ",
dic: "DIČ: ",
},
en: {
title: "PURCHASE ORDER",
heading: "PURCHASE ORDER No.",
supplier: "Supplier",
buyer: "Buyer",
issue_date: "Issue date:",
delivery_date: "Requested delivery:",
billing: "We order from you:",
col_no: "No.",
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total",
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
amounts_in: "Amounts are in",
notes: "Notes",
delivery_terms: "Delivery terms:",
payment_terms: "Payment terms:",
issued_by: "Issued by:",
approved_by: "Approved by:",
ico: "Reg. No.: ",
dic: "Tax ID: ",
},
};
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 = translations[lang];
const applyVat = order.apply_vat !== false;
const currency = order.currency || "CZK";
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
const poNumber = escapeHtml(order.po_number || "");
// Logo embedding (same logic as the confirmation template).
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
// PO direction: our company (settings) = Odběratel (buyer);
// the customer record = Dodavatel (supplier).
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel
const buyerLinesHtml = buyer.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const supplierLinesHtml = supplier.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Items — NET + per-line VAT-on-top, rounded per line (same math the
// service computeIssuedOrderTotals uses; mirrors the confirmation loop).
let subtotal = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
const itemsHtml = items
.map((it, i) => {
const qty = Number(it.quantity) || 0;
const unitPrice = Number(it.unit_price) || 0;
const rate =
it.vat_rate != null && it.vat_rate !== ""
? Number(it.vat_rate)
: docRate;
const lineSubtotal = qty * unitPrice;
const lineVat = applyVat
? Math.round(lineSubtotal * (rate / 100) * 100) / 100
: 0;
const lineTotal = lineSubtotal + lineVat;
subtotal += lineSubtotal;
totalVat += lineVat;
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal;
vatSummary[key].vat += lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const descHtml = `${escapeHtml(it.description)}${
it.item_description
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
: ""
}`;
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${descHtml}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.join("");
subtotal = Math.round(subtotal * 100) / 100;
totalVat = Math.round(totalVat * 100) / 100;
const totalToPay = Math.round((subtotal + totalVat) * 100) / 100;
let vatDetailHtml = "";
if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) {
vatDetailHtml += `
<div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`;
}
}
}
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div>
`
: "";
const issueDateStr = formatDate(order.order_date);
const deliveryDateStr = formatDate(order.delivery_date);
// Order-specific terms (purchase-order addition; not on invoices).
let termsHtml = "";
if (order.delivery_terms) {
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.delivery_terms)}</span> ${escapeHtml(order.delivery_terms)}</div>`;
}
if (order.payment_terms) {
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.payment_terms)}</span> ${escapeHtml(order.payment_terms)}</div>`;
}
const termsBlock = termsHtml ? `<div class="terms">${termsHtml}</div>` : "";
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(t.heading)} ${poNumber}</title>
<style>
@page {
size: A4;
margin: 8mm 12mm 10mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 186mm;
}
.invoice-page {
display: flex;
flex-direction: column;
min-height: calc(297mm - 27mm);
}
.invoice-content { flex: 1 1 auto; }
.invoice-footer {
flex-shrink: 0;
}
.accent { color: #de3a3a; }
/* ── Hlavicka ── */
.invoice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1mm;
padding-bottom: 1mm;
border-bottom: 2pt solid #de3a3a;
}
.invoice-header .left {
display: flex;
align-items: center;
gap: 3mm;
}
.logo-header { text-align: left; }
.company-title {
font-size: 12pt;
font-weight: 700;
}
.invoice-title {
font-size: 13pt;
font-weight: 700;
color: #de3a3a;
text-align: right;
letter-spacing: 0.03em;
}
.logo {
max-width: 42mm;
max-height: 22mm;
object-fit: contain;
}
/* ── Adresy ── */
.header-grid {
border: 0.5pt solid #d0d0d0;
border-collapse: collapse;
width: 100%;
margin-bottom: 1mm;
}
.header-grid td {
padding: 2mm 3mm;
border: 0.5pt solid #d0d0d0;
vertical-align: top;
width: 50%;
}
.header-grid td.addr-customer {
background: #f5f5f5;
}
.header-grid td.details-bank {
background: #f5f5f5;
}
.address-label {
font-size: 8pt;
font-weight: 700;
color: #de3a3a;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 1mm;
}
.address-name {
font-size: 10pt;
font-weight: 700;
color: #1a1a1a;
line-height: 1.3;
margin-bottom: 1mm;
}
.address-line {
font-size: 9pt;
color: #444;
line-height: 1.5;
}
/* ── Detaily (banka + datumy) — inside header-grid ── */
.info-row {
display: flex;
align-items: baseline;
font-size: 9pt;
padding: 1mm 0;
border-bottom: 0.5pt solid #f0f0f0;
}
.info-row:last-child { border-bottom: none; }
.info-row .lbl {
color: #666;
font-weight: 400;
flex-shrink: 0;
white-space: nowrap;
margin-right: 3mm;
}
.info-row .val {
font-weight: 600;
color: #1a1a1a;
text-align: right;
margin-left: auto;
}
/* VS/KS blok */
.vs-block {
font-size: 9pt;
line-height: 1.4;
padding-top: 2mm;
}
/* ── Polozky ── */
.billing-label {
font-weight: 700;
color: #1a1a1a;
font-size: 10pt;
padding: 2mm 0 1mm 0;
border-bottom: 1.5pt solid #de3a3a;
margin-bottom: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8.5pt;
font-weight: 600;
color: #646464;
padding: 4px 4px;
text-align: left;
border-bottom: 0.5pt solid #d0d0d0;
white-space: nowrap;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 4px 4px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
color: #1a1a1a;
}
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
table.items tbody td.center { text-align: center; white-space: nowrap; }
table.items tbody td.right { text-align: right; }
table.items tbody td.row-num {
text-align: center;
color: #969696;
font-size: 9pt;
}
table.items tbody td.desc {
font-size: 9pt;
font-weight: 600;
color: #1a1a1a;
}
table.items tbody td.desc .item-sub {
font-weight: 400;
font-size: 8.5pt;
color: #666;
margin-top: 0.5mm;
}
table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */
.totals-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 2mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 9.5pt;
color: #1a1a1a;
margin-bottom: 2mm;
}
.totals .grand {
border-top: 0.5pt solid #e0e0e0;
padding-top: 4mm;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.totals .grand .label {
font-size: 10.5pt;
font-weight: 400;
color: #1a1a1a;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .currency-note {
text-align: right;
font-size: 8pt;
color: #1a1a1a;
margin-top: 2mm;
}
/* Dodaci / platebni podminky (PO-specificke) */
.terms {
margin-top: 4mm;
font-size: 9pt;
line-height: 1.5;
color: #1a1a1a;
}
.terms-row { margin-bottom: 1mm; }
.terms-row .lbl {
font-weight: 600;
color: #555;
}
/* Vystavil */
.issued-by {
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Upozorneni */
.notice {
font-size: 8pt;
color: #1a1a1a;
margin: 2mm 0;
line-height: 1.3;
}
/* Prevzal / razitko */
.footer-row {
display: flex;
justify-content: space-between;
margin-top: 4mm;
font-size: 9pt;
border-top: 0.5pt solid #aaa;
padding-top: 2mm;
min-height: 15mm;
}
.footer-row .col {
font-weight: 600;
color: #555;
}
/* Poznamky */
.invoice-notes {
margin-top: 4mm;
font-size: 10pt;
line-height: 1.5;
color: #1a1a1a;
}
.invoice-notes-label {
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
color: #555;
margin-bottom: 1mm;
}
.invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
font-family: Tahoma, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
@media screen {
html { background: #525659; }
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
.invoice-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
box-sizing: border-box;
border-radius: 2px;
}
}
</style>
</head>
<body>
<div class="invoice-page">
<div class="invoice-content">
<!-- Hlavicka -->
<div class="invoice-header">
<div class="left">
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
</div>
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
</div>
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily -->
<table class="header-grid" cellspacing="0">
<tr>
<td>
<div class="address-label">${escapeHtml(t.buyer)}</div>
<div class="address-name">${escapeHtml(buyer.name)}</div>
${buyerLinesHtml}
</td>
<td class="addr-customer">
<div class="address-label">${escapeHtml(t.supplier)}</div>
<div class="address-name">${escapeHtml(supplier.name)}</div>
${supplierLinesHtml}
</td>
</tr>
<tr>
<td class="details-bank">
<div class="info-row"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(issueDateStr) || "—"}</span></div>
${deliveryDateStr ? `<div class="info-row"><span class="lbl">${escapeHtml(t.delivery_date)}</span> <span class="val">${escapeHtml(deliveryDateStr)}</span></div>` : ""}
</td>
<td></td>
</tr>
</table>
<!-- Polozky -->
<div class="billing-label">${escapeHtml(t.billing)}</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<!-- Soucty -->
<div class="totals-wrapper">
<div class="totals">
<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t.subtotal)}</span>
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
</div>${vatDetailHtml}
</div>
<div class="grand">
<span class="label">${escapeHtml(t.total)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
</div>
${notesHtml}
${termsBlock}
</div><!-- /.invoice-content -->
<div class="invoice-footer">
<!-- Vystavil -->
${
order.issued_by
? `<div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(order.issued_by)}</div>`
: ""
}
<!-- Vystavil / Schvalil -->
<div class="footer-row">
<div class="col">${escapeHtml(t.issued_by)}</div>
<div class="col" style="text-align:right">${escapeHtml(t.approved_by)}</div>
</div>
</div><!-- /.invoice-footer -->
</div><!-- /.invoice-page -->
</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>");
}
},
);
}

View File

@@ -0,0 +1,148 @@
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,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : 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");
},
);
}

View File

@@ -45,7 +45,7 @@ export default async function ordersRoutes(
{ preHandler: requirePermission("orders.view") }, { preHandler: requirePermission("orders.view") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, unknown>; const query = request.query as Record<string, unknown>;
const { page, limit, skip, sort, order } = parsePagination(query); const { page, limit, skip, sort, order, search } = parsePagination(query);
const result = await listOrders({ const result = await listOrders({
page, page,
@@ -53,8 +53,11 @@ export default async function ordersRoutes(
skip, skip,
sort, sort,
order, order,
search,
status: query.status ? String(query.status) : undefined, status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined, customer_id: query.customer_id ? Number(query.customer_id) : undefined,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined,
}); });
return paginated( return paginated(

View File

@@ -0,0 +1,48 @@
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();

View File

@@ -14,6 +14,7 @@ import rolesRoutes from "./routes/admin/roles";
import attendanceRoutes from "./routes/admin/attendance"; import attendanceRoutes from "./routes/admin/attendance";
import customersRoutes from "./routes/admin/customers"; import customersRoutes from "./routes/admin/customers";
import invoicesRoutes from "./routes/admin/invoices"; import invoicesRoutes from "./routes/admin/invoices";
import issuedOrdersRoutes from "./routes/admin/issued-orders";
import quotationsRoutes from "./routes/admin/quotations"; import quotationsRoutes from "./routes/admin/quotations";
import ordersRoutes from "./routes/admin/orders"; import ordersRoutes from "./routes/admin/orders";
import projectsRoutes from "./routes/admin/projects"; import projectsRoutes from "./routes/admin/projects";
@@ -30,6 +31,7 @@ import sessionsRoutes from "./routes/admin/sessions";
import totpRoutes from "./routes/admin/totp"; import totpRoutes from "./routes/admin/totp";
import scopeTemplatesRoutes from "./routes/admin/scope-templates"; import scopeTemplatesRoutes from "./routes/admin/scope-templates";
import invoicesPdfRoutes from "./routes/admin/invoices-pdf"; import invoicesPdfRoutes from "./routes/admin/invoices-pdf";
import issuedOrdersPdfRoutes from "./routes/admin/issued-orders-pdf";
import offersPdfRoutes from "./routes/admin/offers-pdf"; import offersPdfRoutes from "./routes/admin/offers-pdf";
import ordersPdfRoutes from "./routes/admin/orders-pdf"; import ordersPdfRoutes from "./routes/admin/orders-pdf";
import projectFilesRoutes from "./routes/admin/project-files"; import projectFilesRoutes from "./routes/admin/project-files";
@@ -120,6 +122,12 @@ async function start() {
await app.register(attendanceRoutes, { prefix: "/api/admin/attendance" }); await app.register(attendanceRoutes, { prefix: "/api/admin/attendance" });
await app.register(customersRoutes, { prefix: "/api/admin/customers" }); await app.register(customersRoutes, { prefix: "/api/admin/customers" });
await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" }); await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" });
await app.register(issuedOrdersRoutes, {
prefix: "/api/admin/issued-orders",
});
await app.register(issuedOrdersPdfRoutes, {
prefix: "/api/admin/issued-orders-pdf",
});
await app.register(quotationsRoutes, { prefix: "/api/admin/offers" }); await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });
await app.register(ordersRoutes, { prefix: "/api/admin/orders" }); await app.register(ordersRoutes, { prefix: "/api/admin/orders" });
await app.register(projectsRoutes, { prefix: "/api/admin/projects" }); await app.register(projectsRoutes, { prefix: "/api/admin/projects" });

View File

@@ -0,0 +1,343 @@
import type { $Enums } from "@prisma/client";
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;
month?: number;
year?: 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,
month,
year,
} = 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 } } },
];
}
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
where.order_date = { gte: from, lt: to };
}
// Normalize the direction so an unexpected value can't reach Prisma at runtime.
const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc";
// Tiebreak on id so same-day order_date rows sort deterministically.
const orderBy: Record<string, "asc" | "desc">[] = [
{ [sortField]: dir },
{ id: dir },
];
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 $Enums.issued_orders_status,
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 } });
// Reclaim the number against the year it was allocated in (the creation year),
// not the current year — a cross-year delete must release the right sequence
// or the number is never freed.
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseIssuedOrderNumber(year, existing.po_number ?? undefined);
return existing;
}
export async function getNextIssuedOrderNumberPreview() {
return previewIssuedOrderNumber();
}

View File

@@ -12,6 +12,7 @@ type TxClient = Omit<
const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}"; const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}";
const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}"; const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}"; const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
const DEFAULT_ISSUED_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
/** /**
* Apply a numbering pattern template. * Apply a numbering pattern template.
@@ -43,6 +44,8 @@ async function getSettings() {
offer_number_pattern: true, offer_number_pattern: true,
order_number_pattern: true, order_number_pattern: true,
invoice_number_pattern: true, invoice_number_pattern: true,
issued_order_number_pattern: true,
issued_order_type_code: true,
}, },
}); });
} }
@@ -150,12 +153,17 @@ async function releaseSequence(
? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN ? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN
: type === "invoice" : type === "invoice"
? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN ? 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; : settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
const code = const code =
type === "shared" type === "shared"
? settings?.order_type_code || "71" ? settings?.order_type_code || "71"
: type === "invoice" : type === "invoice"
? settings?.invoice_type_code || "81" ? settings?.invoice_type_code || "81"
: type === "issued_order"
? settings?.issued_order_type_code || "72"
: ""; : "";
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : ""; const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
@@ -205,6 +213,14 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
return !!existing; return !!existing;
} }
/** 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;
}
/** Verify an offer/quotation number is not already used. */ /** Verify an offer/quotation number is not already used. */
export async function isOfferNumberTaken(number: string): Promise<boolean> { export async function isOfferNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.quotations.findFirst({ const existing = await prisma.quotations.findFirst({
@@ -355,6 +371,60 @@ export async function previewInvoiceNumber(
return { number, next_number: number }; return { number, next_number: number };
} }
/**
* Next issued-order (PO) number (consumes sequence).
* Verifies uniqueness against the issued_orders table; retries if taken.
* Pass `tx` when calling inside an existing Prisma transaction.
*/
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");
}
/**
* Preview next issued-order (PO) number (does NOT consume sequence).
*/
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 };
}
/** Release an issued-order number back to the pool (decrement if highest). */
export async function releaseIssuedOrderNumber(
year?: number,
deletedNumber?: string,
) {
await releaseSequence(
"issued_order",
year || new Date().getFullYear(),
deletedNumber,
);
}
/** Release an offer number back to the pool (decrement if highest). */ /** Release an offer number back to the pool (decrement if highest). */
export async function releaseOfferNumber( export async function releaseOfferNumber(
year?: number, year?: number,

View File

@@ -108,10 +108,13 @@ interface ListOrdersParams {
order: "asc" | "desc"; order: "asc" | "desc";
status?: string; status?: string;
customer_id?: number; customer_id?: number;
search?: string;
month?: number;
year?: number;
} }
export async function listOrders(params: ListOrdersParams) { export async function listOrders(params: ListOrdersParams) {
const { page, limit, skip, order } = params; const { page, limit, skip, order, search, month, year } = params;
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort) const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
? params.sort ? params.sort
: "id"; : "id";
@@ -119,13 +122,26 @@ export async function listOrders(params: ListOrdersParams) {
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (params.status) where.status = params.status; if (params.status) where.status = params.status;
if (params.customer_id) where.customer_id = params.customer_id; if (params.customer_id) where.customer_id = params.customer_id;
if (search) {
where.OR = [
{ order_number: { contains: search } },
{ customer_order_number: { contains: search } },
{ customers: { name: { contains: search } } },
];
}
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
where.created_at = { gte: from, lt: to };
}
const [orders, total] = await Promise.all([ const [orders, total] = await Promise.all([
prisma.orders.findMany({ prisma.orders.findMany({
where, where,
skip, skip,
take: limit, take: limit,
orderBy: { [sortField]: order }, // Tiebreak on id so same-second created_at rows sort deterministically.
orderBy: [{ [sortField]: order }, { id: order }],
include: { include: {
customers: { select: { id: true, name: true } }, customers: { select: { id: true, name: true } },
order_items: { orderBy: { position: "asc" } }, order_items: { orderBy: { position: "asc" } },

View File

@@ -129,6 +129,7 @@ export type EntityType =
| "invoice" | "invoice"
| "quotation" | "quotation"
| "order" | "order"
| "issued_order"
| "project" | "project"
| "customer" | "customer"
| "trip" | "trip"