- feat: order confirmation PDF generation with VAT support - feat: order confirmation modal with custom item editing - fix: attendance negative duration clamping and switchProject timing - fix: Quill editor locked to Tahoma 14px, PDF heading sizes - fix: invoice/offer PDF font consistency (Tahoma enforcement) - fix: invoice alert cron improvements - fix: NAS financials manager edge cases - refactor: numbering service with unique sequence constraints Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1577 lines
44 KiB
TypeScript
1577 lines
44 KiB
TypeScript
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();
|
||
|
||
// End active project logs, ensuring ended_at is never before started_at
|
||
// (can happen when arrival_time was rounded up and now is still earlier)
|
||
const activeLogs = await prisma.attendance_project_logs.findMany({
|
||
where: { attendance_id: ongoing.id, ended_at: null },
|
||
});
|
||
for (const log of activeLogs) {
|
||
const endedAt =
|
||
log.started_at && log.started_at > now ? log.started_at : now;
|
||
await prisma.attendance_project_logs.update({
|
||
where: { id: log.id },
|
||
data: { ended_at: endedAt },
|
||
});
|
||
}
|
||
|
||
if (projectId) {
|
||
const existingLogs = await prisma.attendance_project_logs.count({
|
||
where: { attendance_id: ongoing.id },
|
||
});
|
||
const isFirstProject = existingLogs === 0;
|
||
let startedAt = isFirstProject ? ongoing.arrival_time! : now;
|
||
if (startedAt > now) startedAt = now;
|
||
await prisma.attendance_project_logs.create({
|
||
data: {
|
||
attendance_id: ongoing.id,
|
||
project_id: projectId,
|
||
started_at: startedAt,
|
||
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 } },
|
||
attendance_project_logs: {
|
||
orderBy: { started_at: "asc" },
|
||
},
|
||
},
|
||
});
|
||
|
||
// Collect all project ids from both attendance.project_id and project logs
|
||
const projectIds = new Set<number>();
|
||
for (const rec of records) {
|
||
if (rec.project_id) projectIds.add(rec.project_id);
|
||
for (const log of rec.attendance_project_logs) {
|
||
projectIds.add(log.project_id);
|
||
}
|
||
}
|
||
|
||
const projectsMap = new Map<
|
||
number,
|
||
{ name: string; project_number: string }
|
||
>();
|
||
if (projectIds.size > 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 uid = rec.user_id;
|
||
const uName = rec.users
|
||
? `${rec.users.first_name} ${rec.users.last_name}`.trim()
|
||
: `User #${uid}`;
|
||
|
||
if (rec.attendance_project_logs.length === 0) {
|
||
// No detailed project logs — fall back to attendance.project_id
|
||
const pid = rec.project_id;
|
||
const hours = calcWorkedHours(
|
||
rec.arrival_time!,
|
||
rec.departure_time!,
|
||
rec.break_start,
|
||
rec.break_end,
|
||
);
|
||
|
||
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)!;
|
||
if (!pg.userMap.has(uid)) {
|
||
pg.userMap.set(uid, { name: uName, hours: 0 });
|
||
}
|
||
pg.userMap.get(uid)!.hours += hours;
|
||
continue;
|
||
}
|
||
|
||
// Use detailed project logs (started_at/ended_at or hours/minutes)
|
||
for (const log of rec.attendance_project_logs) {
|
||
let hours = 0;
|
||
if (log.hours != null || log.minutes != null) {
|
||
hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60;
|
||
} else if (log.started_at && log.ended_at) {
|
||
hours =
|
||
(new Date(log.ended_at).getTime() -
|
||
new Date(log.started_at).getTime()) /
|
||
(1000 * 60 * 60);
|
||
} else {
|
||
continue;
|
||
}
|
||
|
||
const pid = log.project_id;
|
||
if (!projectMap.has(pid)) {
|
||
const projInfo = projectsMap.get(pid);
|
||
projectMap.set(pid, {
|
||
project_number: projInfo?.project_number || undefined,
|
||
project_name: projInfo?.name || undefined,
|
||
userMap: new Map(),
|
||
});
|
||
}
|
||
|
||
const pg = projectMap.get(pid)!;
|
||
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,
|
||
});
|
||
|
||
// End active project logs, ensuring ended_at is never before started_at
|
||
const activeLogs = await prisma.attendance_project_logs.findMany({
|
||
where: { attendance_id: ongoing.id, ended_at: null },
|
||
});
|
||
for (const log of activeLogs) {
|
||
const endedAt =
|
||
log.started_at && log.started_at > departureTime
|
||
? log.started_at
|
||
: departureTime;
|
||
await prisma.attendance_project_logs.update({
|
||
where: { id: log.id },
|
||
data: { ended_at: endedAt },
|
||
});
|
||
}
|
||
|
||
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 };
|
||
}
|