Files
app/docs/superpowers/plans/2026-06-06-manual-project-order-creation.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

38 KiB

Manual Project & Order Creation — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let users manually create projects on /projects and orders without an offer on /orders, reusing the existing shared order/project numbering pool with correct tracking.

Architecture: Backend re-introduces createProject (removed in 82919d3) but takes the next shared number via generateSharedNumber(tx) inside a transaction; the manual createOrder (already exists) gains an optional linked-project. Frontend adds inline create modals on the Projects and Orders list pages, mirroring the Vehicles.tsx FormModal/FormField pattern. projects.create permission is re-added via a migration + seed.

Tech Stack: Fastify 5, Prisma 6 (MySQL), Zod 4, React 18 + TanStack Query v5, Vitest.

Spec: docs/superpowers/specs/2026-06-06-manual-project-order-creation-design.md

Conventions to honor (from CLAUDE.md):

  • Routes: success()/error()/parseId()/parseBody(); never raw reply.send for new code.
  • AuthData.roleName (admin bypasses permission checks).
  • Services return { data }-ish or { error, status }; own Prisma; routes stay thin.
  • Czech user-facing strings, English identifiers.
  • Broad query invalidation (["projects"], not ["projects","list"]).
  • Numbering: NEVER hardcode — always go through numbering.service.
  • Permissions/data changes ship as a migration (+ seed for dev), never seed-only.

File Map

File Change
src/schemas/projects.schema.ts Add CreateProjectSchema + CreateProjectInput type
src/services/projects.service.ts Add createProject, getNextProjectNumber; extend imports
src/routes/admin/projects.ts Add POST /, GET /next-number
src/schemas/orders.schema.ts Add create_project to CreateOrderSchema
src/services/orders.service.ts CreateOrderData.create_project; create linked project in createOrder
src/routes/admin/orders.ts Audit the linked project on manual create
prisma/migrations/<ts>_add_projects_create_permission/migration.sql New (idempotent INSERT + grant)
prisma/seed.ts Add projects.create to PERMISSIONS
src/admin/lib/queries/projects.ts Add projectNextNumberOptions
src/admin/lib/queries/orders.ts Add orderNextNumberOptions
src/admin/pages/Projects.tsx Inline create modal, "Přidat projekt" button, Typ badge, empty-state copy
src/admin/pages/Orders.tsx Inline create modal, "Vytvořit objednávku" button
src/__tests__/manual-create.test.ts New (service-level numbering/tracking tests)

Task 1: createProject service + schema (TDD)

Files:

  • Create test: src/__tests__/manual-create.test.ts

  • Modify: src/schemas/projects.schema.ts

  • Modify: src/services/projects.service.ts:1-5 (imports) and add createProject/getNextProjectNumber

  • Step 1: Write the failing test — create src/__tests__/manual-create.test.ts:

import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createProject } from "../services/projects.service";

const createdProjectIds: number[] = [];
const createdOrderIds: number[] = [];

afterEach(async () => {
  for (const id of createdProjectIds)
    await prisma.projects.deleteMany({ where: { id } });
  for (const id of createdOrderIds)
    await prisma.orders.deleteMany({ where: { id } });
  createdProjectIds.length = 0;
  createdOrderIds.length = 0;
  await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});

describe("createProject (manual, standalone)", () => {
  it("assigns a shared number and is not linked to an order", async () => {
    const result = await createProject({
      name: "Test projekt",
      status: "aktivni",
    });
    expect("project_number" in result).toBe(true);
    if (!("project_number" in result)) return;
    createdProjectIds.push(result.id);
    expect(typeof result.project_number).toBe("string");
    expect(result.project_number!.length).toBeGreaterThan(0);
    expect(result.order_id).toBeNull();
  });

  it("gives consecutive standalone projects distinct numbers", async () => {
    const a = await createProject({ name: "Projekt A", status: "aktivni" });
    const b = await createProject({ name: "Projekt B", status: "aktivni" });
    if ("project_number" in a) createdProjectIds.push(a.id);
    if ("project_number" in b) createdProjectIds.push(b.id);
    expect("project_number" in a && "project_number" in b).toBe(true);
    if ("project_number" in a && "project_number" in b) {
      expect(a.project_number).not.toBe(b.project_number);
    }
  });
});
  • Step 2: Run it and confirm it fails

