Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to its UTC date part. Under TZ=Europe/Prague a local-midnight boundary (new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of every @db.Date filter in the codebase. New shared helper utcMidnightOfLocalDay() in src/utils/date.ts. Fixed (all previously off by one day): - attendance.service: getStatus today+month windows; getWorkfund (last day of each month was double-counted across months); getPrintData (monthly print included prev month's last day); listAttendance (admin month view included prev month's last day AND dropped the selected month's last day); createAttendance duplicate/overlap validation (checked only the PREVIOUS day - same-day duplicates were never caught, neighbors falsely rejected) - invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired (due==today+3 was outside the fetched window); window computation extracted as computeAlertWindow() + pure tests - invoices.service: month list/totals filter dropped invoices issued on the month's last day; getInvoiceStats month/year bounds; markOverdueInvoices boundary; auto paid_date could store yesterday during 00:00-02:00 - received-invoices auto paid_date, issued-orders default order_date: same night-window write hazard, now via the helper - attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD) +9 regression tests (month boundaries, same-day duplicate, last-day-of-month invoice in list+totals, alert window). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1773 lines
52 KiB
TypeScript
1773 lines
52 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 */
|
||
async function getAttendanceUsers() {
|
||
return prisma.users.findMany({
|
||
where: {
|
||
is_active: true,
|
||
roles: {
|
||
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", "unpaid"] as const;
|
||
|
||
/** Default annual vacation allowance (hours) when a user has no leave_balances row. */
|
||
const DEFAULT_VACATION_TOTAL = 160;
|
||
|
||
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) => {
|
||
if (!minutes || minutes <= 0) return d;
|
||
const ms = minutes * 60 * 1000;
|
||
return new Date(Math.ceil(d.getTime() / ms) * ms);
|
||
};
|
||
|
||
const roundDown = (d: Date, minutes: number) => {
|
||
if (!minutes || minutes <= 0) return d;
|
||
const ms = minutes * 60 * 1000;
|
||
return new Date(Math.floor(d.getTime() / ms) * ms);
|
||
};
|
||
|
||
/**
|
||
* Lock a user row inside an existing transaction. Used to serialize punch
|
||
* actions and project switches for a single user: two parallel taps of the
|
||
* "Příchod" button would otherwise both see "no open shift" and both create
|
||
* one, leaving the user with two parallel open shifts. With the lock, the
|
||
* second call waits, then re-reads and bails.
|
||
*/
|
||
async function lockUserRow(
|
||
tx: Prisma.TransactionClient,
|
||
userId: number,
|
||
): Promise<void> {
|
||
// Must be $queryRaw (not $executeRaw): $executeRaw is for write statements
|
||
// and may not reliably acquire/hold the row lock for a bare SELECT ... FOR
|
||
// UPDATE. $queryRaw runs it as a query so the lock is actually taken.
|
||
await tx.$queryRaw`SELECT id FROM ${Prisma.raw("users")} WHERE id = ${userId} FOR UPDATE`;
|
||
}
|
||
|
||
// ── 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;
|
||
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 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();
|
||
// shift_date is @db.Date: Prisma compares it by its UTC date part, so the
|
||
// range boundaries must be UTC midnights of the LOCAL calendar day. A local
|
||
// midnight (= 22:00/23:00 UTC of the previous day) shifts the whole window
|
||
// one day back. Half-open [gte, lt) ranges.
|
||
const todayStart = new Date(Date.UTC(y, m, d));
|
||
const todayEnd = new Date(Date.UTC(y, m, d + 1));
|
||
|
||
// Monthly fund range (used by query #4)
|
||
const monthStart = new Date(Date.UTC(y, m, 1));
|
||
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
|
||
|
||
// Queries 1-4 are independent of one another → run them in parallel.
|
||
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
|
||
await Promise.all([
|
||
// 1) Ongoing shift (no departure)
|
||
prisma.attendance.findFirst({
|
||
where: {
|
||
user_id: userId,
|
||
departure_time: null,
|
||
arrival_time: { not: null },
|
||
},
|
||
orderBy: { created_at: "desc" },
|
||
}),
|
||
// 2) Today's completed shifts
|
||
prisma.attendance.findMany({
|
||
where: {
|
||
user_id: userId,
|
||
shift_date: { gte: todayStart, lt: todayEnd },
|
||
departure_time: { not: null },
|
||
},
|
||
include: {
|
||
attendance_project_logs: { orderBy: { started_at: "asc" } },
|
||
},
|
||
orderBy: { arrival_time: "asc" },
|
||
}),
|
||
// 3) Leave balance
|
||
prisma.leave_balances.findFirst({
|
||
where: { user_id: userId, year: y },
|
||
}),
|
||
// 4) Monthly fund records
|
||
prisma.attendance.findMany({
|
||
where: {
|
||
user_id: userId,
|
||
shift_date: { gte: monthStart, lt: monthEnd },
|
||
},
|
||
}),
|
||
]);
|
||
|
||
const leaveBalance = {
|
||
vacation_total: balance
|
||
? Number(balance.vacation_total)
|
||
: DEFAULT_VACATION_TOTAL,
|
||
vacation_used: balance ? Number(balance.vacation_used) : 0,
|
||
vacation_remaining: balance
|
||
? Number(balance.vacation_total) - Number(balance.vacation_used)
|
||
: DEFAULT_VACATION_TOTAL,
|
||
sick_used: balance ? Number(balance.sick_used) : 0,
|
||
};
|
||
|
||
const workingDays = getBusinessDaysInMonth(y, m);
|
||
const fund = workingDays * 8;
|
||
|
||
let workedHours = 0;
|
||
let vacationHours = 0;
|
||
let sickHours = 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 === "unpaid") unpaidHours += hrs;
|
||
// "holiday" is no longer a user-selectable leave_type — it is
|
||
// auto-derived from Czech public holidays. Old rows are skipped.
|
||
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 adjustedFund = Math.max(0, fund);
|
||
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,
|
||
worked,
|
||
covered,
|
||
remaining,
|
||
overtime,
|
||
leave_hours: leaveHours,
|
||
vacation_hours: vacationHours,
|
||
sick_hours: sickHours,
|
||
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,
|
||
) {
|
||
// When updating departure address, the punch already set departure_time,
|
||
// so we can't filter on departure_time: null. Find the latest record instead.
|
||
const where: Record<string, unknown> = {
|
||
user_id: userId,
|
||
arrival_time: { not: null },
|
||
};
|
||
if (punchAction === "arrival") {
|
||
where.departure_time = null;
|
||
}
|
||
|
||
const latest = await prisma.attendance.findFirst({
|
||
where,
|
||
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) {
|
||
return prisma.$transaction(async (tx) => {
|
||
// Lock the user row so two parallel "switch project" clicks (or one
|
||
// interleaved with a departure punch) cannot both create overlapping
|
||
// project logs for the same open shift.
|
||
await lockUserRow(tx, userId);
|
||
|
||
const ongoing = await tx.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 tx.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 tx.attendance_project_logs.update({
|
||
where: { id: log.id },
|
||
data: { ended_at: endedAt },
|
||
});
|
||
}
|
||
|
||
if (projectId) {
|
||
const existingLogs = await tx.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 tx.attendance_project_logs.create({
|
||
data: {
|
||
attendance_id: ongoing.id,
|
||
project_id: projectId,
|
||
started_at: startedAt,
|
||
ended_at: null,
|
||
},
|
||
});
|
||
}
|
||
|
||
await tx.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;
|
||
}
|
||
> = {};
|
||
// Single query for every user's balance for the year (was an N+1 loop).
|
||
const balanceRows = await prisma.leave_balances.findMany({
|
||
where: { year, user_id: { in: users.map((u) => u.id) } },
|
||
});
|
||
// Keep the first row seen per user (matches the old per-user findFirst).
|
||
const balanceByUser = new Map<number, (typeof balanceRows)[number]>();
|
||
for (const row of balanceRows) {
|
||
if (!balanceByUser.has(row.user_id)) balanceByUser.set(row.user_id, row);
|
||
}
|
||
|
||
for (const u of users) {
|
||
const lb = balanceByUser.get(u.id);
|
||
const vTotal = lb ? Number(lb.vacation_total) : DEFAULT_VACATION_TOTAL;
|
||
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 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;
|
||
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight
|
||
// month boundaries, half-open range. Local midnights would double-count
|
||
// each previous month's last day.
|
||
const monthStart = new Date(Date.UTC(year, m, 1));
|
||
const monthEnd = new Date(Date.UTC(year, m + 1, 1));
|
||
const monthRecords = await prisma.attendance.findMany({
|
||
where: { shift_date: { gte: monthStart, lt: monthEnd } },
|
||
select: {
|
||
user_id: true,
|
||
shift_date: true,
|
||
leave_type: true,
|
||
arrival_time: true,
|
||
departure_time: true,
|
||
break_start: true,
|
||
break_end: true,
|
||
leave_hours: true,
|
||
},
|
||
});
|
||
|
||
const monthUsers: Record<
|
||
string,
|
||
{
|
||
name: string;
|
||
worked: number;
|
||
covered: number;
|
||
overtime: number;
|
||
missing: number;
|
||
}
|
||
> = {};
|
||
|
||
for (const u of users) {
|
||
const recs = monthRecords.filter((r) => r.user_id === u.id);
|
||
let worked = 0;
|
||
let vacationHours = 0;
|
||
let sickHours = 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;
|
||
}
|
||
// "holiday" is auto-derived from Czech public holidays.
|
||
}
|
||
|
||
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);
|
||
|
||
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight month
|
||
// boundaries, half-open range (local midnights pulled in the previous
|
||
// month's last day).
|
||
const monthStart = new Date(Date.UTC(yr, mo - 1, 1));
|
||
const monthEnd = new Date(Date.UTC(yr, mo, 1));
|
||
|
||
const users = await getAttendanceUsers();
|
||
|
||
const where: Record<string, unknown> = {
|
||
shift_date: { gte: monthStart, lt: 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,
|
||
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 === "unpaid") (userTotals[uid].unpaid_hours as number) += hrs;
|
||
// "holiday" is auto-derived from Czech public holidays.
|
||
} 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.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) || DEFAULT_VACATION_TOTAL,
|
||
vacation_remaining:
|
||
(Number(bal.vacation_total) || DEFAULT_VACATION_TOTAL) -
|
||
(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) {
|
||
// shift_date is @db.Date: Prisma compares by UTC date part, so the month
|
||
// boundaries must be UTC midnights. Local midnights shifted the window a
|
||
// day back (included prev-month's last day, dropped this month's last day).
|
||
where.shift_date = {
|
||
gte: new Date(Date.UTC(params.year, params.month - 1, 1)),
|
||
lt: new Date(Date.UTC(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) || DEFAULT_VACATION_TOTAL,
|
||
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: DEFAULT_VACATION_TOTAL,
|
||
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;
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
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;
|
||
|
||
// Holidays are auto-paid via the fond in mzda math — skip them in
|
||
// the bulk-create work-shift fill. The user shouldn't end up with
|
||
// an explicit "holiday" record; the Svátek summary line and
|
||
// attendance_hours are derived from Czech public holidays instead.
|
||
if (isHoliday(dateStr)) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
if (existingSet.has(`${userId}:${dateStr}`)) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
|
||
|
||
await tx.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);
|
||
const perDayHours = data.leave_hours ? Number(data.leave_hours) : 8;
|
||
let created = 0;
|
||
|
||
try {
|
||
await prisma.$transaction(async (tx) => {
|
||
// A leave range can span a year boundary (e.g. Dec → Jan). Each day's
|
||
// hours must be booked against ITS OWN year's balance, so accumulate
|
||
// per calendar year rather than attributing the whole range to the
|
||
// start year.
|
||
const hoursByYear = new Map<number, number>();
|
||
const current = new Date(start);
|
||
while (current <= end) {
|
||
const dow = current.getDay();
|
||
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
|
||
const shiftDate = new Date(
|
||
current.getFullYear(),
|
||
current.getMonth(),
|
||
current.getDate(),
|
||
12,
|
||
0,
|
||
0,
|
||
);
|
||
const duplicate = await tx.attendance.findFirst({
|
||
where: { user_id: userId, shift_date: shiftDate },
|
||
});
|
||
if (duplicate) {
|
||
throw new Error("Pro zvolené datumy již existují záznamy docházky");
|
||
}
|
||
await tx.attendance.create({
|
||
data: {
|
||
user_id: userId,
|
||
shift_date: shiftDate,
|
||
leave_type: leaveType,
|
||
leave_hours: perDayHours,
|
||
notes: data.notes ? String(data.notes) : null,
|
||
},
|
||
});
|
||
created++;
|
||
const dayYear = current.getFullYear();
|
||
hoursByYear.set(
|
||
dayYear,
|
||
(hoursByYear.get(dayYear) ?? 0) + perDayHours,
|
||
);
|
||
}
|
||
current.setDate(current.getDate() + 1);
|
||
}
|
||
|
||
if (leaveType === "vacation" || leaveType === "sick") {
|
||
const updateField =
|
||
leaveType === "vacation" ? "vacation_used" : "sick_used";
|
||
for (const [year, yearHours] of hoursByYear) {
|
||
if (yearHours <= 0) continue;
|
||
const existingBalance = await tx.leave_balances.findFirst({
|
||
where: { user_id: userId, year },
|
||
});
|
||
if (existingBalance) {
|
||
await tx.leave_balances.update({
|
||
where: { id: existingBalance.id },
|
||
data: {
|
||
[updateField]: Number(existingBalance[updateField]) + yearHours,
|
||
updated_at: new Date(),
|
||
},
|
||
});
|
||
} else {
|
||
await tx.leave_balances.create({
|
||
data: {
|
||
user_id: userId,
|
||
year,
|
||
vacation_total: DEFAULT_VACATION_TOTAL,
|
||
vacation_used: leaveType === "vacation" ? yearHours : 0,
|
||
sick_used: leaveType === "sick" ? yearHours : 0,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
} catch (err) {
|
||
if (
|
||
err instanceof Error &&
|
||
err.message === "Pro zvolené datumy již existují záznamy docházky"
|
||
) {
|
||
return { error: err.message };
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
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(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") {
|
||
return prisma.$transaction(async (tx) => {
|
||
// Lock the user row first so a double-tap of "Příchod" (parallel POSTs
|
||
// to /api/attendance/punch) cannot both see "no open shift" and both
|
||
// create one. The second call waits here, then re-reads and bails.
|
||
await lockUserRow(tx, userId);
|
||
|
||
const ongoing = await tx.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 tx.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") {
|
||
return prisma.$transaction(async (tx) => {
|
||
await lockUserRow(tx, userId);
|
||
|
||
const ongoing = await tx.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 = roundDown(
|
||
new Date(ongoing.arrival_time.getTime() + shiftMs / 2),
|
||
settings.clock_rounding_minutes,
|
||
);
|
||
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 tx.attendance.update({
|
||
where: { id: ongoing.id },
|
||
data: updateData,
|
||
});
|
||
|
||
// End active project logs, ensuring ended_at is never before started_at
|
||
const activeLogs = await tx.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 tx.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") {
|
||
return prisma.$transaction(async (tx) => {
|
||
await lockUserRow(tx, userId);
|
||
|
||
const ongoing = await tx.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." };
|
||
}
|
||
|
||
let msRound = settings.clock_rounding_minutes * 60 * 1000;
|
||
if (
|
||
!settings.clock_rounding_minutes ||
|
||
settings.clock_rounding_minutes <= 0
|
||
) {
|
||
msRound = 0;
|
||
}
|
||
const breakStart =
|
||
msRound > 0
|
||
? new Date(Math.round(now.getTime() / msRound) * msRound)
|
||
: now;
|
||
const breakEnd = new Date(
|
||
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
|
||
);
|
||
|
||
await tx.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 userId = data.user_id ?? authUserId;
|
||
const shiftDate = new Date(data.shift_date);
|
||
// shift_date arrives as "YYYY-MM-DD" → parsed as UTC midnight, and the
|
||
// @db.Date column is compared by UTC date part. The duplicate/overlap
|
||
// window must therefore be the UTC day [shiftDate, shiftDate+1) — building
|
||
// it from LOCAL getters made both bounds 22:00/23:00 UTC of the previous
|
||
// day, so the validation queried ONLY yesterday's records (same-day
|
||
// overlaps undetected, false duplicates when yesterday had records).
|
||
const startOfDay = new Date(
|
||
Date.UTC(
|
||
shiftDate.getUTCFullYear(),
|
||
shiftDate.getUTCMonth(),
|
||
shiftDate.getUTCDate(),
|
||
),
|
||
);
|
||
const endOfDay = new Date(
|
||
Date.UTC(
|
||
shiftDate.getUTCFullYear(),
|
||
shiftDate.getUTCMonth(),
|
||
shiftDate.getUTCDate() + 1,
|
||
),
|
||
);
|
||
|
||
// Multiple work shifts per day are allowed — only block when the new
|
||
// shift's time range overlaps an existing one. Two leave-type records
|
||
// on the same day still make no sense and are blocked.
|
||
const dayRecords = await prisma.attendance.findMany({
|
||
where: {
|
||
user_id: userId,
|
||
shift_date: { gte: startOfDay, lt: endOfDay },
|
||
},
|
||
select: {
|
||
id: true,
|
||
leave_type: true,
|
||
arrival_time: true,
|
||
departure_time: true,
|
||
},
|
||
});
|
||
|
||
const isLeave = (data.leave_type as string) !== "work";
|
||
if (isLeave) {
|
||
const otherLeave = dayRecords.find(
|
||
(r) => (r.leave_type as string) !== "work",
|
||
);
|
||
if (otherLeave) {
|
||
return {
|
||
error:
|
||
"Pro zvolené datum již existuje záznam o nepřítomnosti (dovolená / nemoc / neplacené volno).",
|
||
status: 400,
|
||
};
|
||
}
|
||
} else if (data.arrival_time && data.departure_time) {
|
||
const newStart = new Date(data.arrival_time).getTime();
|
||
const newEnd = new Date(data.departure_time).getTime();
|
||
for (const r of dayRecords) {
|
||
if ((r.leave_type as string) !== "work") continue;
|
||
if (!r.arrival_time || !r.departure_time) continue;
|
||
const eStart = new Date(r.arrival_time).getTime();
|
||
const eEnd = new Date(r.departure_time).getTime();
|
||
// Half-open overlap: [newStart, newEnd) ∩ [eStart, eEnd) ≠ ∅
|
||
if (newStart < eEnd && eStart < newEnd) {
|
||
return {
|
||
error: "Pro zvolený čas se směna překrývá s jinou směnou tentýž den.",
|
||
status: 400,
|
||
};
|
||
}
|
||
}
|
||
} else {
|
||
// Work shift with missing arrival/departure — block any other record
|
||
// on the same day so the user doesn't end up with two ambiguous rows.
|
||
if (dayRecords.length > 0) {
|
||
return {
|
||
error:
|
||
"Pro zvolené datum již existuje záznam docházky. Doplňte příchod a odchod, nebo smažte/upravte stávající záznam.",
|
||
status: 400,
|
||
};
|
||
}
|
||
}
|
||
|
||
const record = await prisma.attendance.create({
|
||
data: {
|
||
user_id: userId,
|
||
shift_date: shiftDate,
|
||
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,
|
||
break_start: data.break_start ? new Date(data.break_start) : null,
|
||
break_end: data.break_end ? new Date(data.break_end) : 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,
|
||
requesterId?: number,
|
||
isAdmin?: boolean,
|
||
) {
|
||
const existing = await prisma.attendance.findUnique({ where: { id } });
|
||
if (!existing) return { error: "Záznam nenalezen" };
|
||
|
||
// Ownership is enforced ONLY when a requester is supplied, keeping the
|
||
// signature backward compatible (existing callers pass nothing and skip
|
||
// the check, behaving exactly as before).
|
||
if (
|
||
requesterId !== undefined &&
|
||
!isAdmin &&
|
||
existing.user_id !== requesterId
|
||
) {
|
||
return { error: "Nemáte oprávnění smazat tento záznam", status: 403 };
|
||
}
|
||
|
||
await prisma.attendance.delete({ where: { id } });
|
||
return { success: true };
|
||
}
|