From 0a0703f1815e079cbe32e050ac7548dc8c6c5a98 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Mon, 13 Oct 2025 12:58:42 +0200 Subject: [PATCH] Added ui to manage absences (#98) * Added ui to manage absences * Added test for new endpoint --------- Co-authored-by: Karl Ludwig Weise --- eslint.config.js | 2 +- project.inlang/messages/de.json | 73 ++++++- project.inlang/messages/en.json | 73 ++++++- .../ui/calendar/calendar-caption.svelte | 76 +++++++ .../ui/calendar/calendar-cell.svelte | 19 ++ .../ui/calendar/calendar-day.svelte | 35 +++ .../ui/calendar/calendar-grid-body.svelte | 12 ++ .../ui/calendar/calendar-grid-head.svelte | 12 ++ .../ui/calendar/calendar-grid-row.svelte | 12 ++ .../ui/calendar/calendar-grid.svelte | 16 ++ .../ui/calendar/calendar-head-cell.svelte | 19 ++ .../ui/calendar/calendar-header.svelte | 19 ++ .../ui/calendar/calendar-heading.svelte | 16 ++ .../ui/calendar/calendar-month-select.svelte | 44 ++++ .../ui/calendar/calendar-month.svelte | 15 ++ .../ui/calendar/calendar-months.svelte | 19 ++ .../ui/calendar/calendar-nav.svelte | 19 ++ .../ui/calendar/calendar-next-button.svelte | 31 +++ .../ui/calendar/calendar-prev-button.svelte | 31 +++ .../ui/calendar/calendar-year-select.svelte | 43 ++++ .../components/ui/calendar/calendar.svelte | 115 ++++++++++ src/lib/components/ui/calendar/index.ts | 40 ++++ .../checkbox-with-label.svelte | 2 +- .../components/ui/input-date-time/index.ts | 7 + .../ui/input-date-time/input-date-time.svelte | 99 +++++++++ src/lib/server/db/tenant-schema.ts | 10 +- src/lib/server/services/agent-service.ts | 49 ++++- src/lib/types/absence.ts | 6 + src/lib/utils/datetime.ts | 33 +++ .../add-absence-form/add-absence-form.svelte | 152 +++++++++++++ .../(components)/add-absence-form/index.ts | 5 + .../(components)/add-absence-form/schema.ts | 34 +++ .../delete-absence-form.svelte | 69 ++++++ .../(components)/delete-absence-form/index.ts | 5 + .../delete-absence-form/schema.ts | 8 + .../edit-absence-form.svelte | 152 +++++++++++++ .../(components)/edit-absence-form/index.ts | 5 + .../(components)/edit-absence-form/schema.ts | 35 +++ .../dashboard/absences/(components)/utils.ts | 10 + .../dashboard/absences/+page.server.ts | 199 +++++++++++++++++ .../(pages)/dashboard/absences/+page.svelte | 146 ++++++++++++- .../add-channel-form/add-channel-form.svelte | 2 +- .../edit-channel-form.svelte | 2 +- .../tenants/[id]/agents/absences/+server.ts | 147 +++++++++++++ .../__tests__/absence-overview-api.test.ts | 200 ++++++++++++++++++ 45 files changed, 2104 insertions(+), 14 deletions(-) create mode 100644 src/lib/components/ui/calendar/calendar-caption.svelte create mode 100644 src/lib/components/ui/calendar/calendar-cell.svelte create mode 100644 src/lib/components/ui/calendar/calendar-day.svelte create mode 100644 src/lib/components/ui/calendar/calendar-grid-body.svelte create mode 100644 src/lib/components/ui/calendar/calendar-grid-head.svelte create mode 100644 src/lib/components/ui/calendar/calendar-grid-row.svelte create mode 100644 src/lib/components/ui/calendar/calendar-grid.svelte create mode 100644 src/lib/components/ui/calendar/calendar-head-cell.svelte create mode 100644 src/lib/components/ui/calendar/calendar-header.svelte create mode 100644 src/lib/components/ui/calendar/calendar-heading.svelte create mode 100644 src/lib/components/ui/calendar/calendar-month-select.svelte create mode 100644 src/lib/components/ui/calendar/calendar-month.svelte create mode 100644 src/lib/components/ui/calendar/calendar-months.svelte create mode 100644 src/lib/components/ui/calendar/calendar-nav.svelte create mode 100644 src/lib/components/ui/calendar/calendar-next-button.svelte create mode 100644 src/lib/components/ui/calendar/calendar-prev-button.svelte create mode 100644 src/lib/components/ui/calendar/calendar-year-select.svelte create mode 100644 src/lib/components/ui/calendar/calendar.svelte create mode 100644 src/lib/components/ui/calendar/index.ts create mode 100644 src/lib/components/ui/input-date-time/index.ts create mode 100644 src/lib/components/ui/input-date-time/input-date-time.svelte create mode 100644 src/lib/types/absence.ts create mode 100644 src/lib/utils/datetime.ts create mode 100644 src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte create mode 100644 src/routes/(pages)/dashboard/absences/(components)/add-absence-form/index.ts create mode 100644 src/routes/(pages)/dashboard/absences/(components)/add-absence-form/schema.ts create mode 100644 src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte create mode 100644 src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/index.ts create mode 100644 src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/schema.ts create mode 100644 src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte create mode 100644 src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/index.ts create mode 100644 src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/schema.ts create mode 100644 src/routes/(pages)/dashboard/absences/(components)/utils.ts create mode 100644 src/routes/(pages)/dashboard/absences/+page.server.ts create mode 100644 src/routes/api/tenants/[id]/agents/absences/+server.ts create mode 100644 src/routes/api/tenants/[id]/agents/absences/__tests__/absence-overview-api.test.ts diff --git a/eslint.config.js b/eslint.config.js index ec83b6b..6e24015 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,7 +17,7 @@ export default ts.config( prettier, ...svelte.configs.prettier, { - ignores: ["static/**"], + ignores: ["static/**", "src/i18n/**"], }, { languageOptions: { diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 0f5e0fb..2c0cfbf 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -47,7 +47,12 @@ "bothPassSet": "Nur Passphrase oder Passkey darf gesetzt sein", "name": "Muss mindestens 2 Zeichen lang sein", "shortname": "Muss zwischen 4 und 15 Zeichen lang sein", - "deleteConfirmation": "Muss exakt »{expectedValue}« sein" + "deleteConfirmation": "Muss exakt »{expectedValue}« sein", + "noAgentsSelected": "Wähle einen Akteur", + "noStartDate": "Beginn ist erforderlich", + "noEndDate": "Ende ist erforderlich", + "endDateBeforeStartDate": "Das Ende muss nach dem Beginn sein", + "endDateTooEarly": "Das Ende muss in der Zukunft liegen" }, "passkey": "Passkey", "name": "Name" @@ -478,5 +483,71 @@ "404": "Die Seite, die Du suchst, existiert nicht." }, "action": "Zur Startseite" + }, + "absences": { + "title": "Abwesenheiten", + "add": { + "title": "Abwesenheit hinzufügen", + "description": "Markiere Akteure als abwesend, damit sie keine Termine entgegennehmen können.", + "fields": { + "agent": { + "title": "Akteur:in", + "placeholder": "Akteur:in auswählen" + }, + "absenceType": { + "title": "Grund (intern)", + "placeholder": "Grund auswählen" + }, + "isAllDay": { + "label": "Ganztägig" + }, + "startDate": { + "title": "Beginn" + }, + "endDate": { + "title": "Ende" + } + }, + "hint": "Kunden werden nicht informiert. Bereits gebuchte Termine müssen manuell abgesagt werden.", + "action": "Abwesenheit hinzufügen", + "success": "Abwesenheit hinzugefügt", + "errors": { + "unknown": "Konnte Abwesenheit nicht hinzufügen" + }, + "unavailable": { + "title": "Keine Akteure verfügbar", + "description": "Du musst mindestens einen Akteur hinzufügen, bevor Du Abwesenheiten hinzufügen kannst." + } + }, + "list": { + "empty": { + "title": "Keine aktuellen Abwesenheiten", + "description": "Markiere Akteure als abwesend, damit sie keine Termine entgegennehmen können." + } + }, + "edit": { + "title": "Abwesenheit bearbeiten", + "description": "Aktualisiere die Verfügbarkeit dieses Akteurs.", + "success": "Abwesenheit aktualisiert", + "error": "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_fallback": "Diese/r Akteur:in", + "action": "Abwesenheit löschen", + "success": "Abwesenheit gelöscht", + "error": "Konnte Abwesenheit nicht löschen." + }, + "loading": "Lade Abwesenheiten", + "reasons": { + "holiday": "Feiertag", + "vacation": "Urlaub", + "sick": "Krank", + "education": "Fortbildung", + "off.site": "Außer Haus", + "other": "Andere" + } } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index b0224d0..c50ff73 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -48,7 +48,12 @@ "bothPassSet": "Only one of passphrase or passkey should be provided", "name": "Must be at least 2 characters", "shortname": "Must be between 4 and 15 characters", - "deleteConfirmation": "Must be exactly »{expectedValue}«" + "deleteConfirmation": "Must be exactly »{expectedValue}«", + "noAgentsSelected": "Select an agent", + "noStartDate": "Start date is required", + "noEndDate": "End date is required", + "endDateBeforeStartDate": "End date must be after start date", + "endDateTooEarly": "End date must be in the future" }, "noPassAtAll": "Either passphrase or passkey is required", "passkey": "Passkey", @@ -487,5 +492,71 @@ "404": "The page you are looking for does not exist." }, "action": "Go to Homepage" + }, + "absences": { + "title": "Absences", + "add": { + "title": "Add Anbsence", + "description": "Absences are a way to mark you agents as being unavailbale.", + "fields": { + "agent": { + "title": "Agent", + "placeholder": "Select agent" + }, + "absenceType": { + "title": "Reason (internal)", + "placeholder": "Select a reason" + }, + "isAllDay": { + "label": "All day" + }, + "startDate": { + "title": "From Date" + }, + "endDate": { + "title": "To Date" + } + }, + "hint": "Clients are not being informed. Already booked appointments have to be canceled manually.", + "action": "Add Absence", + "success": "Absence added", + "errors": { + "unknown": "Could not add absence" + }, + "unavailable": { + "title": "No agents available", + "description": "You need to add at least one agent before you can add absences." + } + }, + "list": { + "empty": { + "title": "No future absences", + "description": "Absences are a way to mark you agents as being unavailbale." + } + }, + "edit": { + "title": "Edit Absence", + "description": "Update the agents availability.", + "success": "Absence updated", + "error": "Could not update absence.", + "action": "Edit Absence" + }, + "delete": { + "title": "Delete Absence", + "description": "{name} will be available for appointments from {startDate} to {endDate}.", + "description_fallback": "This agent", + "action": "Delete Absence", + "success": "Absence deleted", + "error": "Could not delete absence." + }, + "loading": "Loading absences", + "reasons": { + "holiday": "Public Holiday", + "vacation": "Vacation", + "sick": "Sick Leave", + "education": "Training/Education", + "off.site": "Off-Site", + "other": "Other" + } } } diff --git a/src/lib/components/ui/calendar/calendar-caption.svelte b/src/lib/components/ui/calendar/calendar-caption.svelte new file mode 100644 index 0000000..c541f3d --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-caption.svelte @@ -0,0 +1,76 @@ + + +{#snippet MonthSelect()} + { + if (!placeholder) return; + const v = Number.parseInt(e.currentTarget.value); + const newPlaceholder = placeholder.set({ month: v }); + placeholder = newPlaceholder.subtract({ months: monthIndex }); + }} + /> +{/snippet} + +{#snippet YearSelect()} + +{/snippet} + +{#if captionLayout === "dropdown"} + {@render MonthSelect()} + {@render YearSelect()} +{:else if captionLayout === "dropdown-months"} + {@render MonthSelect()} + {#if placeholder} + {formatYear(placeholder)} + {/if} +{:else if captionLayout === "dropdown-years"} + {#if placeholder} + {formatMonth(placeholder)} + {/if} + {@render YearSelect()} +{:else} + {formatMonth(month)} {formatYear(month)} +{/if} diff --git a/src/lib/components/ui/calendar/calendar-cell.svelte b/src/lib/components/ui/calendar/calendar-cell.svelte new file mode 100644 index 0000000..a10981c --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-day.svelte b/src/lib/components/ui/calendar/calendar-day.svelte new file mode 100644 index 0000000..e1c0dde --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-day.svelte @@ -0,0 +1,35 @@ + + +span]:text-xs [&>span]:opacity-70", + className, + )} + {...restProps} +/> diff --git a/src/lib/components/ui/calendar/calendar-grid-body.svelte b/src/lib/components/ui/calendar/calendar-grid-body.svelte new file mode 100644 index 0000000..ecbbbd7 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-grid-body.svelte @@ -0,0 +1,12 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-grid-head.svelte b/src/lib/components/ui/calendar/calendar-grid-head.svelte new file mode 100644 index 0000000..b195f74 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-grid-head.svelte @@ -0,0 +1,12 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-grid-row.svelte b/src/lib/components/ui/calendar/calendar-grid-row.svelte new file mode 100644 index 0000000..ec9c978 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-grid-row.svelte @@ -0,0 +1,12 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-grid.svelte b/src/lib/components/ui/calendar/calendar-grid.svelte new file mode 100644 index 0000000..80a53e9 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-grid.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-head-cell.svelte b/src/lib/components/ui/calendar/calendar-head-cell.svelte new file mode 100644 index 0000000..7919c72 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-head-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-header.svelte b/src/lib/components/ui/calendar/calendar-header.svelte new file mode 100644 index 0000000..bd4920d --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-header.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-heading.svelte b/src/lib/components/ui/calendar/calendar-heading.svelte new file mode 100644 index 0000000..7d995e1 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-heading.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-month-select.svelte b/src/lib/components/ui/calendar/calendar-month-select.svelte new file mode 100644 index 0000000..932e775 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-month-select.svelte @@ -0,0 +1,44 @@ + + + + + {#snippet child({ props, monthItems, selectedMonthItem })} + + + {/snippet} + + diff --git a/src/lib/components/ui/calendar/calendar-month.svelte b/src/lib/components/ui/calendar/calendar-month.svelte new file mode 100644 index 0000000..17358d4 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-month.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/calendar/calendar-months.svelte b/src/lib/components/ui/calendar/calendar-months.svelte new file mode 100644 index 0000000..e78cfd0 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-months.svelte @@ -0,0 +1,19 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/calendar/calendar-nav.svelte b/src/lib/components/ui/calendar/calendar-nav.svelte new file mode 100644 index 0000000..336e8e4 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-nav.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/calendar/calendar-next-button.svelte b/src/lib/components/ui/calendar/calendar-next-button.svelte new file mode 100644 index 0000000..568f4b8 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-next-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/src/lib/components/ui/calendar/calendar-prev-button.svelte b/src/lib/components/ui/calendar/calendar-prev-button.svelte new file mode 100644 index 0000000..9909b00 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-prev-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/src/lib/components/ui/calendar/calendar-year-select.svelte b/src/lib/components/ui/calendar/calendar-year-select.svelte new file mode 100644 index 0000000..f1d10cc --- /dev/null +++ b/src/lib/components/ui/calendar/calendar-year-select.svelte @@ -0,0 +1,43 @@ + + + + + {#snippet child({ props, yearItems, selectedYearItem })} + + + {/snippet} + + diff --git a/src/lib/components/ui/calendar/calendar.svelte b/src/lib/components/ui/calendar/calendar.svelte new file mode 100644 index 0000000..0f842b0 --- /dev/null +++ b/src/lib/components/ui/calendar/calendar.svelte @@ -0,0 +1,115 @@ + + + + + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + {#if day} + {@render day({ + day: date, + outsideMonth: !isEqualMonth(date, month.value), + })} + {:else} + + {/if} + + {/each} + + {/each} + + + + {/each} + + {/snippet} + diff --git a/src/lib/components/ui/calendar/index.ts b/src/lib/components/ui/calendar/index.ts new file mode 100644 index 0000000..8347a54 --- /dev/null +++ b/src/lib/components/ui/calendar/index.ts @@ -0,0 +1,40 @@ +import Root from "./calendar.svelte"; +import Cell from "./calendar-cell.svelte"; +import Day from "./calendar-day.svelte"; +import Grid from "./calendar-grid.svelte"; +import Header from "./calendar-header.svelte"; +import Months from "./calendar-months.svelte"; +import GridRow from "./calendar-grid-row.svelte"; +import Heading from "./calendar-heading.svelte"; +import GridBody from "./calendar-grid-body.svelte"; +import GridHead from "./calendar-grid-head.svelte"; +import HeadCell from "./calendar-head-cell.svelte"; +import NextButton from "./calendar-next-button.svelte"; +import PrevButton from "./calendar-prev-button.svelte"; +import MonthSelect from "./calendar-month-select.svelte"; +import YearSelect from "./calendar-year-select.svelte"; +import Month from "./calendar-month.svelte"; +import Nav from "./calendar-nav.svelte"; +import Caption from "./calendar-caption.svelte"; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + Nav, + Month, + YearSelect, + MonthSelect, + Caption, + // + Root as Calendar, +}; diff --git a/src/lib/components/ui/checkbox-with-label/checkbox-with-label.svelte b/src/lib/components/ui/checkbox-with-label/checkbox-with-label.svelte index 4258328..bcc9b61 100644 --- a/src/lib/components/ui/checkbox-with-label/checkbox-with-label.svelte +++ b/src/lib/components/ui/checkbox-with-label/checkbox-with-label.svelte @@ -27,5 +27,5 @@ {onCheckedChange} class="mt-0.5" /> - {label} + {label} diff --git a/src/lib/components/ui/input-date-time/index.ts b/src/lib/components/ui/input-date-time/index.ts new file mode 100644 index 0000000..0603dda --- /dev/null +++ b/src/lib/components/ui/input-date-time/index.ts @@ -0,0 +1,7 @@ +import Root from "./input-date-time.svelte"; + +export { + Root, + // + Root as InputDateTime, +}; diff --git a/src/lib/components/ui/input-date-time/input-date-time.svelte b/src/lib/components/ui/input-date-time/input-date-time.svelte new file mode 100644 index 0000000..c56545b --- /dev/null +++ b/src/lib/components/ui/input-date-time/input-date-time.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#snippet child({ props })} + + {/snippet} + + + + + + {#if type === "datetime-local"} +
+ +
+ {/if} +
+ diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 318d2a6..217ae86 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -1,15 +1,15 @@ import type { InferSelectModel } from "drizzle-orm"; import { - pgTable, boolean, - uuid, - text, date, - pgEnum, - time, integer, json, + pgEnum, + pgTable, + text, + time, timestamp, + uuid, varchar, } from "drizzle-orm/pg-core"; diff --git a/src/lib/server/services/agent-service.ts b/src/lib/server/services/agent-service.ts index 0f37f14..7a0b11d 100644 --- a/src/lib/server/services/agent-service.ts +++ b/src/lib/server/services/agent-service.ts @@ -419,7 +419,7 @@ export class AgentService { // Validate date range const startDate = new Date(request.startDate); const endDate = new Date(request.endDate); - if (startDate >= endDate) { + if (startDate > endDate) { throw new ValidationError("End date must be after start date"); } @@ -616,6 +616,51 @@ export class AgentService { } } + /** + * Get all absences for a tenant + * @param startDate Optional start date filter + * @param endDate Optional end date filter + * @returns Array of agent absences + */ + async getAbsences(startDate?: string, endDate?: string): Promise { + const log = logger.setContext("AgentService"); + log.debug("Getting all absences", { + tenantId: this.tenantId, + startDate, + endDate, + }); + + try { + const db = await this.getDb(); + + const query = db.select().from(tenantSchema.agentAbsence); + + // Add date filters if provided + if (startDate && endDate) { + const result = await this.queryAbsences({ + startDate, + endDate, + }); + return result; + } + + const result = await query.orderBy(tenantSchema.agentAbsence.startDate); + + log.debug("Retrieved agent absences", { + tenantId: this.tenantId, + count: result.length, + }); + + return result; + } catch (error) { + log.error("Failed to get agent absences", { + tenantId: this.tenantId, + error: String(error), + }); + throw error; + } + } + /** * Get all absences for a specific agent * @param agentId Agent ID @@ -715,7 +760,7 @@ export class AgentService { const newStartDate = updateData.startDate || currentAbsence[0].startDate; const newEndDate = updateData.endDate || currentAbsence[0].endDate; - if (new Date(newStartDate) >= new Date(newEndDate)) { + if (new Date(newStartDate) > new Date(newEndDate)) { throw new ValidationError("End date must be after start date"); } diff --git a/src/lib/types/absence.ts b/src/lib/types/absence.ts new file mode 100644 index 0000000..b3f8c91 --- /dev/null +++ b/src/lib/types/absence.ts @@ -0,0 +1,6 @@ +import type { SelectAgentAbsence } from "$lib/server/db/tenant-schema"; + +export type TAbsence = Omit & { + startDate: string; + endDate: string; +}; diff --git a/src/lib/utils/datetime.ts b/src/lib/utils/datetime.ts new file mode 100644 index 0000000..98e9de8 --- /dev/null +++ b/src/lib/utils/datetime.ts @@ -0,0 +1,33 @@ +import { parseAbsoluteToLocal, toCalendarDateTime } from "@internationalized/date"; + +export const toDisplayDateTime = (date: Date) => { + const hours = date.getHours(); + const minutes = date.getMinutes(); + const formatter = new Intl.DateTimeFormat(navigator.language, { + dateStyle: "short", + ...(hours !== 0 || minutes !== 0 ? { timeStyle: "short" } : {}), + }); + return formatter.format(date); +}; + +export const toInputDateTime = (dateStr: string) => { + return toCalendarDateTime(parseAbsoluteToLocal(dateStr)); +}; + +export const getDefaultStartTime = () => { + const date = new Date(); + date.setHours(7); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date.toISOString(); +}; + +export const getDefaultEndTime = () => { + const date = new Date(); + date.setHours(17); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date.toISOString(); +}; diff --git a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte new file mode 100644 index 0000000..1aa79bf --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte @@ -0,0 +1,152 @@ + + +{#if agents.length === 0} + +{:else} + + + + {#snippet children({ props })} + {m["absences.add.fields.agent.title"]()} + ($formData.agent = v)} + > + + {$formData.agent + ? agents.find((x) => x.id === $formData.agent)?.name + : m["absences.add.fields.agent.placeholder"]()} + + + {#each agents as agent (agent.id)} + {agent.name} + {/each} + + + {/snippet} + + + + + + {#snippet children({ props })} + {m["absences.add.fields.absenceType.title"]()} + ($formData.absenceType = v)} + > + + {$formData.absenceType + ? reasons.find((x) => x.value === $formData.absenceType)?.label + : m["absences.add.fields.absenceType.placeholder"]()} + + + {#each reasons as reason (reason.value)} + {reason.label} + {/each} + + + {/snippet} + + + + { + isAllDay = v; + }} + class="mt-2 mb-1" + /> + + + {#snippet children({ props })} + {m["absences.add.fields.startDate.title"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["absences.add.fields.endDate.title"]()} + + {/snippet} + + + +
+ + {m["absences.add.hint"]()} + + + {m["absences.add.action"]()} + +
+
+{/if} diff --git a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/index.ts b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/index.ts new file mode 100644 index 0000000..2bd235f --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/index.ts @@ -0,0 +1,5 @@ +import AddAbsenceForm from "./add-absence-form.svelte"; + +export { AddAbsenceForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/schema.ts b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/schema.ts new file mode 100644 index 0000000..c52826f --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/schema.ts @@ -0,0 +1,34 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z + .object({ + 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(), + }) + .superRefine((data, ctx) => { + const start = new Date(data.startDate); + const end = new Date(data.endDate); + const now = new Date(); + + if (end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m["form.errors.endDateBeforeStartDate"](), + path: ["endDate"], + }); + } + + if (end <= now) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m["form.errors.endDateTooEarly"](), + path: ["endDate"], + }); + } + }); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte new file mode 100644 index 0000000..3e84d29 --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte @@ -0,0 +1,69 @@ + + + + + {m["absences.delete.description"]({ + 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)), + })} + + + + +
+ + {m["absences.delete.action"]()} + +
+
diff --git a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/index.ts b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/index.ts new file mode 100644 index 0000000..202fbf4 --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/index.ts @@ -0,0 +1,5 @@ +import DeleteAbsenceForm from "./delete-absence-form.svelte"; + +export { DeleteAbsenceForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/schema.ts b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/schema.ts new file mode 100644 index 0000000..a942494 --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const formSchema = z.object({ + id: z.string(), + agent: z.string(), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte new file mode 100644 index 0000000..f5d54fb --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte @@ -0,0 +1,152 @@ + + + + + + + {#snippet children({ props })} + {m["absences.add.fields.agent.title"]()} + ($formData.agent = v)} + > + + {$formData.agent + ? agents.find((x) => x.id === $formData.agent)?.name + : m["absences.add.fields.agent.placeholder"]()} + + + {#each agents as agent (agent.id)} + {agent.name} + {/each} + + + {/snippet} + + + + + + {#snippet children({ props })} + {m["absences.add.fields.absenceType.title"]()} + ($formData.absenceType = v)} + > + + {$formData.absenceType + ? reasons.find((x) => x.value === $formData.absenceType)?.label + : m["absences.add.fields.absenceType.placeholder"]()} + + + {#each reasons as reason (reason.value)} + {reason.label} + {/each} + + + {/snippet} + + + + { + isAllDay = v; + }} + class="mt-2 mb-1" + /> + + + {#snippet children({ props })} + {m["absences.add.fields.startDate.title"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["absences.add.fields.endDate.title"]()} + + {/snippet} + + + +
+ + {m["absences.edit.action"]()} + +
+
diff --git a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/index.ts b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/index.ts new file mode 100644 index 0000000..77e7edf --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/index.ts @@ -0,0 +1,5 @@ +import EditAbsenceForm from "./edit-absence-form.svelte"; + +export { EditAbsenceForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/schema.ts b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/schema.ts new file mode 100644 index 0000000..d61d4c5 --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/schema.ts @@ -0,0 +1,35 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z + .object({ + id: z.string(), + 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(), + }) + .superRefine((data, ctx) => { + const start = new Date(data.startDate); + const end = new Date(data.endDate); + const now = new Date(); + + if (end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m["form.errors.endDateBeforeStartDate"](), + path: ["endDate"], + }); + } + + if (end <= now) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m["form.errors.endDateTooEarly"](), + path: ["endDate"], + }); + } + }); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/absences/(components)/utils.ts b/src/routes/(pages)/dashboard/absences/(components)/utils.ts new file mode 100644 index 0000000..49bdd51 --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/(components)/utils.ts @@ -0,0 +1,10 @@ +import { m } from "$i18n/messages"; + +export const reasons = [ + { label: m["absences.reasons.holiday"](), value: "HOLIDAY" }, + { label: m["absences.reasons.vacation"](), value: "VACATION" }, + { label: m["absences.reasons.sick"](), value: "SICK" }, + { label: m["absences.reasons.education"](), value: "EDUCATION" }, + { label: m["absences.reasons.off.site"](), value: "OFF-SITE" }, + { label: m["absences.reasons.other"](), value: "OTHER" }, +] as const; diff --git a/src/routes/(pages)/dashboard/absences/+page.server.ts b/src/routes/(pages)/dashboard/absences/+page.server.ts new file mode 100644 index 0000000..558976b --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/+page.server.ts @@ -0,0 +1,199 @@ +import { ROUTES } from "$lib/const/routes.js"; +import logger from "$lib/logger"; +import { fail, redirect, type Actions } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import { formSchema as addFormSchema } from "./(components)/add-absence-form"; +import { formSchema as editFormSchema } from "./(components)/edit-absence-form"; +import { formSchema as deleteFormSchema } from "./(components)/delete-absence-form"; +import type { TAbsence } from "$lib/types/absence"; + +const log = logger.setContext(import.meta.filename); + +export const load = async (event) => { + if (!event.locals.user?.tenantId) { + log.error("User trying to access absences, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const thisMorning = new Date(); + thisMorning.setHours(0, 0, 0, 0); + const params = { + startDate: thisMorning.toISOString(), + endDate: "2099-12-31T23:59:59.999Z", + } as const; + const queryString = Object.entries(params) + .map(([key, value]) => `${key}=${value}`) + .join("&"); + const list = event + .fetch(`/api/tenants/${event.locals.user?.tenantId}/agents/absences?${queryString}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then(async (res) => { + // Logout if session expired + if (res.status === 401) { + redirect(302, ROUTES.LOGOUT); + } + + try { + const body = await res.json(); + return body.absences as TAbsence[]; + } catch (error) { + log.error("Failed to parse absences response", { error }); + } + }); + + return { + streamed: { + list, + }, + }; +}; + +export const actions: Actions = { + add: async (event) => { + const form = await superValidate(event, zod(addFormSchema)); + + if (!form.valid) { + log.error("Add absence form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to add an absence, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch( + `/api/tenants/${event.locals.user?.tenantId}/agents/${form.data.agent}/absences`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ + description: form.data.description, + absenceType: form.data.absenceType, + startDate: new Date(form.data.startDate).toISOString(), + endDate: new Date(form.data.endDate).toISOString(), + }), + }, + ); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse add absence error response", { error: e }); + } + return fail(400, { + form, + error, + }); + } + }, + edit: async (event) => { + const form = await superValidate(event, zod(editFormSchema)); + + if (!form.valid) { + log.error("Edit absence form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to edit an absence, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch( + `/api/tenants/${event.locals.user?.tenantId}/agents/${form.data.agent}/absences/${form.data.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ + description: form.data.description, + absenceType: form.data.absenceType, + startDate: new Date(form.data.startDate).toISOString(), + endDate: new Date(form.data.endDate).toISOString(), + }), + }, + ); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse edit absences error response", { error: e }); + } + return fail(400, { + form: { ...form, data: { ...form.data } }, + error, + }); + } + }, + delete: async (event) => { + const form = await superValidate(event, zod(deleteFormSchema)); + + if (!form.valid) { + log.error("Delete absence form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to delete an absence, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch( + `/api/tenants/${event.locals.user?.tenantId}/agents/${form.data.agent}/absences/${form.data.id}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }, + ); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse delete absence error response", { error: e }); + } + return fail(400, { + form, + error, + }); + } + }, +}; diff --git a/src/routes/(pages)/dashboard/absences/+page.svelte b/src/routes/(pages)/dashboard/absences/+page.svelte index 92cae5b..2eebdc9 100644 --- a/src/routes/(pages)/dashboard/absences/+page.svelte +++ b/src/routes/(pages)/dashboard/absences/+page.svelte @@ -1,9 +1,49 @@ + + {m["absences.title"]()} - OpenReception + +> + + {#await data.streamed.list as TAbsence[]} + + {:then items} +
+ + {#snippet triggerLabel()} + {m["absences.add.title"]()} + {/snippet} + { + invalidate(ROUTES.DASHBOARD.ABSENCES); + closeDialog("add"); + }} + /> + + {#if items.length > 0} + + {#each items as item (item.id)} + {@const agent = agents.find((a) => a.id === item.agentId)} + { + curItem = item; + openDialog("edit"); + }, + }, + { + type: "divider", + }, + { + type: "action", + icon: DeleteIcon, + label: m["delete"](), + isDestructive: true, + onClick: () => { + curItem = item; + openDialog("delete"); + }, + }, + ]} + > + {agent?.name || item.agentId} + + {/each} + + + {#if curItem} + { + closeDialog("edit"); + curItem = null; + invalidate(ROUTES.DASHBOARD.ABSENCES); + }} + /> + {/if} + + + {#if curItem} + { + closeDialog("delete"); + curItem = null; + invalidate(ROUTES.DASHBOARD.ABSENCES); + }} + /> + {/if} + + {:else} +
+ + +
+ {/if} +
+ {/await} +
+ diff --git a/src/routes/(pages)/dashboard/channels/(components)/add-channel-form/add-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/add-channel-form/add-channel-form.svelte index 5895b3d..3164909 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/add-channel-form/add-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/add-channel-form/add-channel-form.svelte @@ -20,7 +20,7 @@ let { done }: { done: () => void } = $props(); - const agents = get(agentsStore).agents ?? []; + const agents = $derived($agentsStore.agents ?? []); const tenantLocales = get(tenants).currentTenant?.languages ?? []; const form = superForm( { diff --git a/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte index 33e7418..5b07fb0 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte @@ -21,7 +21,7 @@ let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props(); - const agents = get(agentsStore).agents ?? []; + const agents = $derived($agentsStore.agents ?? []); const tenantLocales = get(tenants).currentTenant?.languages ?? []; const form = superForm( { diff --git a/src/routes/api/tenants/[id]/agents/absences/+server.ts b/src/routes/api/tenants/[id]/agents/absences/+server.ts new file mode 100644 index 0000000..c7d5cc9 --- /dev/null +++ b/src/routes/api/tenants/[id]/agents/absences/+server.ts @@ -0,0 +1,147 @@ +import { json } from "@sveltejs/kit"; +import { AgentService } from "$lib/server/services/agent-service"; +import { ValidationError, logError, BackendError, InternalError } from "$lib/server/utils/errors"; +import type { RequestHandler } from "@sveltejs/kit"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import logger from "$lib/logger"; +import { checkPermission } from "$lib/server/utils/permissions"; + +// Register OpenAPI documentation for GET +registerOpenAPIRoute("/tenants/{id}/agents/absences", "GET", { + summary: "Get all agent absences", + description: + "Retrieves all absences for a all agents. Global admins, tenant admins, and staff can view absences.", + tags: ["Agent Absences"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "startDate", + in: "query", + required: false, + schema: { type: "string", format: "date-time" }, + description: "Filter absences starting from this date", + }, + { + name: "endDate", + in: "query", + required: false, + schema: { type: "string", format: "date-time" }, + description: "Filter absences ending before this date", + }, + ], + responses: { + "200": { + description: "Agent absences retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + absences: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Absence ID" }, + 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" }, + }, + required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"], + }, + }, + }, + required: ["absences"], + }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Insufficient permissions", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Agent or tenant not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +export const GET: RequestHandler = async ({ params, locals, url }) => { + const log = logger.setContext("API"); + + try { + const tenantId = params.id; + + if (!tenantId) { + throw new ValidationError("Missing tenant ID"); + } + + checkPermission(locals, tenantId); + + // Get optional query parameters for date filtering + const startDate = url.searchParams.get("startDate"); + const endDate = url.searchParams.get("endDate"); + + log.debug("Getting all agents absences", { + tenantId, + requestedBy: locals.user?.userId, + startDate, + endDate, + }); + + const agentService = await AgentService.forTenant(tenantId); + const absences = + (await agentService.getAbsences(startDate || undefined, endDate || undefined)) || []; + + log.debug("All Agents absences retrieved successfully", { + tenantId, + count: absences.length, + requestedBy: locals.user?.userId, + }); + + return json({ + absences, + }); + } catch (error) { + logError(log)("Getting all agents absences failed", error, locals.user?.userId, params.id); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/agents/absences/__tests__/absence-overview-api.test.ts b/src/routes/api/tenants/[id]/agents/absences/__tests__/absence-overview-api.test.ts new file mode 100644 index 0000000..716d06c --- /dev/null +++ b/src/routes/api/tenants/[id]/agents/absences/__tests__/absence-overview-api.test.ts @@ -0,0 +1,200 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { RequestEvent } from "@sveltejs/kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "../+server"; + +// Mock dependencies +vi.mock("$lib/server/services/agent-service", () => ({ + AgentService: { + forTenant: vi.fn(), + }, +})); + +vi.mock("$lib/logger", () => ({ + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + })), + }, +})); + +import { AgentService } from "$lib/server/services/agent-service"; +import { NotFoundError } from "$lib/server/utils/errors"; +// import { NotFoundError } from "$lib/server/utils/errors"; + +describe("Agent Absence Detail API Routes", () => { + const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; + const mockAbsenceId = "absence-456"; + const mockAgentService = { + getAbsences: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + (AgentService.forTenant as any).mockResolvedValue(mockAgentService); + }); + + function createMockRequestEvent(overrides: Partial = {}): RequestEvent { + return { + params: { id: mockTenantId }, + searchParams: new URLSearchParams(), + locals: { + user: { + userId: "user123", + role: "TENANT_ADMIN", + tenantId: mockTenantId, + }, + }, + request: { + json: vi.fn().mockResolvedValue({ + startDate: "2024-01-15T00:00:00.000Z", + endDate: "2024-01-17T23:59:59.999Z", + absenceType: "Urlaub", + description: "Jahresurlaub", + isFullDay: true, + }), + } as any, + url: new URL("http://localhost:3000/api/tenants/123/agents/absences"), + ...overrides, + } as RequestEvent; + } + + describe("GET /api/tenants/[id]/agents/absences", () => { + it("should return absences for authenticated tenant admin", async () => { + const mockAbsences = [ + { + id: mockAbsenceId, + startDate: "2024-01-15T00:00:00.000Z", + endDate: "2024-01-17T23:59:59.999Z", + absenceType: "Urlaub", + description: "Jahresurlaub", + isFullDay: true, + }, + ]; + + mockAgentService.getAbsences.mockResolvedValue(mockAbsences); + + const event = createMockRequestEvent(); + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.absences).toEqual(mockAbsences); + expect(mockAgentService.getAbsences).toHaveBeenCalled(); + }); + }); + + it("should allow staff to view absence details", async () => { + const mockAbsences = [ + { + id: mockAbsenceId, + startDate: "2024-01-15T00:00:00.000Z", + endDate: "2024-01-17T23:59:59.999Z", + absenceType: "Urlaub", + description: "Jahresurlaub", + isFullDay: true, + }, + ]; + + mockAgentService.getAbsences.mockResolvedValue(mockAbsences); + + const event = createMockRequestEvent({ + locals: { + user: { + userId: "user123", + role: "STAFF", + tenantId: mockTenantId, + } as any, + }, + }); + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.absences).toEqual(mockAbsences); + }); + + it("should allow global admin to view absence details for any tenant", async () => { + const mockAbsences = [ + { + id: mockAbsenceId, + startDate: "2024-01-15T00:00:00.000Z", + endDate: "2024-01-17T23:59:59.999Z", + absenceType: "Urlaub", + description: "Jahresurlaub", + isFullDay: true, + }, + ]; + + mockAgentService.getAbsences.mockResolvedValue(mockAbsences); + + const event = createMockRequestEvent({ + locals: { + user: { + userId: "user123", + role: "GLOBAL_ADMIN", + tenantId: "different-tenant", + } as any, + }, + }); + + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.absences).toEqual(mockAbsences); + }); + + it("should return empty list when no absences found", async () => { + mockAgentService.getAbsences.mockResolvedValue(null); + + const event = createMockRequestEvent(); + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.absences).toEqual([]); + }); + + it("should reject unauthenticated requests", async () => { + const event = createMockRequestEvent({ + locals: { user: null } as any, + }); + + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Authentication required"); + }); + + it("should reject insufficient permissions", async () => { + const event = createMockRequestEvent({ + locals: { + user: { + userId: "user123", + role: "STAFF", + tenantId: "different-tenant", + } as any, + }, + }); + + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Insufficient permissions"); + }); + + it("should handle service errors", async () => { + mockAgentService.getAbsences.mockRejectedValue(new NotFoundError("Absences not found")); + + const event = createMockRequestEvent(); + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("Absences not found"); + }); +});