Compare commits
11 Commits
fdf180809d
...
103908cfb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
103908cfb8 | ||
|
|
449a1a2243 | ||
|
|
337feb8b3a | ||
|
|
bc62d32efc | ||
|
|
30dd5a3f3b | ||
|
|
5140d23d73 | ||
|
|
a373102f3a | ||
|
|
49ec3482ae | ||
|
|
f6cdfb9801 | ||
|
|
bd9dc8d103 | ||
|
|
0c5d0ee2f7 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.3",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.9.2",
|
||||
"version": "1.9.3",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 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');
|
||||
@@ -172,6 +172,12 @@ const PERMISSIONS: {
|
||||
module: "projects",
|
||||
description: "Prohlížet seznam projektů",
|
||||
},
|
||||
{
|
||||
name: "projects.create",
|
||||
display_name: "Vytvořit projekt",
|
||||
module: "projects",
|
||||
description: "Ručně vytvářet projekty",
|
||||
},
|
||||
{
|
||||
name: "projects.edit",
|
||||
display_name: "Upravit projekt",
|
||||
|
||||
142
src/__tests__/manual-create.test.ts
Normal file
142
src/__tests__/manual-create.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createProject } from "../services/projects.service";
|
||||
import { createOrder } from "../services/orders.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" } });
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrder with price line item + attachment", () => {
|
||||
it("stores a single line item with the entered price", async () => {
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
create_project: false,
|
||||
items: [
|
||||
{
|
||||
description: "Práce",
|
||||
quantity: 1,
|
||||
unit_price: 12345,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
const items = await prisma.order_items.findMany({
|
||||
where: { order_id: res.id },
|
||||
});
|
||||
expect(items.length).toBe(1);
|
||||
expect(Number(items[0].unit_price)).toBe(12345);
|
||||
});
|
||||
|
||||
it("stores an uploaded PO attachment", async () => {
|
||||
const buf = Buffer.from("%PDF-1.4 fake");
|
||||
const res = await createOrder(
|
||||
{ ...baseOrder, create_project: false },
|
||||
buf,
|
||||
"po.pdf",
|
||||
);
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id: res.id },
|
||||
select: { attachment_name: true, attachment_data: true },
|
||||
});
|
||||
expect(order?.attachment_name).toBe("po.pdf");
|
||||
expect(order?.attachment_data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -82,3 +82,12 @@ export const orderDetailOptions = (id: string | undefined) =>
|
||||
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
export const orderNextNumberOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["orders", "next-number"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ next_number?: string; number?: string }>(
|
||||
"/api/admin/orders/next-number",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -109,3 +109,12 @@ export const projectFilesOptions = (projectId: number, path: string) =>
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const projectNextNumberOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["projects", "next-number"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ next_number?: string; number?: string }>(
|
||||
"/api/admin/projects/next-number",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import apiFetch from "../utils/api";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
import FormField from "../components/FormField";
|
||||
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import SortIcon from "../components/SortIcon";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { orderListOptions } from "../lib/queries/orders";
|
||||
import {
|
||||
orderListOptions,
|
||||
orderNextNumberOptions,
|
||||
} from "../lib/queries/orders";
|
||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import Pagination from "../components/Pagination";
|
||||
|
||||
@@ -71,6 +79,126 @@ export default function Orders() {
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
price: "",
|
||||
quantity: "1",
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
|
||||
|
||||
const customersQuery = useQuery({
|
||||
...offerCustomersOptions(),
|
||||
enabled: showCreate,
|
||||
});
|
||||
const nextNumberQuery = useQuery({
|
||||
...orderNextNumberOptions(),
|
||||
enabled: showCreate,
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setCreateForm({
|
||||
customer_id: "",
|
||||
customer_order_number: "",
|
||||
currency: "CZK",
|
||||
vat_rate: "21",
|
||||
apply_vat: true,
|
||||
scope_title: "",
|
||||
scope_description: "",
|
||||
notes: "",
|
||||
create_project: true,
|
||||
price: "",
|
||||
quantity: "1",
|
||||
});
|
||||
setOrderAttachment(null);
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const priceNum = createForm.price ? Number(createForm.price) : 0;
|
||||
const qtyNum = createForm.quantity ? Number(createForm.quantity) : 1;
|
||||
const items =
|
||||
priceNum > 0
|
||||
? [
|
||||
{
|
||||
description: createForm.scope_title || "Položka",
|
||||
quantity: qtyNum,
|
||||
unit_price: priceNum,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
|
||||
let fetchOptions: RequestInit;
|
||||
if (orderAttachment) {
|
||||
const fd = new FormData();
|
||||
if (createForm.customer_id)
|
||||
fd.append("customer_id", createForm.customer_id);
|
||||
fd.append("customer_order_number", createForm.customer_order_number);
|
||||
fd.append("currency", createForm.currency);
|
||||
fd.append("vat_rate", createForm.vat_rate);
|
||||
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
|
||||
fd.append("scope_title", createForm.scope_title);
|
||||
fd.append("scope_description", createForm.scope_description);
|
||||
fd.append("notes", createForm.notes);
|
||||
fd.append("create_project", createForm.create_project ? "1" : "0");
|
||||
if (items) fd.append("items", JSON.stringify(items));
|
||||
fd.append("attachment", orderAttachment);
|
||||
fetchOptions = { method: "POST", body: fd };
|
||||
} else {
|
||||
fetchOptions = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customer_id: createForm.customer_id
|
||||
? Number(createForm.customer_id)
|
||||
: null,
|
||||
customer_order_number: createForm.customer_order_number,
|
||||
currency: createForm.currency,
|
||||
vat_rate: createForm.vat_rate,
|
||||
apply_vat: createForm.apply_vat,
|
||||
scope_title: createForm.scope_title,
|
||||
scope_description: createForm.scope_description,
|
||||
notes: createForm.notes,
|
||||
create_project: createForm.create_project,
|
||||
...(items ? { items } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setShowCreate(false);
|
||||
alert.success(result.message || "Objednávka byla vytvořena");
|
||||
await queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||
if (result.data?.id) navigate(`/orders/${result.data.id}`);
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
items: orders,
|
||||
pagination,
|
||||
@@ -120,6 +248,27 @@ export default function Orders() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -377,6 +526,230 @@ export default function Orders() {
|
||||
type="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Cena za jednotku (bez DPH)">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={createForm.price}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, price: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Množství">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={createForm.quantity}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, quantity: e.target.value })
|
||||
}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<FormField label="Příloha (PO zákazníka, PDF)">
|
||||
{orderAttachment ? (
|
||||
<div className="flex-row gap-2">
|
||||
<span className="text-md">
|
||||
{orderAttachment.name}{" "}
|
||||
<span className="text-tertiary">
|
||||
({(orderAttachment.size / 1024).toFixed(0)} KB)
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrderAttachment(null)}
|
||||
className="admin-btn-icon"
|
||||
title="Odebrat"
|
||||
style={{ marginLeft: "auto" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
Vybrat soubor
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={(e) =>
|
||||
setOrderAttachment(e.target.files?.[0] || null)
|
||||
}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import FormModal from "../components/FormModal";
|
||||
import FormField from "../components/FormField";
|
||||
|
||||
import { formatDate, czechPlural } from "../utils/formatters";
|
||||
import SortIcon from "../components/SortIcon";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { projectListOptions } from "../lib/queries/projects";
|
||||
import {
|
||||
projectListOptions,
|
||||
projectNextNumberOptions,
|
||||
} from "../lib/queries/projects";
|
||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
||||
import { userListOptions } from "../lib/queries/users";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import Pagination from "../components/Pagination";
|
||||
|
||||
@@ -73,6 +81,69 @@ export default function Projects() {
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
items: projects,
|
||||
pagination,
|
||||
@@ -126,6 +197,27 @@ export default function Projects() {
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -172,7 +264,8 @@ export default function Projects() {
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Projekt se vytvoří automaticky při vytvoření objednávky.
|
||||
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
|
||||
tlačítkem „Přidat projekt".
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -233,6 +326,7 @@ export default function Projects() {
|
||||
order={order}
|
||||
/>
|
||||
</th>
|
||||
<th>Typ</th>
|
||||
<th>Objednávka</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
@@ -257,6 +351,11 @@ export default function Projects() {
|
||||
</td>
|
||||
<td className="admin-mono">{formatDate(p.start_date)}</td>
|
||||
<td className="admin-mono">{formatDate(p.end_date)}</td>
|
||||
<td>
|
||||
<span className="admin-badge">
|
||||
{p.order_id ? "Z objednávky" : "Samostatný"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{p.order_id ? (
|
||||
<Link
|
||||
@@ -359,6 +458,114 @@ export default function Projects() {
|
||||
type="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@ export default async function ordersRoutes(
|
||||
request.headers["content-type"]?.includes("multipart");
|
||||
|
||||
if (isMultipart) {
|
||||
// === Order from quotation flow (multipart) ===
|
||||
const fields: Record<string, string> = {};
|
||||
let attachmentBuffer: Buffer | null = null;
|
||||
let attachmentName: string | null = null;
|
||||
@@ -119,37 +118,82 @@ export default async function ordersRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const quotationId = parseInt(fields.quotationId, 10);
|
||||
const customerOrderNumber = fields.customerOrderNumber || "";
|
||||
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, "Chybí ID nabídky", 400);
|
||||
// From-quotation flow (multipart with attachment)
|
||||
if (fields.quotationId) {
|
||||
const quotationId = parseInt(fields.quotationId, 10);
|
||||
const customerOrderNumber = fields.customerOrderNumber || "";
|
||||
if (!quotationId || isNaN(quotationId)) {
|
||||
return error(reply, "Chybí ID nabídky", 400);
|
||||
}
|
||||
const result = await createOrderFromQuotation({
|
||||
quotationId,
|
||||
customerOrderNumber,
|
||||
attachmentBuffer,
|
||||
attachmentName,
|
||||
});
|
||||
if ("error" in result)
|
||||
return error(reply, result.error!, result.status!);
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "order",
|
||||
entityId: result.data.order_id,
|
||||
description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}`,
|
||||
});
|
||||
return success(
|
||||
reply,
|
||||
{
|
||||
order_id: result.data.order_id,
|
||||
id: result.data.id,
|
||||
order_number: result.data.order_number,
|
||||
},
|
||||
201,
|
||||
"Objednávka byla vytvořena",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createOrderFromQuotation({
|
||||
quotationId,
|
||||
customerOrderNumber,
|
||||
// Manual order (multipart, with optional PO attachment)
|
||||
const rawManual: Record<string, unknown> = { ...fields };
|
||||
if (typeof fields.items === "string" && fields.items.length > 0) {
|
||||
try {
|
||||
rawManual.items = JSON.parse(fields.items);
|
||||
} catch {
|
||||
return error(reply, "Neplatná data položek", 400);
|
||||
}
|
||||
}
|
||||
const manualParsed = parseBody(CreateOrderSchema, rawManual);
|
||||
if ("error" in manualParsed)
|
||||
return error(reply, manualParsed.error, 400);
|
||||
|
||||
const result = await createOrder(
|
||||
manualParsed.data,
|
||||
attachmentBuffer,
|
||||
attachmentName,
|
||||
});
|
||||
);
|
||||
if ("error" in result)
|
||||
return error(reply, result.error!, result.status!);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "create",
|
||||
entityType: "order",
|
||||
entityId: result.data.order_id,
|
||||
description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}`,
|
||||
entityId: result.id,
|
||||
description: `Vytvořena objednávka ${result.order_number}`,
|
||||
});
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
return success(
|
||||
reply,
|
||||
{
|
||||
order_id: result.data.order_id,
|
||||
id: result.data.id,
|
||||
order_number: result.data.order_number,
|
||||
},
|
||||
{ id: result.id },
|
||||
201,
|
||||
"Objednávka byla vytvořena",
|
||||
);
|
||||
@@ -216,6 +260,16 @@ export default async function ordersRoutes(
|
||||
entityId: result.id,
|
||||
description: `Vytvořena objednávka ${result.order_number}`,
|
||||
});
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
return success(
|
||||
reply,
|
||||
{ id: result.id },
|
||||
|
||||
@@ -6,11 +6,14 @@ import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
UpdateProjectSchema,
|
||||
CreateProjectSchema,
|
||||
CreateProjectNoteSchema,
|
||||
} from "../../schemas/projects.schema";
|
||||
import {
|
||||
listProjects,
|
||||
getProject,
|
||||
createProject,
|
||||
getNextProjectNumber,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
createProjectNote,
|
||||
@@ -46,6 +49,40 @@ export default async function projectsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// 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");
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("projects.view") },
|
||||
|
||||
@@ -86,6 +86,10 @@ export const CreateOrderSchema = z.object({
|
||||
notes: z.string().nullish(),
|
||||
items: z.array(OrderItemSchema).optional(),
|
||||
sections: z.array(OrderSectionSchema).optional(),
|
||||
create_project: z
|
||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||
.optional()
|
||||
.default(true),
|
||||
});
|
||||
|
||||
export const UpdateOrderSchema = z.object({
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export const UpdateProjectSchema = z.object({
|
||||
name: z.string().nullish(),
|
||||
status: z.string().optional(),
|
||||
@@ -28,5 +50,6 @@ export const CreateProjectNoteSchema = z.object({
|
||||
content: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
||||
export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>;
|
||||
|
||||
@@ -288,9 +288,14 @@ interface CreateOrderData {
|
||||
notes?: string | null;
|
||||
items?: OrderItemInput[];
|
||||
sections?: OrderSectionInput[];
|
||||
create_project?: boolean;
|
||||
}
|
||||
|
||||
export async function createOrder(body: CreateOrderData) {
|
||||
export async function createOrder(
|
||||
body: CreateOrderData,
|
||||
attachmentBuffer?: Buffer | null,
|
||||
attachmentName?: string | null,
|
||||
) {
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const orderNumber =
|
||||
@@ -322,6 +327,10 @@ export async function createOrder(body: CreateOrderData) {
|
||||
scope_title: body.scope_title ?? null,
|
||||
scope_description: body.scope_description ?? null,
|
||||
notes: body.notes ?? null,
|
||||
attachment_data: attachmentBuffer
|
||||
? new Uint8Array(attachmentBuffer)
|
||||
: null,
|
||||
attachment_name: attachmentName || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -352,7 +361,27 @@ export async function createOrder(body: CreateOrderData) {
|
||||
});
|
||||
}
|
||||
|
||||
return { id: order.id, order_number: order.order_number };
|
||||
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,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && "status" in err) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import prisma from "../config/database";
|
||||
import { releaseSharedNumber } from "./numbering.service";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
previewSharedNumber,
|
||||
releaseSharedNumber,
|
||||
} from "./numbering.service";
|
||||
import { NasFileManager } from "./nas-file-manager";
|
||||
import type { CreateProjectInput } from "../schemas/projects.schema";
|
||||
|
||||
const nasFileManager = new NasFileManager();
|
||||
|
||||
@@ -144,6 +149,60 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
export async function deleteProject(id: number, deleteFiles: boolean = false) {
|
||||
const existing = await prisma.projects.findUnique({ where: { id } });
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
|
||||
@@ -10,5 +10,11 @@ export default defineConfig({
|
||||
testTimeout: 15000,
|
||||
hookTimeout: 15000,
|
||||
exclude: ["dist/**", "node_modules/**", ".claude/**"],
|
||||
// Tests run against a real shared MySQL test DB. Running test files in
|
||||
// parallel lets two files (e.g. numbering + manual-create) hit the same
|
||||
// `number_sequences` row with SELECT ... FOR UPDATE + deleteMany at once,
|
||||
// which deadlocks (MySQL 1213). Serialise files so DB-touching suites
|
||||
// don't contend. Tests within a file already run sequentially.
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user