Files
app/docs/superpowers/specs/2026-06-06-manual-project-order-creation-design.md
BOHA 674b44d047 feat(odin): Phase 2a read-only agentic assistant + tool-trace UI
- agentic chat loop (max 6 round-trips, budget re-checked between iterations)
  over 10 read-only, permission-delegated tools in src/services/ai-tools.ts
- persist assistant tool trace in ai_chat_messages.content_json (+ migration)
- consulted-tools chips in OdinThread; header now shows month spend only
  (budget cap hidden from the UI, server-side 402 guard unchanged)
- seed: ai.use permission; Settings exposes the ai permission module
- docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:31:50 +02:00

10 KiB

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 designFormModal + 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, …)createProjectlogAudit (action: "create", entityType: "project", newValues) → success(reply, project, 201, "Projekt vytvořen").
    • GET /next-number guarded by projects.createpreviewSharedNumber()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 INSERTs 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)