Compare commits

..

17 Commits

Author SHA1 Message Date
BOHA
dafa6d6aab chore(release): v1.9.4 — modal UX unification + project responsible-person filter
- Unify modals: ConfirmModal now locks scroll; 8 ad-hoc modals migrated to the
  shared FormModal (consistent open/close animation, blurred backdrop, ESC,
  click-outside, scroll-lock, mobile, reduced-motion). FormModal gains
  submitDisabled + subtitle props.
- Projects: the responsible-person picker (create modal + edit page) lists only
  active users with projects.view (edit page keeps the current assignee).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:54:32 +02:00
BOHA
80803cc868 refactor(modals): migrate 8 ad-hoc modals to the shared FormModal
BulkAttendanceModal, ShiftFormModal, OrderConfirmationModal, DashQuickActions
trip modal, the Attendance leave modal, and DashProfile's 3 modals (edit
profile, 2FA setup, 2FA disable) now use the shared FormModal instead of
hand-rolled overlays, inheriting the canonical open/close animation, blurred
backdrop, ESC-to-close, click-outside, scroll-lock, staggered reveal, mobile
full-screen and reduced-motion. Footers stay pinned via the real FormModal
footer + submitDisabled; multi-step modals (2FA setup, order confirmation)
drive title/footer/close-guards per step. Behaviour preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:28 +02:00
BOHA
55fa4045ad feat(modals): FormModal gains submitDisabled + subtitle props
submitDisabled greys out the primary (so callers can gate it on validity
instead of a clickable-but-inert button); subtitle renders a muted line under
the title in the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:28 +02:00
BOHA
7f156cc721 fix(modals): ConfirmModal locks body scroll + respects loading on backdrop
ConfirmModal never called useModalLock, so the page scrolled behind every
confirmation dialog (8+ usages). Add the ref-counted scroll lock and guard
backdrop-click against the loading state, matching FormModal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:34:48 +02:00
BOHA
e0d2fccf50 fix(projects): edit-page responsible picker shows only active users (keeps current assignee)
Mirror the create-modal fix on ProjectDetail: filter the 'Zodpovědná osoba'
select to active users (already scoped to projects.view), but keep the
project's currently-assigned user selectable even if inactive, labelled
'(neaktivní)', so the saved value still shows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:24:41 +02:00
BOHA
a1c251650b fix(projects): responsible-person picker shows only active users with projects.view
The create-project modal's 'Zodpovědná osoba' select listed every user
(including inactive ones). Filter the query server-side by projects.view and
hide inactive users client-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:19:15 +02:00
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
26 changed files with 2283 additions and 1644 deletions

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.9.2",
"version": "1.9.4",
"description": "",
"main": "dist/server.js",
"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",
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",

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

