Compare commits

..

19 Commits

Author SHA1 Message Date
BOHA
996835dae5 chore(release): v1.9.5 — mobile responsiveness (Phase 1+2)
Global small-phone rules (form-row stacking, KPI collapse, table-action touch
targets, datepicker/filemanager mobile) + area fixes (Quill picker dropdowns,
invoice month-nav, attendance map/clock-card). Mobile-only; desktop unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:45:47 +02:00
BOHA
c73b980ce7 feat(mobile): responsive Phase 1+2 — global small-phone rules + area fixes
All changes are mobile-only (max-width media queries); desktop unchanged.

Global (systemic):
- forms.css: all .admin-form-row* grids stack to 1 column at <=480px (fixes
  detail-page info grids, settings forms, modal forms staying 2-up on phones).
- components.css: .admin-kpi-4/-3 stat grids collapse to 1 column at <=480px.
- tables.css: row-action icons get 44px touch targets at <=768px.
- datepicker.css: constrain the date-picker popper to the viewport at <=480px.
- filemanager.css: toolbar wraps, new-folder input full-width, breadcrumb scrolls.

Area specifics:
- offers.css: Quill picker dropdowns kept on-screen (max-width) at <=480px.
- invoices.css: month-nav wraps + 44px touch targets at <=640px.
- attendance.css: Leaflet map height scales with viewport; clock-card padding
  tightened at <=480px.

Audit confirmed all data tables (warehouse incl.) are already wrapped in the
scroll container, so no table overflow/markup changes were needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:44:53 +02:00
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
33 changed files with 2385 additions and 1645 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.9.2", "version": "1.9.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "1.9.2", "version": "1.9.5",
"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.2", "version": "1.9.5",
"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

@@ -434,3 +434,22 @@
color: var(--text-muted); color: var(--text-muted);
font-family: monospace; font-family: monospace;
} }
/* ============================================================================
Mobile (responsive)
============================================================================ */
/* The Leaflet map at a fixed 400px dominates a phone viewport — let it scale
with the viewport height (with sensible min/max) on small screens. */
@media (max-width: 640px) {
.attendance-location-map {
height: clamp(220px, 45vh, 400px);
}
}
/* The clock card's 2rem padding crushes content at ~360px — tighten it. */
@media (max-width: 480px) {
.attendance-clock-card {
padding: 1rem;
}
}

View File

