- 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>
1155 lines
38 KiB
Markdown
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.
|