# Manual project & order creation — design Date: 2026-06-06 Status: approved (pending spec review) Author: Claude + BOHA ## Problem Today both entities are only ever _derived_ from a parent: - A **project** is created solely as a side-effect of `createOrderFromQuotation` (`orders.service.ts`). There is **no** `POST /projects`, no `createProject` service, no `CreateProjectSchema`. A manual-create feature existed but was **removed in commit `82919d3`** (2026-04-28) — the note: _"Projects can only be created through orders (shared numbering sequence)."_ The old code pulled `project_number` from the shared order/project pool, which consumed an order number and left **gaps in order numbering**. That is the bug we must not repeat. - An **order** is normally created from an offer. A manual `createOrder` (`orders.service.ts`, offer-less) **already exists** and the route already dispatches to it, but there is **no UI**, and it does not create a project. We want: (1) manually create projects on `/projects`, (2) manually create orders without an offer on `/orders`. ## Decisions (locked with the user) 1. **Project numbering: shared pool, _with_ proper tracking.** Standalone projects take the next **shared** number via `generateSharedNumber()` — the same pool as orders — but we add the tracking/transparency that was missing before (see "Tracking" below). Auto-assigned; no manual number entry. 2. **Manual order → project: optional checkbox, default ON.** The order form has a pre-checked "Vytvořit propojený projekt"; when set, the order and its project share the order number (identical to the from-offer flow → no gap). 3. **Order form scope: header only.** Customer, customer order number, currency/VAT, scope title/description, notes. No line-item editor. 4. **Customer: optional** on both forms (matches the existing nullable schema). 5. **Match existing house design** — `FormModal` + `FormField`, the `Vehicles.tsx` create/edit-modal pattern, the `OffersCustomers.tsx` header-button + customer-selector pattern, existing list/badge styling. ## Why the shared pool is safe here (the tracking) The shared sequence is effectively a single "zakázkové číslo" pool. An order and its project share one number; a standalone project simply takes the next number in that pool and has no order. The mechanics that make this coherent: - **Collision-safe:** `isSharedNumberTaken()` already checks **both** `orders.order_number` and `projects.project_number`, so the same number can never be issued twice across the pool (`numbering.service.ts:161-167`). - **Atomic:** `createProject` wraps `generateSharedNumber(tx)` in `prisma.$transaction` — the existing `SELECT … FOR UPDATE` lock (`getNextSequence`) serialises concurrent consumers. - **Release on delete:** `deleteProject` already calls `releaseSharedNumber`, which decrements the sequence only if the deleted number is the current highest — so deleting a standalone project doesn't leave a permanent tail-gap (`numbering.service.ts:116-158, 319-328`). - **Audit trail:** `logAudit` on create/delete records the number in `newValues`/`oldValues`, so every consumed pool number has a "who/when/what" trail showing it went to a project (not a lost order). - **UI transparency:** the Projects list shows a badge — **"z objednávky"** (order-linked, `order_id != null`) vs **"samostatný"** (standalone) — so a pool number with no order reads as intentional. The create form previews the number it is about to assign (`previewSharedNumber()`). ## Part A — Manual projects ### Backend - **`src/schemas/projects.schema.ts`** — add `CreateProjectSchema` (`z.strictObject`, shared coercers from `schemas/common.ts`, Czech messages): - `name` — required, `z.string().min(1)`. - `customer_id` — optional number (coerced, NaN-guarded). - `responsible_user_id` — optional number. - `status` — optional, default `"aktivni"`. - `start_date`, `end_date` — optional date strings. - `notes` — optional string. - **No** `project_number` field (auto-assigned from the pool). - **`src/services/projects.service.ts`** — add `createProject(data)`: - `prisma.$transaction(async (tx) => …)`: `project_number = await generateSharedNumber(tx)`; `tx.projects.create({ … order_id: null, quotation_id: null, status: data.status ?? "aktivni", … })`. - Validate `customer_id` / `responsible_user_id` exist (when provided) → return `{ error, status: 400 }` (Czech) if not. - After commit: `nasFileManager.createProjectFolder(number, name)` when `isConfigured()` — non-fatal, logged on failure (mirrors the old code). - Return `{ data: project }` (or `{ error, status }`). - **`src/routes/admin/projects.ts`**: - `POST /` guarded by `requirePermission("projects.create")` → `parseBody(CreateProjectSchema, …)` → `createProject` → `logAudit` (`action: "create"`, `entityType: "project"`, `newValues`) → `success(reply, project, 201, "Projekt vytvořen")`. - `GET /next-number` guarded by `projects.create` → `previewSharedNumber()` → `success(reply, { number })`. (Preview only; does not consume.) ### Frontend (`src/admin/pages/Projects.tsx` + new modal) - Header **"Přidat projekt"** button, gated on `hasPermission("projects.create")`, matching the `OffersCustomers.tsx` "Přidat zákazníka" header-button pattern. - A project create `FormModal` (house pattern from `Vehicles.tsx`): `FormField` rows for name (required), customer (select from customers list), responsible user (select from `userListOptions`), status, start/end dates, notes, plus a read-only "Číslo projektu: `` (přiděleno automaticky)" line fetched from `GET /projects/next-number` when the modal opens. - `useApiMutation` POST `/api/admin/projects`; on success invalidate **`["projects"]`** (broad domain key, per the invalidation convention) and close modal; surface server errors via `useAlert`. - List: add the **"z objednávky" / "samostatný"** badge (derive from `order_id`). Update the empty-state text (currently "Projekt se vytvoří automaticky při vytvoření objednávky") to mention manual creation. ## Part B — Manual orders (header only, optional project) ### Backend - **`src/schemas/orders.schema.ts`** — add `create_project` to `CreateOrderSchema`: boolean via the existing `preprocess(v => v === true || v === 1 || v === "1")` idiom, `.optional().default(true)`. (Only the manual path uses this schema; the from-quotation path uses `CreateOrderFromQuotationSchema`.) - **`src/services/orders.service.ts` `createOrder`** — inside the existing transaction, after the order is created, when `body.create_project` is true and there is no quotation link: `tx.projects.create({ project_number: orderNumber, order_id: order.id, customer_id: order.customer_id, name: scope_title ?? customer_order_number ?? orderNumber, status: "aktivni" })` — mirrors `createOrderFromQuotation`'s project block, so order + project share the number. Return the project id so the route can audit it. - **`src/routes/admin/orders.ts`** — the manual `POST /` branch already exists (`orders.create`); add a second `logAudit` for the created project when present. ### Frontend (`src/admin/pages/Orders.tsx` + new modal) - Header **"Vytvořit objednávku"** button, gated on `orders.create`. - Header-only `FormModal`: customer (select), customer order number, currency, VAT rate + apply-VAT, language, scope title, scope description, notes, and a pre-checked **"Vytvořit propojený projekt"** checkbox. Read-only next order-number preview via the existing `GET /api/admin/orders/next-number`. - POST `/api/admin/orders` (no `quotationId`); on success invalidate **`["orders"]`** and **`["projects"]`**; errors via `useAlert`. ## Part C — Permissions `projects.create` was removed from the permission set and must come back the **migration** way (per CLAUDE.md — never seed-only for prod data): - New migration `prisma/migrations/_add_projects_create_permission/` that `INSERT`s the `projects.create` permission row and grants it to the **admin** role (mirror `20260603230000_add_warehouse_permissions/migration.sql`). Use `INSERT … ON DUPLICATE KEY UPDATE` / `INSERT IGNORE` style so re-runs are safe. - Add `projects.create` to the `PERMISSIONS` array in `prisma/seed.ts` (dev). - `orders.create` already exists — no change. ## Part D — Tests (`src/__tests__/`) Real-DB Vitest (no Prisma mocks), following `numbering.test.ts` style: - `createProject` assigns the next shared number, increments the `shared` sequence by exactly 1, sets `order_id = null`, writes an audit row. - `createOrder` with `create_project: true` creates an order **and** a project sharing one `order_number`/`project_number`. - Interleaved coherence: order → standalone project → order produces three consecutive shared numbers with no duplicates (the tracking guarantee). - `createOrder` with `create_project: false` creates only the order. ## Error handling - Service returns `{ error, status }` (Czech) for bad customer/user FK and numbering exhaustion (the `generateSharedNumber` 100-retry throw → mapped to a 500 with a Czech message by the route, logged via `app.log.error`). - NAS folder creation is non-fatal and logged (never blocks creation). ## Out of scope (v1) — noted for later - Editing order line-items after creation. - Attaching a standalone project to an order later (conflicts with current `order_id` immutability + the `has_order` delete guard). - Manual project-number override (user chose auto-from-pool). - A unified order+project number lookup/search. ## Affected files (summary) - `src/schemas/projects.schema.ts` (add `CreateProjectSchema`) - `src/schemas/orders.schema.ts` (add `create_project`) - `src/services/projects.service.ts` (add `createProject`) - `src/services/orders.service.ts` (`createOrder` optional project) - `src/routes/admin/projects.ts` (`POST /`, `GET /next-number`) - `src/routes/admin/orders.ts` (audit project on manual order) - `src/admin/pages/Projects.tsx` (+ create modal, badge, empty-state) - `src/admin/pages/Orders.tsx` (+ create modal) - `src/admin/lib/queries/projects.ts`, `orders.ts` (mutations/preview queries) - `prisma/migrations/_add_projects_create_permission/migration.sql` - `prisma/seed.ts` (+ `projects.create`) - `src/__tests__/manual-create.test.ts` (new)