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 { // 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; const authData = request.authData!; const action = query.action ? String(query.action) : null; // --- action=balances: leave balance overview for all users --- if (action === "balances") { 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") { 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") { 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") { const users = await prisma.users.findMany({ where: { is_active: true, roles: { is: { OR: [ { name: "admin" }, { 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") { 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); 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(query.month) : undefined, year: 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; const authData = request.authData!; const postQuery = request.query as Record; // --- 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; 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; 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, ); 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: requireAuth }, 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"); }, ); }