Compare commits

...

12 Commits

Author SHA1 Message Date
BOHA
103908cfb8 chore(release): v1.9.3 — manual project & order creation (price + PO upload)
Manual standalone project creation (shared numbering pool, with tracking:
collision-safe, release-on-delete, audit, Samostatný/Z objednávky badge) and
manual order creation without an offer (header form, optional linked project,
price line item, PO PDF upload mirroring the from-offer flow). Re-adds the
projects.create permission via migration + seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:12:01 +02:00
BOHA
449a1a2243 test: serialise test files (fileParallelism:false) to avoid shared-DB deadlock
The numbering and manual-create suites both lock the number_sequences
'shared' row (SELECT ... FOR UPDATE + deleteMany); running test files in
parallel raced them into a MySQL 1213 deadlock. Production never does that
concurrent delete, so this is purely a parallel-test isolation fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:01:28 +02:00
BOHA
337feb8b3a feat(orders-ui): manual order price line item + PO upload (hybrid submit)
Adds price/quantity fields (→ one line item) and a PDF attachment picker
to the manual-order create modal. Submit now uses apiFetch directly:
FormData (multipart) when a file is attached, JSON otherwise. Navigates
to the new order on success. Removes the old useApiMutation-based
createMutation; deleteMutation is untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 13:58:21 +02:00
BOHA
bc62d32efc feat(orders): manual order PO attachment + price items (multipart)
Extend createOrder service to accept optional attachmentBuffer/attachmentName
parameters and persist them. Restructure the POST / multipart branch in the
orders route to dispatch on quotationId presence: from-quotation path is
unchanged, new manual-multipart path validates with CreateOrderSchema and
forwards the attachment. Add two TDD tests covering price line items and
PO file attachment storage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:52:29 +02:00
BOHA
30dd5a3f3b feat(orders-ui): add manual order create modal (header only, optional project)
Mirrors the Projects.tsx pattern: useQuery-driven customers + next-number
fetches (enabled only when modal is open), OrderCreatePayload type normalises
customer_id "" → null so the server's CreateOrderSchema never receives 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:17:53 +02:00
BOHA
5140d23d73 feat(projects-ui): add project create modal, type badge, empty-state copy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:13:43 +02:00
BOHA
a373102f3a feat(queries): next-number options for project/order create modals 2026-06-06 11:11:18 +02:00
BOHA
49ec3482ae feat(perms): re-add projects.create permission (migration + seed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:10:10 +02:00
BOHA
f6cdfb9801 feat(orders): manual order optionally creates a linked project
Add `create_project` flag (default true) to `CreateOrderSchema` and
`CreateOrderData`. When true and `quotation_id` is absent, `createOrder`
creates a `projects` row sharing the order's shared-pool number and
`order_id` link — mirroring the from-quotation flow. The route audits
the new project creation when it occurs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:06:25 +02:00
BOHA
bd9dc8d103 feat(projects): POST /projects + GET /projects/next-number 2026-06-06 11:04:01 +02:00
BOHA
0c5d0ee2f7 feat(projects): add createProject service (manual, shared numbering)
Introduces CreateProjectSchema (Zod 4, NaN-guarded coercions), the
createProject() service function (atomic shared-number generation inside
a $transaction), getNextProjectNumber() preview helper, and two Vitest
tests that verify standalone project creation and distinct sequential
numbers. Routes are deferred to a subsequent task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:01:10 +02:00
BOHA
fdf180809d fix(plan): make Plán prací usable on mobile + dev port → 3050
Mobile work-plan (Plán prací) responsive fixes — desktop unchanged:
- Toolbar stacks into clean full-width rows on ≤640px (nav group,
  centered range label, full-width segmented view toggle, full-width
  category button); drops the fixed 230px label min-width and the
  margin-left:auto that made the toolbar ragged on phones.
- Grid scroll container: 100dvh-based max-height, momentum scroll, and
  overscroll containment so a fling does not bounce the whole page.
- ≤480px tightens columns (date col 72px via the colgroup <col>, person
  cols 132px) so ~2 people are comfortably visible at 360–390px.
- Touch (@media hover:none): faint persistent + on empty editable
  cells, since the hover-reveal add hint never fires on a phone.

Dev: `npm run dev` (backend) now listens on 3050 instead of 3000 to
avoid colliding with other local apps; CLAUDE.md dev-port note updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:00 +02:00
20 changed files with 1163 additions and 54 deletions

View File

@@ -104,7 +104,7 @@ TOTP_ENCRYPTION_KEY=<64-char hex string>
Optional (with defaults): Optional (with defaults):
``` ```
PORT=3001 # Production port (dev default: 3000) PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
HOST=127.0.0.1 HOST=127.0.0.1
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
ACCESS_TOKEN_EXPIRY=900 # 15 minutes ACCESS_TOKEN_EXPIRY=900 # 15 minutes

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.9.1", "version": "1.9.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "1.9.1", "version": "1.9.3",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.9.1", "version": "1.9.3",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -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');

View File

@@ -172,6 +172,12 @@ const PERMISSIONS: {
module: "projects", module: "projects",
description: "Prohlížet seznam projektů", description: "Prohlížet seznam projektů",
}, },
{
name: "projects.create",
display_name: "Vytvořit projekt",
module: "projects",
description: "Ručně vytvářet projekty",
},
{ {
name: "projects.edit", name: "projects.edit",
display_name: "Upravit projekt", display_name: "Upravit projekt",

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

View File

@@ -82,3 +82,12 @@ export const orderDetailOptions = (id: string | undefined) =>
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`), queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
enabled: !!id, enabled: !!id,
}); });
export const orderNextNumberOptions = () =>
queryOptions({
queryKey: ["orders", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
"/api/admin/orders/next-number",
),
});

View File

@@ -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",
),
});

View File

@@ -1,16 +1,24 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; 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 Forbidden from "../components/Forbidden";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters"; import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon"; import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; 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 { useApiMutation } from "../lib/queries/mutations";
import Pagination from "../components/Pagination"; 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 { const {
items: orders, items: orders,
pagination, pagination,
@@ -120,6 +248,27 @@ export default function Orders() {
)} )}
</p> </p>
</div> </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>
<motion.div <motion.div
@@ -377,6 +526,230 @@ export default function Orders() {
type="danger" type="danger"
loading={deleteMutation.isPending} 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> </div>
); );
} }

View File

@@ -576,29 +576,35 @@ export default function PlanWork() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
> >
<button {/* Nav buttons grouped so they stay on one row when the toolbar
type="button" stacks on mobile (see .plan-toolbar-nav in plan.css). On desktop
className="admin-btn admin-btn-secondary" the wrapper is an inline-flex with the same 12px gap, so the
onClick={goToToday} layout is visually identical. */}
> <div className="plan-toolbar-nav">
Dnes <button
</button> type="button"
<button className="admin-btn admin-btn-secondary"
type="button" onClick={goToToday}
className="admin-btn admin-btn-secondary" >
onClick={goPrev} Dnes
aria-label="Předchozí" </button>
> <button
type="button"
</button> className="admin-btn admin-btn-secondary"
<button onClick={goPrev}
type="button" aria-label="Předchozí"
className="admin-btn admin-btn-secondary" >
onClick={goNext}
aria-label="Další" </button>
> <button
type="button"
</button> className="admin-btn admin-btn-secondary"
onClick={goNext}
aria-label="Další"
>
</button>
</div>
<span className="plan-range-label-slot"> <span className="plan-range-label-slot">
<AnimatePresence mode="popLayout" initial={false}> <AnimatePresence mode="popLayout" initial={false}>
<motion.span <motion.span
@@ -644,7 +650,7 @@ export default function PlanWork() {
{canEdit && ( {canEdit && (
<button <button
type="button" type="button"
className="admin-btn admin-btn-secondary" className="admin-btn admin-btn-secondary plan-toolbar-cat"
onClick={() => setCatModalOpen(true)} onClick={() => setCatModalOpen(true)}
> >
Správa kategorií Správa kategorií

View File

@@ -1,16 +1,24 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { formatDate, czechPlural } from "../utils/formatters"; import { formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon"; import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; 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 { useApiMutation } from "../lib/queries/mutations";
import Pagination from "../components/Pagination"; 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 { const {
items: projects, items: projects,
pagination, pagination,
@@ -126,6 +197,27 @@ export default function Projects() {
)} )}
</p> </p>
</div> </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>
<motion.div <motion.div
@@ -172,7 +264,8 @@ export default function Projects() {
fontSize: "0.875rem", 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> </p>
</div> </div>
) : ( ) : (
@@ -233,6 +326,7 @@ export default function Projects() {
order={order} order={order}
/> />
</th> </th>
<th>Typ</th>
<th>Objednávka</th> <th>Objednávka</th>
<th>Akce</th> <th>Akce</th>
</tr> </tr>
@@ -257,6 +351,11 @@ export default function Projects() {
</td> </td>
<td className="admin-mono">{formatDate(p.start_date)}</td> <td className="admin-mono">{formatDate(p.start_date)}</td>
<td className="admin-mono">{formatDate(p.end_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> <td>
{p.order_id ? ( {p.order_id ? (
<Link <Link
@@ -359,6 +458,114 @@ export default function Projects() {
type="danger" type="danger"
loading={deleteMutation.isPending} 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> </div>
); );
} }

View File

@@ -83,11 +83,17 @@
.plan-grid-wrap { .plan-grid-wrap {
position: relative; position: relative;
overflow: auto; overflow: auto;
/* Smooth momentum scroll on touch, and keep scroll chaining contained so a
fling inside the grid doesn't bounce the whole page (or trigger
pull-to-refresh) on mobile. */
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background: var(--plan-paper); background: var(--plan-paper);
border: 1px solid var(--plan-paper-rule); border: 1px solid var(--plan-paper-rule);
border-radius: 14px; border-radius: 14px;
box-shadow: var(--plan-shadow); box-shadow: var(--plan-shadow);
max-height: calc(100vh - 240px); max-height: calc(100vh - 240px);
max-height: calc(100dvh - 240px);
isolation: isolate; isolation: isolate;
} }
@@ -726,6 +732,16 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* Nav button group (Dnes / ← / →). On desktop it's an inline-flex with the
same 12px gap the toolbar used between the three loose buttons before, so
the layout is unchanged. On mobile it becomes a full-width row (see the
≤640px block below). */
.plan-toolbar-nav {
display: inline-flex;
align-items: center;
gap: 12px;
}
.plan-view-toggle { .plan-view-toggle {
margin-left: auto; margin-left: auto;
display: inline-flex; display: inline-flex;
@@ -956,6 +972,118 @@
} }
} }
/* ----------------------------------------------------------------------------
Mobile toolbar — stack into clean full-width rows.
The desktop toolbar is one wrap-row with a fixed-width range label and a
`margin-left:auto` view toggle; on a phone that collapses into a ragged,
gappy mess. Here we lay it out as tidy stacked rows: nav buttons on row 1,
the range label centered on its own row, a full-width segmented view toggle,
then the category-manager button full-width. Source order already produces
this stack once each block is forced to 100% width — no `order` juggling.
---------------------------------------------------------------------------- */
@media (max-width: 640px) {
.plan-toolbar {
gap: 8px;
margin-top: 1rem;
margin-bottom: 1rem;
}
/* Row 1: Dnes (grows) + the two arrow buttons (compact). */
.plan-toolbar-nav {
display: flex;
flex: 1 1 100%;
gap: 8px;
}
.plan-toolbar-nav .admin-btn {
flex: 1 1 auto;
}
/* The ← / → buttons carry an aria-label; "Dnes" does not — keep the arrows
compact and let "Dnes" take the remaining width. */
.plan-toolbar-nav .admin-btn[aria-label] {
flex: 0 0 auto;
min-width: 56px;
}
/* Row 2: range label, full width, centered — drop the desktop min-width. */
.plan-range-label-slot {
flex: 1 1 100%;
min-width: 0;
justify-content: center;
}
.plan-range-label {
font-size: 15px;
}
/* Row 3: view toggle as a full-width segmented control, two equal halves. */
.plan-view-toggle {
flex: 1 1 100%;
margin-left: 0;
border-radius: 12px;
}
.plan-view-toggle .admin-btn {
flex: 1 1 0;
}
/* Row 4: category-manager button, full width. */
.plan-toolbar-cat {
flex: 1 1 100%;
}
/* Keep the whole grid inside the viewport so the toolbar stays put and the
sticky header / date column do the orienting (rather than the page
scrolling the header out of view). */
.plan-grid-wrap {
max-height: calc(100dvh - 300px);
min-height: 280px;
border-radius: 12px;
}
}
/* ----------------------------------------------------------------------------
Small phones — tighten columns so ~2 people are comfortably visible at
360390px, and shrink the date "stamp" a touch. The colgroup <col> width is
what actually pins the date column (min-width on the cells is only a floor),
so it must be set here too, not just on the cells.
---------------------------------------------------------------------------- */
@media (max-width: 480px) {
.plan-grid col.plan-grid-date-col {
width: 72px;
}
.plan-grid thead th.plan-grid-date-col {
min-width: 72px;
padding-left: 10px;
padding-right: 8px;
}
.plan-grid tbody td.plan-grid-date-col {
min-width: 72px;
padding: 8px 8px 8px 10px;
}
.plan-grid tbody td.plan-grid-date-col .plan-date-daynum {
font-size: 14px;
}
.plan-grid tbody td {
min-width: 132px;
}
.plan-cell {
min-height: 56px;
padding: 8px 9px 9px 14px;
}
}
/* ----------------------------------------------------------------------------
Touch devices — the "+" add-hint and the tape-grow are hover-only, so they
never surface on a phone. Show a faint, persistent "+" on empty *editable*
cells so tapping-to-add is discoverable. Read-only and past cells already
suppress the glyph (content:none / the readonly chain), so they stay inert.
---------------------------------------------------------------------------- */
@media (hover: none) {
.plan-cell--empty:not(.plan-cell--readonly)::after {
opacity: 0.26;
color: var(--plan-ink-faint);
}
}
/* Respect users who don't want a moving target — kill the hover transforms /* Respect users who don't want a moving target — kill the hover transforms
and the rule-line animation. */ and the rule-line animation. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -104,7 +104,6 @@ export default async function ordersRoutes(
request.headers["content-type"]?.includes("multipart"); request.headers["content-type"]?.includes("multipart");
if (isMultipart) { if (isMultipart) {
// === Order from quotation flow (multipart) ===
const fields: Record<string, string> = {}; const fields: Record<string, string> = {};
let attachmentBuffer: Buffer | null = null; let attachmentBuffer: Buffer | null = null;
let attachmentName: string | null = null; let attachmentName: string | null = null;
@@ -119,37 +118,82 @@ export default async function ordersRoutes(
} }
} }
const quotationId = parseInt(fields.quotationId, 10); // From-quotation flow (multipart with attachment)
const customerOrderNumber = fields.customerOrderNumber || ""; if (fields.quotationId) {
const quotationId = parseInt(fields.quotationId, 10);
if (!quotationId || isNaN(quotationId)) { const customerOrderNumber = fields.customerOrderNumber || "";
return error(reply, "Chybí ID nabídky", 400); 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({ // Manual order (multipart, with optional PO attachment)
quotationId, const rawManual: Record<string, unknown> = { ...fields };
customerOrderNumber, 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, attachmentBuffer,
attachmentName, attachmentName,
}); );
if ("error" in result) if ("error" in result)
return error(reply, result.error!, result.status!); return error(reply, result.error!, result.status!);
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
action: "create", action: "create",
entityType: "order", entityType: "order",
entityId: result.data.order_id, entityId: result.id,
description: `Vytvořena objednávka ${result.data.order_number} z nabídky #${result.data.quotationId}`, 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( return success(
reply, reply,
{ { id: result.id },
order_id: result.data.order_id,
id: result.data.id,
order_number: result.data.order_number,
},
201, 201,
"Objednávka byla vytvořena", "Objednávka byla vytvořena",
); );
@@ -216,6 +260,16 @@ export default async function ordersRoutes(
entityId: result.id, entityId: result.id,
description: `Vytvořena objednávka ${result.order_number}`, 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( return success(
reply, reply,
{ id: result.id }, { id: result.id },

View File

@@ -6,11 +6,14 @@ import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { import {
UpdateProjectSchema, UpdateProjectSchema,
CreateProjectSchema,
CreateProjectNoteSchema, CreateProjectNoteSchema,
} from "../../schemas/projects.schema"; } from "../../schemas/projects.schema";
import { import {
listProjects, listProjects,
getProject, getProject,
createProject,
getNextProjectNumber,
updateProject, updateProject,
deleteProject, deleteProject,
createProjectNote, 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 } }>( fastify.get<{ Params: { id: string } }>(
"/:id", "/:id",
{ preHandler: requirePermission("projects.view") }, { preHandler: requirePermission("projects.view") },

View File

@@ -86,6 +86,10 @@ export const CreateOrderSchema = z.object({
notes: z.string().nullish(), notes: z.string().nullish(),
items: z.array(OrderItemSchema).optional(), items: z.array(OrderItemSchema).optional(),
sections: z.array(OrderSectionSchema).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({ export const UpdateOrderSchema = z.object({

View File

@@ -1,5 +1,27 @@
import { z } from "zod"; 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({ export const UpdateProjectSchema = z.object({
name: z.string().nullish(), name: z.string().nullish(),
status: z.string().optional(), status: z.string().optional(),
@@ -28,5 +50,6 @@ export const CreateProjectNoteSchema = z.object({
content: z.string().nullish(), content: z.string().nullish(),
}); });
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>; export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>; export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>;

View File

@@ -220,7 +220,9 @@ async function start() {
} }
// --- Start --- // --- Start ---
const port = config.isProduction ? config.port : 3000; // Dev listens on a fixed local port (prod uses config.port / the PORT env).
// 3050 keeps it clear of other local apps that commonly grab 3000.
const port = config.isProduction ? config.port : 3050;
try { try {
await app.listen({ port, host: config.host }); await app.listen({ port, host: config.host });
app.log.info(`Server running on http://${config.host}:${port}`); app.log.info(`Server running on http://${config.host}:${port}`);

View File

@@ -288,9 +288,14 @@ interface CreateOrderData {
notes?: string | null; notes?: string | null;
items?: OrderItemInput[]; items?: OrderItemInput[];
sections?: OrderSectionInput[]; sections?: OrderSectionInput[];
create_project?: boolean;
} }
export async function createOrder(body: CreateOrderData) { export async function createOrder(
body: CreateOrderData,
attachmentBuffer?: Buffer | null,
attachmentName?: string | null,
) {
try { try {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const orderNumber = const orderNumber =
@@ -322,6 +327,10 @@ export async function createOrder(body: CreateOrderData) {
scope_title: body.scope_title ?? null, scope_title: body.scope_title ?? null,
scope_description: body.scope_description ?? null, scope_description: body.scope_description ?? null,
notes: body.notes ?? 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) { } catch (err) {
if (err instanceof Error && "status" in err) { if (err instanceof Error && "status" in err) {

View File

@@ -1,6 +1,11 @@
import prisma from "../config/database"; import prisma from "../config/database";
import { releaseSharedNumber } from "./numbering.service"; import {
generateSharedNumber,
previewSharedNumber,
releaseSharedNumber,
} from "./numbering.service";
import { NasFileManager } from "./nas-file-manager"; import { NasFileManager } from "./nas-file-manager";
import type { CreateProjectInput } from "../schemas/projects.schema";
const nasFileManager = new NasFileManager(); const nasFileManager = new NasFileManager();
@@ -144,6 +149,60 @@ export async function updateProject(id: number, body: Record<string, any>) {
return updated; 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) { export async function deleteProject(id: number, deleteFiles: boolean = false) {
const existing = await prisma.projects.findUnique({ where: { id } }); const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const }; if (!existing) return { error: "not_found" as const };

View File

@@ -10,5 +10,11 @@ export default defineConfig({
testTimeout: 15000, testTimeout: 15000,
hookTimeout: 15000, hookTimeout: 15000,
exclude: ["dist/**", "node_modules/**", ".claude/**"], 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,
}, },
}); });