@@ -255,12 +255,23 @@
gap: 12px; gap: 12px;
} }
.admin-modal-heading {
min-width: 0;
}
.admin-modal-title { .admin-modal-title {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); 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 { .admin-modal-close {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
@@ -834,7 +845,9 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.admin-kpi-grid { .admin-kpi-grid,
.admin-kpi-4,
.admin-kpi-3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }

View File

@@ -1,6 +1,5 @@
import { motion, AnimatePresence } from "framer-motion"; import FormModal from "./FormModal";
import AdminDatePicker from "./AdminDatePicker"; import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
interface BulkAttendanceForm { interface BulkAttendanceForm {
month: string; month: string;
@@ -39,182 +38,128 @@ export default function BulkAttendanceModal({
toggleUser, toggleUser,
toggleAllUsers, toggleAllUsers,
}: BulkAttendanceModalProps) { }: BulkAttendanceModalProps) {
useModalLock(isOpen);
return ( return (
<AnimatePresence> <FormModal
{isOpen && ( isOpen={isOpen}
<motion.div onClose={onClose}
className="admin-modal-overlay" title="Vyplnit docházku za měsíc"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }} loading={submitting}
exit={{ opacity: 0 }} onSubmit={onSubmit}
transition={{ duration: 0.2 }} submitLabel="Vyplnit měsíc"
> submitDisabled={form.user_ids.length === 0}
<div >
className="admin-modal-backdrop" <p
onClick={() => !submitting && onClose()} 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čí.
</p>
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Měsíc</label>
<AdminDatePicker
mode="month"
value={form.month}
onChange={(val) => setForm({ ...form, month: val })}
/> />
<motion.div </div>
className="admin-modal admin-modal-lg"
role="dialog" <div className="admin-form-group">
aria-modal="true" <label className="admin-form-label">
aria-labelledby="bulk-attendance-modal-title" Zaměstnanci
initial={{ opacity: 0, scale: 0.95, y: 20 }} <button
animate={{ opacity: 1, scale: 1, y: 0 }} type="button"
exit={{ opacity: 0, scale: 0.95, y: 20 }} onClick={toggleAllUsers}
transition={{ duration: 0.2 }} style={{
marginLeft: "0.75rem",
background: "none",
border: "none",
color: "var(--accent-color)",
cursor: "pointer",
fontSize: "0.8125rem",
fontWeight: 500,
padding: 0,
}}
>
{form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</button>
</label>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.375rem",
maxHeight: "200px",
overflowY: "auto",
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: "var(--border-radius-sm)",
border: "1px solid var(--border-color)",
}}
> >
<div className="admin-modal-header"> {users.map((user) => (
<h2 <label key={user.id} className="admin-form-checkbox">
id="bulk-attendance-modal-title" <input
className="admin-modal-title" type="checkbox"
> checked={form.user_ids.includes(String(user.id))}
Vyplnit docházku za měsíc onChange={() => toggleUser(user.id)}
</h2> />
<p <span>{user.name}</span>
style={{ </label>
color: "var(--text-secondary)", ))}
marginTop: "0.25rem", </div>
fontSize: "0.875rem", <small className="admin-form-hint">
}} Vybráno: {form.user_ids.length} z {users.length}
> </small>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky </div>
označí. Existující záznamy se přeskočí.
</p>
</div>
<div className="admin-modal-body"> <div className="admin-form-row">
<div className="admin-form"> <div className="admin-form-group">
<div className="admin-form-group"> <label className="admin-form-label">Příchod</label>
<label className="admin-form-label">Měsíc</label> <AdminDatePicker
<AdminDatePicker mode="time"
mode="month" value={form.arrival_time}
value={form.month} onChange={(val) => setForm({ ...form, arrival_time: val })}
onChange={(val) => setForm({ ...form, month: val })} />
/> </div>
</div> <div className="admin-form-group">
<label className="admin-form-label">Odchod</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) => setForm({ ...form, departure_time: val })}
/>
</div>
</div>
<div className="admin-form-group"> <div className="admin-form-row">
<label className="admin-form-label"> <div className="admin-form-group">
Zaměstnanci <label className="admin-form-label">Začátek pauzy</label>
<button <AdminDatePicker
type="button" mode="time"
onClick={toggleAllUsers} value={form.break_start_time}
style={{ onChange={(val) => setForm({ ...form, break_start_time: val })}
marginLeft: "0.75rem", />
background: "none", </div>
border: "none", <div className="admin-form-group">
color: "var(--accent-color)", <label className="admin-form-label">Konec pauzy</label>
cursor: "pointer", <AdminDatePicker
fontSize: "0.8125rem", mode="time"
fontWeight: 500, value={form.break_end_time}
padding: 0, onChange={(val) => setForm({ ...form, break_end_time: val })}
}} />
> </div>
{form.user_ids.length === users.length </div>
? "Odznačit vše" </div>
: "Vybrat vše"} </FormModal>
</button>
</label>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.375rem",
maxHeight: "200px",
overflowY: "auto",
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: "var(--border-radius-sm)",
border: "1px solid var(--border-color)",
}}
>
{users.map((user) => (
<label key={user.id} className="admin-form-checkbox">
<input
type="checkbox"
checked={form.user_ids.includes(String(user.id))}
onChange={() => toggleUser(user.id)}
/>
<span>{user.name}</span>
</label>
))}
</div>
<small className="admin-form-hint">
Vybráno: {form.user_ids.length} z {users.length}
</small>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Příchod</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) =>
setForm({ ...form, arrival_time: val })
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) =>
setForm({ ...form, departure_time: val })
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Začátek pauzy</label>
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) =>
setForm({ ...form, break_start_time: val })
}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
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>
); );
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion"; import FormModal from "./FormModal";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
interface ConfirmationItem { interface ConfirmationItem {
@@ -103,294 +103,250 @@ export default function OrderConfirmationModal({
}, [defaultVatRate]); }, [defaultVatRate]);
return ( return (
<AnimatePresence> <FormModal
{isOpen && ( isOpen={isOpen}
<motion.div onClose={onClose}
className="admin-modal-overlay" title={`Potvrzení objednávky ${orderNumber}`}
initial={{ opacity: 0 }} size={step === "edit" ? "lg" : "md"}
animate={{ opacity: 1 }} loading={loading}
exit={{ opacity: 0 }} hideFooter
transition={{ duration: 0.2 }} >
> {step === "choose" ? (
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-form">
<motion.div <div className="admin-form-group">
className={ <label className="admin-form-label">Jazyk dokumentu</label>
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal" <div className="flex-row gap-2">
} <button
role="dialog" type="button"
aria-modal="true" onClick={() => setLang("cs")}
aria-labelledby="order-confirmation-modal-title" className={
initial={{ opacity: 0, scale: 0.95, y: 20 }} lang === "cs"
animate={{ opacity: 1, scale: 1, y: 0 }} ? "admin-btn admin-btn-primary admin-btn-sm"
exit={{ opacity: 0, scale: 0.95, y: 20 }} : "admin-btn admin-btn-secondary admin-btn-sm"
transition={{ duration: 0.2 }} }
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
> >
Potvrzení objednávky {orderNumber} Čeština
</h2> </button>
<button
type="button"
onClick={() => setLang("en")}
className={
lang === "en"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
English
</button>
</div> </div>
</div>
<div className="admin-modal-body"> <div className="admin-form-group">
{step === "choose" ? ( <label className="admin-form-label">DPH</label>
<div className="admin-form"> <div className="flex-row gap-2">
<div className="admin-form-group"> <button
<label className="admin-form-label">Jazyk dokumentu</label> type="button"
<div className="flex-row gap-2"> onClick={() => setApplyVatState(true)}
<button className={
type="button" applyVatState
onClick={() => setLang("cs")} ? "admin-btn admin-btn-primary admin-btn-sm"
className={ : "admin-btn admin-btn-secondary admin-btn-sm"
lang === "cs" }
? "admin-btn admin-btn-primary admin-btn-sm" >
: "admin-btn admin-btn-secondary admin-btn-sm" S DPH
} </button>
> <button
Čeština type="button"
</button> onClick={() => setApplyVatState(false)}
<button className={
type="button" !applyVatState
onClick={() => setLang("en")} ? "admin-btn admin-btn-primary admin-btn-sm"
className={ : "admin-btn admin-btn-secondary admin-btn-sm"
lang === "en" }
? "admin-btn admin-btn-primary admin-btn-sm" >
: "admin-btn admin-btn-secondary admin-btn-sm" Bez DPH
} </button>
>
English
</button>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">DPH</label>
<div className="flex-row gap-2">
<button
type="button"
onClick={() => setApplyVatState(true)}
className={
applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
S DPH
</button>
<button
type="button"
onClick={() => setApplyVatState(false)}
className={
!applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Bez DPH
</button>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label>
<p
className="text-secondary"
style={{ marginBottom: "0.75rem" }}
>
Jak chcete připravit potvrzení objednávky?
</p>
<button
onClick={handleUseExisting}
disabled={loading}
className="admin-btn admin-btn-primary w-full"
style={{ marginBottom: "0.5rem" }}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Použít položky z objednávky"
)}
</button>
<button
onClick={() => {
setItems(initialItems.length > 0 ? initialItems : []);
setStep("edit");
}}
disabled={loading}
className="admin-btn admin-btn-secondary w-full"
>
Upravit položky
</button>
</div>
</div>
) : (
<div className="admin-form">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Popis</th>
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th>%DPH</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i}>
<td>
<input
type="text"
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
className="admin-form-input"
style={{ minWidth: "200px" }}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(
i,
"quantity",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "80px" }}
step="0.001"
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) =>
updateItem(i, "unit", e.target.value)
}
className="admin-form-input"
style={{ width: "60px" }}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) =>
updateItem(
i,
"unit_price",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "100px" }}
step="0.01"
/>
</td>
<td>
<input
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(
i,
"vat_rate",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "70px" }}
step="1"
/>
</td>
<td>
<button
onClick={() => removeItem(i)}
className="admin-btn-icon danger"
title="Odstranit"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat položku
</button>
</div>
)}
</div> </div>
</div>
<div className="admin-modal-footer"> <div className="admin-form-group">
{step === "edit" && ( <label className="admin-form-label">Obsah potvrzení</label>
<p className="text-secondary" style={{ marginBottom: "0.75rem" }}>
Jak chcete připravit potvrzení objednávky?
</p>
<button
onClick={handleUseExisting}
disabled={loading}
className="admin-btn admin-btn-primary w-full"
style={{ marginBottom: "0.5rem" }}
>
{loading ? (
<> <>
<button <div className="admin-spinner admin-spinner-sm" />
type="button" Generuji...
onClick={() => setStep("choose")}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zpět
</button>
<button
type="button"
onClick={handleEditGenerate}
className="admin-btn admin-btn-primary"
disabled={loading || items.length === 0}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Vygenerovat PDF"
)}
</button>
</> </>
) : (
"Použít položky z objednávky"
)} )}
{step === "choose" && ( </button>
<button <button
type="button" onClick={() => {
onClick={onClose} setItems(initialItems.length > 0 ? initialItems : []);
className="admin-btn admin-btn-secondary" setStep("edit");
disabled={loading} }}
> disabled={loading}
Zrušit className="admin-btn admin-btn-secondary w-full"
</button> >
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">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Popis</th>
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th>%DPH</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i}>
<td>
<input
type="text"
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
className="admin-form-input"
style={{ minWidth: "200px" }}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(i, "quantity", Number(e.target.value) || 0)
}
className="admin-form-input"
style={{ width: "80px" }}
step="0.001"
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) => updateItem(i, "unit", e.target.value)}
className="admin-form-input"
style={{ width: "60px" }}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) =>
updateItem(
i,
"unit_price",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "100px" }}
step="0.01"
/>
</td>
<td>
<input
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(i, "vat_rate", Number(e.target.value) || 0)
}
className="admin-form-input"
style={{ width: "70px" }}
step="1"
/>
</td>
<td>
<button
onClick={() => removeItem(i)}
className="admin-btn-icon danger"
title="Odstranit"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat položku
</button>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setStep("choose")}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zpět
</button>
<button
type="button"
onClick={handleEditGenerate}
className="admin-btn admin-btn-primary"
disabled={loading || items.length === 0}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Vygenerovat PDF"
)} )}
</div> </button>
</motion.div> </div>
</motion.div> </div>
)} )}
</AnimatePresence> </FormModal>
); );
} }