Run: npx vitest run src/__tests__/manual-create.test.ts Expected: FAIL — createProject is not exported from projects.service.

  • Step 3: Add CreateProjectSchema to src/schemas/projects.schema.ts (above CreateProjectNoteSchema). Match the file's existing z.object style; add NaN guards on the new number coercions (CLAUDE.md) and treat "" as null so an empty <select> value doesn't become 0:
export const CreateProjectSchema = z.object({
  name: z.string().min(1, "Zadejte název projektu"),
  customer_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null || v === "" ? null : Number(v)))
    .refine((v) => v === null || !Number.isNaN(v), {
      message: "Neplatný zákazník",
    })
    .optional(),
  responsible_user_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null || v === "" ? null : Number(v)))
    .refine((v) => v === null || !Number.isNaN(v), {
      message: "Neplatná zodpovědná osoba",
    })
    .optional(),
  status: z.string().optional().default("aktivni"),
  start_date: z.union([z.string(), z.null()]).optional(),
  end_date: z.union([z.string(), z.null()]).optional(),
  notes: z.string().nullish(),
});

And add to the type exports at the bottom of the file:

export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
  • Step 4: Extend the numbering imports in src/services/projects.service.ts — replace the line import { releaseSharedNumber } from "./numbering.service"; (line 2) with:
import {
  generateSharedNumber,
  previewSharedNumber,
  releaseSharedNumber,
} from "./numbering.service";

Also add the schema type import near the top (after the existing imports):

import type { CreateProjectInput } from "../schemas/projects.schema";
  • Step 5: Implement createProject + getNextProjectNumber — add to src/services/projects.service.ts (e.g. just above deleteProject):
/**
 * Create a standalone (manual) project. Takes the next SHARED number — the
 * same pool orders/projects draw from — generated atomically inside the
 * transaction (SELECT … FOR UPDATE via getNextSequence). order_id stays null,
 * which is what marks it "samostatný" in the UI. NAS folder creation is
 * best-effort (the manager self-guards isConfigured() and swallows fs errors).
 */
export async function createProject(data: CreateProjectInput) {
  if (data.customer_id != null) {
    const customer = await prisma.customers.findUnique({
      where: { id: data.customer_id },
    });
    if (!customer) return { error: "Zákazník nenalezen", status: 400 };
  }
  if (data.responsible_user_id != null) {
    const user = await prisma.users.findUnique({
      where: { id: data.responsible_user_id },
    });
    if (!user) return { error: "Zodpovědná osoba nenalezena", status: 400 };
  }

  const project = await prisma.$transaction(async (tx) => {
    const project_number = await generateSharedNumber(tx);
    return tx.projects.create({
      data: {
        project_number,
        name: data.name,
        customer_id: data.customer_id ?? null,
        responsible_user_id: data.responsible_user_id ?? null,
        status: data.status || "aktivni",
        start_date: data.start_date ? new Date(data.start_date) : null,
        end_date: data.end_date ? new Date(data.end_date) : null,
        notes: data.notes ?? null,
        order_id: null,
        quotation_id: null,
      },
    });
  });

  if (project.project_number && nasFileManager.isConfigured()) {
    nasFileManager.createProjectFolder(
      project.project_number,
      project.name || "",
    );
  }

  return project;
}

/** Preview the next shared number for the create form (does NOT consume it). */
export async function getNextProjectNumber(): Promise<string> {
  return previewSharedNumber();
}
  • Step 6: Run the test and confirm it passes

Run: npx vitest run src/__tests__/manual-create.test.ts Expected: PASS (2 tests).

  • Step 7: Commit
git add src/__tests__/manual-create.test.ts src/schemas/projects.schema.ts src/services/projects.service.ts
git commit -m "feat(projects): add createProject service (manual, shared numbering)"

Task 2: Project routes (POST /, GET /next-number)

Files:

  • Modify: src/routes/admin/projects.ts

  • Step 1: Import the new schema + service functions — update the two import blocks at the top of src/routes/admin/projects.ts:

