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, upToDay?: number): number { let count = 0; const cur = new Date(year, month, 1); while (cur.getMonth() === month && (!upToDay || cur.getDate() <= upToDay)) { 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 now = new Date(); const currentYear = now.getFullYear(); const currentMonth = now.getMonth(); // 0-based const maxMonth = year < currentYear ? 11 : (year === currentYear ? currentMonth : -1); if (maxMonth < 0) { return { months: {}, users: users.map(u => ({ id: u.id, name: `${u.first_name} ${u.last_name}`.trim() })), balances: {} }; } const yearStart = new Date(year, 0, 1); const yearEnd = new Date(year, maxMonth + 1, 0, 23, 59, 59); const allRecords = await prisma.attendance.findMany({ where: { shift_date: { gte: yearStart, lte: yearEnd } }, }); const months: Record }> = {}; for (let m = 0; m <= maxMonth; m++) { const isCurrentMonth = year === currentYear && m === currentMonth; const bizDays = countWorkingDays(year, m); const bizDaysToDate = isCurrentMonth ? countWorkingDays(year, m, now.getDate()) : bizDays; const fund = bizDays * 8; const fundToDate = bizDaysToDate * 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 = bizDaysToDate * 8; const workedRound = Math.round(worked * 10) / 10; const holidayHours = holidayDays * 8; const leaveHours = vacationHours + sickHours + holidayHours; 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, fund_to_date: fundToDate, 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 } }, attendance_project_logs: { orderBy: { started_at: 'asc' }, }, }, orderBy: [{ users: { last_name: 'asc' } }, { shift_date: 'asc' }], }); const fundHours = countWorkingDays(yr, mo - 1) * 8; // Load project names for enrichment const projectIds = [...new Set(records.flatMap(r => (r as any).attendance_project_logs?.map((l: any) => l.project_id) || []).filter(Boolean))]; const projectMap: Record = {}; 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) { projectMap[p.id] = p.project_number ? `${p.project_number} – ${p.name}` : (p.name || `#${p.id}`); } } // Group records by user and calculate totals const userTotals: Record> = {}; for (const rec of records) { const uid = String(rec.user_id); if (!userTotals[uid]) { const u = (rec as any).users; userTotals[uid] = { name: u ? `${u.first_name} ${u.last_name}`.trim() : `User #${uid}`, minutes: 0, records: [], vacation_hours: 0, sick_hours: 0, holiday_hours: 0, unpaid_hours: 0, fund: fundHours, worked_hours: 0, covered: 0, missing: 0, overtime: 0, }; } // Build record with project_logs for frontend const projectLogs = (rec as any).attendance_project_logs?.map((log: any) => ({ project_id: log.project_id, project_name: projectMap[log.project_id] || null, hours: log.hours, minutes: log.minutes, started_at: log.started_at, ended_at: log.ended_at, })) || []; (userTotals[uid].records as unknown[]).push({ ...rec, project_logs: projectLogs, project_name: projectLogs.length > 0 ? projectLogs[0].project_name : null, }); const lt = (rec.leave_type as string) || 'work'; if (lt !== 'work') { const hrs = Number(rec.leave_hours) || 8; if (lt === 'vacation') (userTotals[uid].vacation_hours as number) += hrs; else if (lt === 'sick') (userTotals[uid].sick_hours as number) += hrs; else if (lt === 'holiday') (userTotals[uid].holiday_hours as number) += hrs; else if (lt === 'unpaid') (userTotals[uid].unpaid_hours as number) += hrs; } else if (rec.arrival_time && rec.departure_time) { const mins = calcWorkedHours(rec.arrival_time, rec.departure_time, rec.break_start, rec.break_end) * 60; (userTotals[uid].minutes as number) += Math.round(mins); } } // Calculate fund coverage per user for (const uid of Object.keys(userTotals)) { const ut = userTotals[uid]; const workedH = Math.round((ut.minutes as number) / 60 * 10) / 10; ut.worked_hours = workedH; const covered = workedH + (ut.vacation_hours as number) + (ut.sick_hours as number) + (ut.holiday_hours as number); ut.covered = Math.round(covered * 10) / 10; ut.missing = Math.max(0, Math.round(((ut.fund as number) - covered) * 10) / 10); ut.overtime = Math.max(0, Math.round((covered - (ut.fund as number)) * 10) / 10); } // Leave balances const leaveBalances: Record> = {}; const balanceRecords = await prisma.leave_balances.findMany({ where: { year: yr } }); for (const bal of balanceRecords) { const uid = String(bal.user_id); leaveBalances[uid] = { vacation_total: Number(bal.vacation_total) || 160, vacation_remaining: (Number(bal.vacation_total) || 160) - (Number(bal.vacation_used) || 0), }; } // Selected user name let selectedUserName = ''; if (filterUserId) { const u = users.find(u => u.id === filterUserId); if (u) selectedUserName = `${u.first_name} ${u.last_name}`.trim(); } return { user_totals: userTotals, leave_balances: leaveBalances, 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, selected_user_name: selectedUserName, year: yr, fund: { business_days: countWorkingDays(yr, mo - 1), hours: fundHours }, }; } 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}`, })); const u = rec.users; return { ...rec, user_name: u ? `${u.first_name} ${u.last_name}`.trim() : '', 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 }; }