From 82919d39f691819db8bba202580a3a0fdd66a26b Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 28 Apr 2026 11:36:08 +0200 Subject: [PATCH] fix: remove manual project creation, smart sequence release, received-invoices schema fix - Remove ProjectCreate page, POST /projects endpoint, and next-number endpoint - Projects can only be created through orders (shared numbering sequence) - Remove dead CreateProjectSchema and createProject service function - Delete 'order' row from number_sequences (unused; code uses 'shared') - Smart sequence release: decrement last_number only when deleting the highest number - Fix received-invoices stats referencing non-existent is_deleted and amount_czk columns - Update deploy instructions in CLAUDE.md (npm install, prisma migrate deploy) Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 11 +- package.json | 2 +- src/admin/AdminApp.tsx | 2 - src/admin/pages/ProjectCreate.tsx | 381 ------------------------------ src/admin/pages/Projects.tsx | 16 -- src/routes/admin/projects.ts | 37 --- src/schemas/projects.schema.ts | 31 --- src/services/invoices.service.ts | 2 +- src/services/numbering.service.ts | 89 +++++-- src/services/offers.service.ts | 2 +- src/services/orders.service.ts | 15 +- src/services/projects.service.ts | 56 +---- 12 files changed, 86 insertions(+), 558 deletions(-) delete mode 100644 src/admin/pages/ProjectCreate.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 7f91932..e41c97d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -293,10 +293,11 @@ When adding new features, add tests in `src/__tests__/`. Name test files ` import("./pages/OffersTemplates")); const Orders = lazy(() => import("./pages/Orders")); const OrderDetail = lazy(() => import("./pages/OrderDetail")); const Projects = lazy(() => import("./pages/Projects")); -const ProjectCreate = lazy(() => import("./pages/ProjectCreate")); const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const Invoices = lazy(() => import("./pages/Invoices")); const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); @@ -104,7 +103,6 @@ export default function AdminApp() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/admin/pages/ProjectCreate.tsx b/src/admin/pages/ProjectCreate.tsx deleted file mode 100644 index ce3a4e4..0000000 --- a/src/admin/pages/ProjectCreate.tsx +++ /dev/null @@ -1,381 +0,0 @@ -import { useState, useEffect, useMemo } from "react"; -import { useNavigate, Link } from "react-router-dom"; -import { useAlert } from "../context/AlertContext"; -import { useAuth } from "../context/AuthContext"; -import { motion } from "framer-motion"; -import FormField from "../components/FormField"; -import Forbidden from "../components/Forbidden"; -import AdminDatePicker from "../components/AdminDatePicker"; -import apiFetch from "../utils/api"; - -const API_BASE = "/api/admin"; - -interface Customer { - id: number; - name: string; - company_id?: string; - city?: string; -} - -interface User { - id: number; - name: string; -} - -interface ProjectForm { - project_number: string; - name: string; - customer_id: number | null; - customer_name: string; - start_date: string; - responsible_user_id: string; -} - -export default function ProjectCreate() { - const navigate = useNavigate(); - const alert = useAlert(); - const { hasPermission } = useAuth(); - - const [form, setForm] = useState({ - project_number: "", - name: "", - customer_id: null, - customer_name: "", - start_date: new Date().toISOString().split("T")[0], - responsible_user_id: "", - }); - const [users, setUsers] = useState([]); - const [saving, setSaving] = useState(false); - const [errors, setErrors] = useState>({}); - const [loadingNumber, setLoadingNumber] = useState(true); - - // Customer selector state - const [customers, setCustomers] = useState([]); - const [customerSearch, setCustomerSearch] = useState(""); - const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); - - // Load initial data - useEffect(() => { - const load = async () => { - try { - const [numRes, custRes, usersRes] = await Promise.all([ - apiFetch(`${API_BASE}/projects/next-number`), - apiFetch(`${API_BASE}/customers`), - apiFetch(`${API_BASE}/users`), - ]); - - const numData = await numRes.json(); - if (numData.success) { - setForm((prev) => ({ - ...prev, - project_number: - numData.data?.next_number || numData.data?.number || "", - })); - } - - const custData = await custRes.json(); - if (custData.success) { - setCustomers( - Array.isArray(custData.data) - ? custData.data - : custData.data?.items || [], - ); - } - - const usersData = await usersRes.json(); - if (usersData.success) { - const rawUsers = Array.isArray(usersData.data) - ? usersData.data - : usersData.data?.items || []; - setUsers( - rawUsers.map((u: any) => ({ - id: u.id, - name: - `${u.first_name || ""} ${u.last_name || ""}`.trim() || - u.username, - })), - ); - } - } catch { - alert.error("Chyba při načítání dat"); - } finally { - setLoadingNumber(false); - } - }; - load(); - }, [alert]); - - // Customer filtering - const filteredCustomers = useMemo(() => { - if (!customerSearch) return customers; - const q = customerSearch.toLowerCase(); - return customers.filter( - (c) => - (c.name || "").toLowerCase().includes(q) || - (c.company_id || "").includes(customerSearch) || - (c.city || "").toLowerCase().includes(q), - ); - }, [customers, customerSearch]); - - // Close dropdown on outside click - useEffect(() => { - const handleClickOutside = () => setShowCustomerDropdown(false); - if (showCustomerDropdown) { - document.addEventListener("click", handleClickOutside); - return () => document.removeEventListener("click", handleClickOutside); - } - }, [showCustomerDropdown]); - - if (!hasPermission("projects.create")) return ; - - const selectCustomer = (customer: Customer) => { - setForm((prev) => ({ - ...prev, - customer_id: customer.id, - customer_name: customer.name, - })); - setErrors((prev) => ({ ...prev, customer_id: undefined })); - setCustomerSearch(""); - setShowCustomerDropdown(false); - }; - - const clearCustomer = () => { - setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" })); - }; - - const updateForm = (field: keyof ProjectForm, value: unknown) => { - setForm((prev) => ({ ...prev, [field]: value })); - setErrors((prev) => ({ ...prev, [field]: undefined })); - }; - - const handleSave = async () => { - const newErrors: Record = {}; - if (!form.name.trim()) newErrors.name = "Název projektu je povinný"; - if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka"; - setErrors(newErrors); - if (Object.keys(newErrors).length > 0) return; - - setSaving(true); - try { - const body = { - name: form.name.trim(), - customer_id: form.customer_id, - start_date: form.start_date, - responsible_user_id: form.responsible_user_id || null, - }; - - const res = await apiFetch(`${API_BASE}/projects`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const data = await res.json(); - if (data.success) { - alert.success("Projekt byl vytvořen"); - navigate(`/projects/${data.data.id}`); - } else { - alert.error(data.error || "Nepodařilo se vytvořit projekt"); - } - } catch { - alert.error("Chyba připojení"); - } finally { - setSaving(false); - } - }; - - if (loadingNumber) { - return ( -
-
-
-
-
-
- {[0, 1, 2, 3].map((i) => ( -
-
-
-
- ))} -
-
-
- ); - } - - return ( -
- -
- - - - - -
-

Nový projekt

-

Ruční vytvoření projektu

-
-
-
- -
-
- - -
-

Základní údaje

-
-
- - - - - updateForm("name", e.target.value)} - className="admin-form-input" - placeholder="Název projektu" - /> - -
- -
- - {form.customer_id ? ( -
- {form.customer_name} - -
- ) : ( -
e.stopPropagation()} - > - { - setCustomerSearch(e.target.value); - setShowCustomerDropdown(true); - }} - onFocus={() => setShowCustomerDropdown(true)} - className="admin-form-input" - placeholder="Hledat zákazníka..." - /> - {showCustomerDropdown && ( -
- {filteredCustomers.length === 0 ? ( -
- Žádní zákazníci -
- ) : ( - filteredCustomers.slice(0, 20).map((c) => ( -
selectCustomer(c)} - > -
{c.name}
- {c.city &&
{c.city}
} -
- )) - )} -
- )} -
- )} -
- - updateForm("start_date", val)} - /> - -
- -
- - - -
-
-
-
-
- ); -} diff --git a/src/admin/pages/Projects.tsx b/src/admin/pages/Projects.tsx index af156dc..3801876 100644 --- a/src/admin/pages/Projects.tsx +++ b/src/admin/pages/Projects.tsx @@ -160,22 +160,6 @@ export default function Projects() { )}

- {hasPermission("projects.create") && ( - - - - - - Nový projekt - - )} { - const parsed = parseBody(CreateProjectSchema, request.body); - if ("error" in parsed) return error(reply, parsed.error, 400); - - const project = await createProject(parsed.data); - if ("error" in project) { - return error(reply, project.error, (project as any).status ?? 400); - } - - await logAudit({ - request, - authData: request.authData, - action: "create", - entityType: "project", - entityId: project.id, - description: `Vytvořen projekt ${project.name}`, - }); - return success(reply, { id: project.id }, 201, "Projekt byl vytvořen"); - }, - ); - fastify.put<{ Params: { id: string } }>( "/:id", { preHandler: requirePermission("projects.edit") }, @@ -137,16 +110,6 @@ export default async function projectsRoutes( }, ); - // GET /api/admin/projects/next-number — shared sequence with orders (matches PHP) - fastify.get( - "/next-number", - { preHandler: requirePermission("projects.create") }, - async (_request, reply) => { - const nextNumber = await getNextProjectNumber(); - return success(reply, { next_number: nextNumber }); - }, - ); - // DELETE /api/admin/projects/:id/notes/:noteId fastify.delete<{ Params: { id: string; noteId: string } }>( "/:id/notes/:noteId", diff --git a/src/schemas/projects.schema.ts b/src/schemas/projects.schema.ts index 140d16d..04ee1bb 100644 --- a/src/schemas/projects.schema.ts +++ b/src/schemas/projects.schema.ts @@ -1,35 +1,5 @@ import { z } from "zod"; -const safeProjectNumber = z - .string() - .regex(/^[\p{L}\p{N}_\-.]+$/u, "Číslo projektu obsahuje nepovolené znaky") - .nullish(); - -export const CreateProjectSchema = z.object({ - project_number: safeProjectNumber, - name: z.string().nullish(), - customer_id: z - .union([z.number(), z.string()]) - .transform((v) => Number(v)) - .nullish(), - responsible_user_id: z - .union([z.number(), z.string()]) - .transform((v) => Number(v)) - .nullish(), - quotation_id: z - .union([z.number(), z.string()]) - .transform((v) => Number(v)) - .nullish(), - order_id: z - .union([z.number(), z.string()]) - .transform((v) => Number(v)) - .nullish(), - status: z.string().optional().default("aktivni"), - start_date: z.string().nullish(), - end_date: z.string().nullish(), - notes: z.string().nullish(), -}); - export const UpdateProjectSchema = z.object({ name: z.string().nullish(), status: z.string().optional(), @@ -58,6 +28,5 @@ export const CreateProjectNoteSchema = z.object({ content: z.string().nullish(), }); -export type CreateProjectInput = z.infer; export type UpdateProjectInput = z.infer; export type CreateProjectNoteInput = z.infer; diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index aa60f15..ebaf113 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -493,7 +493,7 @@ export async function deleteInvoice(id: number) { const year = existing.invoice_number ? Number(existing.invoice_number.split("/")[1]) || new Date().getFullYear() : new Date().getFullYear(); - await releaseInvoiceNumber(year); + await releaseInvoiceNumber(year, existing.invoice_number ?? undefined); return existing; } diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index ae859fe..dd818b1 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -110,13 +110,51 @@ async function previewNextSequence( /** * Release a sequence number back to the pool. - * NOTE: Blindly decrementing can cause duplicate numbers if numbers were - * allocated after this one. Sequence numbers are consumed but not returned - * to the pool — this is intentionally a no-op. + * Only decrements if the deleted entity held the current highest number, + * preventing gaps while avoiding duplicate re-allocation. */ -async function releaseSequence(_type: string, _year: number) { - // No-op: decrementing can cause duplicate sequence numbers. - // Sequence numbers are consumed but never returned to the pool. +async function releaseSequence( + type: string, + year: number, + deletedNumber?: string, +) { + if (!deletedNumber) return; + + const existing = await prisma.$queryRaw>` + SELECT last_number FROM number_sequences + WHERE \`type\` = ${type} AND \`year\` = ${year} + `; + if (existing.length === 0) return; + + const settings = await getSettings(); + const pattern = + type === "shared" + ? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN + : type === "invoice" + ? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN + : settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN; + const code = + type === "shared" + ? settings?.order_type_code || "71" + : type === "invoice" + ? settings?.invoice_type_code || "81" + : ""; + const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : ""; + + const highestNumber = applyPattern(pattern, { + year, + prefix, + code, + seq: existing[0].last_number, + }); + + if (deletedNumber === highestNumber && existing[0].last_number > 0) { + await prisma.$executeRaw` + UPDATE number_sequences + SET \`last_number\` = ${existing[0].last_number - 1} + WHERE \`type\` = ${type} AND \`year\` = ${year} + `; + } } /** Verify a shared number is not already used by an order or project. */ @@ -265,19 +303,40 @@ export async function previewInvoiceNumber( return { number, next_number: number }; } -/** Release an offer number back to the pool (decrement sequence). */ -export async function releaseOfferNumber(year?: number) { - await releaseSequence("offer", year || new Date().getFullYear()); +/** Release an offer number back to the pool (decrement if highest). */ +export async function releaseOfferNumber( + year?: number, + deletedNumber?: string, +) { + await releaseSequence( + "offer", + year || new Date().getFullYear(), + deletedNumber, + ); } -/** Release a shared number back to the pool (decrement sequence). */ -export async function releaseSharedNumber(year?: number) { - await releaseSequence("shared", year || new Date().getFullYear()); +/** Release a shared number back to the pool (decrement if highest). */ +export async function releaseSharedNumber( + year?: number, + deletedNumber?: string, +) { + await releaseSequence( + "shared", + year || new Date().getFullYear(), + deletedNumber, + ); } -/** Release an invoice number back to the pool (decrement sequence). */ -export async function releaseInvoiceNumber(year?: number) { - await releaseSequence("invoice", year || new Date().getFullYear()); +/** Release an invoice number back to the pool (decrement if highest). */ +export async function releaseInvoiceNumber( + year?: number, + deletedNumber?: string, +) { + await releaseSequence( + "invoice", + year || new Date().getFullYear(), + deletedNumber, + ); } /** Preview what a pattern would produce (for settings UI) */ diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts index 89f6516..1cdf5e0 100644 --- a/src/services/offers.service.ts +++ b/src/services/offers.service.ts @@ -307,7 +307,7 @@ export async function deleteOffer(id: number) { const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); - await releaseOfferNumber(year); + await releaseOfferNumber(year, existing.quotation_number ?? undefined); return existing; } diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index 7d1edce..9ff1776 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -542,23 +542,10 @@ export async function deleteOrder(id: number) { await tx.orders.delete({ where: { id } }); }); - const releasedYears = new Set(); const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); - await releaseSharedNumber(year); - releasedYears.add(year); - - // Release the linked project's shared number(s) too - for (const p of linkedProjects) { - const pYear = p.created_at - ? new Date(p.created_at).getFullYear() - : new Date().getFullYear(); - if (!releasedYears.has(pYear)) { - await releaseSharedNumber(pYear); - releasedYears.add(pYear); - } - } + await releaseSharedNumber(year, existing.order_number ?? undefined); return { data: { id, order_number: existing.order_number } }; } diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 9e415a2..530512f 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -1,9 +1,5 @@ import prisma from "../config/database"; -import { - generateSharedNumber, - previewSharedNumber, - releaseSharedNumber, -} from "./numbering.service"; +import { releaseSharedNumber } from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; const nasFileManager = new NasFileManager(); @@ -96,50 +92,6 @@ export async function getProject(id: number) { }; } -import { isSharedNumberTaken } from "./numbering.service"; - -export async function createProject(body: Record) { - const projectNumber = - body.project_number !== undefined && body.project_number !== null - ? String(body.project_number) - : await generateSharedNumber(); - - if (body.project_number !== undefined && body.project_number !== null) { - const taken = await isSharedNumberTaken(String(body.project_number)); - if (taken) { - return { error: "Číslo projektu je již použito", status: 400 }; - } - } - - const project = await prisma.projects.create({ - data: { - project_number: projectNumber, - name: body.name ? String(body.name) : null, - customer_id: body.customer_id != null ? Number(body.customer_id) : null, - responsible_user_id: - body.responsible_user_id != null - ? Number(body.responsible_user_id) - : null, - quotation_id: - body.quotation_id != null ? Number(body.quotation_id) : null, - order_id: body.order_id != null ? Number(body.order_id) : null, - status: body.status ? String(body.status) : "aktivni", - start_date: body.start_date ? new Date(String(body.start_date)) : null, - end_date: body.end_date ? new Date(String(body.end_date)) : null, - notes: body.notes ? String(body.notes) : null, - }, - }); - - if (project.project_number && nasFileManager.isConfigured()) { - nasFileManager.createProjectFolder( - project.project_number, - project.name || "", - ); - } - - return project; -} - export async function updateProject(id: number, body: Record) { const existing = await prisma.projects.findUnique({ where: { id } }); if (!existing) return null; @@ -206,7 +158,7 @@ export async function deleteProject(id: number, deleteFiles: boolean = false) { const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); - await releaseSharedNumber(year); + await releaseSharedNumber(year, existing.project_number ?? undefined); return existing; } @@ -248,7 +200,3 @@ export async function deleteProjectNote(projectId: number, noteId: number) { await prisma.project_notes.delete({ where: { id: noteId } }); return note; } - -export async function getNextProjectNumber() { - return previewSharedNumber(); -}