import {
  UpdateProjectSchema,
  CreateProjectSchema,
  CreateProjectNoteSchema,
} from "../../schemas/projects.schema";
import {
  listProjects,
  getProject,
  createProject,
  getNextProjectNumber,
  updateProject,
  deleteProject,
  createProjectNote,
  deleteProjectNote,
} from "../../services/projects.service";
  • Step 2: Add the two routes — inside projectsRoutes, immediately after the fastify.get("/", …) list route (before GET /:id, so the static /next-number path is registered alongside the other statics):
// GET /api/admin/projects/next-number — preview the next shared number
fastify.get(
  "/next-number",
  { preHandler: requirePermission("projects.create") },
  async (_request, reply) => {
    const number = await getNextProjectNumber();
    return success(reply, { number, next_number: number });
  },
);

// POST /api/admin/projects — create a standalone (manual) project
fastify.post(
  "/",
  { preHandler: requirePermission("projects.create") },
  async (request, reply) => {
    const parsed = parseBody(CreateProjectSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);

    const result = await createProject(parsed.data);
    if ("error" in result)
      return error(reply, result.error, (result as any).status ?? 400);

    await logAudit({
      request,
      authData: request.authData,
      action: "create",
      entityType: "project",
      entityId: result.id,
      description: `Vytvořen projekt ${result.project_number}`,
    });
    return success(reply, { id: result.id }, 201, "Projekt byl vytvořen");
  },
);
  • Step 3: Verify it compiles

Run: npm run build:server Expected: builds with no TypeScript errors.

  • Step 4: Commit
git add src/routes/admin/projects.ts
git commit -m "feat(projects): POST /projects + GET /projects/next-number"

Task 3: Manual order — optional linked project (TDD)

Files:

  • Modify: src/schemas/orders.schema.ts:49-89 (CreateOrderSchema)

  • Modify: src/services/orders.service.ts:275-291 (CreateOrderData) and 293-366 (createOrder)

  • Modify: src/routes/admin/orders.ts:203-225 (manual branch audit)

  • Modify test: src/__tests__/manual-create.test.ts

  • Step 1: Add the failing test — append a new describe block to src/__tests__/manual-create.test.ts:

import { createOrder } from "../services/orders.service";

const baseOrder = {
  status: "prijata" as const,
  currency: "CZK",
  language: "cs",
  vat_rate: 21,
  apply_vat: true,
  exchange_rate: 1,
};

describe("createOrder (manual, optional linked project)", () => {
  it("creates an order AND a project sharing one number when create_project=true", async () => {
    const res = await createOrder({
      ...baseOrder,
      scope_title: "Ruční zakázka",
      create_project: true,
    });
    expect("id" in res).toBe(true);
    if (!("id" in res)) return;
    createdOrderIds.push(res.id);
    expect(res.project_id).toBeTruthy();
    const project = await prisma.projects.findUnique({
      where: { id: res.project_id! },
    });
    if (project) createdProjectIds.push(project.id);
    expect(project?.project_number).toBe(res.order_number);
    expect(project?.order_id).toBe(res.id);
  });

  it("creates only the order when create_project=false", async () => {
    const res = await createOrder({ ...baseOrder, create_project: false });
    expect("id" in res).toBe(true);
    if (!("id" in res)) return;
    createdOrderIds.push(res.id);
    expect(res.project_id).toBeUndefined();
  });
});

describe("shared pool coherence (tracking)", () => {
  it("order → standalone project → order produce three distinct shared numbers", async () => {
    const o1 = await createOrder({ ...baseOrder, create_project: true });
    if ("id" in o1) {
      createdOrderIds.push(o1.id);
      if (o1.project_id) createdProjectIds.push(o1.project_id);
    }
    const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
    if ("project_number" in p) createdProjectIds.push(p.id);
    const o2 = await createOrder({ ...baseOrder, create_project: false });
    if ("id" in o2) createdOrderIds.push(o2.id);

    const n1 = "id" in o1 ? o1.order_number : null;
    const np = "project_number" in p ? p.project_number : null;
    const n2 = "id" in o2 ? o2.order_number : null;
    expect(new Set([n1, np, n2]).size).toBe(3);
  });
});