@@ -255,12 +255,23 @@
gap: 12px;
}
.admin-modal-heading {
min-width: 0;
}
.admin-modal-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.admin-modal-subtitle {
margin: 2px 0 0;
font-size: 12.5px;
font-weight: 500;
color: var(--text-secondary);
}
.admin-modal-close {
flex-shrink: 0;
display: inline-flex;

View File

@@ -1,6 +1,5 @@
import { motion, AnimatePresence } from "framer-motion";
import FormModal from "./FormModal";
import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
interface BulkAttendanceForm {
month: string;
@@ -39,52 +38,29 @@ export default function BulkAttendanceModal({
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(isOpen);
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={isOpen}
onClose={onClose}
title="Vyplnit docházku za měsíc"
size="lg"
loading={submitting}
onSubmit={onSubmit}
submitLabel="Vyplnit měsíc"
submitDisabled={form.user_ids.length === 0}
>
<div
className="admin-modal-backdrop"
onClick={() => !submitting && onClose()}
/>
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-attendance-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="bulk-attendance-modal-title"
className="admin-modal-title"
>
Vyplnit docházku za měsíc
</h2>
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
marginBottom: "1rem",
fontSize: "0.875rem",
}}
>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky
označí. Existující záznamy se přeskočí.
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
Existující záznamy se přeskočí.
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Měsíc</label>
@@ -152,9 +128,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) =>
setForm({ ...form, arrival_time: val })
}
onChange={(val) => setForm({ ...form, arrival_time: val })}
/>
</div>
<div className="admin-form-group">
@@ -162,9 +136,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) =>
setForm({ ...form, departure_time: val })
}
onChange={(val) => setForm({ ...form, departure_time: val })}
/>
</div>
</div>
@@ -175,9 +147,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) =>
setForm({ ...form, break_start_time: val })
}
onChange={(val) => setForm({ ...form, break_start_time: val })}
/>
</div>
<div className="admin-form-group">
@@ -185,36 +155,11 @@ export default function BulkAttendanceModal({
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) =>
setForm({ ...form, break_end_time: val })
}
onChange={(val) => setForm({ ...form, break_end_time: val })}
/>
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting || form.user_ids.length === 0}
>
{submitting ? "Vytvářím záznamy..." : "Vyplnit měsíc"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormModal>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, type ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
import useReducedMotion from "../hooks/useReducedMotion";
import useModalLock from "../hooks/useModalLock";
interface ConfirmModalProps {
isOpen: boolean;
@@ -93,6 +94,8 @@ export default function ConfirmModal({
loading,
}: ConfirmModalProps) {
const reducedMotion = useReducedMotion();
// Lock body scroll while open (ref-counted, nesting-safe) — matches FormModal.
useModalLock(isOpen);
useEffect(() => {
if (!isOpen) return;
@@ -119,7 +122,10 @@ export default function ConfirmModal({
exit={{ opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<div
className="admin-modal-backdrop"
onClick={() => !loading && onClose()}
/>
<motion.div
className="admin-modal admin-confirm-modal"
role="dialog"

View File

@@ -15,10 +15,15 @@ export interface FormModalProps {
onClose: () => void;
onSubmit?: () => void; // called when user clicks primary button (only if provided)
title: string;
/** Optional muted line shown under the title in the header. */
subtitle?: ReactNode;
children: ReactNode; // modal body (form fields)
submitLabel?: string; // primary button text
cancelLabel?: string; // cancel button text
loading?: boolean;
/** Disable the primary (submit) button — e.g. until the form is valid.
* Keeps the button visibly greyed out instead of clickable-but-inert. */
submitDisabled?: boolean;
hideFooter?: boolean; // for "view-only" modals
size?: "sm" | "md" | "lg"; // optional
closeOnBackdrop?: boolean; // default true
@@ -35,10 +40,12 @@ export default function FormModal({
onClose,
onSubmit,
title,
subtitle,
children,
submitLabel = "Uložit",
cancelLabel = "Zrušit",
loading = false,
submitDisabled = false,
hideFooter = false,
size = "md",
closeOnBackdrop = true,
@@ -107,9 +114,12 @@ export default function FormModal({
transition={{ duration, ease }}
>
<div className="admin-modal-header">
<div className="admin-modal-heading">
<h2 id={titleId} className="admin-modal-title">
{title}
</h2>
{subtitle && <p className="admin-modal-subtitle">{subtitle}</p>}
</div>
{!hideCloseButton && (
<button
type="button"
@@ -175,7 +185,7 @@ export default function FormModal({
type="button"
onClick={handlePrimaryClick}
className="admin-btn admin-btn-primary"
disabled={loading}
disabled={loading || submitDisabled}
>
{loading ? "Zpracování..." : submitLabel}
</button>

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import FormModal from "./FormModal";
import { useAlert } from "../context/AlertContext";
interface ConfirmationItem {
@@ -103,38 +103,14 @@ export default function OrderConfirmationModal({
}, [defaultVatRate]);
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={isOpen}
onClose={onClose}
title={`Potvrzení objednávky ${orderNumber}`}
size={step === "edit" ? "lg" : "md"}
loading={loading}
hideFooter
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
}
role="dialog"
aria-modal="true"
aria-labelledby="order-confirmation-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>
<div className="admin-modal-body">
{step === "choose" ? (
<div className="admin-form">
<div className="admin-form-group">
@@ -195,10 +171,7 @@ export default function OrderConfirmationModal({
<div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label>
<p
className="text-secondary"
style={{ marginBottom: "0.75rem" }}
>
<p className="text-secondary" style={{ marginBottom: "0.75rem" }}>
Jak chcete připravit potvrzení objednávky?
</p>
<button
@@ -227,6 +200,17 @@ export default function OrderConfirmationModal({
Upravit položky
</button>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
</div>
</div>
) : (
<div className="admin-form">
@@ -261,11 +245,7 @@ export default function OrderConfirmationModal({
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(
i,
"quantity",
Number(e.target.value) || 0,
)
updateItem(i, "quantity", Number(e.target.value) || 0)
}
className="admin-form-input"
style={{ width: "80px" }}
@@ -276,9 +256,7 @@ export default function OrderConfirmationModal({
<input
type="text"
value={item.unit}
onChange={(e) =>
updateItem(i, "unit", e.target.value)
}
onChange={(e) => updateItem(i, "unit", e.target.value)}
className="admin-form-input"
style={{ width: "60px" }}
/>
@@ -304,11 +282,7 @@ export default function OrderConfirmationModal({
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(
i,
"vat_rate",
Number(e.target.value) || 0,
)
updateItem(i, "vat_rate", Number(e.target.value) || 0)
}
className="admin-form-input"
style={{ width: "70px" }}
@@ -345,13 +319,8 @@ export default function OrderConfirmationModal({
>
+ Přidat položku
</button>
</div>
)}
</div>
<div className="admin-modal-footer">
{step === "edit" && (
<>
<button
type="button"
onClick={() => setStep("choose")}
@@ -375,22 +344,9 @@ export default function OrderConfirmationModal({
"Vygenerovat PDF"
)}
</button>
</>
)}
{step === "choose" && (
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
)}
</div>
</motion.div>
</motion.div>
</div>
)}
</AnimatePresence>
</FormModal>
);
}

View File

@@ -1,6 +1,5 @@
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
import FormModal from "./FormModal";
import {
calcFormWorkMinutes,
calcProjectMinutesTotal,
@@ -216,7 +215,6 @@ export default function ShiftFormModal({
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(isOpen);
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
@@ -247,51 +245,25 @@ export default function ShiftFormModal({
};
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={isOpen}
onClose={onClose}
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
subtitle={
!isCreate && editingRecord
? `${editingRecord.user_name}${formatDate(editingRecord.shift_date)}`
: undefined
}
size="lg"
onSubmit={onSubmit}
submitLabel="Uložit"
cancelLabel="Zrušit"
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div>
<div className="admin-modal-body">
<div className="admin-form">
{isCreate ? (
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label required">
Zaměstnanec
</label>
<label className="admin-form-label required">Zaměstnanec</label>
<select
value={form.user_id}
onChange={(e) => updateField("user_id", e.target.value)}
@@ -306,9 +278,7 @@ export default function ShiftFormModal({
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label required">
Datum směny
</label>
<label className="admin-form-label required">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
@@ -357,9 +327,7 @@ export default function ShiftFormModal({
className="admin-form-input"
/>
{isCreate && (
<small className="admin-form-hint">
8 hodin = celý den
</small>
<small className="admin-form-hint">8 hodin = celý den</small>
)}
</div>
)}
@@ -368,9 +336,7 @@ export default function ShiftFormModal({
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - datum
</label>
<label className="admin-form-label">Příchod - datum</label>
<AdminDatePicker
mode="date"
value={form.arrival_date}
@@ -378,9 +344,7 @@ export default function ShiftFormModal({
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - čas
</label>
<label className="admin-form-label">Příchod - čas</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
@@ -397,30 +361,22 @@ export default function ShiftFormModal({
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val) =>
updateField("break_start_date", val)
}
onChange={(val) => updateField("break_start_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - čas
</label>
<label className="admin-form-label">Začátek pauzy - čas</label>
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) =>
updateField("break_start_time", val)
}
onChange={(val) => updateField("break_start_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - datum
</label>
<label className="admin-form-label">Konec pauzy - datum</label>
<AdminDatePicker
mode="date"
value={form.break_end_date}
@@ -428,9 +384,7 @@ export default function ShiftFormModal({
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - čas
</label>
<label className="admin-form-label">Konec pauzy - čas</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
@@ -441,9 +395,7 @@ export default function ShiftFormModal({
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - datum
</label>
<label className="admin-form-label">Odchod - datum</label>
<AdminDatePicker
mode="date"
value={form.departure_date}
@@ -496,27 +448,6 @@ export default function ShiftFormModal({
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormModal>
);
}

View File

@@ -1,9 +1,9 @@
import { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api";
import FormModal from "../FormModal";
const API_BASE = "/api/admin";
@@ -71,8 +71,6 @@ export default function DashProfile({
last_name: "",
});
useModalLock(showModal);
const openEditModal = () => {
const nameParts = (user?.fullName || "").split(" ");
setFormData({
@@ -261,30 +259,14 @@ export default function DashProfile({
</motion.div>
{/* Edit Profile Modal */}
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Upravit profil"
size="md"
onSubmit={handleSubmit}
submitLabel="Uložit změny"
>
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit profil</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
@@ -319,9 +301,7 @@ export default function DashProfile({
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Uživatelské jméno
</label>
<label className="admin-form-label">Uživatelské jméno</label>
<input
type="text"
value={formData.username}
@@ -360,9 +340,7 @@ export default function DashProfile({
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Nové heslo
</label>
<label className="admin-form-label">Nové heslo</label>
<input
type="password"
value={formData.new_password}
@@ -377,59 +355,41 @@ export default function DashProfile({
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit změny
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormModal>
{/* 2FA Setup Modal */}
<AnimatePresence>
{show2FASetup && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => {
if (!backupCodes) {
{/* 2FA Setup Modal — multi-step: setup → backup codes */}
<FormModal
isOpen={show2FASetup}
onClose={() => {
// Only reachable on the setup step (backdrop/ESC/× are locked while
// backupCodes is set — that branch is unreachable so removed).
setShow2FASetup(false);
}
setTotpCode("");
}}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
title={backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
size="md"
loading={backupCodes ? false : totpSubmitting}
onSubmit={
backupCodes
? () => {
setShow2FASetup(false);
setBackupCodes(null);
}
: onConfirm2FA
}
submitLabel={
backupCodes ? "Rozumím, uložil jsem si kódy" : "Aktivovat 2FA"
}
submitDisabled={backupCodes ? false : totpCode.length !== 6}
cancelLabel="Zrušit"
// On the backup-codes step the cancel/× would silently dismiss unsaved
// codes, so hide the footer + × and lock backdrop/ESC; the explicit
// in-body primary is the single deliberate exit.
hideFooter={!!backupCodes}
hideCloseButton={!!backupCodes}
closeOnBackdrop={!backupCodes}
closeOnEsc={!backupCodes}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
</h2>
</div>
<div className="admin-modal-body">
{backupCodes ? (
<div>
<div className="admin-role-locked-notice mb-4">
@@ -445,9 +405,8 @@ export default function DashProfile({
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Uložte si tyto kódy na bezpečné místo. Každý kód lze
použít pouze jednou. Po zavření tohoto okna je již
neuvidíte.
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze
jednou. Po zavření tohoto okna je již neuvidíte.
</div>
<div
style={{
@@ -477,9 +436,7 @@ export default function DashProfile({
<div style={{ marginTop: "0.75rem" }}>
<button
onClick={() => {
navigator.clipboard?.writeText(
backupCodes.join("\n"),
);
navigator.clipboard?.writeText(backupCodes.join("\n"));
alert.success("Kódy zkopírovány");
}}
className="admin-btn admin-btn-secondary admin-btn-sm"
@@ -492,19 +449,25 @@ export default function DashProfile({
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Kopírovat kódy
</button>
</div>
{/* Footer is hidden on this step; the single deliberate exit lives
in the body and also clears the codes. */}
<div style={{ marginTop: "1.25rem", textAlign: "right" }}>
<button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
className="admin-btn admin-btn-primary"
>
Rozumím, uložil jsem si kódy
</button>
</div>
</div>
) : (
<div>
@@ -512,13 +475,11 @@ export default function DashProfile({
className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
>
Naskenujte QR kód v autentizační aplikaci (Google
Authenticator, Authy, Microsoft Authenticator apod.)
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<div
style={{ textAlign: "center", marginBottom: "1rem" }}
>
<div style={{ textAlign: "center", marginBottom: "1rem" }}>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
alt="TOTP QR Code"
@@ -573,14 +534,7 @@ export default function DashProfile({
stroke="currentColor"
strokeWidth="2"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
/>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
@@ -598,9 +552,7 @@ export default function DashProfile({
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value.replace(/\D/g, ""))
}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
@@ -618,67 +570,20 @@ export default function DashProfile({
</div>
</div>
)}
</div>
<div className="admin-modal-footer">
{backupCodes ? (
<button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
className="admin-btn admin-btn-primary"
>
Rozumím, uložil jsem si kódy
</button>
) : (
<>
<button
onClick={() => setShow2FASetup(false)}
className="admin-btn admin-btn-secondary"
disabled={totpSubmitting}
>
Zrušit
</button>
<button
onClick={onConfirm2FA}
className="admin-btn admin-btn-primary"
disabled={totpSubmitting || totpCode.length !== 6}
>
{totpSubmitting ? "Ověřuji..." : "Aktivovat 2FA"}
</button>
</>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormModal>
{/* 2FA Disable Modal */}
<AnimatePresence>
{show2FADisable && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={show2FADisable}
onClose={() => setShow2FADisable(false)}
title="Deaktivovat 2FA"
size="md"
loading={totpSubmitting}
onSubmit={onDisable2FA}
submitDisabled={disableCode.length !== 6}
submitLabel="Deaktivovat 2FA"
cancelLabel="Zrušit"
>
<div
className="admin-modal-backdrop"
onClick={() => setShow2FADisable(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Deaktivovat 2FA</h2>
</div>
<div className="admin-modal-body">
<p
style={{
color: "var(--text-secondary)",
@@ -697,9 +602,7 @@ export default function DashProfile({
pattern="[0-9]*"
maxLength={6}
value={disableCode}
onChange={(e) =>
setDisableCode(e.target.value.replace(/\D/g, ""))
}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
@@ -716,27 +619,7 @@ export default function DashProfile({
autoFocus
/>
</div>
</div>
<div className="admin-modal-footer">
<button
onClick={() => setShow2FADisable(false)}
className="admin-btn admin-btn-secondary"
disabled={totpSubmitting}
>
Zrušit
</button>
<button
onClick={onDisable2FA}
className="admin-btn admin-btn-primary"
disabled={totpSubmitting || disableCode.length !== 6}
>
{totpSubmitting ? "Deaktivuji..." : "Deaktivovat 2FA"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormModal>
</>
);
}

View File

@@ -1,12 +1,12 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
import { formatKm, todayLocalStr } from "../../utils/formatters";
import AdminDatePicker from "../AdminDatePicker";
import apiFetch from "../../utils/api";
import useModalLock from "../../hooks/useModalLock";
import FormModal from "../FormModal";
const API_BASE = "/api/admin";
@@ -69,8 +69,6 @@ export default function DashQuickActions({
});
const [tripErrors, setTripErrors] = useState<TripErrors>({});
useModalLock(showTripModal);
const openTripModal = async () => {
setTripForm({
vehicle_id: "",
@@ -316,38 +314,21 @@ export default function DashQuickActions({
)}
</motion.div>
<AnimatePresence>
{showTripModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showTripModal}
onClose={() => setShowTripModal(false)}
title="Přidat jízdu"
size="lg"
onSubmit={handleTripSubmit}
submitLabel="Uložit"
loading={tripSubmitting}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowTripModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Přidat jízdu</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Vozidlo
</label>
<label className="admin-form-label required">Vozidlo</label>
<select
value={tripForm.vehicle_id}
onChange={(e) => {
@@ -375,9 +356,7 @@ export default function DashQuickActions({
<div
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Datum jízdy
</label>
<label className="admin-form-label required">Datum jízdy</label>
<AdminDatePicker
mode="date"
value={tripForm.trip_date}
@@ -390,9 +369,7 @@ export default function DashQuickActions({
}}
/>
{tripErrors.trip_date && (
<span className="admin-form-error">
{tripErrors.trip_date}
</span>
<span className="admin-form-error">{tripErrors.trip_date}</span>
)}
</div>
</div>
@@ -422,9 +399,7 @@ export default function DashQuickActions({
min="0"
/>
{tripErrors.start_km && (
<span className="admin-form-error">
{tripErrors.start_km}
</span>
<span className="admin-form-error">{tripErrors.start_km}</span>
)}
</div>
<div
@@ -451,9 +426,7 @@ export default function DashQuickActions({
min="0"
/>
{tripErrors.end_km && (
<span className="admin-form-error">
{tripErrors.end_km}
</span>
<span className="admin-form-error">{tripErrors.end_km}</span>
)}
</div>
<div className="admin-form-group">
@@ -472,9 +445,7 @@ export default function DashQuickActions({
<div
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Místo odjezdu
</label>
<label className="admin-form-label required">Místo odjezdu</label>
<input
type="text"
value={tripForm.route_from}
@@ -520,9 +491,7 @@ export default function DashQuickActions({
placeholder="Např. Brno"
/>
{tripErrors.route_to && (
<span className="admin-form-error">
{tripErrors.route_to}
</span>
<span className="admin-form-error">{tripErrors.route_to}</span>
)}
</div>
</div>
@@ -560,29 +529,7 @@ export default function DashQuickActions({
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowTripModal(false)}
className="admin-btn admin-btn-secondary"
disabled={tripSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleTripSubmit}
className="admin-btn admin-btn-primary"
disabled={tripSubmitting}
>
{tripSubmitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormModal>
</>
);
}

View File

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

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

@@ -3,10 +3,10 @@ import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import {
formatTime,
calculateWorkMinutes,
@@ -172,8 +172,6 @@ export default function Attendance() {
}
}, [statusQuery.data]);
useModalLock(isLeaveModalOpen);
if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => {
@@ -931,31 +929,24 @@ export default function Attendance() {
</div>
{/* Leave Modal */}
<AnimatePresence>
{isLeaveModalOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={isLeaveModalOpen}
onClose={() => setIsLeaveModalOpen(false)}
title="Žádost o nepřítomnost"
onSubmit={() => {
// Preserve original guard: do not submit when there are 0 business days.
if (
calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
)
return;
handleRequestSubmit();
}}
submitLabel="Odeslat žádost"
submitDisabled={
calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
}
loading={requestSubmitting}
>
<div
className="admin-modal-backdrop"
onClick={() => setIsLeaveModalOpen(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Typ nepřítomnosti">
<select
@@ -1058,36 +1049,7 @@ export default function Attendance() {
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setIsLeaveModalOpen(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleRequestSubmit}
disabled={
requestSubmitting ||
calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
) === 0
}
className="admin-btn admin-btn-primary"
>
{requestSubmitting ? "Odesílám..." : "Odeslat žádost"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</FormModal>
<ConfirmModal
isOpen={gpsConfirm.isOpen}

View File

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

View File

@@ -81,9 +81,17 @@ export default function ProjectDetail() {
const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view"));
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
// Only active users with projects.view — but keep the project's CURRENT
// assignee in the list even if they're now inactive, so the select still
// reflects the saved value (marked "(neaktivní)" so it's clear).
const assignedUserId = project?.responsible_user_id || "";
const users: User[] = (usersData ?? [])
.filter((u: ApiUser) => u.is_active || String(u.id) === assignedUserId)
.map((u: ApiUser) => ({
id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
name:
(`${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username) +
(u.is_active ? "" : " (neaktivní)"),
}));
// Reset form sync when navigating to a different project

View File

@@ -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,74 @@ 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,
});
// Responsible-person picker: only users whose role can view projects
// (projects.view, filtered server-side) and who are active.
const usersQuery = useQuery({
...userListOptions("projects.view"),
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 +202,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 +269,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 +331,7 @@ export default function Projects() {
order={order}
/>
</th>
<th>Typ</th>
<th>Objednávka</th>
<th>Akce</th>
</tr>
@@ -257,6 +356,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 +463,116 @@ 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 ?? [])
.filter((u) => u.is_active)
.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>
);
}

View File

@@ -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,13 +118,13 @@ export default async function ordersRoutes(
}
}
// 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,
@@ -134,7 +133,6 @@ export default async function ordersRoutes(
});
if ("error" in result)
return error(reply, result.error!, result.status!);
await logAudit({
request,
authData: request.authData,
@@ -155,6 +153,52 @@ export default async function ordersRoutes(
);
}
// 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.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 },
201,
"Objednávka byla vytvořena",
);
}
// === JSON body — either from-quotation (no attachment) or manual order ===
const rawBody = request.body as Record<string, unknown>;
@@ -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 },

View File

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

View File

@@ -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({

View File

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

View File

@@ -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) {

View File

@@ -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 };

View File

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