Compare commits
4 Commits
v2.4.45
...
875ef4d79e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
875ef4d79e | ||
|
|
a679f6e8ac | ||
|
|
3530dd247c | ||
|
|
996012e3d3 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.45",
|
"version": "2.4.47",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.45",
|
"version": "2.4.47",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.45",
|
"version": "2.4.47",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -347,6 +347,10 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
|
|||||||
<span class="summary-label">Přesčas:</span>
|
<span class="summary-label">Přesčas:</span>
|
||||||
<span class="summary-value">0,00h</span>
|
<span class="summary-value">0,00h</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="summary-row summary-alert">
|
||||||
|
<span class="summary-label">Chybějící hodiny:</span>
|
||||||
|
<span class="summary-value">152,00h</span>
|
||||||
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Svátek:</span>
|
<span class="summary-label">Svátek:</span>
|
||||||
<span class="summary-value">0,00h</span>
|
<span class="summary-value">0,00h</span>
|
||||||
|
|||||||
@@ -133,6 +133,70 @@ describe("computeMzdaSummary — built-in acceptance dataset (Dominik 5/2026)",
|
|||||||
it("Práce v noci = 36,75 — VČETNĚ směny 31.5.→1.6. (výplatnice s 31 h se mýlí)", () => {
|
it("Práce v noci = 36,75 — VČETNĚ směny 31.5.→1.6. (výplatnice s 31 h se mýlí)", () => {
|
||||||
expect(s.nightMinutes / 60).toBe(36.75);
|
expect(s.nightMinutes / 60).toBe(36.75);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Chybějící hodiny = 0 (145,25 + 24 pokrývá fond 152)", () => {
|
||||||
|
expect(s.manko).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeMzdaSummary — Chybějící hodiny (Dominik 6/2026, reálná data)", () => {
|
||||||
|
// Real production month exposing the gap the paušál hides: 21 attended days
|
||||||
|
// × 8 = 168 + 8h dovolená = 176 = fond exactly (weekend shifts nominally
|
||||||
|
// compensate 3 missing weekdays 1.6./11.6./15.6.), yet REAL worked time is
|
||||||
|
// only 136,5h → 31,5h below the fond. Přesčas must stay 0 (formulas are
|
||||||
|
// binding), manko must surface the 31,5.
|
||||||
|
const JUNE = [
|
||||||
|
W("2026-06-02", "06:15", "14:00", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-03", "06:30", "13:30", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-04", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-05", "06:15", "13:45", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-07", "07:15", "13:00", ["09:30", "10:00"]), // Sunday
|
||||||
|
W("2026-06-08", "07:15", "13:30", ["10:15", "10:45"]),
|
||||||
|
W("2026-06-09", "06:00", "11:15", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-10", "06:00", "11:45", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-12", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-14", "17:30", "2026-06-15 04:15:00", ["22:45", "23:15"]), // Sun→Mon
|
||||||
|
W("2026-06-16", "07:30", "13:30", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-17", "10:45", "13:15"),
|
||||||
|
W("2026-06-18", "06:00", "13:15", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-19", "07:00", "13:15", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-22", "06:00", "13:30", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-23", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-24", "05:30", "13:45", ["09:30", "10:00"]),
|
||||||
|
V("2026-06-25", 8),
|
||||||
|
W("2026-06-26", "05:45", "14:45", ["10:15", "10:45"]),
|
||||||
|
W("2026-06-27", "05:45", "17:00", ["11:15", "11:45"]), // Saturday
|
||||||
|
W("2026-06-29", "11:45", "14:30"),
|
||||||
|
W("2026-06-30", "06:30", "09:15"),
|
||||||
|
W("2026-06-30", "10:45", "13:30"),
|
||||||
|
];
|
||||||
|
const s = computeMzdaSummary(JUNE as never, "2026-06");
|
||||||
|
|
||||||
|
it("verified rows stay exactly as before (fond 176, odpracováno 168, přesčas 0)", () => {
|
||||||
|
expect(s.fund).toBe(176);
|
||||||
|
expect(s.businessDays).toBe(22);
|
||||||
|
expect(s.odpracovano).toBe(168); // 21 days × 8 flat
|
||||||
|
expect(s.vacationHours).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(136.5);
|
||||||
|
expect(s.prescas).toBe(0); // (136,5 + 8) − 176 < 0 — unchanged formula
|
||||||
|
expect(s.vcetneSvatku).toBe(168);
|
||||||
|
expect(s.svatekHours).toBe(0);
|
||||||
|
expect(s.weekendHours).toBe(26.25); // shifts starting 7.6., 14.6., 27.6.
|
||||||
|
// 14.6. shift (22:00–04:15 minus break = 5:45) + early starts before
|
||||||
|
// 06:00: 24.6. 05:30 (0:30), 26.6. 05:45 (0:15), 27.6. 05:45 (0:15).
|
||||||
|
expect(s.nightMinutes / 60).toBe(6.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Chybějící hodiny = 31,5 — the gap the paušál nominally hides", () => {
|
||||||
|
expect(s.manko).toBe(31.5); // 176 − (136,5 + 8)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nemoc a neplacené volno kryjí fond stejně jako dovolená", () => {
|
||||||
|
const withSick = [...JUNE, { ...V("2026-06-01", 8), leave_type: "sick" }];
|
||||||
|
const s2 = computeMzdaSummary(withSick as never, "2026-06");
|
||||||
|
expect(s2.sickHours).toBe(8);
|
||||||
|
expect(s2.manko).toBe(23.5); // 31,5 − 8
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeMzdaSummary — formula edge cases", () => {
|
describe("computeMzdaSummary — formula edge cases", () => {
|
||||||
|
|||||||
124
src/__tests__/attendance-workfund.test.ts
Normal file
124
src/__tests__/attendance-workfund.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { getWorkfund } from "../services/attendance.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print-recap parity for the Bilance page's monthly fund overview
|
||||||
|
* (getWorkfund), per the binding formulas (computeMzdaSummary):
|
||||||
|
* overtime (Přesčas) = max(0, worked + dovolená − fond)
|
||||||
|
* missing (Chybějící hodiny) = max(0, fond − (worked + dovolená + nemoc
|
||||||
|
* + neplacené volno))
|
||||||
|
* The two coverage bases differ deliberately: only dovolená earns toward
|
||||||
|
* overtime, every approved absence covers the fond.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const USERNAME = "wf_parity_test_user";
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
const june = (day: string) => new Date(`2026-06-${day}T00:00:00Z`);
|
||||||
|
const dt = (day: string, hm: string) => new Date(`2026-06-${day}T${hm}:00`);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.attendance.deleteMany({
|
||||||
|
where: { users: { username: USERNAME } },
|
||||||
|
});
|
||||||
|
await prisma.users.deleteMany({ where: { username: USERNAME } });
|
||||||
|
|
||||||
|
// getAttendanceUsers includes only users whose role carries
|
||||||
|
// attendance.record — the seeded admin role has every permission.
|
||||||
|
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||||
|
if (!adminRole) throw new Error("Admin role not found — seed the DB first");
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: USERNAME,
|
||||||
|
email: `${USERNAME}@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||||
|
first_name: "Workfund",
|
||||||
|
last_name: "Parity",
|
||||||
|
is_active: true,
|
||||||
|
role_id: adminRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
// June 2026 (fond 176 = 22 non-holiday weekdays × 8):
|
||||||
|
// 10 work days of 8h net (08:00–16:30, 30min break) → worked 80
|
||||||
|
const workDays = ["01", "02", "03", "04", "05", "08", "09", "10", "11", "12"];
|
||||||
|
for (const d of workDays) {
|
||||||
|
await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
shift_date: june(d),
|
||||||
|
arrival_time: dt(d, "08:00"),
|
||||||
|
departure_time: dt(d, "16:30"),
|
||||||
|
break_start: dt(d, "12:00"),
|
||||||
|
break_end: dt(d, "12:30"),
|
||||||
|
leave_type: "work",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// dovolená 24, nemoc 16, neplacené volno 8
|
||||||
|
const leaves: Array<[string, string, number]> = [
|
||||||
|
["15", "vacation", 8],
|
||||||
|
["16", "vacation", 8],
|
||||||
|
["17", "vacation", 8],
|
||||||
|
["18", "sick", 8],
|
||||||
|
["19", "sick", 8],
|
||||||
|
["22", "unpaid", 8],
|
||||||
|
];
|
||||||
|
for (const [d, type, hours] of leaves) {
|
||||||
|
await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
shift_date: june(d),
|
||||||
|
leave_type: type as never,
|
||||||
|
leave_hours: hours,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.attendance.deleteMany({ where: { user_id: userId } });
|
||||||
|
await prisma.users.deleteMany({ where: { id: userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getWorkfund — print-recap parity", () => {
|
||||||
|
it("computes overtime from worked+dovolená and missing from all approved absences", async () => {
|
||||||
|
const data = await getWorkfund(2026);
|
||||||
|
// getWorkfund's future-year early return types months as a bare {} —
|
||||||
|
// narrow to the populated shape (2026 is never a future year here).
|
||||||
|
const months = data.months as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
fund: number;
|
||||||
|
users: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
worked: number;
|
||||||
|
covered: number;
|
||||||
|
covered_prescas: number;
|
||||||
|
overtime: number;
|
||||||
|
missing: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
const juneData = months["6"];
|
||||||
|
expect(juneData).toBeTruthy();
|
||||||
|
expect(juneData.fund).toBe(176);
|
||||||
|
|
||||||
|
const us = juneData.users[String(userId)];
|
||||||
|
expect(us).toBeTruthy();
|
||||||
|
expect(us.worked).toBe(80);
|
||||||
|
// covered (manko base): 80 + 24 + 16 + 8
|
||||||
|
expect(us.covered).toBe(128);
|
||||||
|
// covered_prescas (Přesčas base): 80 + 24 — sick/unpaid excluded
|
||||||
|
expect(us.covered_prescas).toBe(104);
|
||||||
|
// Chybějící hodiny: 176 − 128
|
||||||
|
expect(us.missing).toBe(48);
|
||||||
|
// Přesčas: max(0, 104 − 176)
|
||||||
|
expect(us.overtime).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,6 +97,7 @@ interface UserTotal {
|
|||||||
weekend_hours?: number;
|
weekend_hours?: number;
|
||||||
night_minutes?: number;
|
night_minutes?: number;
|
||||||
worked_holiday_hours?: number; // sum of hours worked ON a holiday
|
worked_holiday_hours?: number; // sum of hours worked ON a holiday
|
||||||
|
manko?: number; // Chybějící hodiny: real clock time below the fond
|
||||||
records?: AttendanceRecord[];
|
records?: AttendanceRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +244,7 @@ function computeUserTotals(
|
|||||||
t.weekend_hours = s.weekendHours;
|
t.weekend_hours = s.weekendHours;
|
||||||
t.night_minutes = s.nightMinutes;
|
t.night_minutes = s.nightMinutes;
|
||||||
t.worked_holiday_hours = s.svatekHours;
|
t.worked_holiday_hours = s.svatekHours;
|
||||||
|
t.manko = s.manko;
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals;
|
return totals;
|
||||||
@@ -427,6 +429,10 @@ export function buildUserSectionHtml(
|
|||||||
<span class="summary-label">Přesčas:</span>
|
<span class="summary-label">Přesčas:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="summary-row${s.manko > 0 ? " summary-alert" : ""}">
|
||||||
|
<span class="summary-label">Chybějící hodiny:</span>
|
||||||
|
<span class="summary-value">${formatHoursDecimal(s.manko * 60)}h</span>
|
||||||
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Svátek:</span>
|
<span class="summary-label">Svátek:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span>
|
||||||
@@ -547,6 +553,9 @@ export function buildPrintHtml(
|
|||||||
}
|
}
|
||||||
.user-summary .summary-label { color: #555; }
|
.user-summary .summary-label { color: #555; }
|
||||||
.user-summary .summary-value { font-weight: 600; }
|
.user-summary .summary-value { font-weight: 600; }
|
||||||
|
/* Chybějící hodiny > 0 — must jump out of the sheet, not hide in it. */
|
||||||
|
.user-summary .summary-alert .summary-label,
|
||||||
|
.user-summary .summary-alert .summary-value { color: #b00020; font-weight: 700; }
|
||||||
.user-summary .summary-fund {
|
.user-summary .summary-fund {
|
||||||
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export interface BalancesData {
|
|||||||
export interface FundUserData {
|
export interface FundUserData {
|
||||||
name: string;
|
name: string;
|
||||||
worked: number;
|
worked: number;
|
||||||
|
/** worked + dovolená + nemoc + neplacené volno — covers the fond (manko base). */
|
||||||
covered: number;
|
covered: number;
|
||||||
|
/** worked + dovolená only — the Přesčas base (print-recap parity). */
|
||||||
|
covered_prescas: number;
|
||||||
overtime: number;
|
overtime: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,28 +46,29 @@ interface UserTotalData {
|
|||||||
prescas?: number;
|
prescas?: number;
|
||||||
svatek?: number;
|
svatek?: number;
|
||||||
worked_holiday_hours?: number;
|
worked_holiday_hours?: number;
|
||||||
|
/** Chybějící hodiny: real clock time below the fond (approved absences count). */
|
||||||
|
manko?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fond "used" total mirrors the Fond footer line below:
|
/** Fond coverage — print-recap parity (computeMzdaSummary): real worked
|
||||||
* real_worked + vacation + worked_holiday + free_holiday. */
|
* hours + every approved absence (dovolená + nemoc + neplacené volno).
|
||||||
|
* This is the base the Chybějící hodiny line measures against. */
|
||||||
function getFondUsed(data: UserTotalData): number {
|
function getFondUsed(data: UserTotalData): number {
|
||||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
||||||
return (
|
return (
|
||||||
realWorked +
|
realWorked +
|
||||||
(data.vacation_hours ?? 0) +
|
(data.vacation_hours ?? 0) +
|
||||||
(data.worked_holiday_hours ?? 0) +
|
(data.sick_hours ?? 0) +
|
||||||
(data.svatek ?? 0)
|
(data.unpaid_hours ?? 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring
|
/** Bar color follows the print recap: Přesčas > 0 → warning, Chybějící
|
||||||
* the legacy getFundBarBackground conditional: over fund → warning, exactly
|
* hodiny > 0 → error, exactly covered → success. */
|
||||||
* at fund → success, under fund → primary (was the accent gradient). */
|
|
||||||
function getFundBarColor(data: UserTotalData): ProgressColor {
|
function getFundBarColor(data: UserTotalData): ProgressColor {
|
||||||
const delta = getFondUsed(data) - (data.fund ?? 0);
|
if ((data.prescas ?? 0) > 0) return "warning";
|
||||||
if (delta > 0) return "warning";
|
if ((data.manko ?? 0) > 0) return "error";
|
||||||
if (delta >= 0) return "success";
|
return "success";
|
||||||
return "primary";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PrintIcon = (
|
const PrintIcon = (
|
||||||
@@ -293,17 +294,24 @@ export default function AttendanceAdmin() {
|
|||||||
label={`Nep: ${ut.unpaid_hours}h`}
|
label={`Nep: ${ut.unpaid_hours}h`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{ut.manko !== undefined && ut.manko > 0 && (
|
||||||
|
<StatusChip
|
||||||
|
color="error"
|
||||||
|
label={`Chybí: ${String(Math.round(ut.manko * 100) / 100).replace(".", ",")}h`}
|
||||||
|
title="Skutečně odpracované hodiny + schválené absence nedosahují fondu měsíce"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Fond usage */}
|
{/* Fond usage — the ± badges are the PRINT recap values
|
||||||
|
(Přesčas / Chybějící hodiny), so the card and the
|
||||||
|
printed sheet can never disagree. */}
|
||||||
{ut.fund !== null &&
|
{ut.fund !== null &&
|
||||||
(() => {
|
(() => {
|
||||||
// Fond "used" = real_worked + vacation +
|
|
||||||
// worked_holiday + free_holiday (everything the user
|
|
||||||
// actually got paid for this month).
|
|
||||||
const fondUsed = getFondUsed(ut);
|
const fondUsed = getFondUsed(ut);
|
||||||
const fundVal = ut.fund ?? 0;
|
const fundVal = ut.fund ?? 0;
|
||||||
const delta = fondUsed - fundVal;
|
const prescas = ut.prescas ?? 0;
|
||||||
|
const manko = ut.manko ?? 0;
|
||||||
const pct = Math.min(
|
const pct = Math.min(
|
||||||
100,
|
100,
|
||||||
(fondUsed / (ut.fund || 1)) * 100,
|
(fondUsed / (ut.fund || 1)) * 100,
|
||||||
@@ -325,26 +333,28 @@ export default function AttendanceAdmin() {
|
|||||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||||
{formatHoursDecimal(fundVal * 60)}h
|
{formatHoursDecimal(fundVal * 60)}h
|
||||||
</Typography>
|
</Typography>
|
||||||
{delta > 0 && (
|
{prescas > 0 && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
title="Přesčas (worked + dovolená nad fond)"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "warning.main",
|
color: "warning.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+{formatHoursDecimal(delta * 60)}h
|
+{formatHoursDecimal(prescas * 60)}h
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{delta < 0 && (
|
{manko > 0 && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
title="Chybějící hodiny do fondu"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "error.main",
|
color: "error.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
-{formatHoursDecimal(manko * 60)}h
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export default function AttendanceBalances() {
|
|||||||
let totalFund = 0;
|
let totalFund = 0;
|
||||||
let totalWorked = 0;
|
let totalWorked = 0;
|
||||||
let totalCovered = 0;
|
let totalCovered = 0;
|
||||||
|
let totalCoveredPrescas = 0;
|
||||||
for (const monthData of Object.values(fundData.months)) {
|
for (const monthData of Object.values(fundData.months)) {
|
||||||
// Use prorated fund (fund_to_date) for current month, full fund for past
|
// Use prorated fund (fund_to_date) for current month, full fund for past
|
||||||
totalFund += monthData.fund_to_date ?? monthData.fund;
|
totalFund += monthData.fund_to_date ?? monthData.fund;
|
||||||
@@ -258,15 +259,20 @@ export default function AttendanceBalances() {
|
|||||||
if (us) {
|
if (us) {
|
||||||
totalWorked += us.worked;
|
totalWorked += us.worked;
|
||||||
totalCovered += us.covered;
|
totalCovered += us.covered;
|
||||||
|
// Older cached payloads may lack covered_prescas — fall back to
|
||||||
|
// covered (the pre-parity behavior) rather than dropping to 0.
|
||||||
|
totalCoveredPrescas += us.covered_prescas ?? us.covered;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Print-recap parity: missing (Chybějící hodiny) counts every approved
|
||||||
|
// absence; overtime (Přesčas) counts only worked + dovolená.
|
||||||
const missing = Math.max(
|
const missing = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round((totalFund - totalCovered) * 10) / 10,
|
Math.round((totalFund - totalCovered) * 10) / 10,
|
||||||
);
|
);
|
||||||
const overtime = Math.max(
|
const overtime = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round((totalCovered - totalFund) * 10) / 10,
|
Math.round((totalCoveredPrescas - totalFund) * 10) / 10,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
fund: totalFund,
|
fund: totalFund,
|
||||||
|
|||||||
@@ -411,6 +411,20 @@ export interface MzdaSummary {
|
|||||||
fund: number;
|
fund: number;
|
||||||
/** Mon–Fri non-holiday day count backing the fond. */
|
/** Mon–Fri non-holiday day count backing the fond. */
|
||||||
businessDays: number;
|
businessDays: number;
|
||||||
|
/** Nemoc: sum of sick leave_hours (evidence records). */
|
||||||
|
sickHours: number;
|
||||||
|
/** Neplacené volno: sum of unpaid leave_hours (evidence records). */
|
||||||
|
unpaidHours: number;
|
||||||
|
/**
|
||||||
|
* Chybějící hodiny (manko):
|
||||||
|
* max(0, fond − (worked_total + dovolená + nemoc + neplacené volno))
|
||||||
|
* — REAL clock hours below the fond. A supplement next to Přesčas, never a
|
||||||
|
* replacement: the paušál Odpracováno can nominally cover the fond (weekend
|
||||||
|
* work compensating missing weekdays) while dozens of real hours are
|
||||||
|
* missing — e.g. Dominik 6/2026: paušál 168 + 8 dovolená = fond 176, yet
|
||||||
|
* real worked was 136,5 → manko 31,5. This line makes that visible.
|
||||||
|
*/
|
||||||
|
manko: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute the payroll recap for one user's month of records. */
|
/** Compute the payroll recap for one user's month of records. */
|
||||||
@@ -429,6 +443,8 @@ export const computeMzdaSummary = (
|
|||||||
let weekendMinutes = 0;
|
let weekendMinutes = 0;
|
||||||
let nightMinutes = 0;
|
let nightMinutes = 0;
|
||||||
let vacationHours = 0;
|
let vacationHours = 0;
|
||||||
|
let sickHours = 0;
|
||||||
|
let unpaidHours = 0;
|
||||||
// Distinct non-holiday calendar days (start day) with a "Práce" record —
|
// Distinct non-holiday calendar days (start day) with a "Práce" record —
|
||||||
// two shifts on one day still count that day once.
|
// two shifts on one day still count that day once.
|
||||||
const workDays = new Set<string>();
|
const workDays = new Set<string>();
|
||||||
@@ -438,6 +454,8 @@ export const computeMzdaSummary = (
|
|||||||
if (lt !== "work") {
|
if (lt !== "work") {
|
||||||
// "holiday" placeholder records are evidence-only — skip everywhere.
|
// "holiday" placeholder records are evidence-only — skip everywhere.
|
||||||
if (lt === "vacation") vacationHours += Number(r.leave_hours) || 8;
|
if (lt === "vacation") vacationHours += Number(r.leave_hours) || 8;
|
||||||
|
else if (lt === "sick") sickHours += Number(r.leave_hours) || 8;
|
||||||
|
else if (lt === "unpaid") unpaidHours += Number(r.leave_hours) || 8;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const m = calculateWorkMinutesPrint(r);
|
const m = calculateWorkMinutesPrint(r);
|
||||||
@@ -468,6 +486,12 @@ export const computeMzdaSummary = (
|
|||||||
const workedHoursRaw = workedMinutes / 60;
|
const workedHoursRaw = workedMinutes / 60;
|
||||||
const odpracovano = workDays.size * 8;
|
const odpracovano = workDays.size * 8;
|
||||||
const prescas = Math.max(0, workedHoursRaw + vacationHours - fund);
|
const prescas = Math.max(0, workedHoursRaw + vacationHours - fund);
|
||||||
|
// Chybějící hodiny: REAL clock time (not the paušál) vs the fond — approved
|
||||||
|
// absences (dovolená/nemoc/neplacené volno) cover the fond.
|
||||||
|
const manko = Math.max(
|
||||||
|
0,
|
||||||
|
fund - (workedHoursRaw + vacationHours + sickHours + unpaidHours),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workedHoursRaw,
|
workedHoursRaw,
|
||||||
@@ -480,5 +504,8 @@ export const computeMzdaSummary = (
|
|||||||
nightMinutes,
|
nightMinutes,
|
||||||
fund,
|
fund,
|
||||||
businessDays,
|
businessDays,
|
||||||
|
sickHours,
|
||||||
|
unpaidHours,
|
||||||
|
manko,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -574,6 +574,7 @@ export async function getWorkfund(year: number) {
|
|||||||
name: string;
|
name: string;
|
||||||
worked: number;
|
worked: number;
|
||||||
covered: number;
|
covered: number;
|
||||||
|
covered_prescas: number;
|
||||||
overtime: number;
|
overtime: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
}
|
}
|
||||||
@@ -614,6 +615,7 @@ export async function getWorkfund(year: number) {
|
|||||||
name: string;
|
name: string;
|
||||||
worked: number;
|
worked: number;
|
||||||
covered: number;
|
covered: number;
|
||||||
|
covered_prescas: number;
|
||||||
overtime: number;
|
overtime: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
}
|
}
|
||||||
@@ -624,6 +626,7 @@ export async function getWorkfund(year: number) {
|
|||||||
let worked = 0;
|
let worked = 0;
|
||||||
let vacationHours = 0;
|
let vacationHours = 0;
|
||||||
let sickHours = 0;
|
let sickHours = 0;
|
||||||
|
let unpaidHours = 0;
|
||||||
for (const rec of recs) {
|
for (const rec of recs) {
|
||||||
const lt = (rec.leave_type as string) || "work";
|
const lt = (rec.leave_type as string) || "work";
|
||||||
if (lt === "work") {
|
if (lt === "work") {
|
||||||
@@ -639,21 +642,35 @@ export async function getWorkfund(year: number) {
|
|||||||
vacationHours += Number(rec.leave_hours) || 8;
|
vacationHours += Number(rec.leave_hours) || 8;
|
||||||
} else if (lt === "sick") {
|
} else if (lt === "sick") {
|
||||||
sickHours += Number(rec.leave_hours) || 8;
|
sickHours += Number(rec.leave_hours) || 8;
|
||||||
|
} else if (lt === "unpaid") {
|
||||||
|
unpaidHours += Number(rec.leave_hours) || 8;
|
||||||
}
|
}
|
||||||
// "holiday" is auto-derived from Czech public holidays.
|
// "holiday" is auto-derived from Czech public holidays.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print-recap parity (computeMzdaSummary — the binding formulas):
|
||||||
|
// overtime (Přesčas) = max(0, worked + dovolená − fond)
|
||||||
|
// missing (Chybějící hodiny) = max(0, fond − (worked + dovolená
|
||||||
|
// + nemoc + neplacené volno))
|
||||||
|
// The two coverage sets differ deliberately: only dovolená earns
|
||||||
|
// toward overtime, but every approved absence covers the fond.
|
||||||
const userFund = fundToDate;
|
const userFund = fundToDate;
|
||||||
const workedRound = Math.round(worked * 10) / 10;
|
const workedRound = Math.round(worked * 10) / 10;
|
||||||
const leaveHours = vacationHours + sickHours;
|
const coveredPrescas = Math.round((worked + vacationHours) * 10) / 10;
|
||||||
const covered = Math.round((worked + leaveHours) * 10) / 10;
|
const covered =
|
||||||
|
Math.round((worked + vacationHours + sickHours + unpaidHours) * 10) /
|
||||||
|
10;
|
||||||
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
|
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
|
||||||
const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
|
const overtime = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((coveredPrescas - userFund) * 10) / 10,
|
||||||
|
);
|
||||||
|
|
||||||
monthUsers[String(u.id)] = {
|
monthUsers[String(u.id)] = {
|
||||||
name: `${u.first_name} ${u.last_name}`.trim(),
|
name: `${u.first_name} ${u.last_name}`.trim(),
|
||||||
worked: workedRound,
|
worked: workedRound,
|
||||||
covered,
|
covered,
|
||||||
|
covered_prescas: coveredPrescas,
|
||||||
overtime,
|
overtime,
|
||||||
missing,
|
missing,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user