Note: createOrder's success return type must include the optional project_id (added in Step 4). Until then, this test fails to compile/run — that is the expected red state. The coherence test also needs createProject (already imported in Task 1).

  • Step 2: Run it and confirm it fails

Run: npx vitest run src/__tests__/manual-create.test.ts Expected: FAIL — create_project/project_id don't exist yet.

  • Step 3: Add create_project to CreateOrderSchema — in src/schemas/orders.schema.ts, inside CreateOrderSchema (after the sections field, before the closing })):
  create_project: z
    .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
    .optional()
    .default(true),
  • Step 4: Extend CreateOrderData and createOrder — in src/services/orders.service.ts:

Add to the CreateOrderData interface (after sections?: OrderSectionInput[];, line 290):

  create_project?: boolean;

In createOrder, replace the final return { id: order.id, order_number: order.order_number }; (the value returned from inside the prisma.$transaction callback) with a block that optionally creates the linked project, mirroring createOrderFromQuotation's project block:

let projectId: number | undefined;
// Only auto-create a project for genuine offer-less orders; share the
// order's number (same shared pool) exactly like the from-quotation flow.
if (body.create_project !== false && body.quotation_id == null) {
  const project = await tx.projects.create({
    data: {
      project_number: orderNumber,
      name: body.scope_title || body.customer_order_number || orderNumber,
      customer_id: order.customer_id,
      order_id: order.id,
      status: "aktivni",
    },
  });
  projectId = project.id;
}

return {
  id: order.id,
  order_number: order.order_number,
  project_id: projectId,
};
  • Step 5: Audit the linked project in the route — in src/routes/admin/orders.ts, the manual branch (lines 203-225). After the existing logAudit for the order and before the return success(...), add:
if (result.project_id) {
  await logAudit({
    request,
    authData: request.authData,
    action: "create",
    entityType: "project",
    entityId: result.project_id,
    description: `Vytvořen projekt k objednávce ${result.order_number}`,
  });
}

