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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user