View File

@@ -1,6 +1,5 @@
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker"; import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock"; import FormModal from "./FormModal";
import { import {
calcFormWorkMinutes, calcFormWorkMinutes,
calcProjectMinutesTotal, calcProjectMinutesTotal,
@@ -216,7 +215,6 @@ export default function ShiftFormModal({
onShiftDateChange, onShiftDateChange,
editingRecord, editingRecord,
}: ShiftFormModalProps) { }: ShiftFormModalProps) {
useModalLock(isOpen);
const isCreate = mode === "create"; const isCreate = mode === "create";
const isWorkType = form.leave_type === "work"; const isWorkType = form.leave_type === "work";
@@ -247,276 +245,209 @@ export default function ShiftFormModal({
}; };
return ( return (
<AnimatePresence> <FormModal
{isOpen && ( isOpen={isOpen}
<motion.div onClose={onClose}
className="admin-modal-overlay" title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
initial={{ opacity: 0 }} subtitle={
animate={{ opacity: 1 }} !isCreate && editingRecord
exit={{ opacity: 0 }} ? `${editingRecord.user_name}${formatDate(editingRecord.shift_date)}`
transition={{ duration: 0.2 }} : undefined
> }
<div className="admin-modal-backdrop" onClick={onClose} /> size="lg"
<motion.div onSubmit={onSubmit}
className="admin-modal admin-modal-lg" submitLabel="Uložit"
role="dialog" cancelLabel="Zrušit"
aria-modal="true" >
aria-labelledby="shift-form-modal-title" <div className="admin-form">
initial={{ opacity: 0, scale: 0.95, y: 20 }} {isCreate ? (
animate={{ opacity: 1, scale: 1, y: 0 }} <div className="admin-form-row">
exit={{ opacity: 0, scale: 0.95, y: 20 }} <div className="admin-form-group">
transition={{ duration: 0.2 }} <label className="admin-form-label required">Zaměstnanec</label>
> <select
<div className="admin-modal-header"> value={form.user_id}
<h2 id="shift-form-modal-title" className="admin-modal-title"> onChange={(e) => updateField("user_id", e.target.value)}
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"} className="admin-form-select"
</h2> >
{!isCreate && editingRecord && ( <option value="">Vyberte zaměstnance</option>
<p {users.map((user) => (
style={{ <option key={user.id} value={user.id}>
color: "var(--text-secondary)", {user.name}
marginTop: "0.25rem", </option>
}} ))}
> </select>
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div> </div>
<div className="admin-form-group">
<label className="admin-form-label required">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => onShiftDateChange(val)}
/>
</div>
</div>
) : (
<div className="admin-form-group">
<label className="admin-form-label">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => updateField("shift_date", val)}
/>
</div>
)}
<div className="admin-modal-body"> <div className="admin-form-group">
<div className="admin-form"> <label className="admin-form-label">Typ záznamu</label>
{isCreate ? ( <select
<div className="admin-form-row"> value={form.leave_type}
<div className="admin-form-group"> onChange={(e) => updateField("leave_type", e.target.value)}
<label className="admin-form-label required"> className="admin-form-select"
Zaměstnanec >
</label> <option value="work">Práce</option>
<select <option value="vacation">Dovolená</option>
value={form.user_id} <option value="sick">Nemoc</option>
onChange={(e) => updateField("user_id", e.target.value)} <option value="unpaid">Neplacené volno</option>
className="admin-form-select" </select>
> </div>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label required">
Datum směny
</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => onShiftDateChange(val)}
/>
</div>
</div>
) : (
<div className="admin-form-group">
<label className="admin-form-label">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val) => updateField("shift_date", val)}
/>
</div>
)}
<div className="admin-form-group"> {!isWorkType && (
<label className="admin-form-label">Typ záznamu</label> <div className="admin-form-group">
<select <label className="admin-form-label">Počet hodin</label>
value={form.leave_type} <input
onChange={(e) => updateField("leave_type", e.target.value)} type="number"
className="admin-form-select" inputMode="decimal"
> value={form.leave_hours}
<option value="work">Práce</option> onChange={(e) =>
<option value="vacation">Dovolená</option> updateField("leave_hours", parseFloat(e.target.value))
<option value="sick">Nemoc</option> }
<option value="unpaid">Neplacené volno</option> min="0.5"
</select> max="24"
</div> step="0.5"
className="admin-form-input"
/>
{isCreate && (
<small className="admin-form-hint">8 hodin = celý den</small>
)}
</div>
)}
{!isWorkType && ( {isWorkType && (
<div className="admin-form-group"> <>
<label className="admin-form-label">Počet hodin</label> <div className="admin-form-row">
<input <div className="admin-form-group">
type="number" <label className="admin-form-label">Příchod - datum</label>
inputMode="decimal" <AdminDatePicker
value={form.leave_hours} mode="date"
onChange={(e) => value={form.arrival_date}
updateField("leave_hours", parseFloat(e.target.value)) onChange={(val) => updateField("arrival_date", val)}
} />
min="0.5" </div>
max="24" <div className="admin-form-group">
step="0.5" <label className="admin-form-label">Příchod - čas</label>
className="admin-form-input" <AdminDatePicker
/> mode="time"
{isCreate && ( value={form.arrival_time}
<small className="admin-form-hint"> onChange={(val) => updateField("arrival_time", val)}
8 hodin = celý den />
</small>
)}
</div>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - datum
</label>
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val) => updateField("arrival_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Příchod - čas
</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) => updateField("arrival_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val) =>
updateField("break_start_date", val)
}
/>
</div>
<div className="admin-form-group">
<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)
}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val) => updateField("break_end_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Konec pauzy - čas
</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) => updateField("break_end_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Odchod - datum
</label>
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val) => updateField("departure_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod - čas</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) => updateField("departure_time", val)}
/>
</div>
</div>
</>
)}
{isWorkType && projectList.length > 0 && (
<div className="admin-form-group">
<label className="admin-form-label">Projekty</label>
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
key={log._key || i}
log={log}
index={i}
projectList={projectList}
onUpdate={updateProjectLog}
onRemove={removeProjectLog}
/>
))}
<button
type="button"
onClick={addProjectLog}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat projekt
</button>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
value={form.notes}
onChange={(e) => updateField("notes", e.target.value)}
className="admin-form-textarea"
rows={3}
/>
</div>
</div> </div>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-form-row">
<button <div className="admin-form-group">
type="button" <label className="admin-form-label">
onClick={onClose} Začátek pauzy - datum
className="admin-btn admin-btn-secondary" </label>
> <AdminDatePicker
Zrušit mode="date"
</button> value={form.break_start_date}
<button onChange={(val) => updateField("break_start_date", val)}
type="button" />
onClick={onSubmit} </div>
className="admin-btn admin-btn-primary" <div className="admin-form-group">
> <label className="admin-form-label">Začátek pauzy - čas</label>
Uložit <AdminDatePicker
</button> mode="time"
value={form.break_start_time}
onChange={(val) => updateField("break_start_time", val)}
/>
</div>
</div> </div>
</motion.div>
</motion.div> <div className="admin-form-row">
)} <div className="admin-form-group">
</AnimatePresence> <label className="admin-form-label">Konec pauzy - datum</label>
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val) => updateField("break_end_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy - čas</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) => updateField("break_end_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Odchod - datum</label>
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val) => updateField("departure_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod - čas</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) => updateField("departure_time", val)}
/>
</div>
</div>
</>
)}
{isWorkType && projectList.length > 0 && (
<div className="admin-form-group">
<label className="admin-form-label">Projekty</label>
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
key={log._key || i}
log={log}
index={i}
projectList={projectList}
onUpdate={updateProjectLog}
onRemove={removeProjectLog}
/>
))}
<button
type="button"
onClick={addProjectLog}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat projekt
</button>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
value={form.notes}
onChange={(e) => updateField("notes", e.target.value)}
className="admin-form-textarea"
rows={3}
/>
</div>
</div>
</FormModal>
); );
} }

