From 28eb58946fbd247563b3dd171d2fa67dec3ffa5d Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 09:06:40 +0100 Subject: [PATCH] refactor: extract attendance business logic into attendance.service.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/attendance.ts | 1057 +++------------------------- src/services/attendance.service.ts | 999 ++++++++++++++++++++++++++ 2 files changed, 1112 insertions(+), 944 deletions(-) create mode 100644 src/services/attendance.service.ts diff --git a/src/routes/admin/attendance.ts b/src/routes/admin/attendance.ts index 6308374..b15f831 100644 --- a/src/routes/admin/attendance.ts +++ b/src/routes/admin/attendance.ts @@ -1,6 +1,4 @@ import { FastifyInstance } from 'fastify'; -import { attendance_leave_type } from '@prisma/client'; -import prisma from '../../config/database'; import { requireAuth, requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; @@ -17,171 +15,15 @@ import { CreateAttendanceSchema, UpdateAttendanceSchema, } from '../../schemas/attendance.schema'; - -const VALID_LEAVE_TYPES = ['work', 'vacation', 'sick', 'holiday', 'unpaid'] as const; +import * as attendanceService from '../../services/attendance.service'; 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 now = new Date(); - const y = now.getFullYear(), m = now.getMonth(), d = now.getDate(); - const todayStart = new Date(y, m, d, 0, 0, 0); - const todayEnd = new Date(y, m, d, 23, 59, 59); - - // 1) Ongoing shift (no departure) - const ongoingShift = await prisma.attendance.findFirst({ - where: { user_id: authData.userId, departure_time: null, arrival_time: { not: null } }, - orderBy: { created_at: 'desc' }, - }); - - // 2) Today's completed shifts - const todayShifts = await prisma.attendance.findMany({ - where: { - user_id: authData.userId, - shift_date: { gte: todayStart, lte: todayEnd }, - departure_time: { not: null }, - }, - orderBy: { arrival_time: 'asc' }, - }); - - // 3) Leave balance - const balance = await prisma.leave_balances.findFirst({ - where: { user_id: authData.userId, year: y }, - }); - const leaveBalance = { - vacation_total: balance ? Number(balance.vacation_total) : 160, - vacation_used: balance ? Number(balance.vacation_used) : 0, - vacation_remaining: balance ? Number(balance.vacation_total) - Number(balance.vacation_used) : 160, - sick_used: balance ? Number(balance.sick_used) : 0, - }; - - // 4) Monthly fund — work hours this month - const monthStart = new Date(y, m, 1); - const monthEnd = new Date(y, m + 1, 0, 23, 59, 59); - const monthRecords = await prisma.attendance.findMany({ - where: { - user_id: authData.userId, - shift_date: { gte: monthStart, lte: monthEnd }, - }, - }); - - // Count working days in month (Mon-Fri) - let workingDays = 0; - const cur = new Date(y, m, 1); - while (cur.getMonth() === m) { - const dow = cur.getDay(); - if (dow !== 0 && dow !== 6) workingDays++; - cur.setDate(cur.getDate() + 1); - } - const fund = workingDays * 8; - - let workedHours = 0; - let vacationHours = 0; - let sickHours = 0; - let holidayHours = 0; - let unpaidHours = 0; - - for (const rec of monthRecords) { - const lt = (rec.leave_type as string) || 'work'; - if (lt !== 'work') { - const hrs = Number(rec.leave_hours) || 8; - if (lt === 'vacation') vacationHours += hrs; - else if (lt === 'sick') sickHours += hrs; - else if (lt === 'holiday') holidayHours += hrs; - else if (lt === 'unpaid') unpaidHours += hrs; - continue; - } - if (rec.arrival_time && rec.departure_time) { - let mins = (rec.departure_time.getTime() - rec.arrival_time.getTime()) / 60000; - if (rec.break_start && rec.break_end) { - mins -= (rec.break_end.getTime() - rec.break_start.getTime()) / 60000; - } - workedHours += Math.max(0, mins) / 60; - } - } - - const worked = Math.round(workedHours * 100) / 100; - // Subtract holidays from fund (holidays are non-working days, not part of coverage) - const holidayDays = monthRecords.filter(r => (r.leave_type as string) === 'holiday').length; - const adjustedFund = Math.max(0, (workingDays - holidayDays) * 8); - // Covered = worked + vacation + sick only (not holiday/unpaid — matching PHP) - const leaveHours = vacationHours + sickHours; - const covered = worked + leaveHours; - const remaining = Math.max(0, adjustedFund - covered); - const overtime = Math.max(0, covered - adjustedFund); - - const monthNames = [ - 'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen', - 'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec', - ]; - - const monthlyFund = { - month_name: `${monthNames[m]} ${y}`, - fund: adjustedFund, - business_days: workingDays - holidayDays, - worked, - covered, - remaining, - overtime, - leave_hours: leaveHours, - vacation_hours: vacationHours, - sick_hours: sickHours, - holiday_hours: holidayHours, - unpaid_hours: unpaidHours, - }; - - // 5) Project logs for ongoing shift - let projectLogs: Array<{ id: number; attendance_id: number; project_id: number; started_at: Date | null; ended_at: Date | null; project_name?: string }> = []; - let activeProjectId: number | null = null; - - if (ongoingShift) { - const logs = await prisma.attendance_project_logs.findMany({ - where: { attendance_id: ongoingShift.id }, - orderBy: { started_at: 'asc' }, - }); - - // Enrich with project names - const projectIds = [...new Set(logs.map(l => l.project_id))]; - const projectNames = new Map(); - if (projectIds.length > 0) { - const projects = await prisma.projects.findMany({ - where: { id: { in: projectIds } }, - select: { id: true, name: true, project_number: true }, - }); - for (const p of projects) { - projectNames.set(p.id, p.project_number ? `${p.project_number} – ${p.name}` : (p.name || '')); - } - } - - projectLogs = logs.map(l => ({ - ...l, - project_name: projectNames.get(l.project_id) || `Projekt #${l.project_id}`, - })); - - // Active project = the log entry with ended_at IS NULL - const activeLog = logs.find(l => l.ended_at === null); - if (activeLog) { - activeProjectId = activeLog.project_id; - } else { - // Fallback to attendance.project_id - activeProjectId = ongoingShift.project_id ?? null; - } - } - - return reply.send({ - success: true, - data: { - ongoing_shift: ongoingShift, - today_shifts: todayShifts, - leave_balance: leaveBalance, - monthly_fund: monthlyFund, - date: now.toISOString().split('T')[0], - project_logs: projectLogs, - active_project_id: activeProjectId, - }, - }); + const data = await attendanceService.getStatus(authData.userId); + return reply.send({ success: true, data }); }); // POST /api/admin/attendance/notes — save shift notes @@ -191,17 +33,8 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; - const ongoing = await prisma.attendance.findFirst({ - where: { user_id: authData.userId, departure_time: null, arrival_time: { not: null } }, - orderBy: { created_at: 'desc' }, - }); - if (!ongoing) return error(reply, 'Nemáte aktivní směnu.', 400); - - await prisma.attendance.update({ - where: { id: ongoing.id }, - data: { notes: body.notes ? String(body.notes) : null }, - }); - + 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'); }); @@ -211,23 +44,9 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis const parsed = parseBody(AttendanceUpdateAddressSchema, request.body); if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; - const addr = body.address ?? null; - const action = body.punch_action; - const latest = await prisma.attendance.findFirst({ - where: { user_id: authData.userId }, - orderBy: { created_at: 'desc' }, - }); - if (!latest) return error(reply, 'Nenalezen záznam', 404); - - const data: Record = {}; - if (action === 'departure') { - data.departure_address = addr; - } else { - data.arrival_address = addr; - } - - await prisma.attendance.update({ where: { id: latest.id }, 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'); }); @@ -238,39 +57,9 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; - const ongoing = await prisma.attendance.findFirst({ - where: { user_id: authData.userId, departure_time: null, arrival_time: { not: null } }, - orderBy: { created_at: 'desc' }, - }); - if (!ongoing) return error(reply, 'Nemáte aktivní směnu.', 400); - const newProjectId = body.project_id ? Number(body.project_id) : null; - const now = new Date(); - - // Close any currently open project log entry (set ended_at) - await prisma.attendance_project_logs.updateMany({ - where: { attendance_id: ongoing.id, ended_at: null }, - data: { ended_at: now }, - }); - - // If switching to a project (not "no project"), create a new open log entry - if (newProjectId) { - await prisma.attendance_project_logs.create({ - data: { - attendance_id: ongoing.id, - project_id: newProjectId, - started_at: now, - ended_at: null, - }, - }); - } - - // Also update the main attendance record - await prisma.attendance.update({ - where: { id: ongoing.id }, - data: { project_id: newProjectId }, - }); - + 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'); }); @@ -280,231 +69,25 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis const authData = request.authData!; const action = query.action ? String(query.action) : null; - const MONTH_NAMES = [ - 'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen', - 'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec', - ]; - // --- action=balances: leave balance overview for all users --- if (action === 'balances') { const yr = Number(query.year) || new Date().getFullYear(); - const users = await prisma.users.findMany({ - where: { is_active: true }, - select: { id: true, first_name: true, last_name: true }, - orderBy: { last_name: 'asc' }, - }); - - const balances: Record = {}; - for (const u of users) { - const lb = await prisma.leave_balances.findFirst({ where: { user_id: u.id, year: yr } }); - const vTotal = lb ? Number(lb.vacation_total) : 160; - const vUsed = lb ? Number(lb.vacation_used) : 0; - const sUsed = lb ? Number(lb.sick_used) : 0; - balances[String(u.id)] = { - name: `${u.first_name} ${u.last_name}`.trim(), - vacation_total: vTotal, - vacation_used: vUsed, - vacation_remaining: vTotal - vUsed, - sick_used: sUsed, - }; - } - - return reply.send({ - success: true, - data: { - users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })), - balances, - }, - }); + 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 users = await prisma.users.findMany({ - where: { is_active: true }, - select: { id: true, first_name: true, last_name: true }, - orderBy: { last_name: 'asc' }, - }); - - const yearStart = new Date(yr, 0, 1); - const yearEnd = new Date(yr, 11, 31, 23, 59, 59); - const allRecords = await prisma.attendance.findMany({ - where: { shift_date: { gte: yearStart, lte: yearEnd } }, - }); - - const months: Record }> = {}; - - for (let m = 0; m < 12; m++) { - // Count working days - let bizDays = 0; - const cur = new Date(yr, m, 1); - while (cur.getMonth() === m) { - const dow = cur.getDay(); - if (dow !== 0 && dow !== 6) bizDays++; - cur.setDate(cur.getDate() + 1); - } - const fund = bizDays * 8; - const monthStart = new Date(yr, m, 1); - const monthEnd = new Date(yr, m + 1, 0, 23, 59, 59); - - const monthUsers: Record = {}; - - for (const u of users) { - const recs = allRecords.filter(r => r.user_id === u.id && r.shift_date >= monthStart && r.shift_date <= monthEnd); - let worked = 0; - let vacationHours = 0; - let sickHours = 0; - let holidayDays = 0; - - for (const rec of recs) { - const lt = (rec.leave_type as string) || 'work'; - if (lt === 'work') { - if (rec.arrival_time && rec.departure_time) { - let mins = (rec.departure_time.getTime() - rec.arrival_time.getTime()) / 60000; - if (rec.break_start && rec.break_end) { - mins -= (rec.break_end.getTime() - rec.break_start.getTime()) / 60000; - } - worked += Math.max(0, mins) / 60; - } - } else if (lt === 'vacation') { - vacationHours += Number(rec.leave_hours) || 8; - } else if (lt === 'sick') { - sickHours += Number(rec.leave_hours) || 8; - } else if (lt === 'holiday') { - holidayDays++; - } - // unpaid: tracked but not counted toward covered - } - - // Subtract holidays from fund (matching PHP CzechHolidays logic) - const userFund = Math.max(0, (bizDays - holidayDays) * 8); - const workedRound = Math.round(worked * 10) / 10; - // Covered = worked + vacation + sick only (not holiday/unpaid) - const leaveHours = vacationHours + sickHours; - const covered = Math.round((worked + leaveHours) * 10) / 10; - const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10); - const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10); - - monthUsers[String(u.id)] = { - name: `${u.first_name} ${u.last_name}`.trim(), - worked: workedRound, - covered, - overtime, - missing, - }; - } - - months[String(m + 1)] = { - month_name: MONTH_NAMES[m], - fund, - business_days: bizDays, - users: monthUsers, - }; - } - - return reply.send({ - success: true, - data: { - months, - users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })), - balances: {}, - holidays: [], - }, - }); + 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 yearStart = new Date(yr, 0, 1); - const yearEnd = new Date(yr, 11, 31, 23, 59, 59); - - const records = await prisma.attendance.findMany({ - where: { - shift_date: { gte: yearStart, lte: yearEnd }, - leave_type: 'work', - arrival_time: { not: null }, - departure_time: { not: null }, - }, - include: { - users: { select: { id: true, first_name: true, last_name: true } }, - }, - }); - - // Fetch project info for records that have project_id - const projectIds = [...new Set(records.filter(r => r.project_id).map(r => r.project_id!))]; - const projectsMap = new Map(); - if (projectIds.length > 0) { - const projects = await prisma.projects.findMany({ - where: { id: { in: projectIds } }, - select: { id: true, name: true, project_number: true }, - }); - for (const p of projects) { - projectsMap.set(p.id, { name: p.name || '', project_number: p.project_number || '' }); - } - } - - const months: Record }> }> = {}; - - for (let m = 0; m < 12; m++) { - const monthStart = new Date(yr, m, 1); - const monthEnd = new Date(yr, m + 1, 0, 23, 59, 59); - const monthRecs = records.filter(r => r.shift_date >= monthStart && r.shift_date <= monthEnd); - - if (monthRecs.length === 0) continue; - - // Group by project_id - const projectMap = new Map }>(); - - for (const rec of monthRecs) { - let mins = (rec.departure_time!.getTime() - rec.arrival_time!.getTime()) / 60000; - if (rec.break_start && rec.break_end) { - mins -= (rec.break_end.getTime() - rec.break_start.getTime()) / 60000; - } - const hours = Math.max(0, mins) / 60; - const pid = rec.project_id; - - if (!projectMap.has(pid)) { - const projInfo = pid ? projectsMap.get(pid) : undefined; - projectMap.set(pid, { - project_number: projInfo?.project_number || undefined, - project_name: projInfo?.name || undefined, - userMap: new Map(), - }); - } - - const pg = projectMap.get(pid)!; - const uid = rec.user_id; - const uName = rec.users ? `${rec.users.first_name} ${rec.users.last_name}`.trim() : `User #${uid}`; - if (!pg.userMap.has(uid)) { - pg.userMap.set(uid, { name: uName, hours: 0 }); - } - pg.userMap.get(uid)!.hours += hours; - } - - const projects = Array.from(projectMap.entries()).map(([pid, pg]) => ({ - project_id: pid, - project_number: pg.project_number, - project_name: pg.project_name, - hours: Math.round(Array.from(pg.userMap.values()).reduce((s, u) => s + u.hours, 0) * 10) / 10, - users: Array.from(pg.userMap.entries()).map(([uid, ud]) => ({ - user_id: uid, - user_name: ud.name, - hours: Math.round(ud.hours * 10) / 10, - })), - })); - - months[String(m + 1)] = { - month_name: MONTH_NAMES[m], - projects, - }; - } - - return reply.send({ - success: true, - data: { months }, - }); + const data = await attendanceService.getProjectReport(yr); + return reply.send({ success: true, data }); } // --- action=print: attendance print data for admin --- @@ -514,191 +97,50 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis } const monthStr = query.month ? String(query.month) : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; - const [yearStr, monthNumStr] = monthStr.split('-'); - const yr = Number(yearStr); - const mo = Number(monthNumStr); const filterUserId = query.user_id ? Number(query.user_id) : null; - - const monthStart = new Date(yr, mo - 1, 1); - const monthEnd = new Date(yr, mo, 0, 23, 59, 59); - - const users = await prisma.users.findMany({ - where: { is_active: true }, - select: { id: true, first_name: true, last_name: true }, - orderBy: { last_name: 'asc' }, - }); - - const where: Record = { - shift_date: { gte: monthStart, lte: monthEnd }, - }; - if (filterUserId) where.user_id = filterUserId; - - const records = await prisma.attendance.findMany({ - where, - include: { users: { select: { id: true, first_name: true, last_name: true } } }, - orderBy: [{ user_id: 'asc' }, { shift_date: 'asc' }], - }); - - // Calculate user totals - const userTotals: Record = {}; - for (const rec of records) { - const uid = String(rec.user_id); - if (!userTotals[uid]) { - const u = rec.users; - userTotals[uid] = { name: u ? `${u.first_name} ${u.last_name}`.trim() : `User #${uid}`, worked: 0, vacation: 0, sick: 0, holiday: 0, unpaid: 0 }; - } - const lt = (rec.leave_type as string) || 'work'; - if (lt !== 'work') { - const hrs = Number(rec.leave_hours) || 8; - if (lt === 'vacation') userTotals[uid].vacation += hrs; - else if (lt === 'sick') userTotals[uid].sick += hrs; - else if (lt === 'holiday') userTotals[uid].holiday += hrs; - else if (lt === 'unpaid') userTotals[uid].unpaid += hrs; - } else if (rec.arrival_time && rec.departure_time) { - let mins = (rec.departure_time.getTime() - rec.arrival_time.getTime()) / 60000; - if (rec.break_start && rec.break_end) mins -= (rec.break_end.getTime() - rec.break_start.getTime()) / 60000; - userTotals[uid].worked += Math.max(0, mins) / 60; - } - } - - // Round - for (const uid of Object.keys(userTotals)) { - userTotals[uid].worked = Math.round(userTotals[uid].worked * 10) / 10; - } - - // Count working days in month for fund - let bizDays = 0; - const cur = new Date(yr, mo - 1, 1); - while (cur.getMonth() === mo - 1) { - const dow = cur.getDay(); - if (dow !== 0 && dow !== 6) bizDays++; - cur.setDate(cur.getDate() + 1); - } - - return reply.send({ - success: true, - data: { - user_totals: userTotals, - users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })), - month: monthStr, - month_name: `${MONTH_NAMES[mo - 1]} ${yr}`, - selected_user: filterUserId, - year: yr, - fund: { business_days: bizDays, hours: bizDays * 8 }, - }, - }); + const data = await attendanceService.getPrintData(monthStr, filterUserId); + return reply.send({ success: true, data }); } // --- action=projects: active projects for attendance project switching --- if (action === 'projects') { - const activeProjects = await prisma.projects.findMany({ - where: { status: 'aktivni' }, - select: { id: true, name: true, project_number: true }, - orderBy: { name: 'asc' }, - }); - return reply.send({ - success: true, - data: activeProjects.map(p => ({ - id: p.id, - name: p.project_number ? `${p.project_number} – ${p.name}` : p.name, - })), - }); + 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 logs = await prisma.attendance_project_logs.findMany({ - where: { attendance_id: attendanceId }, - orderBy: { started_at: 'asc' }, - }); - return reply.send({ success: true, data: logs }); + 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 prisma.attendance.findUnique({ - where: { id }, - include: { users: { select: { id: true, first_name: true, last_name: true } } }, - }); + 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 where: Record = {}; - if (!isAdmin) { - where.user_id = authData.userId; - } else if (userId) { - where.user_id = userId; - } - if (query.month && query.year) { - const year = Number(query.year); - const month = Number(query.month); - where.shift_date = { - gte: new Date(year, month - 1, 1), - lt: new Date(year, month, 1), - }; - } - - const [records, total] = await Promise.all([ - prisma.attendance.findMany({ - where, - skip, - take: limit, - orderBy: { shift_date: order }, - include: { - users: { select: { id: true, first_name: true, last_name: true, username: true } }, - attendance_project_logs: { orderBy: { started_at: 'asc' } }, - }, - }), - prisma.attendance.count({ where }), - ]); - - // Collect all project IDs for name resolution - const allProjectIds = new Set(); - for (const rec of records) { - if (rec.project_id) allProjectIds.add(rec.project_id); - for (const log of rec.attendance_project_logs) { - allProjectIds.add(log.project_id); - } - } - const projectNameMap = new Map(); - if (allProjectIds.size > 0) { - const projects = await prisma.projects.findMany({ - where: { id: { in: [...allProjectIds] } }, - select: { id: true, name: true, project_number: true }, - }); - for (const p of projects) { - projectNameMap.set(p.id, p.project_number ? `${p.project_number} – ${p.name}` : (p.name || '')); - } - } - - const enriched = records.map(rec => { - const logs = rec.attendance_project_logs.map(l => ({ - ...l, - project_name: projectNameMap.get(l.project_id) || `Projekt #${l.project_id}`, - })); - return { - ...rec, - project_name: rec.project_id ? (projectNameMap.get(rec.project_id) || null) : null, - project_logs: logs, - }; + 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: enriched, - pagination: buildPaginationMeta(total, page, limit), + data: result.records, + pagination: buildPaginationMeta(result.total, result.page, result.limit), }); }); @@ -718,50 +160,25 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis if ('error' in balParsed) return error(reply, balParsed.error, 400); const balBody = balParsed.data; - const userId = balBody.user_id; - const yr = balBody.year || new Date().getFullYear(); - const actionType = balBody.action_type; + 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 (actionType === 'edit') { - await prisma.leave_balances.upsert({ - where: { user_id_year: { user_id: userId, year: yr } }, - update: { - vacation_total: balBody.vacation_total != null ? Number(balBody.vacation_total) : undefined, - vacation_used: balBody.vacation_used != null ? Number(balBody.vacation_used) : undefined, - sick_used: balBody.sick_used != null ? Number(balBody.sick_used) : undefined, - updated_at: new Date(), - }, - create: { - user_id: userId, - year: yr, - vacation_total: Number(balBody.vacation_total) || 160, - vacation_used: Number(balBody.vacation_used) || 0, - sick_used: Number(balBody.sick_used) || 0, - }, - }); + if ('error' in result) return error(reply, result.error, 400); - await logAudit({ - request, authData, action: 'update', entityType: 'leave_balance', - entityId: userId, description: `Upravena bilance dovolené pro rok ${yr}`, - }); - return success(reply, null, 200, 'Bilance byla uložena'); - } - - if (actionType === 'reset') { - await prisma.leave_balances.upsert({ - where: { user_id_year: { user_id: userId, year: yr } }, - update: { vacation_used: 0, sick_used: 0, updated_at: new Date() }, - create: { user_id: userId, year: yr, vacation_total: 160, vacation_used: 0, sick_used: 0 }, - }); - - await logAudit({ - request, authData, action: 'update', entityType: 'leave_balance', - entityId: userId, description: `Resetována bilance pro rok ${yr}`, - }); - return success(reply, null, 200, 'Bilance byla resetována'); - } - - return error(reply, 'Neplatný typ akce', 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 --- @@ -774,68 +191,21 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis if ('error' in bulkParsed) return error(reply, bulkParsed.error, 400); const bulkBody = bulkParsed.data; - const monthStr = bulkBody.month; - const userIds = bulkBody.user_ids; - const arrivalTime = bulkBody.arrival_time; - const departureTime = bulkBody.departure_time; - const breakStartTime = bulkBody.break_start_time; - const breakEndTime = bulkBody.break_end_time; - - const [yrStr, moStr] = monthStr.split('-'); - const yr = Number(yrStr); - const mo = Number(moStr); - const daysInMonth = new Date(yr, mo, 0).getDate(); - - // Fetch existing records for the month - const dateFrom = new Date(yr, mo - 1, 1); - const dateTo = new Date(yr, mo, 0, 23, 59, 59); - const existing = await prisma.attendance.findMany({ - where: { user_id: { in: userIds.map(Number) }, shift_date: { gte: dateFrom, lte: dateTo } }, - select: { user_id: true, shift_date: true }, + 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, }); - const existingSet = new Set(existing.map(r => `${r.user_id}:${r.shift_date.toISOString().split('T')[0]}`)); - - let inserted = 0; - let skipped = 0; - - for (const userId of userIds.map(Number)) { - for (let day = 1; day <= daysInMonth; day++) { - const date = new Date(yr, mo - 1, day); - const dateStr = date.toISOString().split('T')[0]; - const dow = date.getDay(); - - if (dow === 0 || dow === 6) continue; // weekend - - if (existingSet.has(`${userId}:${dateStr}`)) { - skipped++; - continue; - } - - const shiftDate = new Date(Date.UTC(yr, mo - 1, day, 12, 0, 0)); - await prisma.attendance.create({ - data: { - user_id: userId, - shift_date: shiftDate, - arrival_time: new Date(`${dateStr}T${arrivalTime}:00`), - departure_time: new Date(`${dateStr}T${departureTime}:00`), - break_start: new Date(`${dateStr}T${breakStartTime}:00`), - break_end: new Date(`${dateStr}T${breakEndTime}:00`), - leave_type: 'work', - }, - }); - inserted++; - } - } await logAudit({ request, authData, action: 'create', entityType: 'attendance', - entityId: 0, description: `Hromadně vytvořeno ${inserted} záznamů docházky pro ${monthStr}`, + entityId: 0, description: `Hromadně vytvořeno ${result.inserted} záznamů docházky pro ${bulkBody.month}`, }); - let msg = `Vytvořeno ${inserted} záznamů`; - if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`; - - return success(reply, { inserted, skipped }, 200, msg); + return success(reply, { inserted: result.inserted, skipped: result.skipped }, 200, result.message); } // --- action=leave: add leave record directly --- @@ -843,68 +213,18 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis const leaveParsed = parseBody(AttendanceLeaveSchema, rawBody); if ('error' in leaveParsed) return error(reply, leaveParsed.error, 400); const leaveBody = leaveParsed.data; - const userId = leaveBody.user_id ?? authData.userId; - const dateFrom = leaveBody.date_from; - const dateTo = leaveBody.date_to || dateFrom; - const leaveTypeStr = leaveBody.leave_type; - if (!VALID_LEAVE_TYPES.includes(leaveTypeStr as typeof VALID_LEAVE_TYPES[number])) { - return error(reply, 'Neplatný typ nepřítomnosti', 400); - } - const leaveType = leaveTypeStr as attendance_leave_type; - if (!dateFrom) return error(reply, 'Datum je povinné', 400); + 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, + }, authData.userId); - const start = new Date(dateFrom); - const end = new Date(dateTo); - let created = 0; - - const current = new Date(start); - while (current <= end) { - const dow = current.getDay(); - if (dow !== 0 && dow !== 6) { - const dateStr = current.toISOString().split('T')[0]; - const shiftDate = new Date(Date.UTC(current.getFullYear(), current.getMonth(), current.getDate(), 12, 0, 0)); - await prisma.attendance.create({ - data: { - user_id: userId, - shift_date: shiftDate, - leave_type: leaveType, - leave_hours: leaveBody.leave_hours ? Number(leaveBody.leave_hours) : 8, - notes: leaveBody.notes ? String(leaveBody.notes) : null, - }, - }); - created++; - } - current.setDate(current.getDate() + 1); - } - - // Update leave balance for vacation/sick (matching PHP updateLeaveBalance) - const totalLeaveHours = created * (leaveBody.leave_hours ? Number(leaveBody.leave_hours) : 8); - if ((leaveType === 'vacation' || leaveType === 'sick') && totalLeaveHours > 0) { - const year = new Date(dateFrom).getFullYear(); - const existingBalance = await prisma.leave_balances.findFirst({ - where: { user_id: userId, year }, - }); - if (existingBalance) { - const updateField = leaveType === 'vacation' ? 'vacation_used' : 'sick_used'; - await prisma.leave_balances.update({ - where: { id: existingBalance.id }, - data: { [updateField]: Number(existingBalance[updateField]) + totalLeaveHours, updated_at: new Date() }, - }); - } else { - await prisma.leave_balances.create({ - data: { - user_id: userId, - year, - vacation_total: 160, - vacation_used: leaveType === 'vacation' ? totalLeaveHours : 0, - sick_used: leaveType === 'sick' ? totalLeaveHours : 0, - }, - }); - } - } - - return success(reply, { created }, 200, `Vytvořeno ${created} záznamů nepřítomnosti`); + 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 @@ -912,128 +232,22 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis const punchParsed = parseBody(AttendancePunchSchema, rawBody); if ('error' in punchParsed) return error(reply, punchParsed.error, 400); const punchBody = punchParsed.data; - const action = punchBody.punch_action; - const now = new Date(); - // Use noon UTC to avoid timezone shift issues with Prisma/MySQL DATE columns - const y = now.getFullYear(), m = now.getMonth(), d = now.getDate(); - const today = new Date(Date.UTC(y, m, d, 12, 0, 0)); - const gpsLat = punchBody.latitude != null && punchBody.latitude !== '' ? Number(punchBody.latitude) : null; - const gpsLng = punchBody.longitude != null && punchBody.longitude !== '' ? Number(punchBody.longitude) : null; - const gpsAcc = punchBody.accuracy != null && punchBody.accuracy !== '' ? Number(punchBody.accuracy) : null; - const gpsAddr = punchBody.address ?? null; + const result = await attendanceService.punchAction(authData.userId, { + punch_action: punchBody.punch_action, + latitude: punchBody.latitude, + longitude: punchBody.longitude, + accuracy: punchBody.accuracy, + address: punchBody.address, + }); - // Round arrival UP to nearest 15 min, departure DOWN - const roundUp15 = (d: Date) => { - const ms = 15 * 60 * 1000; - return new Date(Math.ceil(d.getTime() / ms) * ms); - }; - const roundDown15 = (d: Date) => { - const ms = 15 * 60 * 1000; - return new Date(Math.floor(d.getTime() / ms) * ms); - }; + if ('error' in result) return error(reply, result.error, 400); - if (action === 'arrival') { - // Check no ongoing shift - const ongoing = await prisma.attendance.findFirst({ - where: { user_id: authData.userId, departure_time: null, arrival_time: { not: null } }, - orderBy: { created_at: 'desc' }, - }); - if (ongoing) { - return error(reply, 'Máte již aktivní směnu. Nejdříve zaznamenejte odchod.', 400); - } - - const arrivalTime = roundUp15(now); - const record = await prisma.attendance.create({ - data: { - user_id: authData.userId, - shift_date: today, - arrival_time: arrivalTime, - arrival_lat: gpsLat, - arrival_lng: gpsLng, - arrival_accuracy: gpsAcc, - arrival_address: gpsAddr, - leave_type: 'work', - }, - }); - - await logAudit({ - request, authData, action: 'create', entityType: 'attendance', - entityId: record.id, description: 'Zaznamenán příchod', - }); - return success(reply, { id: record.id }, 201, 'Příchod zaznamenán'); - - } else if (action === 'departure') { - const ongoing = await prisma.attendance.findFirst({ - where: { user_id: authData.userId, departure_time: null, arrival_time: { not: null } }, - orderBy: { created_at: 'desc' }, - }); - if (!ongoing) { - return error(reply, 'Nemáte aktivní směnu.', 400); - } - - const departureTime = roundDown15(now); - - // Auto-add break if missing and shift > 6h - const updateData: Record = { - departure_time: departureTime, - departure_lat: gpsLat, - departure_lng: gpsLng, - departure_accuracy: gpsAcc, - departure_address: gpsAddr, - }; - - if (!ongoing.break_start && ongoing.arrival_time) { - const shiftMs = departureTime.getTime() - ongoing.arrival_time.getTime(); - const shiftHours = shiftMs / (1000 * 60 * 60); - if (shiftHours > 6) { - const midpoint = new Date(ongoing.arrival_time.getTime() + shiftMs / 2); - const breakMins = shiftHours > 12 ? 30 : 15; - updateData.break_start = midpoint; - updateData.break_end = new Date(midpoint.getTime() + breakMins * 60 * 1000); - } - } - - await prisma.attendance.update({ where: { id: ongoing.id }, data: updateData }); - - // Close any open project logs on departure (matching PHP) - await prisma.attendance_project_logs.updateMany({ - where: { attendance_id: ongoing.id, ended_at: null }, - data: { ended_at: departureTime }, - }); - - await logAudit({ - request, authData, action: 'update', entityType: 'attendance', - entityId: ongoing.id, description: 'Zaznamenán odchod', - }); - return success(reply, { id: ongoing.id }, 200, 'Odchod zaznamenán'); - - } else if (action === 'break_start') { - const ongoing = await prisma.attendance.findFirst({ - where: { user_id: authData.userId, departure_time: null, arrival_time: { not: null }, break_start: null }, - orderBy: { created_at: 'desc' }, - }); - if (!ongoing) { - return error(reply, 'Nemáte aktivní směnu bez přestávky.', 400); - } - - const ms10 = 10 * 60 * 1000; - const breakStart = new Date(Math.round(now.getTime() / ms10) * ms10); - const breakEnd = new Date(breakStart.getTime() + 30 * 60 * 1000); - - await prisma.attendance.update({ - where: { id: ongoing.id }, - data: { break_start: breakStart, break_end: breakEnd }, - }); - - await logAudit({ - request, authData, action: 'update', entityType: 'attendance', - entityId: ongoing.id, description: 'Zaznamenána přestávka', - }); - return success(reply, { id: ongoing.id }, 200, 'Přestávka zaznamenána'); - } - - return error(reply, 'Neplatná akce', 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) @@ -1041,53 +255,36 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis if ('error' in stdParsed) return error(reply, stdParsed.error, 400); const body = stdParsed.data; - const record = await prisma.attendance.create({ - data: { - user_id: body.user_id ?? authData.userId, - shift_date: new Date(body.shift_date), - arrival_time: body.arrival_time ? new Date(body.arrival_time) : null, - arrival_lat: body.arrival_lat ?? null, - arrival_lng: body.arrival_lng ?? null, - arrival_accuracy: body.arrival_accuracy ?? null, - arrival_address: body.arrival_address ?? null, - departure_time: body.departure_time ? new Date(body.departure_time) : null, - departure_lat: body.departure_lat ?? null, - departure_lng: body.departure_lng ?? null, - departure_accuracy: body.departure_accuracy ?? null, - departure_address: body.departure_address ?? null, - notes: body.notes ?? null, - project_id: body.project_id ?? null, - leave_type: body.leave_type as attendance_leave_type, - leave_hours: body.leave_hours ?? null, - }, - }); - - // Save project logs if provided - if (Array.isArray(body.project_logs)) { - const logs = body.project_logs - .filter(l => l.project_id && (Number(l.hours) > 0 || Number(l.minutes) > 0)); - if (logs.length > 0) { - await prisma.attendance_project_logs.createMany({ - data: logs.map(l => ({ - attendance_id: record.id, - project_id: Number(l.project_id), - hours: Number(l.hours) || 0, - minutes: Number(l.minutes) || 0, - })), - }); - } - } + 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: record.id, + entityId: result.id, description: `Vytvořen záznam docházky`, }); - return success(reply, { id: record.id }, 201, 'Záznam byl vytvořen'); + return success(reply, { id: result.id }, 201, 'Záznam byl vytvořen'); }); // PUT /api/admin/attendance/:id @@ -1098,48 +295,22 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; - const existing = await prisma.attendance.findUnique({ where: { id } }); - if (!existing) return error(reply, 'Záznam nenalezen', 404); - - // Ownership check: user can edit own records, admin can edit any const authData = request.authData!; const isAdmin = authData.permissions.includes('attendance.admin'); - if (existing.user_id !== authData.userId && !isAdmin) { - return error(reply, 'Nemáte oprávnění upravit tento záznam', 403); - } - await prisma.attendance.update({ - where: { id }, - data: { - arrival_time: body.arrival_time !== undefined ? (body.arrival_time ? new Date(String(body.arrival_time)) : null) : undefined, - departure_time: body.departure_time !== undefined ? (body.departure_time ? new Date(String(body.departure_time)) : null) : undefined, - break_start: body.break_start !== undefined ? (body.break_start ? new Date(String(body.break_start)) : null) : undefined, - break_end: body.break_end !== undefined ? (body.break_end ? new Date(String(body.break_end)) : null) : undefined, - notes: body.notes !== undefined ? (body.notes ? String(body.notes) : null) : undefined, - project_id: body.project_id !== undefined ? (body.project_id ? Number(body.project_id) : null) : undefined, - leave_type: body.leave_type !== undefined ? String(body.leave_type) as attendance_leave_type : undefined, - leave_hours: body.leave_hours !== undefined ? (body.leave_hours ? Number(body.leave_hours) : null) : undefined, - }, - }); + 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, + project_id: body.project_id, + leave_type: body.leave_type, + leave_hours: body.leave_hours, + project_logs: body.project_logs, + }, authData.userId, isAdmin); - // Save project logs if provided (matching PHP save_project_logs) - if (Array.isArray(body.project_logs)) { - // Delete existing logs for this record - await prisma.attendance_project_logs.deleteMany({ where: { attendance_id: id } }); - // Insert new ones (skip entries with no project_id or zero time) - const logs = body.project_logs - .filter(l => l.project_id && (Number(l.hours) > 0 || Number(l.minutes) > 0)); - if (logs.length > 0) { - await prisma.attendance_project_logs.createMany({ - data: logs.map(l => ({ - attendance_id: id, - project_id: Number(l.project_id), - hours: Number(l.hours) || 0, - minutes: Number(l.minutes) || 0, - })), - }); - } - } + if ('error' in result) return error(reply, result.error, result.status!); await logAudit({ request, @@ -1158,10 +329,8 @@ export default async function attendanceRoutes(fastify: FastifyInstance): Promis const id = parseId(request.params.id, reply); if (id === null) return; - const existing = await prisma.attendance.findUnique({ where: { id } }); - if (!existing) return error(reply, 'Záznam nenalezen', 404); - - await prisma.attendance.delete({ where: { id } }); + const result = await attendanceService.deleteAttendance(id); + if ('error' in result) return error(reply, result.error, 404); await logAudit({ request, diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts new file mode 100644 index 0000000..11a4bba --- /dev/null +++ b/src/services/attendance.service.ts @@ -0,0 +1,999 @@ +import { attendance_leave_type } from '@prisma/client'; +import prisma from '../config/database'; + +const VALID_LEAVE_TYPES = ['work', 'vacation', 'sick', 'holiday', 'unpaid'] as const; + +const MONTH_NAMES = [ + 'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen', + 'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec', +]; + +// ── Helpers ────────────────────────────────────────────────────────── + +function countWorkingDays(year: number, month: number): number { + let count = 0; + const cur = new Date(year, month, 1); + while (cur.getMonth() === month) { + const dow = cur.getDay(); + if (dow !== 0 && dow !== 6) count++; + cur.setDate(cur.getDate() + 1); + } + return count; +} + +function calcWorkedHours(arrival: Date, departure: Date, breakStart: Date | null, breakEnd: Date | null): number { + let mins = (departure.getTime() - arrival.getTime()) / 60000; + if (breakStart && breakEnd) { + mins -= (breakEnd.getTime() - breakStart.getTime()) / 60000; + } + return Math.max(0, mins) / 60; +} + +const roundUp15 = (d: Date) => { + const ms = 15 * 60 * 1000; + return new Date(Math.ceil(d.getTime() / ms) * ms); +}; + +const roundDown15 = (d: Date) => { + const ms = 15 * 60 * 1000; + return new Date(Math.floor(d.getTime() / ms) * ms); +}; + +// ── Interfaces ─────────────────────────────────────────────────────── + +export interface ListAttendanceParams { + page: number; + limit: number; + skip: number; + order: 'asc' | 'desc'; + userId?: number; + isAdmin: boolean; + authUserId: number; + month?: number; + year?: number; +} + +export interface PunchData { + punch_action: string; + latitude?: number | string | null; + longitude?: number | string | null; + accuracy?: number | string | null; + address?: string | null; +} + +export interface CreateAttendanceData { + user_id?: number; + shift_date: string; + arrival_time?: string | null; + arrival_lat?: number | null; + arrival_lng?: number | null; + arrival_accuracy?: number | null; + arrival_address?: string | null; + departure_time?: string | null; + departure_lat?: number | null; + departure_lng?: number | null; + departure_accuracy?: number | null; + departure_address?: string | null; + notes?: string | null; + project_id?: number | null; + leave_type: string; + leave_hours?: number | null; + project_logs?: Array<{ project_id: number; hours?: number; minutes?: number }>; +} + +export interface UpdateAttendanceData { + arrival_time?: string | null; + departure_time?: string | null; + break_start?: string | null; + break_end?: string | null; + notes?: string | null; + project_id?: number | null; + leave_type?: string; + leave_hours?: number | null; + project_logs?: Array<{ project_id: number; hours?: number; minutes?: number }>; +} + +export interface LeaveData { + user_id?: number; + date_from: string; + date_to?: string; + leave_type: string; + leave_hours?: number; + notes?: string; +} + +export interface BulkAttendanceData { + month: string; + user_ids: number[]; + arrival_time: string; + departure_time: string; + break_start_time: string; + break_end_time: string; +} + +export interface BalancesData { + user_id: number; + year?: number; + action_type: string; + vacation_total?: number | null; + vacation_used?: number | null; + sick_used?: number | null; +} + +// ── Service Functions ──────────────────────────────────────────────── + +export async function getStatus(userId: number) { + const now = new Date(); + const y = now.getFullYear(), m = now.getMonth(), d = now.getDate(); + const todayStart = new Date(y, m, d, 0, 0, 0); + const todayEnd = new Date(y, m, d, 23, 59, 59); + + // 1) Ongoing shift (no departure) + const ongoingShift = await prisma.attendance.findFirst({ + where: { user_id: userId, departure_time: null, arrival_time: { not: null } }, + orderBy: { created_at: 'desc' }, + }); + + // 2) Today's completed shifts + const todayShifts = await prisma.attendance.findMany({ + where: { + user_id: userId, + shift_date: { gte: todayStart, lte: todayEnd }, + departure_time: { not: null }, + }, + orderBy: { arrival_time: 'asc' }, + }); + + // 3) Leave balance + const balance = await prisma.leave_balances.findFirst({ + where: { user_id: userId, year: y }, + }); + const leaveBalance = { + vacation_total: balance ? Number(balance.vacation_total) : 160, + vacation_used: balance ? Number(balance.vacation_used) : 0, + vacation_remaining: balance ? Number(balance.vacation_total) - Number(balance.vacation_used) : 160, + sick_used: balance ? Number(balance.sick_used) : 0, + }; + + // 4) Monthly fund + const monthStart = new Date(y, m, 1); + const monthEnd = new Date(y, m + 1, 0, 23, 59, 59); + const monthRecords = await prisma.attendance.findMany({ + where: { user_id: userId, shift_date: { gte: monthStart, lte: monthEnd } }, + }); + + const workingDays = countWorkingDays(y, m); + const fund = workingDays * 8; + + let workedHours = 0; + let vacationHours = 0; + let sickHours = 0; + let holidayHours = 0; + let unpaidHours = 0; + + for (const rec of monthRecords) { + const lt = (rec.leave_type as string) || 'work'; + if (lt !== 'work') { + const hrs = Number(rec.leave_hours) || 8; + if (lt === 'vacation') vacationHours += hrs; + else if (lt === 'sick') sickHours += hrs; + else if (lt === 'holiday') holidayHours += hrs; + else if (lt === 'unpaid') unpaidHours += hrs; + continue; + } + if (rec.arrival_time && rec.departure_time) { + workedHours += calcWorkedHours(rec.arrival_time, rec.departure_time, rec.break_start, rec.break_end); + } + } + + const worked = Math.round(workedHours * 100) / 100; + const holidayDays = monthRecords.filter(r => (r.leave_type as string) === 'holiday').length; + const adjustedFund = Math.max(0, (workingDays - holidayDays) * 8); + const leaveHours = vacationHours + sickHours; + const covered = worked + leaveHours; + const remaining = Math.max(0, adjustedFund - covered); + const overtime = Math.max(0, covered - adjustedFund); + + const monthlyFund = { + month_name: `${MONTH_NAMES[m]} ${y}`, + fund: adjustedFund, + business_days: workingDays - holidayDays, + worked, + covered, + remaining, + overtime, + leave_hours: leaveHours, + vacation_hours: vacationHours, + sick_hours: sickHours, + holiday_hours: holidayHours, + unpaid_hours: unpaidHours, + }; + + // 5) Project logs for ongoing shift + let projectLogs: Array<{ id: number; attendance_id: number; project_id: number; started_at: Date | null; ended_at: Date | null; project_name?: string }> = []; + let activeProjectId: number | null = null; + + if (ongoingShift) { + const logs = await prisma.attendance_project_logs.findMany({ + where: { attendance_id: ongoingShift.id }, + orderBy: { started_at: 'asc' }, + }); + + const projectIds = [...new Set(logs.map(l => l.project_id))]; + const projectNames = new Map(); + if (projectIds.length > 0) { + const projects = await prisma.projects.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, name: true, project_number: true }, + }); + for (const p of projects) { + projectNames.set(p.id, p.project_number ? `${p.project_number} – ${p.name}` : (p.name || '')); + } + } + + projectLogs = logs.map(l => ({ + ...l, + project_name: projectNames.get(l.project_id) || `Projekt #${l.project_id}`, + })); + + const activeLog = logs.find(l => l.ended_at === null); + if (activeLog) { + activeProjectId = activeLog.project_id; + } else { + activeProjectId = ongoingShift.project_id ?? null; + } + } + + return { + ongoing_shift: ongoingShift, + today_shifts: todayShifts, + leave_balance: leaveBalance, + monthly_fund: monthlyFund, + date: now.toISOString().split('T')[0], + project_logs: projectLogs, + active_project_id: activeProjectId, + }; +} + +export async function saveNotes(userId: number, notes: string | null) { + const ongoing = await prisma.attendance.findFirst({ + where: { user_id: userId, departure_time: null, arrival_time: { not: null } }, + orderBy: { created_at: 'desc' }, + }); + if (!ongoing) return { error: 'Nemáte aktivní směnu.' }; + + await prisma.attendance.update({ + where: { id: ongoing.id }, + data: { notes: notes ? String(notes) : null }, + }); + + return { success: true }; +} + +export async function updateAddress(userId: number, address: string | null, punchAction: string) { + const latest = await prisma.attendance.findFirst({ + where: { user_id: userId }, + orderBy: { created_at: 'desc' }, + }); + if (!latest) return { error: 'Nenalezen záznam' }; + + const data: Record = {}; + if (punchAction === 'departure') { + data.departure_address = address; + } else { + data.arrival_address = address; + } + + await prisma.attendance.update({ where: { id: latest.id }, data }); + return { success: true }; +} + +export async function switchProject(userId: number, projectId: number | null) { + const ongoing = await prisma.attendance.findFirst({ + where: { user_id: userId, departure_time: null, arrival_time: { not: null } }, + orderBy: { created_at: 'desc' }, + }); + if (!ongoing) return { error: 'Nemáte aktivní směnu.' }; + + const now = new Date(); + + await prisma.attendance_project_logs.updateMany({ + where: { attendance_id: ongoing.id, ended_at: null }, + data: { ended_at: now }, + }); + + if (projectId) { + await prisma.attendance_project_logs.create({ + data: { + attendance_id: ongoing.id, + project_id: projectId, + started_at: now, + ended_at: null, + }, + }); + } + + await prisma.attendance.update({ + where: { id: ongoing.id }, + data: { project_id: projectId }, + }); + + return { success: true }; +} + +export async function getBalances(year: number) { + const users = await prisma.users.findMany({ + where: { is_active: true }, + select: { id: true, first_name: true, last_name: true }, + orderBy: { last_name: 'asc' }, + }); + + const balances: Record = {}; + for (const u of users) { + const lb = await prisma.leave_balances.findFirst({ where: { user_id: u.id, year } }); + const vTotal = lb ? Number(lb.vacation_total) : 160; + const vUsed = lb ? Number(lb.vacation_used) : 0; + const sUsed = lb ? Number(lb.sick_used) : 0; + balances[String(u.id)] = { + name: `${u.first_name} ${u.last_name}`.trim(), + vacation_total: vTotal, + vacation_used: vUsed, + vacation_remaining: vTotal - vUsed, + sick_used: sUsed, + }; + } + + return { + users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })), + balances, + }; +} + +export async function getWorkfund(year: number) { + const users = await prisma.users.findMany({ + where: { is_active: true }, + select: { id: true, first_name: true, last_name: true }, + orderBy: { last_name: 'asc' }, + }); + + const yearStart = new Date(year, 0, 1); + const yearEnd = new Date(year, 11, 31, 23, 59, 59); + const allRecords = await prisma.attendance.findMany({ + where: { shift_date: { gte: yearStart, lte: yearEnd } }, + }); + + const months: Record }> = {}; + + for (let m = 0; m < 12; m++) { + const bizDays = countWorkingDays(year, m); + const fund = bizDays * 8; + const monthStart = new Date(year, m, 1); + const monthEnd = new Date(year, m + 1, 0, 23, 59, 59); + + const monthUsers: Record = {}; + + for (const u of users) { + const recs = allRecords.filter(r => r.user_id === u.id && r.shift_date >= monthStart && r.shift_date <= monthEnd); + let worked = 0; + let vacationHours = 0; + let sickHours = 0; + let holidayDays = 0; + + for (const rec of recs) { + const lt = (rec.leave_type as string) || 'work'; + if (lt === 'work') { + if (rec.arrival_time && rec.departure_time) { + worked += calcWorkedHours(rec.arrival_time, rec.departure_time, rec.break_start, rec.break_end); + } + } else if (lt === 'vacation') { + vacationHours += Number(rec.leave_hours) || 8; + } else if (lt === 'sick') { + sickHours += Number(rec.leave_hours) || 8; + } else if (lt === 'holiday') { + holidayDays++; + } + } + + const userFund = Math.max(0, (bizDays - holidayDays) * 8); + const workedRound = Math.round(worked * 10) / 10; + const leaveHours = vacationHours + sickHours; + const covered = Math.round((worked + leaveHours) * 10) / 10; + const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10); + const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10); + + monthUsers[String(u.id)] = { + name: `${u.first_name} ${u.last_name}`.trim(), + worked: workedRound, + covered, + overtime, + missing, + }; + } + + months[String(m + 1)] = { + month_name: MONTH_NAMES[m], + fund, + business_days: bizDays, + users: monthUsers, + }; + } + + return { + months, + users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })), + balances: {}, + holidays: [], + }; +} + +export async function getProjectReport(year: number) { + const yearStart = new Date(year, 0, 1); + const yearEnd = new Date(year, 11, 31, 23, 59, 59); + + const records = await prisma.attendance.findMany({ + where: { + shift_date: { gte: yearStart, lte: yearEnd }, + leave_type: 'work', + arrival_time: { not: null }, + departure_time: { not: null }, + }, + include: { + users: { select: { id: true, first_name: true, last_name: true } }, + }, + }); + + const projectIds = [...new Set(records.filter(r => r.project_id).map(r => r.project_id!))]; + const projectsMap = new Map(); + if (projectIds.length > 0) { + const projects = await prisma.projects.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, name: true, project_number: true }, + }); + for (const p of projects) { + projectsMap.set(p.id, { name: p.name || '', project_number: p.project_number || '' }); + } + } + + const months: Record }> }> = {}; + + for (let m = 0; m < 12; m++) { + const monthStart = new Date(year, m, 1); + const monthEnd = new Date(year, m + 1, 0, 23, 59, 59); + const monthRecs = records.filter(r => r.shift_date >= monthStart && r.shift_date <= monthEnd); + + if (monthRecs.length === 0) continue; + + const projectMap = new Map }>(); + + for (const rec of monthRecs) { + const hours = calcWorkedHours(rec.arrival_time!, rec.departure_time!, rec.break_start, rec.break_end); + const pid = rec.project_id; + + if (!projectMap.has(pid)) { + const projInfo = pid ? projectsMap.get(pid) : undefined; + projectMap.set(pid, { + project_number: projInfo?.project_number || undefined, + project_name: projInfo?.name || undefined, + userMap: new Map(), + }); + } + + const pg = projectMap.get(pid)!; + const uid = rec.user_id; + const uName = rec.users ? `${rec.users.first_name} ${rec.users.last_name}`.trim() : `User #${uid}`; + if (!pg.userMap.has(uid)) { + pg.userMap.set(uid, { name: uName, hours: 0 }); + } + pg.userMap.get(uid)!.hours += hours; + } + + const projects = Array.from(projectMap.entries()).map(([pid, pg]) => ({ + project_id: pid, + project_number: pg.project_number, + project_name: pg.project_name, + hours: Math.round(Array.from(pg.userMap.values()).reduce((s, u) => s + u.hours, 0) * 10) / 10, + users: Array.from(pg.userMap.entries()).map(([uid, ud]) => ({ + user_id: uid, + user_name: ud.name, + hours: Math.round(ud.hours * 10) / 10, + })), + })); + + months[String(m + 1)] = { + month_name: MONTH_NAMES[m], + projects, + }; + } + + return { months }; +} + +export async function getPrintData(monthStr: string, filterUserId: number | null) { + const [yearStr, monthNumStr] = monthStr.split('-'); + const yr = Number(yearStr); + const mo = Number(monthNumStr); + + const monthStart = new Date(yr, mo - 1, 1); + const monthEnd = new Date(yr, mo, 0, 23, 59, 59); + + const users = await prisma.users.findMany({ + where: { is_active: true }, + select: { id: true, first_name: true, last_name: true }, + orderBy: { last_name: 'asc' }, + }); + + const where: Record = { + shift_date: { gte: monthStart, lte: monthEnd }, + }; + if (filterUserId) where.user_id = filterUserId; + + const records = await prisma.attendance.findMany({ + where, + include: { users: { select: { id: true, first_name: true, last_name: true } } }, + orderBy: [{ user_id: 'asc' }, { shift_date: 'asc' }], + }); + + const userTotals: Record = {}; + for (const rec of records) { + const uid = String(rec.user_id); + if (!userTotals[uid]) { + const u = rec.users; + userTotals[uid] = { name: u ? `${u.first_name} ${u.last_name}`.trim() : `User #${uid}`, worked: 0, vacation: 0, sick: 0, holiday: 0, unpaid: 0 }; + } + const lt = (rec.leave_type as string) || 'work'; + if (lt !== 'work') { + const hrs = Number(rec.leave_hours) || 8; + if (lt === 'vacation') userTotals[uid].vacation += hrs; + else if (lt === 'sick') userTotals[uid].sick += hrs; + else if (lt === 'holiday') userTotals[uid].holiday += hrs; + else if (lt === 'unpaid') userTotals[uid].unpaid += hrs; + } else if (rec.arrival_time && rec.departure_time) { + userTotals[uid].worked += calcWorkedHours(rec.arrival_time, rec.departure_time, rec.break_start, rec.break_end); + } + } + + for (const uid of Object.keys(userTotals)) { + userTotals[uid].worked = Math.round(userTotals[uid].worked * 10) / 10; + } + + const bizDays = countWorkingDays(yr, mo - 1); + + return { + user_totals: userTotals, + users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })), + month: monthStr, + month_name: `${MONTH_NAMES[mo - 1]} ${yr}`, + selected_user: filterUserId, + year: yr, + fund: { business_days: bizDays, hours: bizDays * 8 }, + }; +} + +export async function getActiveProjects() { + const activeProjects = await prisma.projects.findMany({ + where: { status: 'aktivni' }, + select: { id: true, name: true, project_number: true }, + orderBy: { name: 'asc' }, + }); + return activeProjects.map(p => ({ + id: p.id, + name: p.project_number ? `${p.project_number} – ${p.name}` : p.name, + })); +} + +export async function getProjectLogs(attendanceId: number) { + return prisma.attendance_project_logs.findMany({ + where: { attendance_id: attendanceId }, + orderBy: { started_at: 'asc' }, + }); +} + +export async function getLocationRecord(id: number) { + return prisma.attendance.findUnique({ + where: { id }, + include: { users: { select: { id: true, first_name: true, last_name: true } } }, + }); +} + +export async function listAttendance(params: ListAttendanceParams) { + const { page, limit, skip, order, isAdmin, authUserId } = params; + + const where: Record = {}; + if (!isAdmin) { + where.user_id = authUserId; + } else if (params.userId) { + where.user_id = params.userId; + } + if (params.month && params.year) { + where.shift_date = { + gte: new Date(params.year, params.month - 1, 1), + lt: new Date(params.year, params.month, 1), + }; + } + + const [records, total] = await Promise.all([ + prisma.attendance.findMany({ + where, + skip, + take: limit, + orderBy: { shift_date: order }, + include: { + users: { select: { id: true, first_name: true, last_name: true, username: true } }, + attendance_project_logs: { orderBy: { started_at: 'asc' } }, + }, + }), + prisma.attendance.count({ where }), + ]); + + const allProjectIds = new Set(); + for (const rec of records) { + if (rec.project_id) allProjectIds.add(rec.project_id); + for (const log of rec.attendance_project_logs) { + allProjectIds.add(log.project_id); + } + } + const projectNameMap = new Map(); + if (allProjectIds.size > 0) { + const projects = await prisma.projects.findMany({ + where: { id: { in: [...allProjectIds] } }, + select: { id: true, name: true, project_number: true }, + }); + for (const p of projects) { + projectNameMap.set(p.id, p.project_number ? `${p.project_number} – ${p.name}` : (p.name || '')); + } + } + + const enriched = records.map(rec => { + const logs = rec.attendance_project_logs.map(l => ({ + ...l, + project_name: projectNameMap.get(l.project_id) || `Projekt #${l.project_id}`, + })); + return { + ...rec, + project_name: rec.project_id ? (projectNameMap.get(rec.project_id) || null) : null, + project_logs: logs, + }; + }); + + return { records: enriched, total, page, limit }; +} + +export async function handleBalances(data: BalancesData) { + const yr = data.year || new Date().getFullYear(); + + if (data.action_type === 'edit') { + await prisma.leave_balances.upsert({ + where: { user_id_year: { user_id: data.user_id, year: yr } }, + update: { + vacation_total: data.vacation_total != null ? Number(data.vacation_total) : undefined, + vacation_used: data.vacation_used != null ? Number(data.vacation_used) : undefined, + sick_used: data.sick_used != null ? Number(data.sick_used) : undefined, + updated_at: new Date(), + }, + create: { + user_id: data.user_id, + year: yr, + vacation_total: Number(data.vacation_total) || 160, + vacation_used: Number(data.vacation_used) || 0, + sick_used: Number(data.sick_used) || 0, + }, + }); + return { success: true, message: 'Bilance byla uložena', year: yr }; + } + + if (data.action_type === 'reset') { + await prisma.leave_balances.upsert({ + where: { user_id_year: { user_id: data.user_id, year: yr } }, + update: { vacation_used: 0, sick_used: 0, updated_at: new Date() }, + create: { user_id: data.user_id, year: yr, vacation_total: 160, vacation_used: 0, sick_used: 0 }, + }); + return { success: true, message: 'Bilance byla resetována', year: yr }; + } + + return { error: 'Neplatný typ akce' }; +} + +export async function bulkCreateAttendance(data: BulkAttendanceData) { + const [yrStr, moStr] = data.month.split('-'); + const yr = Number(yrStr); + const mo = Number(moStr); + const daysInMonth = new Date(yr, mo, 0).getDate(); + + const dateFrom = new Date(yr, mo - 1, 1); + const dateTo = new Date(yr, mo, 0, 23, 59, 59); + const existing = await prisma.attendance.findMany({ + where: { user_id: { in: data.user_ids.map(Number) }, shift_date: { gte: dateFrom, lte: dateTo } }, + select: { user_id: true, shift_date: true }, + }); + const existingSet = new Set(existing.map(r => `${r.user_id}:${r.shift_date.toISOString().split('T')[0]}`)); + + let inserted = 0; + let skipped = 0; + + for (const userId of data.user_ids.map(Number)) { + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(yr, mo - 1, day); + const dateStr = date.toISOString().split('T')[0]; + const dow = date.getDay(); + + if (dow === 0 || dow === 6) continue; + + if (existingSet.has(`${userId}:${dateStr}`)) { + skipped++; + continue; + } + + const shiftDate = new Date(Date.UTC(yr, mo - 1, day, 12, 0, 0)); + await prisma.attendance.create({ + data: { + user_id: userId, + shift_date: shiftDate, + arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`), + departure_time: new Date(`${dateStr}T${data.departure_time}:00`), + break_start: new Date(`${dateStr}T${data.break_start_time}:00`), + break_end: new Date(`${dateStr}T${data.break_end_time}:00`), + leave_type: 'work', + }, + }); + inserted++; + } + } + + let msg = `Vytvořeno ${inserted} záznamů`; + if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`; + + return { inserted, skipped, message: msg }; +} + +export async function createLeave(data: LeaveData, authUserId: number) { + const userId = data.user_id ?? authUserId; + const dateFrom = data.date_from; + const dateTo = data.date_to || dateFrom; + const leaveTypeStr = data.leave_type; + if (!VALID_LEAVE_TYPES.includes(leaveTypeStr as typeof VALID_LEAVE_TYPES[number])) { + return { error: 'Neplatný typ nepřítomnosti' }; + } + const leaveType = leaveTypeStr as attendance_leave_type; + + if (!dateFrom) return { error: 'Datum je povinné' }; + + const start = new Date(dateFrom); + const end = new Date(dateTo); + let created = 0; + + const current = new Date(start); + while (current <= end) { + const dow = current.getDay(); + if (dow !== 0 && dow !== 6) { + const dateStr = current.toISOString().split('T')[0]; + const shiftDate = new Date(Date.UTC(current.getFullYear(), current.getMonth(), current.getDate(), 12, 0, 0)); + await prisma.attendance.create({ + data: { + user_id: userId, + shift_date: shiftDate, + leave_type: leaveType, + leave_hours: data.leave_hours ? Number(data.leave_hours) : 8, + notes: data.notes ? String(data.notes) : null, + }, + }); + created++; + } + current.setDate(current.getDate() + 1); + } + + // Update leave balance for vacation/sick + const totalLeaveHours = created * (data.leave_hours ? Number(data.leave_hours) : 8); + if ((leaveType === 'vacation' || leaveType === 'sick') && totalLeaveHours > 0) { + const year = new Date(dateFrom).getFullYear(); + const existingBalance = await prisma.leave_balances.findFirst({ + where: { user_id: userId, year }, + }); + if (existingBalance) { + const updateField = leaveType === 'vacation' ? 'vacation_used' : 'sick_used'; + await prisma.leave_balances.update({ + where: { id: existingBalance.id }, + data: { [updateField]: Number(existingBalance[updateField]) + totalLeaveHours, updated_at: new Date() }, + }); + } else { + await prisma.leave_balances.create({ + data: { + user_id: userId, + year, + vacation_total: 160, + vacation_used: leaveType === 'vacation' ? totalLeaveHours : 0, + sick_used: leaveType === 'sick' ? totalLeaveHours : 0, + }, + }); + } + } + + return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` }; +} + +export async function punchAction(userId: number, data: PunchData) { + const action = data.punch_action; + const now = new Date(); + const y = now.getFullYear(), m = now.getMonth(), d = now.getDate(); + const today = new Date(Date.UTC(y, m, d, 12, 0, 0)); + + const gpsLat = data.latitude != null && data.latitude !== '' ? Number(data.latitude) : null; + const gpsLng = data.longitude != null && data.longitude !== '' ? Number(data.longitude) : null; + const gpsAcc = data.accuracy != null && data.accuracy !== '' ? Number(data.accuracy) : null; + const gpsAddr = data.address ?? null; + + if (action === 'arrival') { + const ongoing = await prisma.attendance.findFirst({ + where: { user_id: userId, departure_time: null, arrival_time: { not: null } }, + orderBy: { created_at: 'desc' }, + }); + if (ongoing) { + return { error: 'Máte již aktivní směnu. Nejdříve zaznamenejte odchod.' }; + } + + const arrivalTime = roundUp15(now); + const record = await prisma.attendance.create({ + data: { + user_id: userId, + shift_date: today, + arrival_time: arrivalTime, + arrival_lat: gpsLat, + arrival_lng: gpsLng, + arrival_accuracy: gpsAcc, + arrival_address: gpsAddr, + leave_type: 'work', + }, + }); + + return { id: record.id, status: 201, message: 'Příchod zaznamenán', auditAction: 'create' as const, auditDescription: 'Zaznamenán příchod' }; + + } else if (action === 'departure') { + const ongoing = await prisma.attendance.findFirst({ + where: { user_id: userId, departure_time: null, arrival_time: { not: null } }, + orderBy: { created_at: 'desc' }, + }); + if (!ongoing) { + return { error: 'Nemáte aktivní směnu.' }; + } + + const departureTime = roundDown15(now); + + const updateData: Record = { + departure_time: departureTime, + departure_lat: gpsLat, + departure_lng: gpsLng, + departure_accuracy: gpsAcc, + departure_address: gpsAddr, + }; + + if (!ongoing.break_start && ongoing.arrival_time) { + const shiftMs = departureTime.getTime() - ongoing.arrival_time.getTime(); + const shiftHours = shiftMs / (1000 * 60 * 60); + if (shiftHours > 6) { + const midpoint = new Date(ongoing.arrival_time.getTime() + shiftMs / 2); + const breakMins = shiftHours > 12 ? 30 : 15; + updateData.break_start = midpoint; + updateData.break_end = new Date(midpoint.getTime() + breakMins * 60 * 1000); + } + } + + await prisma.attendance.update({ where: { id: ongoing.id }, data: updateData }); + + await prisma.attendance_project_logs.updateMany({ + where: { attendance_id: ongoing.id, ended_at: null }, + data: { ended_at: departureTime }, + }); + + return { id: ongoing.id, status: 200, message: 'Odchod zaznamenán', auditAction: 'update' as const, auditDescription: 'Zaznamenán odchod' }; + + } else if (action === 'break_start') { + const ongoing = await prisma.attendance.findFirst({ + where: { user_id: userId, departure_time: null, arrival_time: { not: null }, break_start: null }, + orderBy: { created_at: 'desc' }, + }); + if (!ongoing) { + return { error: 'Nemáte aktivní směnu bez přestávky.' }; + } + + const ms10 = 10 * 60 * 1000; + const breakStart = new Date(Math.round(now.getTime() / ms10) * ms10); + const breakEnd = new Date(breakStart.getTime() + 30 * 60 * 1000); + + await prisma.attendance.update({ + where: { id: ongoing.id }, + data: { break_start: breakStart, break_end: breakEnd }, + }); + + return { id: ongoing.id, status: 200, message: 'Přestávka zaznamenána', auditAction: 'update' as const, auditDescription: 'Zaznamenána přestávka' }; + } + + return { error: 'Neplatná akce' }; +} + +export async function createAttendance(data: CreateAttendanceData, authUserId: number) { + const record = await prisma.attendance.create({ + data: { + user_id: data.user_id ?? authUserId, + shift_date: new Date(data.shift_date), + arrival_time: data.arrival_time ? new Date(data.arrival_time) : null, + arrival_lat: data.arrival_lat ?? null, + arrival_lng: data.arrival_lng ?? null, + arrival_accuracy: data.arrival_accuracy ?? null, + arrival_address: data.arrival_address ?? null, + departure_time: data.departure_time ? new Date(data.departure_time) : null, + departure_lat: data.departure_lat ?? null, + departure_lng: data.departure_lng ?? null, + departure_accuracy: data.departure_accuracy ?? null, + departure_address: data.departure_address ?? null, + notes: data.notes ?? null, + project_id: data.project_id ?? null, + leave_type: data.leave_type as attendance_leave_type, + leave_hours: data.leave_hours ?? null, + }, + }); + + if (Array.isArray(data.project_logs)) { + const logs = data.project_logs + .filter(l => l.project_id && (Number(l.hours) > 0 || Number(l.minutes) > 0)); + if (logs.length > 0) { + await prisma.attendance_project_logs.createMany({ + data: logs.map(l => ({ + attendance_id: record.id, + project_id: Number(l.project_id), + hours: Number(l.hours) || 0, + minutes: Number(l.minutes) || 0, + })), + }); + } + } + + return { id: record.id }; +} + +export async function updateAttendance(id: number, data: UpdateAttendanceData, authUserId: number, isAdmin: boolean) { + const existing = await prisma.attendance.findUnique({ where: { id } }); + if (!existing) return { error: 'Záznam nenalezen', status: 404 }; + + if (existing.user_id !== authUserId && !isAdmin) { + return { error: 'Nemáte oprávnění upravit tento záznam', status: 403 }; + } + + await prisma.attendance.update({ + where: { id }, + data: { + arrival_time: data.arrival_time !== undefined ? (data.arrival_time ? new Date(String(data.arrival_time)) : null) : undefined, + departure_time: data.departure_time !== undefined ? (data.departure_time ? new Date(String(data.departure_time)) : null) : undefined, + break_start: data.break_start !== undefined ? (data.break_start ? new Date(String(data.break_start)) : null) : undefined, + break_end: data.break_end !== undefined ? (data.break_end ? new Date(String(data.break_end)) : null) : undefined, + notes: data.notes !== undefined ? (data.notes ? String(data.notes) : null) : undefined, + project_id: data.project_id !== undefined ? (data.project_id ? Number(data.project_id) : null) : undefined, + leave_type: data.leave_type !== undefined ? String(data.leave_type) as attendance_leave_type : undefined, + leave_hours: data.leave_hours !== undefined ? (data.leave_hours ? Number(data.leave_hours) : null) : undefined, + }, + }); + + if (Array.isArray(data.project_logs)) { + await prisma.attendance_project_logs.deleteMany({ where: { attendance_id: id } }); + const logs = data.project_logs + .filter(l => l.project_id && (Number(l.hours) > 0 || Number(l.minutes) > 0)); + if (logs.length > 0) { + await prisma.attendance_project_logs.createMany({ + data: logs.map(l => ({ + attendance_id: id, + project_id: Number(l.project_id), + hours: Number(l.hours) || 0, + minutes: Number(l.minutes) || 0, + })), + }); + } + } + + return { id }; +} + +export async function deleteAttendance(id: number) { + const existing = await prisma.attendance.findUnique({ where: { id } }); + if (!existing) return { error: 'Záznam nenalezen' }; + + await prisma.attendance.delete({ where: { id } }); + return { success: true }; +}