import { attendance_leave_type, Prisma } from "@prisma/client"; import prisma from "../config/database"; import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays"; import { localDateStr } from "../utils/date"; import { getSystemSettings } from "./system-settings"; /** Get active users whose role has attendance.record permission (or admin role) */ async function getAttendanceUsers() { return prisma.users.findMany({ where: { is_active: true, roles: { is: { OR: [ { name: "admin" }, { role_permissions: { some: { permissions: { name: "attendance.record" } }, }, }, ], }, }, }, select: { id: true, first_name: true, last_name: true }, orderBy: { last_name: "asc" }, }); } type AttendanceWithRelations = Prisma.attendanceGetPayload<{ include: { users: { select: { id: true; first_name: true; last_name: true } }; attendance_project_logs: true; }; }>; 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 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 roundUp = (d: Date, minutes: number) => { const ms = minutes * 60 * 1000; return new Date(Math.ceil(d.getTime() / ms) * ms); }; const roundDown = (d: Date, minutes: number) => { const ms = minutes * 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 todayShiftsRaw = await prisma.attendance.findMany({ where: { user_id: userId, shift_date: { gte: todayStart, lte: todayEnd }, departure_time: { not: null }, }, include: { attendance_project_logs: { orderBy: { started_at: "asc" } }, }, 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 = getBusinessDaysInMonth(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 const completedProjectIds = new Set(); for (const shift of todayShiftsRaw) { for (const log of shift.attendance_project_logs) { completedProjectIds.add(log.project_id); } } const completedProjectNames = new Map(); if (completedProjectIds.size > 0) { const projects = await prisma.projects.findMany({ where: { id: { in: [...completedProjectIds] } }, select: { id: true, name: true, project_number: true }, }); for (const p of projects) { completedProjectNames.set( p.id, p.project_number ? `${p.project_number} – ${p.name}` : p.name || "", ); } } const todayShifts = todayShiftsRaw.map((shift) => ({ ...shift, project_logs: shift.attendance_project_logs.map((l) => ({ ...l, project_name: completedProjectNames.get(l.project_id) || `Projekt #${l.project_id}`, })), })); 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: localDateStr(now), 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 getAttendanceUsers(); const balances: Record< string, { name: string; vacation_total: number; vacation_used: number; vacation_remaining: number; sick_used: number; } > = {}; 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 getAttendanceUsers(); 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< string, { month_name: string; fund: number; fund_to_date: number; business_days: number; users: Record< string, { name: string; worked: number; covered: number; overtime: number; missing: number; } >; } > = {}; for (let m = 0; m <= maxMonth; m++) { const isCurrentMonth = year === currentYear && m === currentMonth; const bizDays = getBusinessDaysInMonth(year, m); const bizDaysToDate = isCurrentMonth ? getBusinessDaysInMonth(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< string, { name: string; worked: number; covered: number; overtime: number; missing: number; } > = {}; 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 = fundToDate; 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, 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< number, { name: string; project_number: string } >(); 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< string, { month_name: string; projects: Array<{ project_id: number | null; project_number?: string; project_name?: string; hours: number; users: Array<{ user_id: number; user_name: string; hours: number }>; }>; } > = {}; 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< number | null, { project_number?: string; project_name?: string; userMap: 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 getAttendanceUsers(); 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 = getBusinessDaysInMonth(yr, mo - 1) * 8; const typedRecords = records as AttendanceWithRelations[]; const projectIds = [ ...new Set( typedRecords .flatMap( (r) => r.attendance_project_logs?.map((l) => 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}`; } } const userTotals: Record> = {}; for (const rec of typedRecords) { 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}`, 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, }; } const projectLogs = rec.attendance_project_logs?.map((log) => ({ 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); } } 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, ); } 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), }; } 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: getBusinessDaysInMonth(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.name, project_number: p.project_number ?? "", })); } 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}:${localDateStr(r.shift_date)}`), ); 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 = localDateStr(date); 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)); if (isHoliday(dateStr)) { await prisma.attendance.create({ data: { user_id: userId, shift_date: shiftDate, leave_type: "holiday", leave_hours: 8, }, }); inserted++; continue; } 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 = localDateStr(current); 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); } 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 settings = await getSystemSettings(); 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 = roundUp(now, settings.clock_rounding_minutes); 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 = roundDown(now, settings.clock_rounding_minutes); 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 > settings.break_threshold_hours) { const midpoint = new Date(ongoing.arrival_time.getTime() + shiftMs / 2); const breakMins = shiftHours > settings.break_threshold_hours * 2 ? settings.break_duration_long : settings.break_duration_short; 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 msRound = settings.clock_rounding_minutes * 60 * 1000; const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound); const breakEnd = new Date( breakStart.getTime() + settings.break_duration_long * 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 }; }