result.order_number is already returned by createOrder (it's part of the success object); no extra fetch needed.

  • Step 6: Run the test and confirm it passes

Run: npx vitest run src/__tests__/manual-create.test.ts Expected: PASS (all 4 tests).

  • Step 7: Commit
git add src/schemas/orders.schema.ts src/services/orders.service.ts src/routes/admin/orders.ts src/__tests__/manual-create.test.ts
git commit -m "feat(orders): manual order optionally creates a linked project"

Task 4: projects.create permission (migration + seed)

Files:

  • Create: prisma/migrations/20260606120000_add_projects_create_permission/migration.sql
  • Modify: prisma/seed.ts (PERMISSIONS array, projects block ~lines 168-192)

⚠️ Verify 20260606120000 sorts AFTER the latest existing migration folder (ls prisma/migrations). If a later timestamp exists, bump the prefix so this is last.

  • Step 1: Create the migrationprisma/migrations/20260606120000_add_projects_create_permission/migration.sql:
-- Re-add the projects.create permission (removed in commit 82919d3 when manual
-- project creation was deleted). Idempotent: re-running is a no-op.
-- Mirrors prisma/seed.ts PERMISSIONS exactly (name, display_name, module, description).

-- 1. Insert the permission (INSERT IGNORE skips on duplicate name).
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
  ('projects.create', 'Vytvořit projekt', 'projects', 'Ručně vytvářet projekty', NOW());

-- 2. Grant it to the admin role (idempotent via composite PK).
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM   `roles` r
CROSS  JOIN `permissions` p
WHERE  r.name = 'admin'
  AND  p.name IN ('projects.create');
  • Step 2: Add to seed.ts — in prisma/seed.ts, inside the PERMISSIONS array's // Projekty block, add (e.g. after projects.view):
  {
    name: "projects.create",
    display_name: "Vytvořit projekt",
    module: "projects",
    description: "Ručně vytvářet projekty",
  },
  • Step 3: Apply to the dev DB. Ask the user to confirm before touching the database, then:

Run: npx prisma migrate deploy Expected: applies 20260606120000_add_projects_create_permission. (migrate deploy only runs pending migrations and needs no shadow DB; admin already bypasses permission checks, so this mainly matters for non-admin roles and prod.)

  • Step 4: Commit
git add prisma/migrations/20260606120000_add_projects_create_permission prisma/seed.ts
git commit -m "feat(perms): re-add projects.create permission (migration + seed)"

Task 5: Query factories for the number previews

Files:

  • Modify: src/admin/lib/queries/projects.ts

  • Modify: src/admin/lib/queries/orders.ts

  • Step 1: Add projectNextNumberOptions to the end of src/admin/lib/queries/projects.ts (mirrors offerNextNumberOptions):

export const projectNextNumberOptions = () =>
  queryOptions({
    queryKey: ["projects", "next-number"],
    queryFn: () =>
      jsonQuery<{ next_number?: string; number?: string }>(
        "/api/admin/projects/next-number",
      ),
  });
  • Step 2: Add orderNextNumberOptions to the end of src/admin/lib/queries/orders.ts:
export const orderNextNumberOptions = () =>
  queryOptions({
    queryKey: ["orders", "next-number"],
    queryFn: () =>
      jsonQuery<{ next_number?: string; number?: string }>(
        "/api/admin/orders/next-number",
      ),
  });

Both files already import queryOptions and jsonQuery. Confirm those imports exist at the top; if jsonQuery is missing in orders.ts, add it to the existing from "../apiAdapter" import.

  • Step 3: Verify build

Run: npm run build:client Expected: builds clean.

  • Step 4: Commit
git add src/admin/lib/queries/projects.ts src/admin/lib/queries/orders.ts
git commit -m "feat(queries): next-number options for project/order create modals"

Task 6: Projects page — create modal, badge, empty-state

Files:

  • Modify: src/admin/pages/Projects.tsx

This mirrors the Vehicles.tsx inline create-modal pattern (state + useApiMutation + handleSubmit + FormModal/FormField).

  • Step 1: Add imports — extend the import block at the top of src/admin/pages/Projects.tsx:
import { useQuery } from "@tanstack/react-query";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import {
  projectListOptions,
  projectNextNumberOptions,
} from "../lib/queries/projects";
import { offerCustomersOptions } from "../lib/queries/offers";
import { userListOptions } from "../lib/queries/users";

(Remove the now-duplicated single projectListOptions import line so it isn't imported twice.)

  • Step 2: Add create-modal state + mutation + submit — inside the Projects() component, after the existing deleteMutation block:
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
  name: "",
  customer_id: "",
  responsible_user_id: "",
  status: "aktivni",
  start_date: "",
  end_date: "",
  notes: "",
});
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
const [creating, setCreating] = useState(false);

const customersQuery = useQuery({
  ...offerCustomersOptions(),
  enabled: showCreate,
});
const usersQuery = useQuery({ ...userListOptions(), enabled: showCreate });
const nextNumberQuery = useQuery({
  ...projectNextNumberOptions(),
  enabled: showCreate,
});

const createMutation = useApiMutation<typeof createForm, { id: number }>({
  url: () => `${API_BASE}/projects`,
  method: () => "POST",
  invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
  onSuccess: () => {
    setShowCreate(false);
    alert.success("Projekt byl vytvořen");
  },
});

const openCreate = () => {
  setCreateForm({
    name: "",
    customer_id: "",
    responsible_user_id: "",
    status: "aktivni",
    start_date: "",
    end_date: "",
    notes: "",
  });
  setCreateErrors({});
  setShowCreate(true);
};

const handleCreate = async () => {
  const errs: Record<string, string> = {};
  if (!createForm.name.trim()) errs.name = "Zadejte název projektu";
  setCreateErrors(errs);
  if (Object.keys(errs).length > 0) return;

  setCreating(true);
  try {
    await createMutation.mutateAsync(createForm);
  } catch (e) {
    alert.error(e instanceof Error ? e.message : "Chyba připojení");
  } finally {
    setCreating(false);
  }
};
  • Step 3: Add the header button — replace the page-header <div> (the one holding only the title/subtitle, ~lines 117-128) so it gains an actions slot gated by projects.create:
