v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights: - Warehouse module: receipts, issues, reservations, inventory, reports, dashboard, master data (categories, suppliers, locations), FIFO service, integration tests - Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek / So/Ne / Noc) with Czech weekday names and decimal hours - AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep - Remove leave_type=holiday entirely (auto-computed from Czech public holidays) - Allow multiple work shifts per day (overlap detection only) - Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads - Prisma: company_settings gets 6 nullable columns for warehouse numbering (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
This commit is contained in:
@@ -27,13 +27,7 @@ type AttendanceWithRelations = Prisma.attendanceGetPayload<{
|
||||
};
|
||||
}>;
|
||||
|
||||
const VALID_LEAVE_TYPES = [
|
||||
"work",
|
||||
"vacation",
|
||||
"sick",
|
||||
"holiday",
|
||||
"unpaid",
|
||||
] as const;
|
||||
const VALID_LEAVE_TYPES = ["work", "vacation", "sick", "unpaid"] as const;
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Leden",
|
||||
@@ -77,6 +71,20 @@ const roundDown = (d: Date, minutes: number) => {
|
||||
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> {
|
||||
await tx.$executeRaw`SELECT id FROM ${Prisma.raw("users")} WHERE id = ${userId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
// ── Interfaces ───────────────────────────────────────────────────────
|
||||
|
||||
export interface ListAttendanceParams {
|
||||
@@ -225,7 +233,6 @@ export async function getStatus(userId: number) {
|
||||
let workedHours = 0;
|
||||
let vacationHours = 0;
|
||||
let sickHours = 0;
|
||||
let holidayHours = 0;
|
||||
let unpaidHours = 0;
|
||||
|
||||
for (const rec of monthRecords) {
|
||||
@@ -234,8 +241,9 @@ export async function getStatus(userId: number) {
|
||||
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;
|
||||
// "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) {
|
||||
@@ -266,7 +274,6 @@ export async function getStatus(userId: number) {
|
||||
leave_hours: leaveHours,
|
||||
vacation_hours: vacationHours,
|
||||
sick_hours: sickHours,
|
||||
holiday_hours: holidayHours,
|
||||
unpaid_hours: unpaidHours,
|
||||
};
|
||||
|
||||
@@ -409,55 +416,62 @@ export async function updateAddress(
|
||||
}
|
||||
|
||||
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." };
|
||||
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 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,
|
||||
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." };
|
||||
|
||||
await prisma.attendance.update({
|
||||
where: { id: ongoing.id },
|
||||
data: { project_id: projectId },
|
||||
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 };
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getBalances(year: number) {
|
||||
@@ -578,8 +592,6 @@ export async function getWorkfund(year: number) {
|
||||
let worked = 0;
|
||||
let vacationHours = 0;
|
||||
let sickHours = 0;
|
||||
let holidayHours = 0;
|
||||
|
||||
for (const rec of recs) {
|
||||
const lt = (rec.leave_type as string) || "work";
|
||||
if (lt === "work") {
|
||||
@@ -595,14 +607,13 @@ export async function getWorkfund(year: number) {
|
||||
vacationHours += Number(rec.leave_hours) || 8;
|
||||
} else if (lt === "sick") {
|
||||
sickHours += Number(rec.leave_hours) || 8;
|
||||
} else if (lt === "holiday") {
|
||||
holidayHours += 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 + holidayHours;
|
||||
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);
|
||||
@@ -868,7 +879,6 @@ export async function getPrintData(
|
||||
records: [],
|
||||
vacation_hours: 0,
|
||||
sick_hours: 0,
|
||||
holiday_hours: 0,
|
||||
unpaid_hours: 0,
|
||||
fund: fundHours,
|
||||
worked_hours: 0,
|
||||
@@ -899,9 +909,8 @@ export async function getPrintData(
|
||||
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;
|
||||
// "holiday" is auto-derived from Czech public holidays.
|
||||
} else if (rec.arrival_time && rec.departure_time) {
|
||||
const mins =
|
||||
calcWorkedHours(
|
||||
@@ -919,10 +928,7 @@ export async function getPrintData(
|
||||
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);
|
||||
workedH + (ut.vacation_hours as number) + (ut.sick_hours as number);
|
||||
ut.covered = Math.round(covered * 10) / 10;
|
||||
ut.missing = Math.max(
|
||||
0,
|
||||
@@ -1153,6 +1159,15 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
||||
|
||||
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;
|
||||
@@ -1160,19 +1175,6 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
||||
|
||||
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
|
||||
|
||||
if (isHoliday(dateStr)) {
|
||||
await tx.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
shift_date: shiftDate,
|
||||
leave_type: "holiday",
|
||||
leave_hours: 8,
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
@@ -1321,146 +1323,167 @@ export async function punchAction(userId: number, data: PunchData) {
|
||||
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." };
|
||||
}
|
||||
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 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 = 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 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 },
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: ongoing.id,
|
||||
status: 200,
|
||||
message: "Odchod zaznamenán",
|
||||
auditAction: "update" as const,
|
||||
auditDescription: "Zaznamenán 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") {
|
||||
const ongoing = await prisma.attendance.findFirst({
|
||||
where: {
|
||||
user_id: userId,
|
||||
departure_time: null,
|
||||
arrival_time: { not: null },
|
||||
break_start: null,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
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",
|
||||
};
|
||||
});
|
||||
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 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" };
|
||||
@@ -1483,17 +1506,60 @@ export async function createAttendance(
|
||||
shiftDate.getDate() + 1,
|
||||
);
|
||||
|
||||
const duplicate = await prisma.attendance.findFirst({
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
if (duplicate) {
|
||||
return {
|
||||
error: "Pro zvolené datumy již existují záznamy docházky",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user