mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Complete regular absences management
This commit is contained in:
@@ -60,6 +60,8 @@
|
||||
"noEndDate": "Ende ist erforderlich",
|
||||
"endDateBeforeStartDate": "Das Ende muss nach dem Beginn sein",
|
||||
"endDateTooEarly": "Das Ende muss in der Zukunft liegen",
|
||||
"noWeekdaysSelected": "Wähle mindestens einen Wochentag",
|
||||
"fromAfterTo": "»Von« muss vor »Bis« liegen",
|
||||
"street": "Straße hinzufügen",
|
||||
"houseNo": "Pflichtfeld",
|
||||
"zip": "Postleitzahl hinzufügen",
|
||||
@@ -613,6 +615,7 @@
|
||||
"action": "Abwesenheit hinzufügen",
|
||||
"success": "Abwesenheit hinzugefügt",
|
||||
"errors": {
|
||||
"conflict": "Akteur ist bereits in diesem Zeitraum (teilweise) abwesend",
|
||||
"unknown": "Konnte Abwesenheit nicht hinzufügen"
|
||||
},
|
||||
"unavailable": {
|
||||
@@ -630,12 +633,15 @@
|
||||
"title": "Abwesenheit bearbeiten",
|
||||
"description": "Aktualisiere die Verfügbarkeit dieses Akteurs.",
|
||||
"success": "Abwesenheit aktualisiert",
|
||||
"error": "Konnte Abwesenheit nicht aktualisieren.",
|
||||
"errors": {
|
||||
"conflict": "Akteur ist bereits in diesem Zeitraum (teilweise) abwesend",
|
||||
"unknown": "Konnte Abwesenheit nicht aktualisieren"
|
||||
},
|
||||
"action": "Übernehmen"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Abwesenheit löschen",
|
||||
"description": "{name} wird vom {startDate} bis {endDate} wieder für Termine verfügbar sein.",
|
||||
"description": "{name} wird in diesem Zeitraum wieder für Termine verfügbar sein: {timespan}.",
|
||||
"description_fallback": "Diese/r Akteur:in",
|
||||
"action": "Abwesenheit löschen",
|
||||
"success": "Abwesenheit gelöscht",
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"noEndDate": "End date is required",
|
||||
"endDateBeforeStartDate": "End date must be after start date",
|
||||
"endDateTooEarly": "End date must be in the future",
|
||||
"noWeekdaysSelected": "Select at least one weekday",
|
||||
"fromAfterTo": "»From« time must be before »To« time",
|
||||
"street": "Add a street",
|
||||
"houseNo": "Required",
|
||||
"zip": "Add a zip code",
|
||||
@@ -621,6 +623,7 @@
|
||||
"action": "Add Absence",
|
||||
"success": "Absence added",
|
||||
"errors": {
|
||||
"conflict": "Agent is already (partially) absent in this time period",
|
||||
"unknown": "Could not add absence"
|
||||
},
|
||||
"unavailable": {
|
||||
@@ -638,12 +641,15 @@
|
||||
"title": "Edit Absence",
|
||||
"description": "Update the agents availability.",
|
||||
"success": "Absence updated",
|
||||
"error": "Could not update absence.",
|
||||
"errors": {
|
||||
"conflict": "Agent is already (partially) absent in this time period",
|
||||
"unknown": "Could not update absence"
|
||||
},
|
||||
"action": "Edit Absence"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete Absence",
|
||||
"description": "{name} will be available for appointments from {startDate} to {endDate}.",
|
||||
"description": "{name} will be available for appointments again in this timespan: {timespan}.",
|
||||
"description_fallback": "This agent",
|
||||
"action": "Delete Absence",
|
||||
"success": "Absence deleted",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
let {
|
||||
image,
|
||||
title,
|
||||
icons,
|
||||
description,
|
||||
descriptionOnClick,
|
||||
actions,
|
||||
@@ -35,6 +36,7 @@
|
||||
}: HTMLAttributes<HTMLLIElement> & {
|
||||
image?: string | Component;
|
||||
title: string;
|
||||
icons?: Component[];
|
||||
description?: string;
|
||||
descriptionOnClick?: () => void;
|
||||
actions?: ListItemAction[];
|
||||
@@ -65,7 +67,16 @@
|
||||
<div></div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<Text style="md" class="font-medium">{title}</Text>
|
||||
<Text style="md" class="flex items-center gap-2 font-medium">
|
||||
{title}
|
||||
{#if icons && icons.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
{#each icons as Icon, index (`icon-${index}`)}
|
||||
<Icon class="size-3" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Text>
|
||||
{#if badges && badges.length > 0}
|
||||
<div class="flex flex-wrap gap-1 py-1">
|
||||
{#each badges as badge, index (`${badge.label}-${index}`)}
|
||||
|
||||
@@ -100,7 +100,10 @@
|
||||
{/if}
|
||||
</Dialog.Trigger>
|
||||
{/if}
|
||||
<Dialog.Content class="max-h-[95vh] sm:max-w-106.25">
|
||||
<Dialog.Content
|
||||
class="max-h-[95vh] sm:max-w-106.25"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
|
||||
{#if description}
|
||||
@@ -129,6 +132,7 @@
|
||||
{/if}
|
||||
<Drawer.Content
|
||||
class="data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Drawer.Header class="text-left">
|
||||
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
|
||||
|
||||
@@ -26,6 +26,8 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [
|
||||
"NO_SHOW",
|
||||
]);
|
||||
|
||||
export const absenceTypeEnum = pgEnum("agent_absence_type", ["ONE_TIME", "REGULAR"]);
|
||||
|
||||
export const notificationTypes = [
|
||||
"APPOINTMENT_CONFIRMED",
|
||||
"APPOINTMENT_CANCELLED",
|
||||
@@ -200,6 +202,8 @@ export const appointment = pgTable("appointment", {
|
||||
export const agentAbsence = pgTable("agent_absence", {
|
||||
/** Primary key - unique identifier */
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** Enum ONE_TIME or REGULAR, defaults to ONE_TIME */
|
||||
type: absenceTypeEnum("type").notNull().default("ONE_TIME"),
|
||||
/** Foreign key to agent who is absent */
|
||||
agentId: uuid("agent_id")
|
||||
.notNull()
|
||||
@@ -212,6 +216,12 @@ export const agentAbsence = pgTable("agent_absence", {
|
||||
absenceType: text("absence_type").notNull().default(""),
|
||||
/** Optional description/reason for absence */
|
||||
description: text("description"),
|
||||
/** Set, when of type REGULAR: Bitmask for weekdays (1=Monday, 2=Tuesday, 4=Wednesday, etc.), is NULL by default */
|
||||
weekdays: integer("weekdays"),
|
||||
/** Set, when of type REGULAR: Start time for the regular absence */
|
||||
from: time("from"),
|
||||
/** Set, when of type REGULAR: End time for the regular absence */
|
||||
to: time("to"),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,11 @@ vi.mock("$lib/logger", () => ({
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { AgentService, type AbsenceCreationRequest } from "../agent-service";
|
||||
import {
|
||||
AgentService,
|
||||
type AbsenceCreationRequest,
|
||||
type AbsenceUpdateRequest,
|
||||
} from "../agent-service";
|
||||
import { getTenantDb } from "../../db";
|
||||
import { ValidationError, NotFoundError, ConflictError } from "../../utils/errors";
|
||||
|
||||
@@ -676,6 +680,7 @@ describe("AgentService", () => {
|
||||
describe("createAbsence", () => {
|
||||
it("should create absence successfully", async () => {
|
||||
const request: AbsenceCreationRequest = {
|
||||
type: "ONE_TIME",
|
||||
agentId: "123e4567-e89b-12d3-a456-426614174001",
|
||||
startDate: "2024-01-01T08:00:00.000Z",
|
||||
endDate: "2024-01-01T17:00:00.000Z",
|
||||
@@ -730,11 +735,15 @@ describe("AgentService", () => {
|
||||
|
||||
expect(result).toEqual(mockAbsence);
|
||||
expect(insertChain.values).toHaveBeenCalledWith({
|
||||
type: "ONE_TIME",
|
||||
agentId: request.agentId,
|
||||
startDate: new Date(request.startDate),
|
||||
endDate: new Date(request.endDate),
|
||||
absenceType: request.absenceType,
|
||||
description: request.description,
|
||||
weekdays: null,
|
||||
from: null,
|
||||
to: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -754,6 +763,7 @@ describe("AgentService", () => {
|
||||
|
||||
it("should validate date range", async () => {
|
||||
const request: AbsenceCreationRequest = {
|
||||
type: "ONE_TIME",
|
||||
agentId: "agent-123",
|
||||
startDate: "2024-01-01T17:00:00.000Z", // End before start
|
||||
endDate: "2024-01-01T08:00:00.000Z",
|
||||
@@ -765,6 +775,7 @@ describe("AgentService", () => {
|
||||
|
||||
it("should throw NotFoundError if agent does not exist", async () => {
|
||||
const request: AbsenceCreationRequest = {
|
||||
type: "ONE_TIME",
|
||||
agentId: "123e4567-e89b-12d3-a456-426614174099", // Valid UUID format
|
||||
startDate: "2024-01-01T08:00:00.000Z",
|
||||
endDate: "2024-01-01T17:00:00.000Z",
|
||||
@@ -792,6 +803,7 @@ describe("AgentService", () => {
|
||||
|
||||
it("should throw ConflictError if absence period overlaps", async () => {
|
||||
const request: AbsenceCreationRequest = {
|
||||
type: "ONE_TIME",
|
||||
agentId: "123e4567-e89b-12d3-a456-426614174001",
|
||||
startDate: "2024-01-01T08:00:00.000Z",
|
||||
endDate: "2024-01-01T17:00:00.000Z",
|
||||
@@ -921,7 +933,8 @@ describe("AgentService", () => {
|
||||
|
||||
describe("updateAbsence", () => {
|
||||
it("should update absence successfully", async () => {
|
||||
const updateData = {
|
||||
const updateData: AbsenceUpdateRequest = {
|
||||
type: "ONE_TIME",
|
||||
absenceType: "Krankheit",
|
||||
description: "Sick leave",
|
||||
};
|
||||
@@ -959,7 +972,8 @@ describe("AgentService", () => {
|
||||
});
|
||||
|
||||
it("should validate update request", async () => {
|
||||
const invalidUpdate = {
|
||||
const invalidUpdate: AbsenceUpdateRequest = {
|
||||
type: "ONE_TIME",
|
||||
absenceType: "", // Invalid empty type
|
||||
startDate: "invalid-date",
|
||||
};
|
||||
@@ -970,7 +984,7 @@ describe("AgentService", () => {
|
||||
});
|
||||
|
||||
it("should throw NotFoundError if absence does not exist", async () => {
|
||||
const updateData = { absenceType: "Krankheit" };
|
||||
const updateData: AbsenceUpdateRequest = { type: "ONE_TIME", absenceType: "Krankheit" };
|
||||
|
||||
const selectChain = {
|
||||
from: vi.fn(() => ({
|
||||
@@ -994,7 +1008,8 @@ describe("AgentService", () => {
|
||||
});
|
||||
|
||||
it("should validate new date range", async () => {
|
||||
const updateData = {
|
||||
const updateData: AbsenceUpdateRequest = {
|
||||
type: "ONE_TIME",
|
||||
startDate: "2024-01-01T17:00:00.000Z", // End before start
|
||||
endDate: "2024-01-01T08:00:00.000Z",
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getTenantDb } from "../db";
|
||||
import * as tenantSchema from "../db/tenant-schema";
|
||||
import { type SelectAgent, type SelectAgentAbsence } from "../db/tenant-schema";
|
||||
|
||||
import { eq, and, between, or, lte, gte, ne } from "drizzle-orm";
|
||||
import { eq, and, between, or, lte, gte, ne, sql } from "drizzle-orm";
|
||||
import logger from "$lib/logger";
|
||||
import { z } from "zod";
|
||||
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
|
||||
@@ -23,20 +23,47 @@ const agentUpdateSchema = z.object({
|
||||
languages: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const absenceCreationSchema = z.object({
|
||||
agentId: z.string().uuid({ message: "Invalid UUID format" }),
|
||||
startDate: z.string().datetime({ message: "Invalid datetime format" }),
|
||||
endDate: z.string().datetime({ message: "Invalid datetime format" }),
|
||||
absenceType: z.string().min(1).max(100),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
const absenceCreationSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("ONE_TIME"),
|
||||
agentId: z.uuid({ message: "Invalid UUID format" }),
|
||||
startDate: z.string().datetime({ message: "Invalid datetime format" }),
|
||||
endDate: z.string().datetime({ message: "Invalid datetime format" }),
|
||||
absenceType: z.string().min(1).max(100),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("REGULAR"),
|
||||
agentId: z.uuid({ message: "Invalid UUID format" }),
|
||||
startDate: z.string().datetime({ message: "Invalid datetime format" }),
|
||||
endDate: z.string().datetime({ message: "Invalid datetime format" }),
|
||||
absenceType: z.string().min(1).max(100),
|
||||
description: z.string().optional(),
|
||||
weekdays: z.number().int().min(0).max(127).nullable(),
|
||||
from: z.string().time({ message: "Invalid time format" }),
|
||||
to: z.string().time({ message: "Invalid time format" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
const absenceUpdateSchema = z.object({
|
||||
startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
|
||||
endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
|
||||
absenceType: z.string().min(1).max(100).optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
const absenceUpdateSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("ONE_TIME"),
|
||||
startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
|
||||
endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
|
||||
absenceType: z.string().min(1).max(100).optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("REGULAR"),
|
||||
startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
|
||||
endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
|
||||
absenceType: z.string().min(1).max(100).optional(),
|
||||
description: z.string().optional(),
|
||||
weekdays: z.number().int().min(0).max(127).nullable(),
|
||||
from: z.string().time({ message: "Invalid time format" }),
|
||||
to: z.string().time({ message: "Invalid time format" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
const absenceQuerySchema = z.object({
|
||||
agentId: z.string().uuid({ message: "Invalid UUID format" }).optional(),
|
||||
@@ -433,6 +460,11 @@ export class AgentService {
|
||||
|
||||
const validation = absenceCreationSchema.safeParse(request);
|
||||
if (!validation.success) {
|
||||
log.error("Error creating new absence", {
|
||||
tenantId: this.tenantId,
|
||||
request,
|
||||
validation,
|
||||
});
|
||||
throw new ValidationError("Invalid absence creation request");
|
||||
}
|
||||
|
||||
@@ -446,7 +478,7 @@ export class AgentService {
|
||||
log.debug("Creating new absence", {
|
||||
tenantId: this.tenantId,
|
||||
agentId: request.agentId,
|
||||
absenceType: request.absenceType,
|
||||
type: request.type,
|
||||
startDate: request.startDate,
|
||||
endDate: request.endDate,
|
||||
});
|
||||
@@ -468,37 +500,40 @@ export class AgentService {
|
||||
}
|
||||
|
||||
// Check for overlapping absences
|
||||
const overlappingAbsences = await db
|
||||
.select()
|
||||
.from(tenantSchema.agentAbsence)
|
||||
.where(
|
||||
and(
|
||||
eq(tenantSchema.agentAbsence.agentId, request.agentId),
|
||||
or(
|
||||
// New absence starts during existing absence
|
||||
and(
|
||||
between(
|
||||
tenantSchema.agentAbsence.startDate,
|
||||
new Date(request.startDate),
|
||||
new Date(request.endDate),
|
||||
const overlappingAbsences =
|
||||
request.type === "REGULAR"
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(tenantSchema.agentAbsence)
|
||||
.where(
|
||||
and(
|
||||
eq(tenantSchema.agentAbsence.agentId, request.agentId),
|
||||
or(
|
||||
// New absence starts during existing absence
|
||||
and(
|
||||
between(
|
||||
tenantSchema.agentAbsence.startDate,
|
||||
new Date(request.startDate),
|
||||
new Date(request.endDate),
|
||||
),
|
||||
),
|
||||
// New absence ends during existing absence
|
||||
and(
|
||||
between(
|
||||
tenantSchema.agentAbsence.endDate,
|
||||
new Date(request.startDate),
|
||||
new Date(request.endDate),
|
||||
),
|
||||
),
|
||||
// New absence entirely contains existing absence
|
||||
and(
|
||||
gte(tenantSchema.agentAbsence.startDate, new Date(request.startDate)),
|
||||
lte(tenantSchema.agentAbsence.endDate, new Date(request.endDate)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// New absence ends during existing absence
|
||||
and(
|
||||
between(
|
||||
tenantSchema.agentAbsence.endDate,
|
||||
new Date(request.startDate),
|
||||
new Date(request.endDate),
|
||||
),
|
||||
),
|
||||
// New absence entirely contains existing absence
|
||||
and(
|
||||
gte(tenantSchema.agentAbsence.startDate, new Date(request.startDate)),
|
||||
lte(tenantSchema.agentAbsence.endDate, new Date(request.endDate)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
if (overlappingAbsences.length > 0) {
|
||||
throw new ConflictError("Absence period overlaps with existing absence");
|
||||
@@ -507,11 +542,15 @@ export class AgentService {
|
||||
const result = await db
|
||||
.insert(tenantSchema.agentAbsence)
|
||||
.values({
|
||||
type: request.type,
|
||||
agentId: request.agentId,
|
||||
startDate: new Date(request.startDate),
|
||||
endDate: new Date(request.endDate),
|
||||
absenceType: request.absenceType,
|
||||
description: request.description,
|
||||
weekdays: request.type === "REGULAR" ? request.weekdays : null,
|
||||
from: request.type === "REGULAR" ? request.from : null,
|
||||
to: request.type === "REGULAR" ? request.to : null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -621,7 +660,10 @@ export class AgentService {
|
||||
.select()
|
||||
.from(tenantSchema.agentAbsence)
|
||||
.where(and(...conditions))
|
||||
.orderBy(tenantSchema.agentAbsence.startDate);
|
||||
.orderBy(
|
||||
sql`CASE WHEN ${tenantSchema.agentAbsence.type} = 'ONE_TIME' THEN 0 ELSE 1 END`,
|
||||
tenantSchema.agentAbsence.startDate,
|
||||
);
|
||||
|
||||
log.debug("Retrieved absences", {
|
||||
tenantId: this.tenantId,
|
||||
@@ -666,7 +708,10 @@ export class AgentService {
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = await query.orderBy(tenantSchema.agentAbsence.startDate);
|
||||
const result = await query.orderBy(
|
||||
sql`CASE WHEN ${tenantSchema.agentAbsence.type} = 'ONE_TIME' THEN 0 ELSE 1 END`,
|
||||
tenantSchema.agentAbsence.startDate,
|
||||
);
|
||||
|
||||
log.debug("Retrieved agent absences", {
|
||||
tenantId: this.tenantId,
|
||||
@@ -787,33 +832,36 @@ export class AgentService {
|
||||
}
|
||||
|
||||
// Check for overlapping absences (excluding current absence)
|
||||
const overlappingAbsences = await db
|
||||
.select()
|
||||
.from(tenantSchema.agentAbsence)
|
||||
.where(
|
||||
and(
|
||||
eq(tenantSchema.agentAbsence.agentId, currentAbsence[0].agentId),
|
||||
// Exclude current absence from check
|
||||
ne(tenantSchema.agentAbsence.id, absenceId),
|
||||
or(
|
||||
between(
|
||||
tenantSchema.agentAbsence.startDate,
|
||||
new Date(newStartDate),
|
||||
new Date(newEndDate),
|
||||
),
|
||||
between(
|
||||
tenantSchema.agentAbsence.endDate,
|
||||
new Date(newStartDate),
|
||||
new Date(newEndDate),
|
||||
),
|
||||
// New period entirely contains existing absence
|
||||
and(
|
||||
gte(tenantSchema.agentAbsence.startDate, new Date(newStartDate)),
|
||||
lte(tenantSchema.agentAbsence.endDate, new Date(newEndDate)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const overlappingAbsences =
|
||||
updateData.type === "REGULAR"
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(tenantSchema.agentAbsence)
|
||||
.where(
|
||||
and(
|
||||
eq(tenantSchema.agentAbsence.agentId, currentAbsence[0].agentId),
|
||||
// Exclude current absence from check
|
||||
ne(tenantSchema.agentAbsence.id, absenceId),
|
||||
or(
|
||||
between(
|
||||
tenantSchema.agentAbsence.startDate,
|
||||
new Date(newStartDate),
|
||||
new Date(newEndDate),
|
||||
),
|
||||
between(
|
||||
tenantSchema.agentAbsence.endDate,
|
||||
new Date(newStartDate),
|
||||
new Date(newEndDate),
|
||||
),
|
||||
// New period entirely contains existing absence
|
||||
and(
|
||||
gte(tenantSchema.agentAbsence.startDate, new Date(newStartDate)),
|
||||
lte(tenantSchema.agentAbsence.endDate, new Date(newEndDate)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (overlappingAbsences.length > 0) {
|
||||
throw new ConflictError("Updated absence period overlaps with existing absence");
|
||||
|
||||
@@ -250,7 +250,7 @@ export const getWeekDays = (day: CalendarDate, skipWeekend = false): CalendarDat
|
||||
return days;
|
||||
};
|
||||
|
||||
export const toWeekdaysLabel = (bitmap: number | undefined) => {
|
||||
export const toWeekdaysLabel = (bitmap: number | undefined | null) => {
|
||||
if (!bitmap) return m["components.slotTemplate.empty_weekdays"]();
|
||||
|
||||
switch (bitmap) {
|
||||
|
||||
+17
-6
@@ -38,9 +38,9 @@
|
||||
absenceType: "VACATION",
|
||||
startDate: getDefaultStartTime(),
|
||||
endDate: getDefaultEndTime(),
|
||||
weekdays: 0,
|
||||
from: timeLocalWithoutOffsetToUTC("09:00:00"),
|
||||
to: timeLocalWithoutOffsetToUTC("17:00:00"),
|
||||
weekdays: 0 as number | undefined,
|
||||
from: timeLocalWithoutOffsetToUTC("09:00:00") as string | undefined,
|
||||
to: timeLocalWithoutOffsetToUTC("17:00:00") as string | undefined,
|
||||
},
|
||||
{
|
||||
dataType: "json",
|
||||
@@ -50,7 +50,11 @@
|
||||
toast.success(m["absences.add.success"]());
|
||||
done();
|
||||
} else if (event.result.type === "failure") {
|
||||
toast.error(m["absences.add.errors.unknown"]());
|
||||
if (event.result.status === 409) {
|
||||
toast.error(m["absences.add.errors.conflict"]());
|
||||
} else {
|
||||
toast.error(m["absences.add.errors.unknown"]());
|
||||
}
|
||||
}
|
||||
isSubmitting = false;
|
||||
},
|
||||
@@ -100,8 +104,15 @@
|
||||
onValueChange={(v) => {
|
||||
if (v === "REGULAR") {
|
||||
isAllDay = true;
|
||||
} else {
|
||||
$formData.absenceType = "OTHER";
|
||||
$formData.weekdays = 0;
|
||||
$formData.from = timeLocalWithoutOffsetToUTC("09:00:00");
|
||||
$formData.to = timeLocalWithoutOffsetToUTC("17:00:00");
|
||||
} else {
|
||||
$formData.weekdays = undefined;
|
||||
$formData.from = undefined;
|
||||
$formData.to = undefined;
|
||||
$formData.absenceType = "VACATION";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -134,7 +145,7 @@
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="absenceType">
|
||||
<Form.Field {form} name="absenceType" class={$formData.type === "REGULAR" ? "hidden" : ""}>
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["absences.add.fields.absenceType.title"]()}</Form.Label>
|
||||
|
||||
@@ -3,11 +3,45 @@ import { z } from "zod";
|
||||
|
||||
export const formSchema = z
|
||||
.object({
|
||||
type: z.enum(["ONE_TIME", "REGULAR"]),
|
||||
agent: z.string().uuid({ message: m["form.errors.noAgentsSelected"]() }),
|
||||
absenceType: z.string().min(1).max(100),
|
||||
description: z.string().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
weekdays: z.number().int().min(0).max(127).optional(),
|
||||
from: z
|
||||
.string()
|
||||
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
|
||||
.optional(),
|
||||
to: z
|
||||
.string()
|
||||
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === "REGULAR") {
|
||||
if (data.weekdays == null || data.weekdays === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: m["form.errors.noWeekdaysSelected"](),
|
||||
path: ["weekdays"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.from && data.to) {
|
||||
const fromDate = new Date(`1970-01-01T${data.from}Z`);
|
||||
const toDate = new Date(`1970-01-01T${data.to}Z`);
|
||||
|
||||
if (fromDate >= toDate) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: m["form.errors.fromAfterTo"](),
|
||||
path: ["from"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const start = new Date(data.startDate);
|
||||
|
||||
+2
-3
@@ -5,11 +5,11 @@
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import type { TAbsence } from "$lib/types/absence";
|
||||
import { toDisplayDateTime } from "$lib/utils/datetime";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { renderAbsenceTimespan } from "../../utils";
|
||||
|
||||
const agents = $derived($agentsStore.agents ?? []);
|
||||
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
|
||||
@@ -43,8 +43,7 @@
|
||||
name:
|
||||
agents.find((a) => a.id === entity.agentId)?.name ||
|
||||
m["absences.delete.description_fallback"](),
|
||||
startDate: toDisplayDateTime(new Date(entity.startDate)),
|
||||
endDate: toDisplayDateTime(new Date(entity.endDate)),
|
||||
timespan: renderAbsenceTimespan(entity),
|
||||
})}
|
||||
</Text>
|
||||
<Form.Field {form} name="id" class="hidden">
|
||||
|
||||
+178
-53
@@ -7,14 +7,22 @@
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import type { TAbsence } from "$lib/types/absence";
|
||||
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { reasons } from "../utils";
|
||||
import { toInputDateTime } from "$lib/utils/datetime";
|
||||
import { reasons, types } from "../utils";
|
||||
import {
|
||||
timeLocalWithoutOffsetToUTC,
|
||||
timeUTCToLocalWithoutOffset,
|
||||
toInputDateTime,
|
||||
toWeekdaysLabel,
|
||||
weekdays,
|
||||
} from "$lib/utils/datetime";
|
||||
import { SvelteDate } from "svelte/reactivity";
|
||||
import { times } from "$lib/components/ui/slot-template/utils";
|
||||
import { RadioCards } from "$lib/components/ui/radio-cards";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
|
||||
|
||||
@@ -23,11 +31,15 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
const form = superForm(
|
||||
{
|
||||
type: entity.type,
|
||||
id: entity.id,
|
||||
agent: entity.agentId,
|
||||
absenceType: entity.absenceType,
|
||||
startDate: entity.startDate,
|
||||
endDate: entity.endDate,
|
||||
weekdays: entity.weekdays || null,
|
||||
from: entity.from || null,
|
||||
to: entity.to || null,
|
||||
},
|
||||
{
|
||||
dataType: "json",
|
||||
@@ -37,7 +49,11 @@
|
||||
toast.success(m["absences.edit.success"]());
|
||||
done();
|
||||
} else if (event.result.type === "failure") {
|
||||
toast.error(m["absences.edit.error"]());
|
||||
if (event.result.status === 409) {
|
||||
toast.error(m["absences.edit.errors.conflict"]());
|
||||
} else {
|
||||
toast.error(m["absences.edit.errors.unknown"]());
|
||||
}
|
||||
}
|
||||
isSubmitting = false;
|
||||
},
|
||||
@@ -61,6 +77,19 @@
|
||||
let endDateTouched = $state(false);
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
|
||||
const onWeekdaysChange = (values: string[]) => {
|
||||
const newValue = values.map((v) => parseInt(v)).reduce((sum, x) => sum + x, 0);
|
||||
$formData.weekdays = newValue;
|
||||
};
|
||||
const toSelectedWeekdays = (bitmap: number | null) => {
|
||||
if (bitmap == null || bitmap === 0) return [];
|
||||
return weekdays.filter(({ bit }) => (bitmap & bit) === bit).map(({ bit }) => `${bit}`);
|
||||
};
|
||||
const getSelectedTime = (utcTime: string) => {
|
||||
const localTime = timeUTCToLocalWithoutOffset(utcTime) as keyof typeof times;
|
||||
return times[localTime]?.label ?? "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<Form.Root {enhance} action="?/edit">
|
||||
@@ -71,6 +100,33 @@
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="type">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["absences.add.fields.type.title"]()}</Form.Label>
|
||||
<RadioCards
|
||||
{...props}
|
||||
bind:value={$formData.type}
|
||||
options={Object.values(types)}
|
||||
onValueChange={(v) => {
|
||||
if (v === "REGULAR") {
|
||||
isAllDay = true;
|
||||
$formData.absenceType = "OTHER";
|
||||
$formData.weekdays = 0;
|
||||
$formData.from = timeLocalWithoutOffsetToUTC("09:00:00");
|
||||
$formData.to = timeLocalWithoutOffsetToUTC("17:00:00");
|
||||
} else {
|
||||
$formData.weekdays = null;
|
||||
$formData.from = null;
|
||||
$formData.to = null;
|
||||
$formData.absenceType = "VACATION";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="agent">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
@@ -97,7 +153,7 @@
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="absenceType">
|
||||
<Form.Field {form} name="absenceType" class={$formData.type === "REGULAR" ? "hidden" : ""}>
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["absences.add.fields.absenceType.title"]()}</Form.Label>
|
||||
@@ -131,56 +187,125 @@
|
||||
setTimeout(() => (endDateTouched = false), 100);
|
||||
}
|
||||
}}
|
||||
class="mt-2 mb-1"
|
||||
class={cn("mt-2 mb-1", $formData.type === "REGULAR" ? "hidden" : "")}
|
||||
/>
|
||||
<Form.Field {form} name="startDate">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["absences.add.fields.startDate.title"]()}</Form.Label>
|
||||
<InputDateTime
|
||||
{...props}
|
||||
type={isAllDay ? "date" : "datetime-local"}
|
||||
bind:value={$formData.startDate}
|
||||
defaultTime={{ hour: 0, minute: 0, second: 0 }}
|
||||
onChanged={() => {
|
||||
if (!endDateTouched && $formData.endDate <= $formData.startDate) {
|
||||
if (!isAllDay) {
|
||||
const startDate = new SvelteDate($formData.startDate);
|
||||
const endDate = new Date($formData.endDate);
|
||||
startDate.setHours(
|
||||
endDate.getHours(),
|
||||
endDate.getMinutes(),
|
||||
endDate.getSeconds(),
|
||||
endDate.getMilliseconds(),
|
||||
);
|
||||
$formData.endDate = startDate.toISOString();
|
||||
} else {
|
||||
const startDate = new SvelteDate($formData.startDate);
|
||||
startDate.setHours(23, 59, 59, 999);
|
||||
$formData.endDate = startDate.toISOString();
|
||||
<div class={cn($formData.type === "REGULAR" ? "grid grid-cols-2 gap-2" : "")}>
|
||||
<Form.Field {form} name="startDate">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["absences.add.fields.startDate.title"]()}</Form.Label>
|
||||
<InputDateTime
|
||||
{...props}
|
||||
type={isAllDay ? "date" : "datetime-local"}
|
||||
bind:value={$formData.startDate}
|
||||
defaultTime={{ hour: 0, minute: 0, second: 0 }}
|
||||
onChanged={() => {
|
||||
if (!endDateTouched && $formData.endDate <= $formData.startDate) {
|
||||
if (!isAllDay) {
|
||||
const startDate = new SvelteDate($formData.startDate);
|
||||
const endDate = new Date($formData.endDate);
|
||||
startDate.setHours(
|
||||
endDate.getHours(),
|
||||
endDate.getMinutes(),
|
||||
endDate.getSeconds(),
|
||||
endDate.getMilliseconds(),
|
||||
);
|
||||
$formData.endDate = startDate.toISOString();
|
||||
} else {
|
||||
const startDate = new SvelteDate($formData.startDate);
|
||||
startDate.setHours(23, 59, 59, 999);
|
||||
$formData.endDate = startDate.toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="endDate">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["absences.add.fields.endDate.title"]()}</Form.Label>
|
||||
<InputDateTime
|
||||
{...props}
|
||||
type={isAllDay ? "date" : "datetime-local"}
|
||||
bind:value={$formData.endDate}
|
||||
defaultTime={{ hour: 23, minute: 59, second: 59 }}
|
||||
onChanged={() => (endDateTouched = true)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="endDate">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["absences.add.fields.endDate.title"]()}</Form.Label>
|
||||
<InputDateTime
|
||||
{...props}
|
||||
type={isAllDay ? "date" : "datetime-local"}
|
||||
bind:value={$formData.endDate}
|
||||
defaultTime={{ hour: 23, minute: 59, second: 59 }}
|
||||
onChanged={() => (endDateTouched = true)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</div>
|
||||
{#if $formData.type === "REGULAR"}
|
||||
<Form.Field {form} name="weekdays">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["components.slotTemplate.weekdays"]()}</Form.Label>
|
||||
<Select.Root
|
||||
type="multiple"
|
||||
onValueChange={onWeekdaysChange}
|
||||
name={props.name}
|
||||
value={toSelectedWeekdays($formData.weekdays)}
|
||||
>
|
||||
<Select.Trigger {...props} class="w-full">
|
||||
{toWeekdaysLabel($formData.weekdays)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={weekdays[0].bit.toString()}>{weekdays[0].day}</Select.Item>
|
||||
<Select.Item value={weekdays[1].bit.toString()}>{weekdays[1].day}</Select.Item>
|
||||
<Select.Item value={weekdays[2].bit.toString()}>{weekdays[2].day}</Select.Item>
|
||||
<Select.Item value={weekdays[3].bit.toString()}>{weekdays[3].day}</Select.Item>
|
||||
<Select.Item value={weekdays[4].bit.toString()}>{weekdays[4].day}</Select.Item>
|
||||
<Select.Item value={weekdays[5].bit.toString()}>{weekdays[5].day}</Select.Item>
|
||||
<Select.Item value={weekdays[6].bit.toString()}>{weekdays[6].day}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<div class="flex gap-2">
|
||||
<Form.Field {form} name="from" class="flex-1">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["components.slotTemplate.from"]()}</Form.Label>
|
||||
<Select.Root type="single" name={props.name} bind:value={$formData.from as string}>
|
||||
<Select.Trigger {...props} class="w-full">
|
||||
{getSelectedTime($formData.from as keyof typeof times)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.entries(times) as [time, value] (time)}
|
||||
<Select.Item value={timeLocalWithoutOffsetToUTC(time)}>{value.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="to" class="flex-1">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["components.slotTemplate.to"]()}</Form.Label>
|
||||
<Select.Root type="single" name={props.name} bind:value={$formData.to as string}>
|
||||
<Select.Trigger {...props} class="w-full">
|
||||
{getSelectedTime($formData.to as keyof typeof times)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.entries(times) as [time, value] (time)}
|
||||
<Select.Item value={timeLocalWithoutOffsetToUTC(time)}>{value.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-6 flex flex-col gap-4">
|
||||
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
|
||||
{m["absences.edit.action"]()}
|
||||
|
||||
@@ -4,11 +4,45 @@ import { z } from "zod";
|
||||
export const formSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["ONE_TIME", "REGULAR"]),
|
||||
agent: z.string().uuid({ message: m["form.errors.noAgentsSelected"]() }),
|
||||
absenceType: z.string().min(1).max(100),
|
||||
description: z.string().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
weekdays: z.number().int().min(0).max(127).nullable(),
|
||||
from: z
|
||||
.string()
|
||||
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
|
||||
.nullable(),
|
||||
to: z
|
||||
.string()
|
||||
.regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
|
||||
.nullable(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === "REGULAR") {
|
||||
if (data.weekdays == null || data.weekdays === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: m["form.errors.noWeekdaysSelected"](),
|
||||
path: ["weekdays"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.from && data.to) {
|
||||
const fromDate = new Date(`1970-01-01T${data.from}Z`);
|
||||
const toDate = new Date(`1970-01-01T${data.to}Z`);
|
||||
|
||||
if (fromDate >= toDate) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: m["form.errors.fromAfterTo"](),
|
||||
path: ["from"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const start = new Date(data.startDate);
|
||||
@@ -17,7 +51,7 @@ export const formSchema = z
|
||||
|
||||
if (end < start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
code: "custom",
|
||||
message: m["form.errors.endDateBeforeStartDate"](),
|
||||
path: ["endDate"],
|
||||
});
|
||||
@@ -25,7 +59,7 @@ export const formSchema = z
|
||||
|
||||
if (end <= now) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
code: "custom",
|
||||
message: m["form.errors.endDateTooEarly"](),
|
||||
path: ["endDate"],
|
||||
});
|
||||
|
||||
@@ -80,10 +80,14 @@ export const actions: Actions = {
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
type: form.data.type,
|
||||
description: form.data.description,
|
||||
absenceType: form.data.absenceType,
|
||||
startDate: new Date(form.data.startDate).toISOString(),
|
||||
endDate: new Date(form.data.endDate).toISOString(),
|
||||
weekdays: form.data.weekdays,
|
||||
from: form.data.from,
|
||||
to: form.data.to,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -98,7 +102,7 @@ export const actions: Actions = {
|
||||
} catch (e) {
|
||||
log.error("Failed to parse add absence error response", { error: e });
|
||||
}
|
||||
return fail(400, {
|
||||
return fail(resp.status, {
|
||||
form,
|
||||
error,
|
||||
});
|
||||
@@ -129,10 +133,14 @@ export const actions: Actions = {
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
type: form.data.type,
|
||||
description: form.data.description,
|
||||
absenceType: form.data.absenceType,
|
||||
startDate: new Date(form.data.startDate).toISOString(),
|
||||
endDate: new Date(form.data.endDate).toISOString(),
|
||||
weekdays: form.data.weekdays,
|
||||
from: form.data.from,
|
||||
to: form.data.to,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -147,7 +155,7 @@ export const actions: Actions = {
|
||||
} catch (e) {
|
||||
log.error("Failed to parse edit absences error response", { error: e });
|
||||
}
|
||||
return fail(400, {
|
||||
return fail(resp.status, {
|
||||
form: { ...form, data: { ...form.data } },
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import { type TAbsence } from "$lib/types/absence";
|
||||
import { toDisplayDateTime } from "$lib/utils/datetime";
|
||||
import EditIcon from "@lucide/svelte/icons/pencil";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import DeleteIcon from "@lucide/svelte/icons/trash-2";
|
||||
@@ -21,7 +20,8 @@
|
||||
import { DeleteAbsenceForm } from "./(components)/delete-absence-form";
|
||||
import EditAbsenceForm from "./(components)/edit-absence-form/edit-absence-form.svelte";
|
||||
import { reasons } from "./(components)/utils";
|
||||
import { getLocalTimeZone } from "@internationalized/date";
|
||||
import { renderAbsenceTimespan } from "./utils";
|
||||
import { RefreshCw } from "@lucide/svelte";
|
||||
|
||||
const { data } = $props();
|
||||
const agents = $derived($agentsStore.agents ?? []);
|
||||
@@ -35,34 +35,11 @@
|
||||
});
|
||||
|
||||
const renderDescription = (item: TAbsence) => {
|
||||
const startDate = toDisplayDateTime(new Date(item.startDate));
|
||||
const fullStartDay = toDisplayDateTime(new Date(item.startDate), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: getLocalTimeZone(),
|
||||
});
|
||||
const endDate = toDisplayDateTime(new Date(item.endDate));
|
||||
const fullEndDay = toDisplayDateTime(new Date(item.endDate), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: getLocalTimeZone(),
|
||||
});
|
||||
const reason = reasons.find((r) => r.value === item.absenceType);
|
||||
const isAllDay =
|
||||
new Date(item.startDate).getHours() === 0 &&
|
||||
new Date(item.startDate).getMinutes() === 0 &&
|
||||
new Date(item.endDate).getHours() === 23 &&
|
||||
new Date(item.endDate).getMinutes() === 59;
|
||||
const isSameDay =
|
||||
new Date(item.startDate).toDateString() === new Date(item.endDate).toDateString();
|
||||
if (isSameDay && isAllDay) {
|
||||
return `${reason?.label}: ${fullStartDay}`;
|
||||
} else if (isAllDay) {
|
||||
return `${reason?.label}: ${fullStartDay} - ${fullEndDay}`;
|
||||
if (item.type === "ONE_TIME") {
|
||||
const reason = reasons.find((r) => r.value === item.absenceType);
|
||||
return `${reason?.label}: ${renderAbsenceTimespan(item)}`;
|
||||
} else {
|
||||
return `${reason?.label}: ${startDate} - ${endDate}`;
|
||||
return renderAbsenceTimespan(item);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -107,6 +84,7 @@
|
||||
title={agent?.name || item.agentId}
|
||||
image={agent?.image || UnknownItemIcon}
|
||||
description={renderDescription(item)}
|
||||
icons={item.type === "REGULAR" ? [RefreshCw] : []}
|
||||
actions={[
|
||||
{
|
||||
type: "action",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { times } from "$lib/components/ui/slot-template/utils";
|
||||
import type { TAbsence } from "$lib/types/absence";
|
||||
import {
|
||||
timeUTCToLocalWithoutOffset,
|
||||
toDisplayDateTime,
|
||||
toWeekdaysLabel,
|
||||
} from "$lib/utils/datetime";
|
||||
import { getLocalTimeZone } from "@internationalized/date";
|
||||
|
||||
export const renderAbsenceTimespan = (item: TAbsence) => {
|
||||
if (item.type === "ONE_TIME") {
|
||||
return renderOneTimeAbsenceTimespan(item);
|
||||
} else if (item.type === "REGULAR") {
|
||||
return renderRegularAbsenceTimespan(item);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const renderOneTimeAbsenceTimespan = (item: TAbsence) => {
|
||||
const startDate = toDisplayDateTime(new Date(item.startDate));
|
||||
const fullStartDay = toDisplayDateTime(new Date(item.startDate), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: getLocalTimeZone(),
|
||||
});
|
||||
const endDate = toDisplayDateTime(new Date(item.endDate));
|
||||
const fullEndDay = toDisplayDateTime(new Date(item.endDate), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: getLocalTimeZone(),
|
||||
});
|
||||
const isAllDay =
|
||||
new Date(item.startDate).getHours() === 0 &&
|
||||
new Date(item.startDate).getMinutes() === 0 &&
|
||||
new Date(item.endDate).getHours() === 23 &&
|
||||
new Date(item.endDate).getMinutes() === 59;
|
||||
const isSameDay =
|
||||
new Date(item.startDate).toDateString() === new Date(item.endDate).toDateString();
|
||||
if (isSameDay && isAllDay) {
|
||||
return `${fullStartDay}`;
|
||||
} else if (isAllDay) {
|
||||
return `${fullStartDay} - ${fullEndDay}`;
|
||||
} else {
|
||||
return `${startDate} - ${endDate}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const renderRegularAbsenceTimespan = (item: TAbsence) => {
|
||||
if (!item.from || !item.to) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const weekdays = item.weekdays ?? 0;
|
||||
const from = timeUTCToLocalWithoutOffset(item.from) as keyof typeof times;
|
||||
const to = timeUTCToLocalWithoutOffset(item.to) as keyof typeof times;
|
||||
return `${renderOneTimeAbsenceTimespan(item)}; ${toWeekdaysLabel(weekdays)} ${times[from]?.label ?? ""} - ${times[to]?.label ?? ""}`;
|
||||
};
|
||||
@@ -35,6 +35,12 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["ONE_TIME", "REGULAR"],
|
||||
description: "Type of absence",
|
||||
example: "ONE_TIME",
|
||||
},
|
||||
startDate: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
@@ -58,14 +64,26 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", {
|
||||
description: "Optional description of the absence",
|
||||
example: "Jahresurlaub",
|
||||
},
|
||||
isFullDay: {
|
||||
type: "boolean",
|
||||
description: "Whether this is a full day absence",
|
||||
example: true,
|
||||
default: true,
|
||||
weekdays: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
|
||||
example: 62,
|
||||
},
|
||||
from: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "Start time for REGULAR absences",
|
||||
example: "09:00:00",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "End time for REGULAR absences",
|
||||
example: "17:00:00",
|
||||
},
|
||||
},
|
||||
required: ["startDate", "endDate", "absenceType"],
|
||||
required: ["type", "startDate", "endDate", "absenceType"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -83,14 +101,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "uuid", description: "Absence ID" },
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["ONE_TIME", "REGULAR"],
|
||||
description: "Type of absence",
|
||||
},
|
||||
agentId: { type: "string", format: "uuid", description: "Agent ID" },
|
||||
startDate: { type: "string", format: "date-time", description: "Start date" },
|
||||
endDate: { type: "string", format: "date-time", description: "End date" },
|
||||
absenceType: { type: "string", description: "Type of absence" },
|
||||
description: { type: "string", description: "Description" },
|
||||
isFullDay: { type: "boolean", description: "Full day absence" },
|
||||
weekdays: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
|
||||
},
|
||||
from: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "Start time for REGULAR absences",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "End time for REGULAR absences",
|
||||
},
|
||||
},
|
||||
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
|
||||
required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"],
|
||||
},
|
||||
},
|
||||
required: ["message", "absence"],
|
||||
@@ -199,14 +236,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "GET", {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "uuid", description: "Absence ID" },
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["ONE_TIME", "REGULAR"],
|
||||
description: "Type of absence",
|
||||
},
|
||||
agentId: { type: "string", format: "uuid", description: "Agent ID" },
|
||||
startDate: { type: "string", format: "date-time", description: "Start date" },
|
||||
endDate: { type: "string", format: "date-time", description: "End date" },
|
||||
absenceType: { type: "string", description: "Type of absence" },
|
||||
description: { type: "string", description: "Description" },
|
||||
isFullDay: { type: "boolean", description: "Full day absence" },
|
||||
weekdays: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
|
||||
},
|
||||
from: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "Start time for REGULAR absences",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "End time for REGULAR absences",
|
||||
},
|
||||
},
|
||||
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
|
||||
required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -278,6 +334,10 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
absenceType: body.absenceType,
|
||||
startDate: body.startDate,
|
||||
endDate: body.endDate,
|
||||
type: body.type,
|
||||
weekdays: body.weekdays,
|
||||
from: body.from,
|
||||
to: body.to,
|
||||
});
|
||||
|
||||
const agentService = await AgentService.forTenant(tenantId);
|
||||
|
||||
@@ -53,14 +53,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "GET
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "uuid", description: "Absence ID" },
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["ONE_TIME", "REGULAR"],
|
||||
description: "Type of absence",
|
||||
},
|
||||
agentId: { type: "string", format: "uuid", description: "Agent ID" },
|
||||
startDate: { type: "string", format: "date-time", description: "Start date" },
|
||||
endDate: { type: "string", format: "date-time", description: "End date" },
|
||||
absenceType: { type: "string", description: "Type of absence" },
|
||||
description: { type: "string", description: "Description" },
|
||||
isFullDay: { type: "boolean", description: "Full day absence" },
|
||||
weekdays: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
|
||||
},
|
||||
from: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "Start time for REGULAR absences",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "End time for REGULAR absences",
|
||||
},
|
||||
},
|
||||
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
|
||||
required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"],
|
||||
},
|
||||
},
|
||||
required: ["absence"],
|
||||
@@ -162,11 +181,6 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "PUT
|
||||
description: "Optional description of the absence",
|
||||
example: "Erkältung",
|
||||
},
|
||||
isFullDay: {
|
||||
type: "boolean",
|
||||
description: "Whether this is a full day absence",
|
||||
example: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -185,14 +199,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "PUT
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "uuid", description: "Absence ID" },
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["ONE_TIME", "REGULAR"],
|
||||
description: "Type of absence",
|
||||
},
|
||||
agentId: { type: "string", format: "uuid", description: "Agent ID" },
|
||||
startDate: { type: "string", format: "date-time", description: "Start date" },
|
||||
endDate: { type: "string", format: "date-time", description: "End date" },
|
||||
absenceType: { type: "string", description: "Type of absence" },
|
||||
description: { type: "string", description: "Description" },
|
||||
isFullDay: { type: "boolean", description: "Full day absence" },
|
||||
weekdays: {
|
||||
type: "integer",
|
||||
description:
|
||||
"Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.",
|
||||
},
|
||||
from: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "Start time for REGULAR absences",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "End time for REGULAR absences",
|
||||
},
|
||||
},
|
||||
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
|
||||
required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"],
|
||||
},
|
||||
},
|
||||
required: ["message", "absence"],
|
||||
|
||||
@@ -49,13 +49,40 @@ registerOpenAPIRoute("/tenants/{id}/agents/absences", "GET", {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "uuid", description: "Absence ID" },
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["REGULAR", "ONE_TIME"],
|
||||
description: "Type of absence",
|
||||
},
|
||||
agentId: { type: "string", format: "uuid", description: "Agent ID" },
|
||||
startDate: { type: "string", format: "date-time", description: "Start date" },
|
||||
endDate: { type: "string", format: "date-time", description: "End date" },
|
||||
absenceType: { type: "string", description: "Type of absence" },
|
||||
description: { type: "string", description: "Description" },
|
||||
weekdays: {
|
||||
type: "integer",
|
||||
description: "Weekdays bitmask for regular absences",
|
||||
},
|
||||
from: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "Start time for regular absences",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
format: "time",
|
||||
description: "End time for regular absences",
|
||||
},
|
||||
},
|
||||
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
|
||||
required: [
|
||||
"id",
|
||||
"type",
|
||||
"agentId",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"absenceType",
|
||||
"isFullDay",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TYPE "public"."absence_type" AS ENUM('ONE_TIME', 'REGULAR');--> statement-breakpoint
|
||||
ALTER TABLE "agent_absence" ADD COLUMN "type" "absence_type" DEFAULT 'ONE_TIME' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "agent_absence" ADD COLUMN "weekdays" integer;--> statement-breakpoint
|
||||
ALTER TABLE "agent_absence" ADD COLUMN "from" time;--> statement-breakpoint
|
||||
ALTER TABLE "agent_absence" ADD COLUMN "to" time;
|
||||
|
||||
-- Migrate existing absences
|
||||
UPDATE "agent_absence" SET "type" = 'ONE_TIME';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1781796561280,
|
||||
"tag": "0016_mighty_gamma_corps",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1784551934887,
|
||||
"tag": "0017_optimal_professor_monster",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user