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,52 +38,29 @@ 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"
onClick={() => !submitting && onClose()}
/>
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-attendance-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="bulk-attendance-modal-title"
className="admin-modal-title"
>
Vyplnit docházku za měsíc
</h2>
<p <p
style={{ style={{
color: "var(--text-secondary)", color: "var(--text-secondary)",
marginTop: "0.25rem", marginTop: "0.25rem",
marginBottom: "1rem",
fontSize: "0.875rem", fontSize: "0.875rem",
}} }}
> >
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
označí. Existující záznamy se přeskočí. Existující záznamy se přeskočí.
</p> </p>
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Měsíc</label> <label className="admin-form-label">Měsíc</label>
@@ -152,9 +128,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.arrival_time} value={form.arrival_time}
onChange={(val) => onChange={(val) => setForm({ ...form, arrival_time: val })}
setForm({ ...form, arrival_time: val })
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -162,9 +136,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.departure_time} value={form.departure_time}
onChange={(val) => onChange={(val) => setForm({ ...form, departure_time: val })}
setForm({ ...form, departure_time: val })
}
/> />
</div> </div>
</div> </div>
@@ -175,9 +147,7 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_start_time} value={form.break_start_time}
onChange={(val) => onChange={(val) => setForm({ ...form, break_start_time: val })}
setForm({ ...form, break_start_time: val })
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -185,36 +155,11 @@ export default function BulkAttendanceModal({
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_end_time} value={form.break_end_time}
onChange={(val) => onChange={(val) => setForm({ ...form, break_end_time: val })}
setForm({ ...form, break_end_time: val })
}
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </FormModal>
<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">
<div className="admin-modal-heading">
<h2 id={titleId} className="admin-modal-title"> <h2 id={titleId} className="admin-modal-title">
{title} {title}
</h2> </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,38 +103,14 @@ 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 }}
> >
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
}
role="dialog"
aria-modal="true"
aria-labelledby="order-confirmation-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>
<div className="admin-modal-body">
{step === "choose" ? ( {step === "choose" ? (
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-group"> <div className="admin-form-group">
@@ -195,10 +171,7 @@ export default function OrderConfirmationModal({
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label> <label className="admin-form-label">Obsah potvrzení</label>
<p <p className="text-secondary" style={{ marginBottom: "0.75rem" }}>
className="text-secondary"
style={{ marginBottom: "0.75rem" }}
>
Jak chcete připravit potvrzení objednávky? Jak chcete připravit potvrzení objednávky?
</p> </p>
<button <button
@@ -227,6 +200,17 @@ export default function OrderConfirmationModal({
Upravit položky Upravit položky
</button> </button>
</div> </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>
) : ( ) : (
<div className="admin-form"> <div className="admin-form">
@@ -261,11 +245,7 @@ export default function OrderConfirmationModal({
type="number" type="number"
value={item.quantity} value={item.quantity}
onChange={(e) => onChange={(e) =>
updateItem( updateItem(i, "quantity", Number(e.target.value) || 0)
i,
"quantity",
Number(e.target.value) || 0,
)
} }
className="admin-form-input" className="admin-form-input"
style={{ width: "80px" }} style={{ width: "80px" }}
@@ -276,9 +256,7 @@ export default function OrderConfirmationModal({
<input <input
type="text" type="text"
value={item.unit} value={item.unit}
onChange={(e) => onChange={(e) => updateItem(i, "unit", e.target.value)}
updateItem(i, "unit", e.target.value)
}
className="admin-form-input" className="admin-form-input"
style={{ width: "60px" }} style={{ width: "60px" }}
/> />
@@ -304,11 +282,7 @@ export default function OrderConfirmationModal({
type="number" type="number"
value={item.vat_rate} value={item.vat_rate}
onChange={(e) => onChange={(e) =>
updateItem( updateItem(i, "vat_rate", Number(e.target.value) || 0)
i,
"vat_rate",
Number(e.target.value) || 0,
)
} }
className="admin-form-input" className="admin-form-input"
style={{ width: "70px" }} style={{ width: "70px" }}
@@ -345,13 +319,8 @@ export default function OrderConfirmationModal({
> >
+ Přidat položku + Přidat položku
</button> </button>
</div>
)}
</div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">
{step === "edit" && (
<>
<button <button
type="button" type="button"
onClick={() => setStep("choose")} onClick={() => setStep("choose")}
@@ -375,22 +344,9 @@ export default function OrderConfirmationModal({
"Vygenerovat PDF" "Vygenerovat PDF"
)} )}
</button> </button>
</>
)}
{step === "choose" && (
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
)}
</div> </div>
</motion.div> </div>
</motion.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,51 +245,25 @@ 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
}
size="lg"
onSubmit={onSubmit}
submitLabel="Uložit"
cancelLabel="Zrušit"
> >
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
{isCreate ? ( {isCreate ? (
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label required"> <label className="admin-form-label required">Zaměstnanec</label>
Zaměstnanec
</label>
<select <select
value={form.user_id} value={form.user_id}
onChange={(e) => updateField("user_id", e.target.value)} onChange={(e) => updateField("user_id", e.target.value)}
@@ -306,9 +278,7 @@ export default function ShiftFormModal({
</select> </select>
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label required"> <label className="admin-form-label required">Datum směny</label>
Datum směny
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.shift_date} value={form.shift_date}
@@ -357,9 +327,7 @@ export default function ShiftFormModal({
className="admin-form-input" className="admin-form-input"
/> />
{isCreate && ( {isCreate && (
<small className="admin-form-hint"> <small className="admin-form-hint">8 hodin = celý den</small>
8 hodin = celý den
</small>
)} )}
</div> </div>
)} )}
@@ -368,9 +336,7 @@ export default function ShiftFormModal({
<> <>
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Příchod - datum</label>
Příchod - datum
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.arrival_date} value={form.arrival_date}
@@ -378,9 +344,7 @@ export default function ShiftFormModal({
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Příchod - čas</label>
Příchod - čas
</label>
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.arrival_time} value={form.arrival_time}
@@ -397,30 +361,22 @@ export default function ShiftFormModal({
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.break_start_date} value={form.break_start_date}
onChange={(val) => onChange={(val) => updateField("break_start_date", val)}
updateField("break_start_date", val)
}
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Začátek pauzy - čas</label>
Začátek pauzy - čas
</label>
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_start_time} value={form.break_start_time}
onChange={(val) => onChange={(val) => updateField("break_start_time", val)}
updateField("break_start_time", val)
}
/> />
</div> </div>
</div> </div>
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Konec pauzy - datum</label>
Konec pauzy - datum
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.break_end_date} value={form.break_end_date}
@@ -428,9 +384,7 @@ export default function ShiftFormModal({
/> />
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Konec pauzy - čas</label>
Konec pauzy - čas
</label>
<AdminDatePicker <AdminDatePicker
mode="time" mode="time"
value={form.break_end_time} value={form.break_end_time}
@@ -441,9 +395,7 @@ export default function ShiftFormModal({
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <div className="admin-form-group">
<label className="admin-form-label"> <label className="admin-form-label">Odchod - datum</label>
Odchod - datum
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={form.departure_date} value={form.departure_date}
@@ -496,27 +448,6 @@ export default function ShiftFormModal({
/> />
</div> </div>
</div> </div>
</div> </FormModal>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
); );
} }

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

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,38 +314,21 @@ 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-modal-backdrop"
onClick={() => setShowTripModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Přidat jízdu</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-row"> <div className="admin-form-row">
<div <div
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`} className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
> >
<label className="admin-form-label required"> <label className="admin-form-label required">Vozidlo</label>
Vozidlo
</label>
<select <select
value={tripForm.vehicle_id} value={tripForm.vehicle_id}
onChange={(e) => { onChange={(e) => {
@@ -375,9 +356,7 @@ export default function DashQuickActions({
<div <div
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`} className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
> >
<label className="admin-form-label required"> <label className="admin-form-label required">Datum jízdy</label>
Datum jízdy
</label>
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"
value={tripForm.trip_date} value={tripForm.trip_date}
@@ -390,9 +369,7 @@ export default function DashQuickActions({
}} }}
/> />
{tripErrors.trip_date && ( {tripErrors.trip_date && (
<span className="admin-form-error"> <span className="admin-form-error">{tripErrors.trip_date}</span>
{tripErrors.trip_date}
</span>
)} )}
</div> </div>
</div> </div>
@@ -422,9 +399,7 @@ export default function DashQuickActions({
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 <div
@@ -451,9 +426,7 @@ export default function DashQuickActions({
min="0" min="0"
/> />
{tripErrors.end_km && ( {tripErrors.end_km && (
<span className="admin-form-error"> <span className="admin-form-error">{tripErrors.end_km}</span>
{tripErrors.end_km}
</span>
)} )}
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">
@@ -472,9 +445,7 @@ export default function DashQuickActions({
<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
</label>
<input <input
type="text" type="text"
value={tripForm.route_from} value={tripForm.route_from}
@@ -520,9 +491,7 @@ export default function DashQuickActions({
placeholder="Např. Brno" placeholder="Např. Brno"
/> />
{tripErrors.route_to && ( {tripErrors.route_to && (
<span className="admin-form-error"> <span className="admin-form-error">{tripErrors.route_to}</span>
{tripErrors.route_to}
</span>
)} )}
</div> </div>
</div> </div>
@@ -560,29 +529,7 @@ export default function DashQuickActions({
/> />
</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,31 +929,24 @@ 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
)
return;
handleRequestSubmit();
}}
submitLabel="Odeslat žádost"
submitDisabled={
calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
}
loading={requestSubmitting}
> >
<div
className="admin-modal-backdrop"
onClick={() => setIsLeaveModalOpen(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<FormField label="Typ nepřítomnosti"> <FormField label="Typ nepřítomnosti">
<select <select
@@ -1058,36 +1049,7 @@ export default function Attendance() {
/> />
</FormField> </FormField>
</div> </div>
</div> </FormModal>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setIsLeaveModalOpen(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleRequestSubmit}
disabled={
requestSubmitting ||
calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
) === 0
}
className="admin-btn admin-btn-primary"
>
{requestSubmitting ? "Odesílám..." : "Odeslat žádost"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<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,9 +81,17 @@ 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
// assignee in the list even if they're now inactive, so the select still
// reflects the saved value (marked "(neaktivní)" so it's clear).
const assignedUserId = project?.responsible_user_id || "";
const users: User[] = (usersData ?? [])
.filter((u: ApiUser) => u.is_active || String(u.id) === assignedUserId)
.map((u: ApiUser) => ({
id: u.id, id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username, name:
(`${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username) +
(u.is_active ? "" : " (neaktivní)"),
})); }));
// Reset form sync when navigating to a different project // Reset form sync when navigating to a different project

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,13 +118,13 @@ export default async function ordersRoutes(
} }
} }
// From-quotation flow (multipart with attachment)
if (fields.quotationId) {
const quotationId = parseInt(fields.quotationId, 10); const quotationId = parseInt(fields.quotationId, 10);
const customerOrderNumber = fields.customerOrderNumber || ""; const customerOrderNumber = fields.customerOrderNumber || "";
if (!quotationId || isNaN(quotationId)) { if (!quotationId || isNaN(quotationId)) {
return error(reply, "Chybí ID nabídky", 400); return error(reply, "Chybí ID nabídky", 400);
} }
const result = await createOrderFromQuotation({ const result = await createOrderFromQuotation({
quotationId, quotationId,
customerOrderNumber, customerOrderNumber,
@@ -134,7 +133,6 @@ export default async function ordersRoutes(
}); });
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,
@@ -155,6 +153,52 @@ export default async function ordersRoutes(
); );
} }
// Manual order (multipart, with optional PO attachment)
const rawManual: Record<string, unknown> = { ...fields };
if (typeof fields.items === "string" && fields.items.length > 0) {
try {
rawManual.items = JSON.parse(fields.items);
} catch {
return error(reply, "Neplatná data položek", 400);
}
}
const manualParsed = parseBody(CreateOrderSchema, rawManual);
if ("error" in manualParsed)
return error(reply, manualParsed.error, 400);
const result = await createOrder(
manualParsed.data,
attachmentBuffer,
attachmentName,
);
if ("error" in result)
return error(reply, result.error!, result.status!);
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "order",
entityId: result.id,
description: `Vytvořena objednávka ${result.order_number}`,
});
if (result.project_id) {
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "project",
entityId: result.project_id,
description: `Vytvořen projekt k objednávce ${result.order_number}`,
});
}
return success(
reply,
{ id: result.id },
201,
"Objednávka byla vytvořena",
);
}
// === JSON body — either from-quotation (no attachment) or manual order === // === JSON body — either from-quotation (no attachment) or manual order ===
const rawBody = request.body as Record<string, unknown>; const rawBody = request.body as Record<string, unknown>;
@@ -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,
}, },
}); });