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();
-}