v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix

Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
This commit is contained in:
BOHA
2026-06-03 23:13:10 +02:00
parent dac45baaa8
commit 5233db2002
149 changed files with 11132 additions and 26950 deletions

View File

@@ -1,12 +1,12 @@
import { useState, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Navigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import CompanySettings from "./CompanySettings";
import {
companySettingsOptions,
@@ -14,9 +14,8 @@ import {
require2FAOptions,
} from "../lib/queries/settings";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import SettingsFixture from "../fixtures/SettingsFixture";
const API_BASE = "/api/admin";
interface SystemSettingsData {
@@ -108,7 +107,6 @@ const DEFAULT_SYS_FORM: Omit<SystemSettingsData, "app_version"> = {
export default function Settings() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const canManageRoles = hasPermission("settings.roles");
const canManageCompany = hasPermission("settings.company");
@@ -130,11 +128,11 @@ export default function Settings() {
queryKey: ["settings", "roles"],
queryFn: async () => {
const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/roles`).then((r) => r.json()),
apiFetch(`${API_BASE}/roles/permissions`).then((r) => r.json()),
]);
const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json();
const rolesResult = await rolesRes;
const permsResult = await permsRes;
if (!rolesResult.success)
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
if (!permsResult.success)
@@ -194,11 +192,8 @@ export default function Settings() {
});
// ── Local state ──
const [require2FASaving, setRequire2FASaving] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<RoleForm>({
name: "",
display_name: "",
@@ -210,9 +205,7 @@ export default function Settings() {
show: boolean;
role: Role | null;
}>({ show: false, role: null });
const [deleting, setDeleting] = useState(false);
const [sysSettingsSaving, setSysSettingsSaving] = useState(false);
const [sysForm, setSysForm] = useState(DEFAULT_SYS_FORM);
const [sysFormInitialized, setSysFormInitialized] = useState(false);
@@ -256,61 +249,108 @@ export default function Settings() {
setSysFormInitialized(true);
}, [sysSettingsData, sysFormInitialized]);
useModalLock(showModal);
// ── Early return after all hooks ──
if (!canAccessSettings) {
return <Navigate to="/" replace />;
}
const saveSystemSettingsMutation = useApiMutation<
typeof sysForm,
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/company-settings`,
method: () => "PUT",
invalidate: [
"settings",
"company-settings",
"attendance",
"offers",
"orders",
"invoices",
"leave",
],
onSuccess: (data) => {
alert.success(data?.message || "Systémová nastavení byla uložena");
},
});
const toggle2FARoleMutation = useApiMutation<
{ required: boolean },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/totp/required`,
method: () => "POST",
invalidate: ["settings", "company-settings", "dashboard", "users"],
onSuccess: (data) => {
alert.success(data?.message || "2FA nastavení uloženo");
},
});
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema shape (CreateRoleSchema / UpdateRoleSchema)
// directly — NOT a wrapper that nests the body. id is captured in the URL closure.
const createRoleMutation = useApiMutation<
{
name: string;
display_name: string;
description?: string;
permission_ids: number[];
},
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/roles`,
method: () => "POST",
invalidate: ["settings", "users"],
onSuccess: (data) => {
closeModal();
alert.success(data?.message || "Role byla vytvořena");
},
});
const updateRoleMutation = useApiMutation<
{
id: number;
display_name?: string;
description?: string;
permission_ids: number[];
},
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/roles/${id}`,
method: () => "PUT",
invalidate: ["settings", "users"],
onSuccess: (data) => {
closeModal();
alert.success(data?.message || "Role byla aktualizována");
},
});
const deleteRoleMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/roles/${id}`,
method: () => "DELETE",
invalidate: ["settings", "users"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, role: null });
alert.success(data?.message || "Role byla smazána");
},
});
const handleSaveSystemSettings = async () => {
setSysSettingsSaving(true);
try {
const response = await apiFetch(`${API_BASE}/company-settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sysForm),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Systémová nastavení byla uložena");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSysSettingsSaving(false);
await saveSystemSettingsMutation.mutateAsync(sysForm);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleToggle2FARequired = async () => {
setRequire2FASaving(true);
try {
const response = await apiFetch(`${API_BASE}/totp/required`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ required: !require2FA }),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "2FA nastavení uloženo");
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setRequire2FASaving(false);
await toggle2FARoleMutation.mutateAsync({ required: !require2FA });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -389,91 +429,53 @@ export default function Settings() {
return;
}
setSaving(true);
const permission_ids = form.permissions
.map((name) => {
// Find permission ID by name from groups
for (const perms of Object.values(permissionGroups)) {
const found = perms.find((p) => p.name === name);
if (found) return found.id;
}
return null;
})
.filter((id): id is number => id !== null);
try {
const url = editingRole
? `${API_BASE}/roles/${editingRole.id}`
: `${API_BASE}/roles`;
const response = await apiFetch(url, {
method: editingRole ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
permission_ids: form.permissions
.map((name) => {
// Find permission ID by name from groups
for (const perms of Object.values(permissionGroups)) {
const found = perms.find((p) => p.name === name);
if (found) return found.id;
}
return null;
})
.filter(Boolean),
}),
});
const result = await response.json();
if (result.success) {
closeModal();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.message ||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
);
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
if (editingRole) {
await updateRoleMutation.mutateAsync({
id: editingRole.id,
display_name: form.display_name,
description: form.description,
permission_ids,
});
} else {
alert.error(result.error || "Nepodařilo se uložit roli");
await createRoleMutation.mutateAsync({
name: form.name,
display_name: form.display_name,
description: form.description,
permission_ids,
});
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!deleteConfirm.role) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/roles/${deleteConfirm.role.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, role: null });
alert.success(result.message || "Role byla smazána");
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se smazat roli");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
await deleteRoleMutation.mutateAsync(deleteConfirm.role.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (canManageRoles && rolesLoading) {
return (
<Skeleton
name="settings"
loading={rolesLoading}
fixture={<SettingsFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -482,13 +484,9 @@ export default function Settings() {
const get2FADescription = (): React.ReactNode => {
if (require2FALoading) {
return (
<Skeleton
name="settings-2fa"
loading={require2FALoading}
fixture={<SettingsFixture />}
>
<span />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
if (require2FA)
@@ -497,12 +495,14 @@ export default function Settings() {
};
const get2FAButtonLabel = (): string => {
if (require2FASaving) return "Ukládání...";
if (toggle2FARoleMutation.isPending) return "Ukládání...";
return require2FA ? "Vypnout" : "Zapnout";
};
const renderRoleButtonContent = (): React.ReactNode => {
if (saving) {
const isPending =
createRoleMutation.isPending || updateRoleMutation.isPending;
if (isPending) {
return (
<>
<div className="admin-spinner admin-spinner-sm" />
@@ -634,7 +634,7 @@ export default function Settings() {
{!require2FALoading && (
<button
onClick={handleToggle2FARequired}
disabled={require2FASaving}
disabled={toggle2FARoleMutation.isPending}
className={`admin-btn admin-btn-sm ${require2FA ? "admin-btn-secondary" : "admin-btn-primary"}`}
style={require2FA ? { color: "var(--danger)" } : {}}
>
@@ -693,9 +693,11 @@ export default function Settings() {
<button
onClick={handleSaveSystemSettings}
className="admin-btn admin-btn-primary admin-btn-sm"
disabled={sysSettingsSaving}
disabled={saveSystemSettingsMutation.isPending}
>
{sysSettingsSaving ? "Ukládání..." : "Uložit"}
{saveSystemSettingsMutation.isPending
? "Ukládání..."
: "Uložit"}
</button>
</div>
</div>
@@ -852,13 +854,9 @@ export default function Settings() {
{activeTab === "system" && (
<>
{sysSettingsLoading && !sysFormInitialized ? (
<Skeleton
name="settings-system"
loading={sysSettingsLoading && !sysFormInitialized}
fixture={<SettingsFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{/* Section 1: Docházka */}
@@ -1346,10 +1344,10 @@ export default function Settings() {
>
<button
onClick={handleSaveSystemSettings}
disabled={sysSettingsSaving}
disabled={saveSystemSettingsMutation.isPending}
className="admin-btn admin-btn-primary w-full"
>
{sysSettingsSaving ? (
{saveSystemSettingsMutation.isPending ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
@@ -1368,218 +1366,163 @@ export default function Settings() {
{activeTab === "firma" && <CompanySettings embedded />}
{/* Create/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={closeModal} />
<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 }}
<FormModal
isOpen={showModal}
onClose={closeModal}
onSubmit={handleSubmit}
title={editingRole ? "Upravit roli" : "Nová role"}
size="lg"
loading={createRoleMutation.isPending || updateRoleMutation.isPending}
>
<div className="admin-form">
{editingRole && isAdminRole(editingRole) && (
<div className="admin-role-locked-notice">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Administrátor vždy plný přístup ke všem funkcím
</div>
)}
<FormField label="Zobrazovaný název">
<input
type="text"
value={form.display_name}
onChange={(e) => handleDisplayNameChange(e.target.value)}
className="admin-form-input"
placeholder="např. Manažer"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<FormField label="Systémový název (slug)">
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
className="admin-form-input"
placeholder="např. manager"
disabled={!!editingRole}
/>
{!editingRole && (
<small
className="text-xs"
style={{
color: "var(--text-tertiary)",
}}
>
Pouze malá písmena, čísla a pomlčky. Nelze později změnit.
</small>
)}
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) =>
setForm((prev) => ({
...prev,
description: e.target.value,
}))
}
className="admin-form-input"
rows={2}
placeholder="Volitelný popis role"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<div className="admin-form-group">
<label
className="admin-form-label"
style={{ marginBottom: "0.75rem" }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingRole ? "Upravit roli" : "Nová role"}
</h2>
</div>
Oprávnění
</label>
<div className="admin-modal-body">
<div className="admin-form">
{editingRole && isAdminRole(editingRole) && (
<div className="admin-role-locked-notice">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Administrátor vždy plný přístup ke všem funkcím
</div>
)}
{Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => {
if (a === "settings") return 1;
if (b === "settings") return -1;
const aMin = Math.min(...aPerms.map((p) => p.id));
const bMin = Math.min(...bPerms.map((p) => p.id));
return aMin - bMin;
})
.map(([module, perms], index) => {
const modulePerms = perms.map((p) => p.name);
const allChecked = modulePerms.every((p) =>
form.permissions.includes(p),
);
const someChecked = modulePerms.some((p) =>
form.permissions.includes(p),
);
const disabled = !!(editingRole && isAdminRole(editingRole));
<FormField label="Zobrazovaný název">
<input
type="text"
value={form.display_name}
onChange={(e) => handleDisplayNameChange(e.target.value)}
className="admin-form-input"
placeholder="např. Manažer"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<FormField label="Systémový název (slug)">
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
className="admin-form-input"
placeholder="např. manager"
disabled={!!editingRole}
/>
{!editingRole && (
<small
className="text-xs"
return (
<div key={module}>
{index > 0 && (
<hr
style={{
color: "var(--text-tertiary)",
border: "none",
borderTop: "1px solid var(--border-color, #e0e0e0)",
margin: "0.75rem 0",
}}
>
Pouze malá písmena, čísla a pomlčky. Nelze později
změnit.
</small>
/>
)}
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) =>
setForm((prev) => ({
...prev,
description: e.target.value,
}))
}
className="admin-form-input"
rows={2}
placeholder="Volitelný popis role"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<div className="admin-form-group">
<label
className="admin-form-label"
style={{ marginBottom: "0.75rem" }}
>
Oprávnění
</label>
{Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => {
if (a === "settings") return 1;
if (b === "settings") return -1;
const aMin = Math.min(...aPerms.map((p) => p.id));
const bMin = Math.min(...bPerms.map((p) => p.id));
return aMin - bMin;
})
.map(([module, perms], index) => {
const modulePerms = perms.map((p) => p.name);
const allChecked = modulePerms.every((p) =>
form.permissions.includes(p),
);
const someChecked = modulePerms.some((p) =>
form.permissions.includes(p),
);
const disabled = !!(
editingRole && isAdminRole(editingRole)
);
return (
<div key={module}>
{index > 0 && (
<hr
style={{
border: "none",
borderTop:
"1px solid var(--border-color, #e0e0e0)",
margin: "0.75rem 0",
}}
<div className="admin-permission-group">
<div className="admin-permission-group-title">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
if (el)
el.indeterminate = someChecked && !allChecked;
}}
onChange={() => toggleModulePermissions(module)}
disabled={disabled}
/>
<span>{MODULE_LABELS[module] || module}</span>
</label>
</div>
<div className="admin-permission-list">
{perms.map((perm) => (
<div key={perm.id} className="admin-permission-item">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.permissions.includes(perm.name)}
onChange={() => togglePermission(perm.name)}
disabled={disabled}
/>
<span>{perm.display_name}</span>
</label>
{perm.description && (
<div className="admin-permission-desc">
{perm.description}
</div>
)}
<div className="admin-permission-group">
<div className="admin-permission-group-title">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
if (el)
el.indeterminate =
someChecked && !allChecked;
}}
onChange={() =>
toggleModulePermissions(module)
}
disabled={disabled}
/>
<span>{MODULE_LABELS[module] || module}</span>
</label>
</div>
<div className="admin-permission-list">
{perms.map((perm) => (
<div
key={perm.id}
className="admin-permission-item"
>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.permissions.includes(
perm.name,
)}
onChange={() =>
togglePermission(perm.name)
}
disabled={disabled}
/>
<span>{perm.display_name}</span>
</label>
{perm.description && (
<div className="admin-permission-desc">
{perm.description}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
})}
))}
</div>
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
{!(editingRole && isAdminRole(editingRole)) && (
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{renderRoleButtonContent()}
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
})}
</div>
</div>
</FormModal>
{/* Delete Confirm Modal */}
<ConfirmModal
@@ -1591,7 +1534,7 @@ export default function Settings() {
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
loading={deleteRoleMutation.isPending}
/>
</div>
);