fix(attendance): admin month view no longer truncates at 100 records

The KPI cards and on-screen summary totals (computeUserTotals) are computed
client-side from one month fetch. The hook requested limit=1000 but
parsePagination silently clamped it to 100, so once a month exceeded 100
attendance rows (~5 employees x 21 shifts) the newest-first list dropped the
EARLIEST days of the month — screen totals undercounted while the print path
(complete server fetch) stayed correct. This is the data-volume cliff that
made the summary counting look like it changed without any formula change:
git archaeology confirms every counting formula is bit-identical from v1.9.1
through HEAD.

- routes/admin/attendance.ts: list endpoint passes maxLimit 2000 to
  parsePagination (complete-month view; 2000 ~ 64 employees x 31 days)
- useAttendanceAdmin: month fetch requests limit=2000 with the constraint
  documented
- regression test: a 120-row month returns all 120 records

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 10:20:40 +02:00
parent 1826fc7976
commit b3e2abf9b2
3 changed files with 48 additions and 2 deletions

View File

@@ -222,6 +222,42 @@ describe("POST /api/admin/attendance (combined datetimes)", () => {
}); });
}); });
describe("GET /api/admin/attendance (complete month, no truncation)", () => {
it("returns every record of a month with more than 100 rows", async () => {
// The admin month view computes the KPI cards and summary totals from
// this single fetch, so it must cover the COMPLETE month. With the old
// 100-row pagination cap, months with >100 records were silently
// truncated (newest-first) and the screen totals dropped the earliest
// days while the print stayed complete.
const rows = [];
for (let day = 1; day <= 30; day++) {
for (let s = 0; s < 4; s++) {
rows.push({
user_id: adminUserId,
shift_date: new Date(2098, 4, day, 12, 0, 0),
leave_type: "work" as const,
arrival_time: new Date(2098, 4, day, 6 + s, 0, 0),
departure_time: new Date(2098, 4, day, 10 + s, 0, 0),
notes: "attendance_wire_test bulk month",
});
}
}
await prisma.attendance.createMany({ data: rows });
const res = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=5&limit=2000",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data).toHaveLength(120);
expect(body.pagination.total).toBe(120);
});
});
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => { describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
it("updates a record with combined datetimes (incl. overnight departure)", async () => { it("updates a record with combined datetimes (incl. overnight departure)", async () => {
const created = await prisma.attendance.create({ const created = await prisma.attendance.create({

View File

@@ -867,7 +867,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
try { try {
const [yearStr, monthStr] = month.split("-"); const [yearStr, monthStr] = month.split("-");
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000`; // The KPI cards + summary totals are computed from THIS fetch — it
// must cover the complete month (the server allows limit up to 2000
// for exactly this view; 2000 ≈ 64 employees × 31 days).
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=2000`;
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`; if (filterUserId) recordsUrl += `&user_id=${filterUserId}`;
const [recordsResponse, balancesResponse] = await Promise.all([ const [recordsResponse, balancesResponse] = await Promise.all([

View File

@@ -219,7 +219,14 @@ export default async function attendanceRoutes(
} }
// --- Default: paginated records list --- // --- Default: paginated records list ---
const { page, limit, skip, order } = parsePagination(query); // maxLimit must allow a COMPLETE month: the admin month view fetches all
// records at once and computes the KPI cards / summary totals client-side
// (useAttendanceAdmin). With the default 100-row cap, months with more
// than 100 records were silently truncated (newest-first), so the screen
// totals dropped the earliest days while the print stayed complete.
const { page, limit, skip, order } = parsePagination(query, {
maxLimit: 2000,
});
const isAdmin = authData.permissions.includes("attendance.manage"); const isAdmin = authData.permissions.includes("attendance.manage");
const parsedUserId = query.user_id ? Number(query.user_id) : undefined; const parsedUserId = query.user_id ? Number(query.user_id) : undefined;
const userId = const userId =