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,5 +1,5 @@
import { useState, useEffect, useRef } 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 { Link } from "react-router-dom";
@@ -16,8 +16,8 @@ import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { Skeleton } from "boneyard-js/react";
import AttendanceFixture from "../fixtures/AttendanceFixture";
import { useApiMutation } from "../lib/queries/mutations";
import { czechPlural } from "../utils/formatters";
const API_BASE = "/api/admin";
@@ -65,7 +65,6 @@ interface MonthlyFund {
leave_hours: number;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
}
@@ -79,12 +78,6 @@ interface AttendanceData {
active_project_id: number | null;
}
function pluralizeDays(n: number) {
if (n === 1) return "den";
if (n >= 2 && n <= 4) return "dny";
return "dnů";
}
function getFundBarBackground(fund: MonthlyFund) {
if (fund.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
@@ -96,7 +89,6 @@ function getFundBarBackground(fund: MonthlyFund) {
export default function Attendance() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ["attendance", "status"],
@@ -109,6 +101,41 @@ export default function Attendance() {
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
});
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance"],
});
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
{
url: () => `${API_BASE}/attendance/notes`,
method: () => "POST",
invalidate: ["attendance"],
},
);
const switchProjectMutation = useApiMutation<
{ project_id: number | null },
{ message?: string }
>({
url: () => `${API_BASE}/attendance/switch-project`,
method: () => "POST",
invalidate: ["attendance"],
});
const leaveRequestMutation = useApiMutation<
{ leave_type: string; date_from: string; date_to: string; notes: string },
{ message?: string }
>({
url: () => `${API_BASE}/leave-requests`,
method: () => "POST",
invalidate: ["attendance", "leave-requests", "users"],
});
const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({
@@ -232,55 +259,29 @@ export default function Attendance() {
gpsData: Record<string, unknown> = {},
) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: action, ...gpsData }),
const result = await punchMutation.mutateAsync({
punch_action: action,
...gpsData,
});
if (response.status === 401) return;
const result = await response.json();
setSubmitting(false);
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
} else {
alert.error(result.error);
}
} catch {
punchTimeoutRef.current = setTimeout(() => {
alert.success(result?.message || "Uloženo");
}, 300);
} catch (e) {
setSubmitting(false);
alert.error("Chyba připojení");
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleBreak = async () => {
setSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: "break_start" }),
const result = await punchMutation.mutateAsync({
punch_action: "break_start",
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(result?.message || "Přestávka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSubmitting(false);
}
@@ -288,48 +289,22 @@ export default function Attendance() {
const handleSaveNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Poznámka byla uložena");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await notesMutation.mutateAsync({ notes });
alert.success("Poznámka byla uložena");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleSwitchProject = async (newProjectId: string | null) => {
setSwitchingProject(true);
try {
const response = await apiFetch(`${API_BASE}/attendance/switch-project`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: newProjectId || null }),
const result = await switchProjectMutation.mutateAsync({
project_id: newProjectId ? Number(newProjectId) : null,
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Projekt přepnut",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(result?.message || "Projekt přepnut");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSwitchingProject(false);
}
@@ -353,56 +328,33 @@ export default function Attendance() {
const handleRequestSubmit = async () => {
setRequestSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(leaveForm),
const result = await leaveRequestMutation.mutateAsync(leaveForm);
setIsLeaveModalOpen(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result?.message || "Žádost odeslána");
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setIsLeaveModalOpen(false);
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
await queryClient.invalidateQueries({
queryKey: ["leave-requests"],
});
await queryClient.invalidateQueries({
queryKey: ["leave"],
});
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.data?.message || result.message || "Žádost odeslána",
);
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setRequestSubmitting(false);
}
};
if (!statusQuery.data) {
if (statusQuery.isPending) {
return (
<Skeleton
name="attendance"
loading={statusQuery.isPending}
fixture={<AttendanceFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
if (!statusQuery.data) {
return null;
}
const {
ongoing_shift: ongoingShift,
@@ -671,7 +623,13 @@ export default function Attendance() {
{formatTime(shift.departure_time)}
</td>
<td className="admin-mono">
{formatMinutes(calculateWorkMinutes(shift), true)}
{formatMinutes(
calculateWorkMinutes({
...shift,
notes: shift.notes ?? undefined,
}),
true,
)}
</td>
{projects.length > 0 && (
<td>
@@ -741,7 +699,7 @@ export default function Attendance() {
{vacationDaysRemaining}
</span>
<span className="attendance-balance-unit">
{pluralizeDays(vacationDaysRemaining)}
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</span>
</div>
@@ -831,8 +789,6 @@ export default function Attendance() {
` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.holiday_hours > 0 &&
` + svátek ${data.monthly_fund.holiday_hours}h`}
{data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
@@ -1069,15 +1025,15 @@ export default function Attendance() {
leaveForm.date_to,
)}
</strong>{" "}
{(() => {
const d = calculateBusinessDays(
{czechPlural(
calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
);
if (d === 1) return "pracovní den";
if (d >= 2 && d <= 4) return "pracovní dny";
return "pracovních dnů";
})()}
),
"pracovní den",
"pracovní dny",
"pracovních dnů",
)}
</span>
<span className="text-muted">
{calculateBusinessDays(