- 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>
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 rawreply.sendfor 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 addcreateProject/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
CreateProjectSchematosrc/schemas/projects.schema.ts(aboveCreateProjectNoteSchema). Match the file's existingz.objectstyle; add NaN guards on the new number coercions (CLAUDE.md) and treat""as null so an empty<select>value doesn't become0:
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 lineimport { 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 tosrc/services/projects.service.ts(e.g. just abovedeleteProject):
/**
* 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 thefastify.get("/", …)list route (beforeGET /:id, so the static/next-numberpath 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) and293-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
describeblock tosrc/__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 optionalproject_id(added in Step 4). Until then, this test fails to compile/run — that is the expected red state. The coherence test also needscreateProject(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_projecttoCreateOrderSchema— insrc/schemas/orders.schema.ts, insideCreateOrderSchema(after thesectionsfield, before the closing})):
create_project: z
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
.optional()
.default(true),
- Step 4: Extend
CreateOrderDataandcreateOrder— insrc/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 existinglogAuditfor the order and before thereturn 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_numberis already returned bycreateOrder(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
20260606120000sorts 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:
-- 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 thePERMISSIONSarray's// Projektyblock, add (e.g. afterprojects.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
projectNextNumberOptionsto the end ofsrc/admin/lib/queries/projects.ts(mirrorsofferNextNumberOptions):
export const projectNextNumberOptions = () =>
queryOptions({
queryKey: ["projects", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
"/api/admin/projects/next-number",
),
});
- Step 2: Add
orderNextNumberOptionsto the end ofsrc/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
queryOptionsandjsonQuery. Confirm those imports exist at the top; ifjsonQueryis missing inorders.ts, add it to the existingfrom "../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 existingdeleteMutationblock:
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 byprojects.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 theStav<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 existingConfirmModal), 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 existingdeleteMutation:
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 toConfirmModal):
<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 aprojectcreate 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
sharedpool. 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 bypassgenerateSharedNumber/previewSharedNumber. - Release is automatic on delete (
deleteProject/deleteOrderalready callreleaseSharedNumber); no new release code is needed.