Files
app/src/routes/admin/attendance.ts
BOHA 746d17e182 fix: parse YYYY-MM month filter correctly in attendance history
The frontend sends month as "YYYY-MM" but the route handler was passing
it through Number() which parsed only the year portion, causing the
service to ignore the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:29:47 +02:00

499 lines
17 KiB
TypeScript

import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
AttendanceNotesSchema,
AttendanceUpdateAddressSchema,
AttendanceSwitchProjectSchema,
AttendanceBalancesSchema,
AttendanceBulkSchema,
AttendanceLeaveSchema,
AttendancePunchSchema,
CreateAttendanceSchema,
UpdateAttendanceSchema,
} from "../../schemas/attendance.schema";
import * as attendanceService from "../../services/attendance.service";
import { localMonthStr } from "../../utils/date";
export default async function attendanceRoutes(
fastify: FastifyInstance,
): Promise<void> {
// GET /api/admin/attendance/status — clock-in/out page data
fastify.get(
"/status",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!;
const data = await attendanceService.getStatus(authData.userId);
return reply.send({ success: true, data });
},
);
// POST /api/admin/attendance/notes — save shift notes
fastify.post(
"/notes",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!;
const parsed = parseBody(AttendanceNotesSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const result = await attendanceService.saveNotes(
authData.userId,
body.notes ? String(body.notes) : null,
);
if ("error" in result) return error(reply, result.error!, 400);
return success(reply, null, 200, "Poznámka uložena");
},
);
// POST /api/admin/attendance/update-address — update GPS address after punch
fastify.post(
"/update-address",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!;
const parsed = parseBody(AttendanceUpdateAddressSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const result = await attendanceService.updateAddress(
authData.userId,
body.address ?? null,
body.punch_action,
);
if ("error" in result) return error(reply, result.error!, 404);
return success(reply, null, 200, "Adresa aktualizována");
},
);
// POST /api/admin/attendance/switch-project — switch active project on current shift
fastify.post(
"/switch-project",
{ preHandler: requireAuth },
async (request, reply) => {
const authData = request.authData!;
const parsed = parseBody(AttendanceSwitchProjectSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const newProjectId = body.project_id ? Number(body.project_id) : null;
const result = await attendanceService.switchProject(
authData.userId,
newProjectId,
);
if ("error" in result) return error(reply, result.error!, 400);
return success(reply, null, 200, "Projekt přepnut");
},
);
// GET /api/admin/attendance
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const authData = request.authData!;
const action = query.action ? String(query.action) : null;
// --- action=balances: leave balance overview for all users ---
if (action === "balances") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getBalances(yr);
return reply.send({ success: true, data });
}
// --- action=workfund: monthly work fund overview ---
if (action === "workfund") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getWorkfund(yr);
return reply.send({ success: true, data });
}
// --- action=project_report: monthly project hours ---
if (action === "project_report") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
const data = await attendanceService.getProjectReport(yr);
return reply.send({ success: true, data });
}
// --- action=print: attendance print data for admin ---
if (action === "print") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const monthStr = query.month
? String(query.month)
: localMonthStr(new Date());
const filterUserId = query.user_id ? Number(query.user_id) : null;
const data = await attendanceService.getPrintData(monthStr, filterUserId);
return reply.send({ success: true, data });
}
// --- action=attendance_users: users with attendance.record permission ---
if (action === "attendance_users") {
if (
!authData.permissions.includes("attendance.admin") &&
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const users = await prisma.users.findMany({
where: {
is_active: true,
roles: {
role_permissions: {
some: { permissions: { name: "attendance.record" } },
},
},
},
select: { id: true, first_name: true, last_name: true, username: true },
orderBy: { last_name: "asc" },
});
return reply.send({
success: true,
data: users.map((u) => ({
id: u.id,
first_name: u.first_name,
last_name: u.last_name,
username: u.username,
})),
});
}
// --- action=projects: active projects for attendance project switching ---
if (action === "projects") {
const data = await attendanceService.getActiveProjects();
return reply.send({ success: true, data });
}
// --- action=project_logs: get project logs for a specific attendance record ---
if (action === "project_logs") {
if (
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const attendanceId = Number(query.attendance_id);
if (!attendanceId) return error(reply, "Missing attendance_id", 400);
const data = await attendanceService.getProjectLogs(attendanceId);
return reply.send({ success: true, data });
}
// --- action=location: single record with GPS data ---
if (action === "location") {
const id = Number(query.id);
if (!id) return error(reply, "Missing id", 400);
const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, "Záznam nenalezen", 404);
const isAdmin = authData.permissions.includes("attendance.admin");
if (record.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nedostatečná oprávnění", 403);
}
return reply.send({ success: true, data: record });
}
// --- Default: paginated records list ---
const { page, limit, skip, order } = parsePagination(query);
const isAdmin = authData.permissions.includes("attendance.admin");
const userId = query.user_id ? Number(query.user_id) : undefined;
const result = await attendanceService.listAttendance({
page,
limit,
skip,
order,
userId,
isAdmin,
authUserId: authData.userId,
month: query.month
? Number(String(query.month).split("-")[1])
: undefined,
year: query.month
? Number(String(query.month).split("-")[0])
: query.year
? Number(query.year)
: undefined,
});
return reply.send({
success: true,
data: result.records,
pagination: buildPaginationMeta(result.total, result.page, result.limit),
});
});
// POST /api/admin/attendance
fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
const rawBody = request.body as Record<string, unknown>;
const authData = request.authData!;
const postQuery = request.query as Record<string, unknown>;
// --- action=balances: edit or reset leave balance ---
if (postQuery.action === "balances") {
if (!authData.permissions.includes("attendance.balances")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const balParsed = parseBody(AttendanceBalancesSchema, rawBody);
if ("error" in balParsed) return error(reply, balParsed.error, 400);
const balBody = balParsed.data;
const result = await attendanceService.handleBalances({
user_id: balBody.user_id,
year: balBody.year,
action_type: balBody.action_type,
vacation_total: balBody.vacation_total,
vacation_used: balBody.vacation_used,
sick_used: balBody.sick_used,
});
if ("error" in result) return error(reply, result.error!, 400);
await logAudit({
request,
authData,
action: "update",
entityType: "leave_balance",
entityId: balBody.user_id,
description: result.message.includes("resetována")
? `Resetována bilance pro rok ${result.year}`
: `Upravena bilance dovolené pro rok ${result.year}`,
});
return success(reply, null, 200, result.message);
}
// --- action=bulk_attendance: bulk fill month ---
if (postQuery.action === "bulk_attendance") {
if (!authData.permissions.includes("attendance.admin")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const bulkParsed = parseBody(AttendanceBulkSchema, rawBody);
if ("error" in bulkParsed) return error(reply, bulkParsed.error, 400);
const bulkBody = bulkParsed.data;
const result = await attendanceService.bulkCreateAttendance({
month: bulkBody.month,
user_ids: bulkBody.user_ids,
arrival_time: bulkBody.arrival_time,
departure_time: bulkBody.departure_time,
break_start_time: bulkBody.break_start_time,
break_end_time: bulkBody.break_end_time,
});
await logAudit({
request,
authData,
action: "create",
entityType: "attendance",
entityId: 0,
description: `Hromadně vytvořeno ${result.inserted} záznamů docházky pro ${bulkBody.month}`,
});
return success(
reply,
{ inserted: result.inserted, skipped: result.skipped },
200,
result.message,
);
}
// --- action=leave: add leave record directly ---
if (postQuery.action === "leave") {
const leaveParsed = parseBody(AttendanceLeaveSchema, rawBody);
if ("error" in leaveParsed) return error(reply, leaveParsed.error, 400);
const leaveBody = leaveParsed.data;
if (
leaveBody.user_id != null &&
leaveBody.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const result = await attendanceService.createLeave(
{
user_id: leaveBody.user_id,
date_from: leaveBody.date_from,
date_to: leaveBody.date_to,
leave_type: leaveBody.leave_type,
leave_hours: leaveBody.leave_hours,
notes: leaveBody.notes ?? undefined,
},
authData.userId,
);
if ("error" in result) return error(reply, result.error!, 400);
return success(reply, { created: result.created }, 200, result.message!);
}
// Punch action (arrival / departure / break_start) from Dashboard or Attendance page
if (rawBody.punch_action) {
const punchParsed = parseBody(AttendancePunchSchema, rawBody);
if ("error" in punchParsed) return error(reply, punchParsed.error, 400);
const punchBody = punchParsed.data;
const result = await attendanceService.punchAction(authData.userId, {
punch_action: punchBody.punch_action,
latitude: punchBody.latitude,
longitude: punchBody.longitude,
accuracy: punchBody.accuracy,
address: punchBody.address,
});
if ("error" in result) return error(reply, result.error!, 400);
await logAudit({
request,
authData,
action: result.auditAction,
entityType: "attendance",
entityId: result.id,
description: result.auditDescription,
});
return success(reply, { id: result.id }, result.status, result.message);
}
// Standard attendance record creation (from admin forms)
const stdParsed = parseBody(CreateAttendanceSchema, rawBody);
if ("error" in stdParsed) return error(reply, stdParsed.error, 400);
const body = stdParsed.data;
if (
body.user_id != null &&
body.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const result = await attendanceService.createAttendance(
{
user_id: body.user_id,
shift_date: body.shift_date,
arrival_time: body.arrival_time,
arrival_lat: body.arrival_lat,
arrival_lng: body.arrival_lng,
arrival_accuracy: body.arrival_accuracy,
arrival_address: body.arrival_address,
departure_time: body.departure_time,
departure_lat: body.departure_lat,
departure_lng: body.departure_lng,
departure_accuracy: body.departure_accuracy,
departure_address: body.departure_address,
notes: body.notes,
project_id: body.project_id,
leave_type: body.leave_type,
leave_hours: body.leave_hours,
project_logs: body.project_logs,
},
authData.userId,
);
if ("error" in result)
return error(reply, result.error!, result.status ?? 400);
await logAudit({
request,
authData,
action: "create",
entityType: "attendance",
entityId: result.id,
description: `Vytvořen záznam docházky`,
});
return success(reply, { id: result.id }, 201, "Záznam byl vytvořen");
});
// PUT /api/admin/attendance/:id
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("attendance.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateAttendanceSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authData = request.authData!;
const isAdmin = authData.permissions.includes("attendance.admin");
const result = await attendanceService.updateAttendance(
id,
{
arrival_time: body.arrival_time,
departure_time: body.departure_time,
break_start: body.break_start,
break_end: body.break_end,
notes: body.notes ?? undefined,
project_id:
body.project_id != null
? Number(body.project_id)
: (body.project_id as number | null | undefined),
leave_type: body.leave_type,
leave_hours:
body.leave_hours != null
? Number(body.leave_hours)
: (body.leave_hours as number | null | undefined),
project_logs: body.project_logs,
},
authData.userId,
isAdmin,
);
if ("error" in result) return error(reply, result.error!, result.status!);
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "attendance",
entityId: id,
description: `Upraven záznam docházky`,
});
return success(reply, { id }, 200, "Záznam byl aktualizován");
},
);
// DELETE /api/admin/attendance/:id
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("attendance.admin") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const result = await attendanceService.deleteAttendance(id);
if ("error" in result) return error(reply, result.error!, 404);
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "attendance",
entityId: id,
description: `Smazán záznam docházky`,
});
return success(reply, null, 200, "Záznam smazán");
},
);
}