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

1155 lines
38 KiB
Markdown

# 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.