<div>
  <h1 className="admin-page-title">Projekty</h1>
  <p className="admin-page-subtitle">
    {pagination?.total ?? projects.length}{" "}
    {czechPlural(
      pagination?.total ?? projects.length,
      "projekt",
      "projekty",
      "projektů",
    )}
  </p>
</div>;
{
  hasPermission("projects.create") && (
    <div className="admin-page-actions">
      <button onClick={openCreate} className="admin-btn admin-btn-primary">
        <svg
          width="20"
          height="20"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
        >
          <line x1="12" y1="5" x2="12" y2="19" />
          <line x1="5" y1="12" x2="19" y2="12" />
        </svg>
        Přidat projekt
      </button>
    </div>
  );
}
  • Step 4: Update the empty-state copy — change the second <p> in the empty state (currently "Projekt se vytvoří automaticky při vytvoření objednávky.") to:
                Projekty vznikají z objednávek nebo je můžete vytvořit ručně
                tlačítkem Přidat projekt.
  • Step 5: Add the "Typ" badge column — in <thead>, add a header after the Stav <th>:
<th>Typ</th>

In <tbody>, add a matching cell after the Stav cell (the <td> with the status badge):

<td>
  <span className="admin-badge">
    {p.order_id ? "Z objednávky" : "Samostatný"}
  </span>
</td>
  • Step 6: Render the create modal — just before the closing </div> of the component's returned JSX (next to the existing ConfirmModal), add the modal. Customer + responsible-user are <select>s populated from the queries; the number preview is read-only:
<FormModal
  isOpen={showCreate}
  onClose={() => setShowCreate(false)}
  onSubmit={handleCreate}
  title="Přidat projekt"
  submitLabel="Vytvořit"
  loading={creating}
>
  <div className="admin-form">
    <FormField label="Název" error={createErrors.name} required>
      <input
        type="text"
        value={createForm.name}
        onChange={(e) => {
          setCreateForm({ ...createForm, name: e.target.value });
          setCreateErrors((p) => ({ ...p, name: "" }));
        }}
        className="admin-form-input"
        placeholder="Název projektu"
        aria-invalid={!!createErrors.name}
      />
    </FormField>

    <div className="admin-form-row">
      <FormField label="Zákazník">
        <select
          value={createForm.customer_id}
          onChange={(e) =>
            setCreateForm({ ...createForm, customer_id: e.target.value })
          }
          className="admin-form-input"
        >
          <option value=""> bez zákazníka </option>
          {(customersQuery.data ?? []).map((c) => (
            <option key={c.id} value={c.id}>
              {c.name}
            </option>
          ))}
        </select>
      </FormField>

      <FormField label="Zodpovědná osoba">
        <select
          value={createForm.responsible_user_id}
          onChange={(e) =>
            setCreateForm({
              ...createForm,
              responsible_user_id: e.target.value,
            })
          }
          className="admin-form-input"
        >
          <option value=""> nepřiřazeno </option>
          {(usersQuery.data ?? []).map((u) => (
            <option key={u.id} value={u.id}>
              {u.first_name} {u.last_name}
            </option>
          ))}
        </select>
      </FormField>
    </div>

    <div className="admin-form-row">
      <FormField label="Začátek">
        <input
          type="date"
          value={createForm.start_date}
          onChange={(e) =>
            setCreateForm({ ...createForm, start_date: e.target.value })
          }
          className="admin-form-input"
        />
      </FormField>
      <FormField label="Konec">
        <input
          type="date"
          value={createForm.end_date}
          onChange={(e) =>
            setCreateForm({ ...createForm, end_date: e.target.value })
          }
          className="admin-form-input"
        />
      </FormField>
    </div>

    <FormField label="Poznámka">
      <textarea
        value={createForm.notes}
        onChange={(e) =>
          setCreateForm({ ...createForm, notes: e.target.value })
        }
        className="admin-form-input"
        rows={3}
      />
    </FormField>

    <p className="admin-form-hint">
      Číslo projektu:{" "}
      <strong>
        {nextNumberQuery.data?.number ??
          nextNumberQuery.data?.next_number ??
          "…"}
      </strong>{" "}
      (přiděleno automaticky)
    </p>
  </div>