View File

@@ -1,9 +1,9 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext"; import { useAlert } from "../../context/AlertContext";
import useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import FormModal from "../FormModal";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -71,8 +71,6 @@ export default function DashProfile({
last_name: "", last_name: "",
}); });
useModalLock(showModal);
const openEditModal = () => { const openEditModal = () => {
const nameParts = (user?.fullName || "").split(" "); const nameParts = (user?.fullName || "").split(" ");
setFormData({ setFormData({
@@ -261,482 +259,367 @@ export default function DashProfile({
</motion.div> </motion.div>
{/* Edit Profile Modal */} {/* Edit Profile Modal */}
<AnimatePresence> <FormModal
{showModal && ( isOpen={showModal}
<motion.div onClose={() => setShowModal(false)}
className="admin-modal-overlay" title="Upravit profil"
initial={{ opacity: 0 }} size="md"
animate={{ opacity: 1 }} onSubmit={handleSubmit}
exit={{ opacity: 0 }} submitLabel="Uložit změny"
transition={{ duration: 0.2 }} >
> <div className="admin-form">
<div <div className="admin-form-row">
className="admin-modal-backdrop" <div className="admin-form-group">
onClick={() => setShowModal(false)} <label className="admin-form-label">Jméno</label>
/> <input
<motion.div type="text"
className="admin-modal" value={formData.first_name}
initial={{ opacity: 0, scale: 0.95, y: 20 }} onChange={(e) =>
animate={{ opacity: 1, scale: 1, y: 0 }} setFormData({
exit={{ opacity: 0, scale: 0.95, y: 20 }} ...formData,
transition={{ duration: 0.2 }} first_name: e.target.value,
> })
<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">
<label className="admin-form-label">Jméno</label>
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Uživatelské jméno
</label>
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">E-mail</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Aktuální heslo</label>
<input
type="password"
value={formData.current_password}
onChange={(e) =>
setFormData({
...formData,
current_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte aktuální heslo"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Nové heslo
</label>
<input
type="password"
value={formData.new_password}
onChange={(e) =>
setFormData({
...formData,
new_password: e.target.value,
})
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte nové heslo"
/>
</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>
{/* 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) {
setShow2FASetup(false);
} }
}} required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Uživatelské jméno</label>
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/> />
<motion.div </div>
className="admin-modal" <div className="admin-form-group">
initial={{ opacity: 0, scale: 0.95, y: 20 }} <label className="admin-form-label">E-mail</label>
animate={{ opacity: 1, scale: 1, y: 0 }} <input
exit={{ opacity: 0, scale: 0.95, y: 20 }} type="email"
transition={{ duration: 0.2 }} value={formData.email}
> onChange={(e) =>
<div className="admin-modal-header"> setFormData({ ...formData, email: e.target.value })
<h2 className="admin-modal-title"> }
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"} required
</h2> className="admin-form-input"
</div> />
<div className="admin-modal-body"> </div>
{backupCodes ? ( <div className="admin-form-group">
<div> <label className="admin-form-label">Aktuální heslo</label>
<div className="admin-role-locked-notice mb-4"> <input
<svg type="password"
width="16" value={formData.current_password}
height="16" onChange={(e) =>
viewBox="0 0 24 24" setFormData({
fill="none" ...formData,
stroke="currentColor" current_password: e.target.value,
strokeWidth="2" })
> }
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /> className="admin-form-input"
<line x1="12" y1="9" x2="12" y2="13" /> placeholder="Pro změnu hesla, zadejte aktuální heslo"
<line x1="12" y1="17" x2="12.01" y2="17" /> />
</svg> </div>
Uložte si tyto kódy na bezpečné místo. Každý kód lze <div className="admin-form-group">
použít pouze jednou. Po zavření tohoto okna je již <label className="admin-form-label">Nové heslo</label>
neuvidíte. <input
</div> type="password"
<div value={formData.new_password}
style={{ onChange={(e) =>
display: "grid", setFormData({
gridTemplateColumns: "repeat(2, 1fr)", ...formData,
gap: "0.5rem", new_password: e.target.value,
padding: "1rem", })
background: "var(--bg-secondary)", }
borderRadius: "0.5rem", className="admin-form-input"
fontFamily: "monospace", placeholder="Pro změnu hesla, zadejte nové heslo"
fontSize: "1rem", />
}} </div>
> </div>
{backupCodes.map((code) => ( </FormModal>
<div
key={code}
style={{
padding: "0.25rem 0.5rem",
textAlign: "center",
color: "var(--text-primary)",
}}
>
{code}
</div>
))}
</div>
<div style={{ marginTop: "0.75rem" }}>
<button
onClick={() => {
navigator.clipboard?.writeText(
backupCodes.join("\n"),
);
alert.success("Kódy zkopírovány");
}}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="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>
</div>
) : (
<div>
<p
className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
>
Naskenujte QR kód v autentizační aplikaci (Google
Authenticator, Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<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"
style={{
width: 200,
height: 200,
borderRadius: "0.5rem",
border: "1px solid var(--border-color)",
}}
/>
</div>
)}
{totpSecret && (
<div className="mb-4">
<label
className="admin-form-label"
style={{ fontSize: "0.75rem" }}
>
Nebo zadejte klíč ručně:
</label>
<div
style={{
padding: "0.5rem 0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.375rem",
fontFamily: "monospace",
fontSize: "0.875rem",
wordBreak: "break-all",
color: "var(--text-primary)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<span>{totpSecret}</span>
<button
onClick={() => {
navigator.clipboard?.writeText(totpSecret);
alert.success("Klíč zkopírován");
}}
className="admin-btn-icon"
title="Kopírovat"
aria-label="Kopírovat"
style={{ flexShrink: 0 }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="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>
</div>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">
Ověřovací kód z aplikace
</label>
<input
ref={totpSetupRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value.replace(/\D/g, ""))
}
placeholder="000000"
className="admin-form-input"
style={{
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && totpCode.length === 6) {
onConfirm2FA();
}
}}
/>
</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>
{/* 2FA Disable Modal */} {/* 2FA Setup Modal — multi-step: setup → backup codes */}
<AnimatePresence> <FormModal
{show2FADisable && ( isOpen={show2FASetup}
<motion.div onClose={() => {
className="admin-modal-overlay" // Only reachable on the setup step (backdrop/ESC/× are locked while
initial={{ opacity: 0 }} // backupCodes is set — that branch is unreachable so removed).
animate={{ opacity: 1 }} setShow2FASetup(false);
exit={{ opacity: 0 }} setTotpCode("");
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}
>
{backupCodes ? (
<div>
<div className="admin-role-locked-notice mb-4">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<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.
</div>
<div <div
className="admin-modal-backdrop" style={{
onClick={() => setShow2FADisable(false)} display: "grid",
/> gridTemplateColumns: "repeat(2, 1fr)",
<motion.div gap: "0.5rem",
className="admin-modal" padding: "1rem",
initial={{ opacity: 0, scale: 0.95, y: 20 }} background: "var(--bg-secondary)",
animate={{ opacity: 1, scale: 1, y: 0 }} borderRadius: "0.5rem",
exit={{ opacity: 0, scale: 0.95, y: 20 }} fontFamily: "monospace",
transition={{ duration: 0.2 }} fontSize: "1rem",
}}
> >
<div className="admin-modal-header"> {backupCodes.map((code) => (
<h2 className="admin-modal-title">Deaktivovat 2FA</h2> <div
</div> key={code}
<div className="admin-modal-body">
<p
style={{ style={{
color: "var(--text-secondary)", padding: "0.25rem 0.5rem",
fontSize: "0.875rem", textAlign: "center",
marginBottom: "1rem", color: "var(--text-primary)",
}} }}
> >
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z {code}
autentizační aplikace. </div>
</p> ))}
<div className="admin-form-group"> </div>
<label className="admin-form-label">Ověřovací kód</label> <div style={{ marginTop: "0.75rem" }}>
<input <button
type="text" onClick={() => {
inputMode="numeric" navigator.clipboard?.writeText(backupCodes.join("\n"));
pattern="[0-9]*" alert.success("Kódy zkopírovány");
maxLength={6} }}
value={disableCode} className="admin-btn admin-btn-secondary admin-btn-sm"
onChange={(e) => >
setDisableCode(e.target.value.replace(/\D/g, "")) <svg
} width="14"
placeholder="000000" height="14"
className="admin-form-input" viewBox="0 0 24 24"
style={{ fill="none"
textAlign: "center", stroke="currentColor"
fontSize: "1.25rem", strokeWidth="2"
letterSpacing: "0.4rem", >
fontFamily: "monospace", <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>
<p
className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
>
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<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"
style={{
width: 200,
height: 200,
borderRadius: "0.5rem",
border: "1px solid var(--border-color)",
}}
/>
</div>
)}
{totpSecret && (
<div className="mb-4">
<label
className="admin-form-label"
style={{ fontSize: "0.75rem" }}
>
Nebo zadejte klíč ručně:
</label>
<div
style={{
padding: "0.5rem 0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.375rem",
fontFamily: "monospace",
fontSize: "0.875rem",
wordBreak: "break-all",
color: "var(--text-primary)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<span>{totpSecret}</span>
<button
onClick={() => {
navigator.clipboard?.writeText(totpSecret);
alert.success("Klíč zkopírován");
}} }}
onKeyDown={(e) => { className="admin-btn-icon"
if (e.key === "Enter" && disableCode.length === 6) { title="Kopírovat"
onDisable2FA(); aria-label="Kopírovat"
} style={{ flexShrink: 0 }}
}} >
autoFocus <svg
/> width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="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>
</div> </div>
</div> </div>
<div className="admin-modal-footer"> )}
<button <div className="admin-form-group">
onClick={() => setShow2FADisable(false)} <label className="admin-form-label">
className="admin-btn admin-btn-secondary" Ověřovací kód z aplikace
disabled={totpSubmitting} </label>
> <input
Zrušit ref={totpSetupRef}
</button> type="text"
<button inputMode="numeric"
onClick={onDisable2FA} pattern="[0-9]*"
className="admin-btn admin-btn-primary" maxLength={6}
disabled={totpSubmitting || disableCode.length !== 6} value={totpCode}
> onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
{totpSubmitting ? "Deaktivuji..." : "Deaktivovat 2FA"} placeholder="000000"
</button> className="admin-form-input"
</div> style={{
</motion.div> textAlign: "center",
</motion.div> fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && totpCode.length === 6) {
onConfirm2FA();
}
}}
/>
</div>
</div>
)} )}
</AnimatePresence> </FormModal>
{/* 2FA Disable Modal */}
<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"
>
<p
style={{
color: "var(--text-secondary)",
fontSize: "0.875rem",
marginBottom: "1rem",
}}
>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
autentizační aplikace.
</p>
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && disableCode.length === 6) {
onDisable2FA();
}
}}
autoFocus
/>
</div>
</FormModal>
</> </>
); );
} }

View File

@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext"; import { useAlert } from "../../context/AlertContext";
import { formatKm, todayLocalStr } from "../../utils/formatters"; import { formatKm, todayLocalStr } from "../../utils/formatters";
import AdminDatePicker from "../AdminDatePicker"; import AdminDatePicker from "../AdminDatePicker";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import useModalLock from "../../hooks/useModalLock"; import FormModal from "../FormModal";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -69,8 +69,6 @@ export default function DashQuickActions({
}); });
const [tripErrors, setTripErrors] = useState<TripErrors>({}); const [tripErrors, setTripErrors] = useState<TripErrors>({});
useModalLock(showTripModal);
const openTripModal = async () => { const openTripModal = async () => {
setTripForm({ setTripForm({
vehicle_id: "", vehicle_id: "",
@@ -316,273 +314,222 @@ export default function DashQuickActions({
)} )}
</motion.div> </motion.div>
<AnimatePresence> <FormModal
{showTripModal && ( isOpen={showTripModal}
<motion.div onClose={() => setShowTripModal(false)}
className="admin-modal-overlay" title="Přidat jízdu"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }} onSubmit={handleTripSubmit}
exit={{ opacity: 0 }} submitLabel="Uložit"
transition={{ duration: 0.2 }} loading={tripSubmitting}
> >
<div className="admin-form">
<div className="admin-form-row">
<div <div
className="admin-modal-backdrop" className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
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"> <label className="admin-form-label required">Vozidlo</label>
<h2 className="admin-modal-title">Přidat jízdu</h2> <select
</div> value={tripForm.vehicle_id}
<div className="admin-modal-body"> onChange={(e) => {
<div className="admin-form"> handleTripVehicleChange(e.target.value);
<div className="admin-form-row"> setTripErrors((prev) => ({
<div ...prev,
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`} vehicle_id: undefined,
> }));
<label className="admin-form-label required"> }}
Vozidlo className="admin-form-select"
</label> >
<select <option value="">Vyberte vozidlo</option>
value={tripForm.vehicle_id} {tripVehicles.map((v) => (
onChange={(e) => { <option key={v.id} value={v.id}>
handleTripVehicleChange(e.target.value); {v.spz} - {v.name}
setTripErrors((prev) => ({ </option>
...prev, ))}
vehicle_id: undefined, </select>
})); {tripErrors.vehicle_id && (
}} <span className="admin-form-error">
className="admin-form-select" {tripErrors.vehicle_id}
> </span>
<option value="">Vyberte vozidlo</option> )}
{tripVehicles.map((v) => ( </div>
<option key={v.id} value={v.id}> <div
{v.spz} - {v.name} className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
</option> >
))} <label className="admin-form-label required">Datum jízdy</label>
</select> <AdminDatePicker
{tripErrors.vehicle_id && ( mode="date"
<span className="admin-form-error"> value={tripForm.trip_date}
{tripErrors.vehicle_id} onChange={(val: string) => {
</span> setTripForm((prev) => ({ ...prev, trip_date: val }));
)} setTripErrors((prev) => ({
</div> ...prev,
<div trip_date: undefined,
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`} }));
> }}
<label className="admin-form-label required"> />
Datum jízdy {tripErrors.trip_date && (
</label> <span className="admin-form-error">{tripErrors.trip_date}</span>
<AdminDatePicker )}
mode="date" </div>
value={tripForm.trip_date} </div>
onChange={(val: string) => {
setTripForm((prev) => ({ ...prev, trip_date: val }));
setTripErrors((prev) => ({
...prev,
trip_date: undefined,
}));
}}
/>
{tripErrors.trip_date && (
<span className="admin-form-error">
{tripErrors.trip_date}
</span>
)}
</div>
</div>
<div className="admin-form-row admin-form-row-3"> <div className="admin-form-row admin-form-row-3">
<div <div
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`} className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
> >
<label className="admin-form-label required"> <label className="admin-form-label required">
Počáteční stav km Počáteční stav km
</label> </label>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
value={tripForm.start_km} value={tripForm.start_km}
onChange={(e) => { onChange={(e) => {
setTripForm((prev) => ({ setTripForm((prev) => ({
...prev, ...prev,
start_km: e.target.value, start_km: e.target.value,
})); }));
setTripErrors((prev) => ({ setTripErrors((prev) => ({
...prev, ...prev,
start_km: undefined, start_km: undefined,
})); }));
}} }}
className="admin-form-input" className="admin-form-input"
min="0" min="0"
/> />
{tripErrors.start_km && ( {tripErrors.start_km && (
<span className="admin-form-error"> <span className="admin-form-error">{tripErrors.start_km}</span>
{tripErrors.start_km} )}
</span> </div>
)} <div
</div> className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
<div >
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`} <label className="admin-form-label required">
> Konečný stav km
<label className="admin-form-label required"> </label>
Konečný stav km <input
</label> type="number"
<input inputMode="numeric"
type="number" value={tripForm.end_km}
inputMode="numeric" onChange={(e) => {
value={tripForm.end_km} setTripForm((prev) => ({
onChange={(e) => { ...prev,
setTripForm((prev) => ({ end_km: e.target.value,
...prev, }));
end_km: e.target.value, setTripErrors((prev) => ({
})); ...prev,
setTripErrors((prev) => ({ end_km: undefined,
...prev, }));
end_km: undefined, }}
})); className="admin-form-input"
}} min="0"
className="admin-form-input" />
min="0" {tripErrors.end_km && (
/> <span className="admin-form-error">{tripErrors.end_km}</span>
{tripErrors.end_km && ( )}
<span className="admin-form-error"> </div>
{tripErrors.end_km} <div className="admin-form-group">
</span> <label className="admin-form-label">Vzdálenost</label>
)} <input
</div> type="text"
<div className="admin-form-group"> value={`${formatKm(tripDistance())} km`}
<label className="admin-form-label">Vzdálenost</label> className="admin-form-input"
<input readOnly
type="text" disabled
value={`${formatKm(tripDistance())} km`} />
className="admin-form-input" </div>
readOnly </div>
disabled
/>
</div>
</div>
<div className="admin-form-row"> <div className="admin-form-row">
<div <div
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`} className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
> >
<label className="admin-form-label required"> <label className="admin-form-label required">Místo odjezdu</label>
Místo odjezdu <input
</label> type="text"
<input value={tripForm.route_from}
type="text" onChange={(e) => {
value={tripForm.route_from} setTripForm((prev) => ({
onChange={(e) => { ...prev,
setTripForm((prev) => ({ route_from: e.target.value,
...prev, }));
route_from: e.target.value, setTripErrors((prev) => ({
})); ...prev,
setTripErrors((prev) => ({ route_from: undefined,
...prev, }));
route_from: undefined, }}
})); className="admin-form-input"
}} placeholder="Např. Praha"
className="admin-form-input" />
placeholder="Např. Praha" {tripErrors.route_from && (
/> <span className="admin-form-error">
{tripErrors.route_from && ( {tripErrors.route_from}
<span className="admin-form-error"> </span>
{tripErrors.route_from} )}
</span> </div>
)} <div
</div> className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
<div >
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`} <label className="admin-form-label required">
> Místo příjezdu
<label className="admin-form-label required"> </label>
Místo příjezdu <input
</label> type="text"
<input value={tripForm.route_to}
type="text" onChange={(e) => {
value={tripForm.route_to} setTripForm((prev) => ({
onChange={(e) => { ...prev,
setTripForm((prev) => ({ route_to: e.target.value,
...prev, }));
route_to: e.target.value, setTripErrors((prev) => ({
})); ...prev,
setTripErrors((prev) => ({ route_to: undefined,
...prev, }));
route_to: undefined, }}
})); className="admin-form-input"
}} placeholder="Např. Brno"
className="admin-form-input" />
placeholder="Např. Brno" {tripErrors.route_to && (
/> <span className="admin-form-error">{tripErrors.route_to}</span>
{tripErrors.route_to && ( )}
<span className="admin-form-error"> </div>
{tripErrors.route_to} </div>
</span>
)}
</div>
</div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label> <label className="admin-form-label">Typ jízdy</label>
<select <select
value={tripForm.is_business} value={tripForm.is_business}
onChange={(e) => onChange={(e) =>
setTripForm((prev) => ({ setTripForm((prev) => ({
...prev, ...prev,
is_business: parseInt(e.target.value), is_business: parseInt(e.target.value),
})) }))
} }
className="admin-form-select" className="admin-form-select"
> >
<option value={1}>Služební</option> <option value={1}>Služební</option>
<option value={0}>Soukromá</option> <option value={0}>Soukromá</option>
</select> </select>
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Poznámky</label> <label className="admin-form-label">Poznámky</label>
<textarea <textarea
value={tripForm.notes} value={tripForm.notes}
onChange={(e) => onChange={(e) =>
setTripForm((prev) => ({ setTripForm((prev) => ({
...prev, ...prev,
notes: e.target.value, notes: e.target.value,
})) }))
} }
className="admin-form-textarea" className="admin-form-textarea"
rows={2} rows={2}
placeholder="Volitelné poznámky..." placeholder="Volitelné poznámky..."
/> />
</div> </div>
</div> </div>
</div> </FormModal>
<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>
</> </>
); );
} }

View File

@@ -197,3 +197,15 @@
.react-datepicker__close-icon::after { .react-datepicker__close-icon::after {
background-color: var(--accent-color) !important; background-color: var(--accent-color) !important;
} }
/* Mobile safety net — keep the popper inside the viewport on small phones.
(Native date input is used on touch; this guards the rare desktop-style popper.) */
@media (max-width: 480px) {
.react-datepicker-popper {
max-width: calc(100vw - 24px);
}
.react-datepicker {
font-size: 0.8rem !important;
}
}

View File

@@ -169,3 +169,31 @@
color: var(--text-tertiary); color: var(--text-tertiary);
cursor: help; cursor: help;
} }
/* ============================================================================
File Manager — mobile
============================================================================ */
@media (max-width: 640px) {
/* Toolbar wraps so actions drop below the path instead of overflowing */
.fm-toolbar {
flex-wrap: wrap;
}
/* New-folder input goes full width (override the 250px desktop cap) */
.fm-new-folder .admin-form-input {
max-width: none;
width: 100%;
}
/* Breadcrumb scrolls horizontally instead of overflowing the container */
.fm-breadcrumb {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.fm-breadcrumb-segment {
flex-shrink: 0;
}
}

View File

@@ -310,6 +310,8 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.admin-form-row,
.admin-form-row-3,
.admin-form-row-4, .admin-form-row-4,
.admin-form-row-5 { .admin-form-row-5 {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -127,9 +127,17 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
/* Month-nav row may wrap so it never forces horizontal page overflow */
.invoice-month-nav {
flex-wrap: wrap;
}
/* 44px touch target for the prev/next month buttons */
.invoice-month-btn { .invoice-month-btn {
width: 44px; width: 44px;
height: 44px; height: 44px;
min-width: 44px;
min-height: 44px;
} }
.received-upload-row { .received-upload-row {

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

@@ -464,6 +464,26 @@
display: none; display: none;
} }
/* Keep Quill picker dropdowns on-screen on narrow phones.
The font picker hard-codes min-width: 11rem and the color picker a fixed
176px width — both overflow a ~360px viewport. */
@media (max-width: 480px) {
.admin-rich-editor .ql-snow .ql-picker-options,
.admin-rich-editor .ql-snow .ql-font .ql-picker-options,
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
min-width: 0;
max-width: calc(100vw - 32px);
}
.admin-rich-editor
.ql-snow
.ql-color-picker
.ql-picker-options[aria-hidden="false"] {
width: auto;
max-width: calc(100vw - 32px);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input { .offers-items-table .admin-table td .admin-form-input {
font-size: 16px; font-size: 16px;

View File

@@ -3,10 +3,10 @@ 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 { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker"; import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal"; import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock"; import FormModal from "../components/FormModal";
import { import {
formatTime, formatTime,
calculateWorkMinutes, calculateWorkMinutes,
@@ -172,8 +172,6 @@ export default function Attendance() {
} }
}, [statusQuery.data]); }, [statusQuery.data]);
useModalLock(isLeaveModalOpen);
if (!hasPermission("attendance.record")) return <Forbidden />; if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => { const handlePunch = (action: string) => {
@@ -931,163 +929,127 @@ export default function Attendance() {
</div> </div>
{/* Leave Modal */} {/* Leave Modal */}
<AnimatePresence> <FormModal
{isLeaveModalOpen && ( isOpen={isLeaveModalOpen}
<motion.div onClose={() => setIsLeaveModalOpen(false)}
className="admin-modal-overlay" title="Žádost o nepřítomnost"
initial={{ opacity: 0 }} onSubmit={() => {
animate={{ opacity: 1 }} // Preserve original guard: do not submit when there are 0 business days.
exit={{ opacity: 0 }} if (
transition={{ duration: 0.2 }} calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
> )
<div return;
className="admin-modal-backdrop" handleRequestSubmit();
onClick={() => setIsLeaveModalOpen(false)} }}
/> submitLabel="Odeslat žádost"
<motion.div submitDisabled={
className="admin-modal" calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
initial={{ opacity: 0, scale: 0.95, y: 20 }} }
animate={{ opacity: 1, scale: 1, y: 0 }} loading={requestSubmitting}
exit={{ opacity: 0, scale: 0.95, y: 20 }} >
transition={{ duration: 0.2 }} <div className="admin-form">
<FormField label="Typ nepřítomnosti">
<select
value={leaveForm.leave_type}
onChange={(e) =>
setLeaveForm({
...leaveForm,
leave_type: e.target.value,
})
}
className="admin-form-select"
> >
<div className="admin-modal-header"> <option value="vacation">Dovolená</option>
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2> <option value="sick">Nemoc</option>
</div> <option value="unpaid">Neplacené volno</option>
</select>
</FormField>
<div className="admin-modal-body"> <div
<div className="admin-form"> style={{
<FormField label="Typ nepřítomnosti"> display: "grid",
<select gridTemplateColumns: "1fr 1fr",
value={leaveForm.leave_type} gap: "1rem",
onChange={(e) => }}
setLeaveForm({ >
...leaveForm, <FormField label="Od">
leave_type: e.target.value, <AdminDatePicker
}) mode="date"
} value={leaveForm.date_from}
className="admin-form-select" onChange={(val: string) => {
> setLeaveForm((prev) => ({
<option value="vacation">Dovolená</option> ...prev,
<option value="sick">Nemoc</option> date_from: val,
<option value="unpaid">Neplacené volno</option> date_to: prev.date_to < val ? val : prev.date_to,
</select> }));
</FormField> }}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={leaveForm.date_to}
minDate={leaveForm.date_from}
onChange={(val: string) =>
setLeaveForm({ ...leaveForm, date_to: val })
}
/>
</FormField>
</div>
<div {leaveForm.date_from && leaveForm.date_to && (
style={{ <div className="admin-form-group">
display: "grid", <div
gridTemplateColumns: "1fr 1fr", style={{
gap: "1rem", display: "flex",
}} gap: "1.5rem",
> padding: "0.75rem 1rem",
<FormField label="Od"> background: "var(--bg-tertiary)",
<AdminDatePicker borderRadius: "var(--border-radius)",
mode="date" fontSize: "0.875rem",
value={leaveForm.date_from} }}
onChange={(val: string) => { >
setLeaveForm((prev) => ({ <span>
...prev, <strong>
date_from: val, {calculateBusinessDays(
date_to: prev.date_to < val ? val : prev.date_to, leaveForm.date_from,
})); leaveForm.date_to,
}} )}
/> </strong>{" "}
</FormField> {czechPlural(
<FormField label="Do">
<AdminDatePicker
mode="date"
value={leaveForm.date_to}
minDate={leaveForm.date_from}
onChange={(val: string) =>
setLeaveForm({ ...leaveForm, date_to: val })
}
/>
</FormField>
</div>
{leaveForm.date_from && leaveForm.date_to && (
<div className="admin-form-group">
<div
style={{
display: "flex",
gap: "1.5rem",
padding: "0.75rem 1rem",
background: "var(--bg-tertiary)",
borderRadius: "var(--border-radius)",
fontSize: "0.875rem",
}}
>
<span>
<strong>
{calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
)}
</strong>{" "}
{czechPlural(
calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
),
"pracovní den",
"pracovní dny",
"pracovních dnů",
)}
</span>
<span className="text-muted">
{calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
) * 8}{" "}
hodin
</span>
</div>
</div>
)}
<FormField label="Poznámka">
<textarea
value={leaveForm.notes}
onChange={(e) =>
setLeaveForm({ ...leaveForm, notes: e.target.value })
}
placeholder="Volitelná poznámka..."
className="admin-form-textarea"
rows={2}
/>
</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( calculateBusinessDays(
leaveForm.date_from, leaveForm.date_from,
leaveForm.date_to, leaveForm.date_to,
) === 0 ),
} "pracovní den",
className="admin-btn admin-btn-primary" "pracovní dny",
> "pracovních dnů",
{requestSubmitting ? "Odesílám..." : "Odeslat žádost"} )}
</button> </span>
<span className="text-muted">
{calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
) * 8}{" "}
hodin
</span>
</div> </div>
</motion.div> </div>
</motion.div> )}
)}
</AnimatePresence> <FormField label="Poznámka">
<textarea
value={leaveForm.notes}
onChange={(e) =>
setLeaveForm({ ...leaveForm, notes: e.target.value })
}
placeholder="Volitelná poznámka..."
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
</FormModal>
<ConfirmModal <ConfirmModal
isOpen={gpsConfirm.isOpen} isOpen={gpsConfirm.isOpen}

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

@@ -81,10 +81,18 @@ export default function ProjectDetail() {
const notes: ProjectNote[] = project?.project_notes || []; const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view")); 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
id: u.id, // assignee in the list even if they're now inactive, so the select still
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username, // 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) +
(u.is_active ? "" : " (neaktivní)"),
}));
// Reset form sync when navigating to a different project // Reset form sync when navigating to a different project
const formInitialized = useRef(false); const formInitialized = useRef(false);

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,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 { const {
items: projects, items: projects,
pagination, pagination,
@@ -126,6 +202,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 +269,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 +331,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 +356,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 +463,116 @@ 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 ?? [])
.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> </div>
); );
} }

View File

@@ -65,6 +65,16 @@
} }
} }
/* Row actions — enlarge touch targets so icon buttons are tappable */
@media (max-width: 768px) {
.admin-table-actions a,
.admin-table-actions button,
.admin-table-actions .admin-btn-icon {
min-width: 44px;
min-height: 44px;
}
}
.admin-table-user { .admin-table-user {
display: flex; display: flex;
align-items: center; align-items: center;

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

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