# 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`:

```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`:

```ts
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:

```ts
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:

```ts
import {
  generateSharedNumber,
  previewSharedNumber,
  releaseSharedNumber,
} from "./numbering.service";
```

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

```ts
import type { CreateProjectInput } from "../schemas/projects.schema";
```

- [ ] **Step 5: Implement `createProject` + `getNextProjectNumber`** — add to `src/services/projects.service.ts` (e.g. just above `deleteProject`):

```ts
/**
 * 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**

```bash
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`:

```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):

```ts
// 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**

```bash
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`:

```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 `})`):

```ts
  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):

```ts
  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:

```ts
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:

```ts
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**

```bash
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 migration** — `prisma/migrations/20260606120000_add_projects_create_permission/migration.sql`:

```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`):

```ts
  {
    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**

```bash
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`):

```ts
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`:

```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**

```bash
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`:

```ts
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:

```ts
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`:

```tsx
<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:

```tsx
                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>`:

```tsx
<th>Typ</th>
```

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

```tsx
<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:

```tsx
<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**

```bash
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`:

```ts
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`:

```ts
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:

```tsx
{
  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`):

```tsx
<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**

```bash
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)**

```bash
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.
