Files
app/src/services/attendance.service.ts
BOHA 2718a7b716 fix: attendance admin — add user_name to records, fix Czech diacritics in table headers
- listAttendance() now maps users.first_name + last_name to user_name
- Fixed escaped Unicode in table headers (Zaměstnanec, Příchod, Poznámka)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:41:55 +01:00

1079 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } },
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<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) {
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<string, Record<string, unknown>> = {};
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<string, Record<string, number>> = {};
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<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}`,
}));
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<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 };
}