</FormModal>
  • Step 7: Verify build + types

Run: npm run build:client Expected: builds clean (no TS errors). Visually confirm later in the running app.

  • Step 8: Commit
git add src/admin/pages/Projects.tsx
git commit -m "feat(projects-ui): add project create modal, type badge, empty-state copy"

Task 7: Orders page — manual create modal (header only)

Files:

  • Modify: src/admin/pages/Orders.tsx

  • Step 1: Add imports — extend the top of src/admin/pages/Orders.tsx:

import { useQuery } from "@tanstack/react-query";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import {
  orderListOptions,
  orderNextNumberOptions,
} from "../lib/queries/orders";
import { offerCustomersOptions } from "../lib/queries/offers";

(Remove the duplicated single orderListOptions import line.)

  • Step 2: Add state + mutation + submit — inside Orders(), after the existing deleteMutation:
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,
});
const [creating, setCreating] = useState(false);

const customersQuery = useQuery({
  ...offerCustomersOptions(),
  enabled: showCreate,
});
const nextNumberQuery = useQuery({
  ...orderNextNumberOptions(),
  enabled: showCreate,
});

// The payload normalises customer_id: "" → null. CreateOrderSchema's
// customer_id coercion does NOT special-case "", so a raw "" would become
// Number("") = 0 and hit a foreign-key violation. (Note: the projects schema
// in Task 1 DOES special-case "", so the project modal can send createForm raw.)
type OrderCreatePayload = Omit<typeof createForm, "customer_id"> & {
  customer_id: number | null;
};

const createMutation = useApiMutation<OrderCreatePayload, { id: number }>({
  url: () => `${API_BASE}/orders`,
  method: () => "POST",
  invalidate: ["orders", "projects", "offers", "invoices"],
  onSuccess: () => {
    setShowCreate(false);
    alert.success("Objednávka byla vytvořena");
  },
});

const openCreate = () => {
  setCreateForm({
    customer_id: "",
    customer_order_number: "",
    currency: "CZK",
    vat_rate: "21",
    apply_vat: true,
    scope_title: "",
    scope_description: "",
    notes: "",
    create_project: true,
  });
  setShowCreate(true);
};

const handleCreate = async () => {
  setCreating(true);
  try {
    await createMutation.mutateAsync({
      ...createForm,
      customer_id: createForm.customer_id
        ? Number(createForm.customer_id)
        : null,
    });
  } catch (e) {
    alert.error(e instanceof Error ? e.message : "Chyba připojení");
  } finally {
    setCreating(false);
  }
};
  • Step 3: Add the header button — in the page-header motion.div (lines 105-123), after the title/subtitle <div>, add:
{
  hasPermission("orders.create") && (
    <div className="admin-page-actions">
      <button onClick={openCreate} className="admin-btn admin-btn-primary">
        <svg
          width="20"
          height="20"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
        >
          <line x1="12" y1="5" x2="12" y2="19" />
          <line x1="5" y1="12" x2="19" y2="12" />
        </svg>
        Vytvořit objednávku
      </button>
    </div>
  );
}
  • Step 4: Render the modal — before the component's closing </div> (next to ConfirmModal):
<FormModal
  isOpen={showCreate}
  onClose={() => setShowCreate(false)}
  onSubmit={handleCreate}
  title="Vytvořit objednávku bez nabídky"
  submitLabel="Vytvořit"
  loading={creating}
