# 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: `<preview>` (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/<ts>_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/<ts>_add_projects_create_permission/migration.sql`
- `prisma/seed.ts` (+ `projects.create`)
- `src/__tests__/manual-create.test.ts` (new)
