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:
2414
docs/superpowers/plans/2026-06-09-issued-orders.md
Normal file
2414
docs/superpowers/plans/2026-06-09-issued-orders.md
Normal file
File diff suppressed because it is too large
Load Diff
356
docs/superpowers/specs/2026-06-09-issued-orders-design.md
Normal file
356
docs/superpowers/specs/2026-06-09-issued-orders-design.md
Normal 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.
|
||||||
@@ -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;
|
||||||
52
prisma/migrations/20260609085152_issued_orders/migration.sql
Normal file
52
prisma/migrations/20260609085152_issued_orders/migration.sql
Normal 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;
|
||||||
@@ -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)
|
||||||
|
|||||||
292
src/__tests__/issued-orders.test.ts
Normal file
292
src/__tests__/issued-orders.test.ts
Normal 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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
114
src/__tests__/orders-list.test.ts
Normal file
114
src/__tests__/orders-list.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
79
src/admin/lib/queries/issued-orders.ts
Normal file
79
src/admin/lib/queries/issued-orders.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -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}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|||||||
1287
src/admin/pages/IssuedOrderDetail.tsx
Normal file
1287
src/admin/pages/IssuedOrderDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
359
src/admin/pages/IssuedOrders.tsx
Normal file
359
src/admin/pages/IssuedOrders.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
686
src/admin/pages/OrdersReceived.tsx
Normal file
686
src/admin/pages/OrdersReceived.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
874
src/routes/admin/issued-orders-pdf.ts
Normal file
874
src/routes/admin/issued-orders-pdf.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/( )/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>");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/routes/admin/issued-orders.ts
Normal file
148
src/routes/admin/issued-orders.ts
Normal 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");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
48
src/schemas/issued-orders.schema.ts
Normal file
48
src/schemas/issued-orders.schema.ts
Normal 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();
|
||||||
@@ -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" });
|
||||||
|
|||||||
343
src/services/issued-orders.service.ts
Normal file
343
src/services/issued-orders.service.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" } },
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export type EntityType =
|
|||||||
| "invoice"
|
| "invoice"
|
||||||
| "quotation"
|
| "quotation"
|
||||||
| "order"
|
| "order"
|
||||||
|
| "issued_order"
|
||||||
| "project"
|
| "project"
|
||||||
| "customer"
|
| "customer"
|
||||||
| "trip"
|
| "trip"
|
||||||
|
|||||||
Reference in New Issue
Block a user