Files
app/src/services/attendance.service.ts
BOHA ecd97ae5a3 fix: bulk attendance fill creates holiday records instead of skipping
Holidays now get leave_type: "holiday" with 8h so they count in fund calculation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:05:42 +02:00

1507 lines
42 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, 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<number>();
for (const shift of todayShiftsRaw) {
for (const log of shift.attendance_project_logs) {
completedProjectIds.add(log.project_id);
}
}
const completedProjectNames = new Map<number, string>();
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<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: 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<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 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<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 getAttendanceUsers();
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 = 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<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}`;
}
}
const userTotals: Record<string, Record<string, unknown>> = {};
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<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),
};
}
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<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}:${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<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 > 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 };
}