refactor: extract attendance business logic into attendance.service.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 09:06:40 +01:00
parent 0e9d30f5a8
commit 28eb58946f
2 changed files with 1112 additions and 944 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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<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) {
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<string, unknown> = {};
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<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 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<string, { month_name: string; fund: number; business_days: number; users: Record<string, { name: string; worked: number; covered: number; overtime: number; missing: number }> }> = {};
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<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 = 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<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<number, { name: string; hours: number }> }>();
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<string, unknown> = {
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<string, { name: string; worked: number; vacation: number; sick: number; holiday: number; unpaid: number }> = {};
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<string, unknown> = {};
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<number>();
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<number, string>();
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<string, unknown> = {
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 };
}