Compare commits
23 Commits
v1.9.2
...
e082a6ef06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e082a6ef06 | ||
|
|
1662453d9c | ||
|
|
9dd2e07f19 | ||
|
|
18064a972c | ||
|
|
996835dae5 | ||
|
|
c73b980ce7 | ||
|
|
dafa6d6aab | ||
|
|
80803cc868 | ||
|
|
55fa4045ad | ||
|
|
7f156cc721 | ||
|
|
e0d2fccf50 | ||
|
|
a1c251650b | ||
|
|
103908cfb8 | ||
|
|
449a1a2243 | ||
|
|
337feb8b3a | ||
|
|
bc62d32efc | ||
|
|
30dd5a3f3b | ||
|
|
5140d23d73 | ||
|
|
a373102f3a | ||
|
|
49ec3482ae | ||
|
|
f6cdfb9801 | ||
|
|
bd9dc8d103 | ||
|
|
0c5d0ee2f7 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.9.2",
|
"version": "1.9.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.9.2",
|
"version": "1.9.7",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.9.2",
|
"version": "1.9.7",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Re-add the projects.create permission (removed in commit 82919d3 when manual
|
||||||
|
-- project creation was deleted). Idempotent: re-running is a no-op.
|
||||||
|
-- Mirrors prisma/seed.ts PERMISSIONS exactly (name, display_name, module, description).
|
||||||
|
|
||||||
|
-- 1. Insert the permission (INSERT IGNORE skips on duplicate name).
|
||||||
|
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||||
|
('projects.create', 'Vytvořit projekt', 'projects', 'Ručně vytvářet projekty', NOW());
|
||||||
|
|
||||||
|
-- 2. Grant it to the admin role (idempotent via composite PK).
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM `roles` r
|
||||||
|
CROSS JOIN `permissions` p
|
||||||
|
WHERE r.name = 'admin'
|
||||||
|
AND p.name IN ('projects.create');
|
||||||
@@ -172,6 +172,12 @@ const PERMISSIONS: {
|
|||||||
module: "projects",
|
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",
|
||||||
|
|||||||
142
src/__tests__/manual-create.test.ts
Normal file
142
src/__tests__/manual-create.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { createProject } from "../services/projects.service";
|
||||||
|
import { createOrder } from "../services/orders.service";
|
||||||
|
|
||||||
|
const createdProjectIds: number[] = [];
|
||||||
|
const createdOrderIds: number[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of createdProjectIds)
|
||||||
|
await prisma.projects.deleteMany({ where: { id } });
|
||||||
|
for (const id of createdOrderIds)
|
||||||
|
await prisma.orders.deleteMany({ where: { id } });
|
||||||
|
createdProjectIds.length = 0;
|
||||||
|
createdOrderIds.length = 0;
|
||||||
|
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseOrder = {
|
||||||
|
status: "prijata" as const,
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
exchange_rate: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createOrder (manual, optional linked project)", () => {
|
||||||
|
it("creates an order AND a project sharing one number when create_project=true", async () => {
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
scope_title: "Ruční zakázka",
|
||||||
|
create_project: true,
|
||||||
|
});
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
expect(res.project_id).toBeTruthy();
|
||||||
|
const project = await prisma.projects.findUnique({
|
||||||
|
where: { id: res.project_id! },
|
||||||
|
});
|
||||||
|
if (project) createdProjectIds.push(project.id);
|
||||||
|
expect(project?.project_number).toBe(res.order_number);
|
||||||
|
expect(project?.order_id).toBe(res.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates only the order when create_project=false", async () => {
|
||||||
|
const res = await createOrder({ ...baseOrder, create_project: false });
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
expect(res.project_id).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared pool coherence (tracking)", () => {
|
||||||
|
it("order → standalone project → order produce three distinct shared numbers", async () => {
|
||||||
|
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||||
|
if ("id" in o1) {
|
||||||
|
createdOrderIds.push(o1.id);
|
||||||
|
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
||||||
|
}
|
||||||
|
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
||||||
|
if ("project_number" in p) createdProjectIds.push(p.id);
|
||||||
|
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
||||||
|
if ("id" in o2) createdOrderIds.push(o2.id);
|
||||||
|
|
||||||
|
const n1 = "id" in o1 ? o1.order_number : null;
|
||||||
|
const np = "project_number" in p ? p.project_number : null;
|
||||||
|
const n2 = "id" in o2 ? o2.order_number : null;
|
||||||
|
expect(new Set([n1, np, n2]).size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createProject (manual, standalone)", () => {
|
||||||
|
it("assigns a shared number and is not linked to an order", async () => {
|
||||||
|
const result = await createProject({
|
||||||
|
name: "Test projekt",
|
||||||
|
status: "aktivni",
|
||||||
|
});
|
||||||
|
expect("project_number" in result).toBe(true);
|
||||||
|
if (!("project_number" in result)) return;
|
||||||
|
createdProjectIds.push(result.id);
|
||||||
|
expect(typeof result.project_number).toBe("string");
|
||||||
|
expect(result.project_number!.length).toBeGreaterThan(0);
|
||||||
|
expect(result.order_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives consecutive standalone projects distinct numbers", async () => {
|
||||||
|
const a = await createProject({ name: "Projekt A", status: "aktivni" });
|
||||||
|
const b = await createProject({ name: "Projekt B", status: "aktivni" });
|
||||||
|
if ("project_number" in a) createdProjectIds.push(a.id);
|
||||||
|
if ("project_number" in b) createdProjectIds.push(b.id);
|
||||||
|
expect("project_number" in a && "project_number" in b).toBe(true);
|
||||||
|
if ("project_number" in a && "project_number" in b) {
|
||||||
|
expect(a.project_number).not.toBe(b.project_number);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createOrder with price line item + attachment", () => {
|
||||||
|
it("stores a single line item with the entered price", async () => {
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
create_project: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "Práce",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 12345,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
const items = await prisma.order_items.findMany({
|
||||||
|
where: { order_id: res.id },
|
||||||
|
});
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
expect(Number(items[0].unit_price)).toBe(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores an uploaded PO attachment", async () => {
|
||||||
|
const buf = Buffer.from("%PDF-1.4 fake");
|
||||||
|
const res = await createOrder(
|
||||||
|
{ ...baseOrder, create_project: false },
|
||||||
|
buf,
|
||||||
|
"po.pdf",
|
||||||
|
);
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
const order = await prisma.orders.findUnique({
|
||||||
|
where: { id: res.id },
|
||||||
|
select: { attachment_name: true, attachment_data: true },
|
||||||
|
});
|
||||||
|
expect(order?.attachment_name).toBe("po.pdf");
|
||||||
|
expect(order?.attachment_data).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -599,6 +610,25 @@
|
|||||||
0 0 0 1px var(--border-color);
|
0 0 0 1px var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Empty State
|
Empty State
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -834,7 +864,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1062,37 +1094,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
/* Compact 2-up filter grid instead of a tall full-width column:
|
||||||
|
the search box spans the row; the filter selects / date pickers sit
|
||||||
|
two-per-row; the reset button spans the row. Touch sizing (44px height +
|
||||||
|
16px font from forms.css) is unchanged — this just halves the vertical
|
||||||
|
footprint of multi-filter bars on phones. */
|
||||||
.admin-search-bar {
|
.admin-search-bar {
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-search-bar .admin-form-input,
|
/* Search box spans the full row (handles both the .admin-search wrapper
|
||||||
.admin-search-bar .admin-form-select {
|
and a bare text input used directly as the search field). */
|
||||||
max-width: 100%;
|
.admin-search-bar > .admin-search,
|
||||||
}
|
.admin-search-bar > .admin-form-input:not([type="date"]) {
|
||||||
|
flex: 1 1 100%;
|
||||||
.admin-search-bar .react-datepicker-wrapper {
|
max-width: none;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-search {
|
.admin-search {
|
||||||
max-width: 100%;
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters (status/supplier selects, native date inputs, datepicker
|
||||||
|
wrappers): two per row. */
|
||||||
|
.admin-search-bar > .admin-form-select,
|
||||||
|
.admin-search-bar > .admin-form-input[type="date"],
|
||||||
|
.admin-search-bar > .react-datepicker-wrapper {
|
||||||
|
flex: 1 1 calc(50% - 0.25rem);
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons (e.g. "Zrušit filtry") span the full row. */
|
||||||
|
.admin-search-bar > .admin-btn {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.admin-search-bar .admin-form-input,
|
|
||||||
.admin-search-bar .admin-form-select {
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-search-bar .react-datepicker-wrapper {
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Item Picker (Warehouse)
|
Item Picker (Warehouse)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -1172,3 +1211,14 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-item-picker-list {
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-picker-item {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -486,3 +488,23 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-customer-dropdown {
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-customer-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-customer-selected .admin-btn-icon {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-right: -10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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") },
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user