>
  <div className="admin-form">
    <div className="admin-form-row">
      <FormField label="Zákazník">
        <select
          value={createForm.customer_id}
          onChange={(e) =>
            setCreateForm({ ...createForm, customer_id: e.target.value })
          }
          className="admin-form-input"
        >
          <option value=""> bez zákazníka </option>
          {(customersQuery.data ?? []).map((c) => (
            <option key={c.id} value={c.id}>
              {c.name}
            </option>
          ))}
        </select>
      </FormField>
      <FormField label="Číslo objednávky zákazníka">
        <input
          type="text"
          value={createForm.customer_order_number}
          onChange={(e) =>
            setCreateForm({
              ...createForm,
              customer_order_number: e.target.value,
            })
          }
          className="admin-form-input"
        />
      </FormField>
    </div>

    <div className="admin-form-row">
      <FormField label="Měna">
        <select
          value={createForm.currency}
          onChange={(e) =>
            setCreateForm({ ...createForm, currency: e.target.value })
          }
          className="admin-form-input"
        >
          <option value="CZK">CZK</option>
          <option value="EUR">EUR</option>
          <option value="USD">USD</option>
          <option value="GBP">GBP</option>
        </select>
      </FormField>
      <FormField label="Sazba DPH (%)">
        <input
          type="number"
          inputMode="numeric"
          value={createForm.vat_rate}
          onChange={(e) =>
            setCreateForm({ ...createForm, vat_rate: e.target.value })
          }
          className="admin-form-input"
          min="0"
        />
      </FormField>
    </div>

    <FormField label="Předmět (scope)">
      <input
        type="text"
        value={createForm.scope_title}
        onChange={(e) =>
          setCreateForm({ ...createForm, scope_title: e.target.value })
        }
        className="admin-form-input"
      />
    </FormField>

    <FormField label="Popis">
      <textarea
        value={createForm.scope_description}
        onChange={(e) =>
          setCreateForm({
            ...createForm,
            scope_description: e.target.value,
          })
        }
        className="admin-form-input"
        rows={3}
      />
    </FormField>

    <FormField label="Poznámka">
      <textarea
        value={createForm.notes}
        onChange={(e) =>
          setCreateForm({ ...createForm, notes: e.target.value })
        }
        className="admin-form-input"
        rows={2}
      />
    </FormField>

    <label className="admin-form-checkbox">
      <input
        type="checkbox"
        checked={createForm.apply_vat}
        onChange={(e) =>
          setCreateForm({ ...createForm, apply_vat: e.target.checked })
        }
      />
      <span>Účtovat DPH</span>
    </label>

    <label className="admin-form-checkbox">
      <input
        type="checkbox"
        checked={createForm.create_project}
        onChange={(e) =>
          setCreateForm({
            ...createForm,
            create_project: e.target.checked,
          })
        }
      />
      <span>Vytvořit propojený projekt</span>
    </label>

    <p className="admin-form-hint">
      Číslo objednávky:{" "}
      <strong>
        {nextNumberQuery.data?.number ??
          nextNumberQuery.data?.next_number ??
          "…"}
      </strong>{" "}
      (přiděleno automaticky)
    </p>
  </div>
</FormModal>
  • Step 5: Verify build

Run: npm run build:client Expected: builds clean.

  • Step 6: Commit
git add src/admin/pages/Orders.tsx
git commit -m "feat(orders-ui): add manual order create modal (header only, optional project)"

Task 8: Full verification

Files: none (verification only)

  • Step 1: Type-check + build everything

Run: npm run build Expected: server + client build with zero TypeScript errors.

  • Step 2: Run the whole test suite

Run: npm test Expected: all tests pass (the prior 134 + the 4 new manual-create tests = 138).

  • Step 3: Manual smoke (in the running app — user-driven, do not start the dev server yourself)

Confirm in the browser:

  • /projects → "Přidat projekt" creates a standalone project; it appears with a "Samostatný" badge and an auto-assigned number; the audit log shows a project create entry.

  • /orders → "Vytvořit objednávku" with the checkbox on creates an order and a linked project sharing the same number; the project shows "Z objednávky".

  • Creating with the checkbox off makes only the order.

  • Step 4: Final commit (if any verification fixups were needed)

git add -A
git commit -m "test: verify manual project/order creation end-to-end"

Notes for the executor

  • Do not start the dev server (the user manages it). For the Task 4 migration, ask the user to confirm before prisma migrate deploy.
  • Admin bypasses permission checks, so the new routes work for admin even before Task 4; the migration matters for non-admin roles and production.
  • Numbering is the sensitive part: the project/order share one shared pool. The tracking guarantees (collision check across both tables, release-on-delete, audit, the "Samostatný/Z objednávky" badge) are what make standalone projects coherent — do not bypass generateSharedNumber/previewSharedNumber.
  • Release is automatic on delete (deleteProject/deleteOrder already call releaseSharedNumber); no new release code is needed.