mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Calendar: Added week view
This commit is contained in:
@@ -944,6 +944,18 @@
|
||||
"title": "Kalender",
|
||||
"selectDate": "Datum auswählen",
|
||||
"today": "Heute",
|
||||
"zoomOptions": {
|
||||
"title": "Kalender Zoom-Optionen",
|
||||
"zoomIn": "Hinein-zoomen",
|
||||
"zoomOut": "Heraus-zoomen"
|
||||
},
|
||||
"viewOptions": {
|
||||
"title": "Kalender Ansicht",
|
||||
"day": "Tagesansicht",
|
||||
"week": "Wochenansicht",
|
||||
"week-workdays": "Wochenansicht (ohne Wochenende)"
|
||||
},
|
||||
"calendarWeek": "KW {week}, {year}",
|
||||
"thisMonth": "Aktueller Monat",
|
||||
"addThreeMonths": "+3",
|
||||
"addSixMonths": "+6",
|
||||
|
||||
@@ -952,6 +952,18 @@
|
||||
"title": "Calendar",
|
||||
"selectDate": "Select a date",
|
||||
"today": "Today",
|
||||
"zoomOptions": {
|
||||
"title": "Calendar zoom options",
|
||||
"zoomIn": "zoom-in",
|
||||
"zoomOut": "zoom-out"
|
||||
},
|
||||
"viewOptions": {
|
||||
"title": "Calendar view options",
|
||||
"day": "Day",
|
||||
"week": "Week",
|
||||
"week-workdays": "Week (without weekend)"
|
||||
},
|
||||
"calendarWeek": "CW {week}, {year}",
|
||||
"thisMonth": "Current month",
|
||||
"addThreeMonths": "+3",
|
||||
"addSixMonths": "+6",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let { ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...rest}>
|
||||
<g
|
||||
id="calendar-week-workdays"
|
||||
stroke="none"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
>
|
||||
<g id="Group" stroke="currentColor">
|
||||
<line x1="8" y1="2" x2="8" y2="6" id="Path" stroke-width="2"></line>
|
||||
<line x1="16" y1="2" x2="16" y2="6" id="Path" stroke-width="2"></line>
|
||||
<line x1="16" y1="11" x2="16" y2="21" id="Path" stroke-width="2"></line>
|
||||
<rect id="Rectangle" stroke-width="2" x="3" y="4" width="18" height="18" rx="2"></rect>
|
||||
<line x1="3" y1="10" x2="21" y2="10" id="Path" stroke-width="2"></line>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let { ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...rest}>
|
||||
<title>calendar-week</title>
|
||||
<g
|
||||
id="calendar-week"
|
||||
stroke="none"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
>
|
||||
<g id="Group" stroke="currentColor">
|
||||
<line x1="8" y1="2" x2="8" y2="6" id="Path" stroke-width="2"></line>
|
||||
<line x1="6" y1="11" x2="6" y2="21" id="Path-2" stroke-width="2"></line>
|
||||
<line x1="9" y1="11" x2="9" y2="21" id="Path-5" stroke-width="2"></line>
|
||||
<line x1="12" y1="11" x2="12" y2="21" id="Path-3" stroke-width="2"></line>
|
||||
<line x1="15" y1="11" x2="15" y2="21" id="Path-6" stroke-width="2"></line>
|
||||
<line x1="18" y1="11" x2="18" y2="21" id="Path-4" stroke-width="2"></line>
|
||||
<line x1="16" y1="2" x2="16" y2="6" id="Path" stroke-width="2"></line>
|
||||
<rect id="Rectangle" stroke-width="2" x="3" y="4" width="18" height="18" rx="2"></rect>
|
||||
<line x1="3" y1="10" x2="21" y2="10" id="Path" stroke-width="2"></line>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -13,6 +13,7 @@
|
||||
import { toDisplayDateTime } from "$lib/utils/datetime";
|
||||
import { getCurrentTranlslation } from "$lib/utils/localizations";
|
||||
import { getLocalTimeZone } from "@internationalized/date";
|
||||
import { useQueryClient } from "@tanstack/svelte-query";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let {
|
||||
@@ -23,6 +24,7 @@
|
||||
closePopover: () => void;
|
||||
} = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const channels = $derived($channelsStore.channels);
|
||||
let appointment: TAppointment | undefined = $state();
|
||||
|
||||
@@ -57,6 +59,9 @@
|
||||
onclick={() => {
|
||||
if (appointment) {
|
||||
closePopover();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["calendar"],
|
||||
});
|
||||
if (page.url.pathname === ROUTES.DASHBOARD.CALENDAR) {
|
||||
goto(resolve(ROUTES.DASHBOARD.CALENDAR), {
|
||||
state: {
|
||||
|
||||
@@ -19,17 +19,20 @@
|
||||
</script>
|
||||
|
||||
<Calendar.Day
|
||||
class="relative data-outside-month:bg-transparent! data-outside-month:hover:bg-transparent data-today:rounded-sm data-today:border-2 data-today:border-red-500/50!"
|
||||
class="relative data-today:rounded-sm data-today:border-2 data-today:border-red-500/50!"
|
||||
>
|
||||
{#if !outsideMonth}
|
||||
<div class="py-1">
|
||||
<div class={cn(isDateEmpty && isDateEmpty(day) && "text-muted-foreground")}>
|
||||
{day.day}
|
||||
</div>
|
||||
{#if isDateHighlighted && isDateHighlighted(day)}
|
||||
<span class="absolute bottom-0 left-1/2 h-1 w-1 -translate-1/2 rounded-full bg-green-500"
|
||||
></span>
|
||||
{/if}
|
||||
<div class="py-1">
|
||||
<div
|
||||
class={cn(
|
||||
isDateEmpty && isDateEmpty(day) && "text-muted-foreground",
|
||||
outsideMonth && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{day.day}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isDateHighlighted && isDateHighlighted(day)}
|
||||
<span class="absolute bottom-0 left-1/2 h-1 w-1 -translate-1/2 rounded-full bg-green-500"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
</Calendar.Day>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import type { TCalendarSlot } from "$lib/types/calendar";
|
||||
import {
|
||||
CalendarDate,
|
||||
getLocalTimeZone,
|
||||
parseAbsoluteToLocal,
|
||||
toCalendarDateTime,
|
||||
@@ -228,3 +229,22 @@ export const getWeekStartsOn = (): 0 | 1 | 2 | 3 | 4 | 5 | 6 => {
|
||||
if (sundayStartLocales.has(locale)) return 0;
|
||||
return 1;
|
||||
};
|
||||
|
||||
export const getWeekDays = (day: CalendarDate, skipWeekend = false): CalendarDate[] => {
|
||||
const jsDay = day.toDate("UTC").getDay();
|
||||
const weekStartsOn = getWeekStartsOn();
|
||||
let offset = jsDay - weekStartsOn;
|
||||
if (offset < 0) offset += 7;
|
||||
|
||||
const weekStart = day.subtract({ days: offset });
|
||||
const days = Array.from({ length: 7 }, (_, i) => weekStart.add({ days: i }));
|
||||
|
||||
if (skipWeekend) {
|
||||
return days.filter((d) => {
|
||||
const dow = d.toDate("UTC").getDay();
|
||||
return dow !== 0 && dow !== 6;
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
import { calendarStore } from "$lib/stores/calendar";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { m } from "$i18n/messages";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
item,
|
||||
scale,
|
||||
}: {
|
||||
item: TCalendarItem;
|
||||
scale: number;
|
||||
} = $props();
|
||||
|
||||
let decrypted = $state<AppointmentData | undefined>();
|
||||
@@ -101,13 +104,16 @@
|
||||
{:else if decrypted === undefined}
|
||||
<div class="flex h-full items-center gap-1 px-1">
|
||||
<Loader2Icon class="h-3/4 max-h-4 w-auto animate-spin" />
|
||||
<Text style="xs" class="leading-none text-(--channel-color-contrast)"
|
||||
>{m["calendar.decrypting"]()}</Text
|
||||
>
|
||||
<Text style="xs" class="leading-none text-(--channel-color-contrast)">
|
||||
{m["calendar.decrypting"]()}
|
||||
</Text>
|
||||
</div>
|
||||
{:else if decrypted}
|
||||
<Button
|
||||
class="m-0 h-full w-full cursor-pointer justify-start rounded-none px-1 leading-none text-(--channel-color-contrast) hover:bg-transparent! hover:text-(--channel-color-contrast) focus:ring-1"
|
||||
class={cn(
|
||||
"m-0 h-full w-full cursor-pointer items-start justify-start rounded-none px-px py-px text-left leading-none break-all whitespace-pre-line text-(--channel-color-contrast) hover:bg-transparent! hover:text-(--channel-color-contrast) focus:ring-1",
|
||||
scale === 1 ? "text-xs" : "",
|
||||
)}
|
||||
variant="ghost"
|
||||
onclick={setCalendarItem}
|
||||
>
|
||||
|
||||
@@ -1,49 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { clock } from "$lib/stores/time";
|
||||
import { type TCalendarItem } from "$lib/types/calendar";
|
||||
import { type TAppointmentFilter, type TCalendar, type TCalendarItem } from "$lib/types/calendar";
|
||||
import { cn } from "$lib/utils";
|
||||
import {
|
||||
CalendarDate,
|
||||
getLocalTimeZone,
|
||||
toCalendarDate,
|
||||
toCalendarDateTime,
|
||||
today,
|
||||
} from "@internationalized/date";
|
||||
import Loader from "@lucide/svelte/icons/loader-2";
|
||||
import { tv } from "tailwind-variants";
|
||||
import { positionItems } from "./utils";
|
||||
import AppointmentPreview from "./AppointmentPreview.svelte";
|
||||
import { getContrastColor } from "$lib/utils/color";
|
||||
import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime";
|
||||
import { CalendarDate, getLocalTimeZone, toCalendarDate, today } from "@internationalized/date";
|
||||
import { tv } from "tailwind-variants";
|
||||
import AppointmentPreview from "./AppointmentPreview.svelte";
|
||||
import SlotPreview from "./SlotPreview.svelte";
|
||||
import { positionItems } from "./utils";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
|
||||
let {
|
||||
day = $bindable(),
|
||||
items,
|
||||
selectedDate,
|
||||
calendar,
|
||||
shownAppointments,
|
||||
shownChannels,
|
||||
shownAgents,
|
||||
earliestStartHour,
|
||||
latestEndHour,
|
||||
scale = $bindable(),
|
||||
isWeekView,
|
||||
}: {
|
||||
day: CalendarDate;
|
||||
items: TCalendarItem[] | undefined;
|
||||
selectedDate: CalendarDate;
|
||||
calendar: TCalendar | undefined;
|
||||
shownAppointments: TAppointmentFilter;
|
||||
shownChannels: string[];
|
||||
shownAgents: string[];
|
||||
earliestStartHour: number;
|
||||
latestEndHour: number;
|
||||
scale: number;
|
||||
isWeekView?: boolean;
|
||||
} = $props();
|
||||
|
||||
let items: TCalendarItem[] | undefined = $derived.by(() => {
|
||||
if (!calendar) return undefined;
|
||||
const dayEntry = calendar.calendar.find((d) => d.date === day.toString());
|
||||
if (!dayEntry) return [];
|
||||
return Object.keys(dayEntry.channels).reduce<TCalendarItem[]>((allItems, channelId) => {
|
||||
const channelData = dayEntry.channels[channelId];
|
||||
const channelItems: TCalendarItem[] = [];
|
||||
|
||||
// Available slots
|
||||
if (["all", "available"].includes(shownAppointments)) {
|
||||
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
|
||||
channelData.availableSlots.forEach((slot) => {
|
||||
const isSlotInPast = utcToLocalWithoutDST(new Date(slot.to)) < new Date();
|
||||
if (!isSlotInPast) {
|
||||
if (
|
||||
shownAgents.length === 0 ||
|
||||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))
|
||||
) {
|
||||
channelItems.push({
|
||||
id: `${channelId}-${slot.from}`,
|
||||
date: dayEntry.date,
|
||||
start: slot.from,
|
||||
duration: slot.duration,
|
||||
channelId,
|
||||
color: channelData.channel.color,
|
||||
column: 0,
|
||||
status: "available",
|
||||
availableAgents: slot.availableAgents,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Appointments
|
||||
if (["all", "booked", "reserved"].includes(shownAppointments)) {
|
||||
channelData.appointments.forEach((appointment) => {
|
||||
const status = appointment.status === "CONFIRMED" ? "booked" : "reserved";
|
||||
if (shownAppointments === "all" || shownAppointments === status) {
|
||||
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
|
||||
if (shownAgents.length === 0 || shownAgents.includes(appointment.agentId)) {
|
||||
channelItems.push({
|
||||
id: appointment.id,
|
||||
date: dayEntry.date,
|
||||
start: new Date(appointment.appointmentDate).toISOString(),
|
||||
duration: appointment.duration,
|
||||
channelId,
|
||||
color: channelData.channel.color,
|
||||
column: 0,
|
||||
status,
|
||||
appointment: {
|
||||
dateTime: new Date(appointment.appointmentDate),
|
||||
encryptedPayload: appointment.encryptedPayload,
|
||||
tunnelId: appointment.tunnelId,
|
||||
agentId: appointment.agentId,
|
||||
staffKeyShare: appointment.staffKeyShare,
|
||||
iv: appointment.iv || undefined,
|
||||
authTag: appointment.authTag || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [...allItems, ...channelItems];
|
||||
}, []);
|
||||
});
|
||||
// Process items to handle overlaps
|
||||
let processedItems = $derived(positionItems(items));
|
||||
|
||||
const hourSize = $derived(60 * scale);
|
||||
const hours = Array.from({ length: 25 }, (_, i) => i);
|
||||
const shownHours = $derived(hours.slice(0, latestEndHour + 1).slice(earliestStartHour));
|
||||
const focusAdjustment = $derived(30 * scale);
|
||||
const curTimeIndicator = $derived(
|
||||
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
|
||||
);
|
||||
|
||||
const slotVariants = tv({
|
||||
base: "",
|
||||
@@ -59,41 +125,26 @@
|
||||
</script>
|
||||
|
||||
<div class="relative flex w-full flex-col">
|
||||
<div
|
||||
class="relative flex w-full items-start justify-between transition-all duration-200"
|
||||
style:height={`${focusAdjustment}px`}
|
||||
>
|
||||
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
|
||||
</div>
|
||||
|
||||
<!-- Hour markers -->
|
||||
{#each shownHours as hour (`hour-${hour}`)}
|
||||
<div
|
||||
class="relative flex w-full items-start justify-between transition-all duration-200 select-none"
|
||||
style:height={`${hourSize}px`}
|
||||
{#if isWeekView && items}
|
||||
{@const isSelected = selectedDate.toString() === items[0]?.date}
|
||||
{@const isToday = toCalendarDate(day).toString() === today(getLocalTimeZone()).toString()}
|
||||
<Text
|
||||
style="xs"
|
||||
class={cn(
|
||||
"absolute -top-5 left-1 z-20",
|
||||
isSelected && "bg-primary text-secondary rounded-sm px-1",
|
||||
isToday && "rounded-sm border-2 border-red-500/50 px-1",
|
||||
)}
|
||||
>
|
||||
<Text style="xs" class="text-muted-foreground -mt-2 w-16 shrink-0">
|
||||
{Intl.DateTimeFormat(getLocale(), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: getLocalTimeZone().toString(),
|
||||
}).format(toCalendarDateTime(day).set({ hour }).toDate(getLocalTimeZone()))}
|
||||
</Text>
|
||||
<Separator class="bg-muted-foreground h-px w-auto! grow" />
|
||||
<Separator class="bg-secondary absolute top-1/2 right-0 left-16 h-px w-auto!" />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if items === undefined}
|
||||
<div class="absolute top-0 left-0 -mt-5">
|
||||
<Loader class="size-4 animate-spin" strokeWidth={1} />
|
||||
<span class="sr-only">{m["calendar.loading"]()}</span>
|
||||
</div>
|
||||
{toDisplayDateTime(day.toDate(getLocalTimeZone()), { weekday: "short" })}
|
||||
</Text>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
class="bg-muted-foreground absolute top-0 bottom-0 left-0 z-10 transition-all duration-200"
|
||||
style={{ height: `${(latestEndHour * 30 + 30) * scale + 20}px`, marginTop: "-10px" }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Day content area -->
|
||||
<div class="absolute top-0 right-0 bottom-0 left-16">
|
||||
<div class="absolute top-0 right-0 bottom-0 left-0 z-10">
|
||||
{#each processedItems as item, index (`${item.id}-${index}`)}
|
||||
{@const top =
|
||||
(item.startMinutes / 60) * hourSize + focusAdjustment - earliestStartHour * hourSize}
|
||||
@@ -119,7 +170,7 @@
|
||||
)}
|
||||
>
|
||||
{#if ["booked", "reserved"].includes(item.status)}
|
||||
<AppointmentPreview {item} />
|
||||
<AppointmentPreview {item} {scale} />
|
||||
{:else if item.status === "available"}
|
||||
<SlotPreview {item} />
|
||||
{/if}
|
||||
@@ -127,26 +178,4 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Current Time Indicator -->
|
||||
{#if curTimeIndicator && toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString() && latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize && earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
|
||||
{@const top =
|
||||
focusAdjustment +
|
||||
curTimeIndicator.hour * hourSize +
|
||||
(curTimeIndicator.minute / 60) * hourSize -
|
||||
earliestStartHour * hourSize}
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 left-0 z-10 flex h-1 items-center transition-all duration-200 select-none"
|
||||
style:top={`${top}px`}
|
||||
>
|
||||
<Text style="xs" class="z-10 -ml-1 rounded-full bg-red-500 px-1 text-white">
|
||||
{Intl.DateTimeFormat(getLocale(), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: getLocalTimeZone().toString(),
|
||||
}).format(toCalendarDateTime($clock).toDate(getLocalTimeZone()))}
|
||||
</Text>
|
||||
<div class="absolute right-0 left-0 h-px bg-red-500"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import CalendarWeekWorkdays from "$lib/assets/custom-icons/calendar-week-workdays.svelte";
|
||||
import CalendarWeek from "$lib/assets/custom-icons/calendar-week.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as ButtonGroup from "$lib/components/ui/button-group";
|
||||
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
|
||||
@@ -14,16 +16,19 @@
|
||||
import { sidebar as sidebarStore } from "$lib/stores/sidebar";
|
||||
import type { TAppointmentFilter } from "$lib/types/calendar";
|
||||
import { cn } from "$lib/utils";
|
||||
import { CalendarDate } from "@internationalized/date";
|
||||
import { FunnelX, PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte";
|
||||
import { CalendarDate, type DateValue, isWeekend } from "@internationalized/date";
|
||||
import { Calendar, FunnelX, PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte";
|
||||
import { type ComponentProps } from "svelte";
|
||||
import type { CalendarView } from "../types";
|
||||
import CalendarMonth from "./CalendarMonth.svelte";
|
||||
import { CALENDAR_ZOOM_STEPS } from "./utils";
|
||||
|
||||
let {
|
||||
shownAppointments = $bindable(),
|
||||
shownChannels = $bindable(),
|
||||
shownAgents = $bindable(),
|
||||
scale = $bindable(),
|
||||
view = $bindable(),
|
||||
selectedDate = $bindable(),
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
@@ -32,6 +37,7 @@
|
||||
shownChannels: string[];
|
||||
shownAgents: string[];
|
||||
scale: number;
|
||||
view: CalendarView;
|
||||
selectedDate: CalendarDate;
|
||||
} = $props();
|
||||
|
||||
@@ -45,13 +51,17 @@
|
||||
let channels = $derived($channelsStore.channels.filter((x) => !x.archived));
|
||||
let agents = $derived($agentsStore.agents.filter((x) => !x.archived));
|
||||
|
||||
const zoomSteps = [1, 2, 3, 4];
|
||||
|
||||
const zoom = (direction: number) => {
|
||||
const currentIndex = zoomSteps.indexOf(scale);
|
||||
const currentIndex = CALENDAR_ZOOM_STEPS.indexOf(scale);
|
||||
if (currentIndex === -1) return;
|
||||
const nextIndex = currentIndex + direction;
|
||||
scale = zoomSteps[nextIndex];
|
||||
scale = CALENDAR_ZOOM_STEPS[nextIndex];
|
||||
document.cookie = `calendarZoom=${CALENDAR_ZOOM_STEPS[nextIndex]}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Strict`;
|
||||
};
|
||||
|
||||
const changeView = (newView: CalendarView) => {
|
||||
view = newView;
|
||||
document.cookie = `calendarView=${newView}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Strict`;
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
@@ -71,7 +81,7 @@
|
||||
{...restProps}
|
||||
>
|
||||
<Sidebar.Header class="border-sidebar-border h-16 border-b">
|
||||
<div class="flex h-full items-center gap-5">
|
||||
<div class="flex h-full items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -80,22 +90,58 @@
|
||||
>
|
||||
<PanelRightClose />
|
||||
</Button>
|
||||
<ButtonGroup.Root aria-label="Media controls">
|
||||
<ButtonGroup.Root aria-label={m["calendar.zoomOptions.title"]()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={zoomSteps[0] === scale}
|
||||
disabled={CALENDAR_ZOOM_STEPS[0] === scale}
|
||||
onclick={() => zoom(-1)}
|
||||
title={m["calendar.zoomOptions.zoomOut"]()}
|
||||
>
|
||||
<ZoomOut />
|
||||
<span class="sr-only">{m["calendar.zoomOptions.zoomOut"]()}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={zoomSteps.slice(-1)[0] === scale}
|
||||
disabled={CALENDAR_ZOOM_STEPS.slice(-1)[0] === scale}
|
||||
onclick={() => zoom(1)}
|
||||
title={m["calendar.zoomOptions.zoomIn"]()}
|
||||
>
|
||||
<ZoomIn />
|
||||
<span class="sr-only">{m["calendar.zoomOptions.zoomIn"]()}</span>
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
<ButtonGroup.Root aria-label={m["calendar.viewOptions.title"]()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => changeView("day")}
|
||||
class={cn(view === "day" && "bg-muted")}
|
||||
title={m["calendar.viewOptions.day"]()}
|
||||
>
|
||||
<Calendar />
|
||||
<span class="sr-only">{m["calendar.viewOptions.day"]()}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => changeView("week-workdays")}
|
||||
class={cn(view === "week-workdays" && "bg-muted")}
|
||||
title={m["calendar.viewOptions.week-workdays"]()}
|
||||
>
|
||||
<CalendarWeekWorkdays />
|
||||
<span class="sr-only">{m["calendar.viewOptions.week-workdays"]()}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => changeView("week")}
|
||||
class={cn(view === "week" && "bg-muted")}
|
||||
title={m["calendar.viewOptions.week"]()}
|
||||
>
|
||||
<CalendarWeek />
|
||||
<span class="sr-only">{m["calendar.viewOptions.week"]()}</span>
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
</div>
|
||||
@@ -177,13 +223,18 @@
|
||||
</div>
|
||||
</HorizontalPagePadding>
|
||||
<Sidebar.Separator class="mx-0 my-1" />
|
||||
<HorizontalPagePadding class="pt-2">
|
||||
<HorizontalPagePadding class="py-2">
|
||||
<CalendarMonth
|
||||
bind:selectedDate
|
||||
{shownAppointments}
|
||||
{shownAgents}
|
||||
{shownChannels}
|
||||
onSelectDay={() => sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded)}
|
||||
onSelectDay={(v: DateValue | undefined) => {
|
||||
sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded);
|
||||
if (view === "week-workdays" && v && isWeekend(v, getLocale())) {
|
||||
changeView("week");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HorizontalPagePadding>
|
||||
</Sidebar.Content>
|
||||
|
||||
@@ -9,20 +9,24 @@
|
||||
import CalendarMonth from "./CalendarMonth.svelte";
|
||||
import type { TAppointmentFilter } from "$lib/types/calendar";
|
||||
import type { OnChangeFn } from "vaul-svelte";
|
||||
import type { CalendarView } from "../types";
|
||||
|
||||
let {
|
||||
selectedDate = $bindable(),
|
||||
view,
|
||||
shownAppointments,
|
||||
shownChannels,
|
||||
shownAgents,
|
||||
}: {
|
||||
selectedDate: CalendarDate;
|
||||
view: CalendarView;
|
||||
shownAppointments: TAppointmentFilter;
|
||||
shownChannels: string[];
|
||||
shownAgents: string[];
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let isWeekView = $derived(view !== "day");
|
||||
|
||||
const prev = () => {
|
||||
const nextDate = new CalendarDate(
|
||||
@@ -30,14 +34,14 @@
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
).subtract({
|
||||
days: 1,
|
||||
days: isWeekView ? 7 : 1,
|
||||
});
|
||||
selectedDate = nextDate;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
const nextDate = new CalendarDate(selectedDate.year, selectedDate.month, selectedDate.day).add({
|
||||
days: 1,
|
||||
days: isWeekView ? 7 : 1,
|
||||
});
|
||||
selectedDate = nextDate;
|
||||
};
|
||||
@@ -49,6 +53,24 @@
|
||||
const onSelectDay: OnChangeFn<unknown> = () => {
|
||||
open = false;
|
||||
};
|
||||
|
||||
const getISOWeek = (date: CalendarDate): { week: number; year: number } => {
|
||||
// Convert to a Thursday of the same ISO week
|
||||
const dayOfWeek = date.toDate("UTC").getUTCDay() || 7; // Mon=1 ... Sun=7
|
||||
const thursday = date.add({ days: 4 - dayOfWeek });
|
||||
|
||||
// The ISO year is the year of that Thursday
|
||||
const year = thursday.year;
|
||||
|
||||
// Day of year for that Thursday
|
||||
const jan1 = new CalendarDate(year, 1, 1);
|
||||
const dayOfYear =
|
||||
(thursday.toDate("UTC").getTime() - jan1.toDate("UTC").getTime()) / 86_400_000 + 1;
|
||||
|
||||
const week = Math.ceil(dayOfYear / 7);
|
||||
|
||||
return { week, year };
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -62,13 +84,18 @@
|
||||
<Popover.Trigger
|
||||
class={cn(buttonVariants({ variant: "ghost" }), "h-auto py-1 leading-none font-normal")}
|
||||
>
|
||||
{Intl.DateTimeFormat(getLocale(), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
timeZone: getLocalTimeZone().toString(),
|
||||
}).format(selectedDate.toDate(getLocalTimeZone()))}
|
||||
{#if isWeekView}
|
||||
{@const week = getISOWeek(selectedDate)}
|
||||
{m["calendar.calendarWeek"](week)}
|
||||
{:else}
|
||||
{Intl.DateTimeFormat(getLocale(), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
timeZone: getLocalTimeZone().toString(),
|
||||
}).format(selectedDate.toDate(getLocalTimeZone()))}
|
||||
{/if}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-64">
|
||||
<CalendarMonth
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { clock } from "$lib/stores/time";
|
||||
import {
|
||||
CalendarDate,
|
||||
getLocalTimeZone,
|
||||
toCalendarDate,
|
||||
toCalendarDateTime,
|
||||
today,
|
||||
} from "@internationalized/date";
|
||||
import Loader from "@lucide/svelte/icons/loader-2";
|
||||
|
||||
let {
|
||||
day = $bindable(),
|
||||
earliestStartHour,
|
||||
latestEndHour,
|
||||
scale = $bindable(),
|
||||
isLoading,
|
||||
}: {
|
||||
day: CalendarDate;
|
||||
earliestStartHour: number;
|
||||
latestEndHour: number;
|
||||
scale: number;
|
||||
isLoading: boolean;
|
||||
} = $props();
|
||||
|
||||
const hourSize = $derived(60 * scale);
|
||||
const focusAdjustment = $derived(30 * scale);
|
||||
const curTimeIndicator = $derived(
|
||||
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="relative flex w-16 shrink-0 flex-col">
|
||||
<div
|
||||
class="relative flex w-full items-start justify-between transition-all duration-200"
|
||||
style:height={`${focusAdjustment}px`}
|
||||
>
|
||||
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if isLoading}
|
||||
<div class="absolute top-0 left-0 -mt-5">
|
||||
<Loader class="size-4 animate-spin" strokeWidth={1} />
|
||||
<span class="sr-only">{m["calendar.loading"]()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Current Time Indicator -->
|
||||
{#if !isLoading && curTimeIndicator}
|
||||
{@const isToday = toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString()}
|
||||
{@const isNotAfterHours =
|
||||
latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize}
|
||||
{@const isNotBeforeHours =
|
||||
earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
|
||||
{#if isToday && isNotAfterHours && isNotBeforeHours}
|
||||
{@const top =
|
||||
focusAdjustment +
|
||||
curTimeIndicator.hour * hourSize +
|
||||
(curTimeIndicator.minute / 60) * hourSize -
|
||||
earliestStartHour * hourSize}
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 left-0 z-20 flex h-1 items-center transition-all duration-200 select-none"
|
||||
style:top={`${top}px`}
|
||||
>
|
||||
<Text style="xs" class="z-90 -ml-1 rounded-full bg-red-500 px-1 text-white">
|
||||
{Intl.DateTimeFormat(getLocale(), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: getLocalTimeZone().toString(),
|
||||
}).format(toCalendarDateTime($clock).toDate(getLocalTimeZone()))}
|
||||
</Text>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { clock } from "$lib/stores/time";
|
||||
import {
|
||||
CalendarDate,
|
||||
getLocalTimeZone,
|
||||
toCalendarDate,
|
||||
toCalendarDateTime,
|
||||
today,
|
||||
} from "@internationalized/date";
|
||||
|
||||
let {
|
||||
day = $bindable(),
|
||||
earliestStartHour,
|
||||
latestEndHour,
|
||||
scale = $bindable(),
|
||||
isLoading,
|
||||
isWeekView,
|
||||
}: {
|
||||
day: CalendarDate;
|
||||
earliestStartHour: number;
|
||||
latestEndHour: number;
|
||||
scale: number;
|
||||
isLoading: boolean;
|
||||
isWeekView?: boolean;
|
||||
} = $props();
|
||||
|
||||
const hourSize = $derived(60 * scale);
|
||||
const hours = Array.from({ length: 25 }, (_, i) => i);
|
||||
const shownHours = $derived(hours.slice(0, latestEndHour + 1).slice(earliestStartHour));
|
||||
const focusAdjustment = $derived(30 * scale);
|
||||
const curTimeIndicator = $derived(
|
||||
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if !isLoading}
|
||||
<div class="absolute flex w-[calc(100%-2rem)] flex-col">
|
||||
<div
|
||||
class="relative flex w-full items-start justify-between transition-all duration-200"
|
||||
style:height={`${focusAdjustment}px`}
|
||||
>
|
||||
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
|
||||
</div>
|
||||
|
||||
<!-- Current Time Indicator -->
|
||||
{#if !isLoading && curTimeIndicator}
|
||||
{@const isToday = toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString()}
|
||||
{@const isNotAfterHours =
|
||||
latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize}
|
||||
{@const isNotBeforeHours =
|
||||
earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
|
||||
{#if isToday && isNotAfterHours && isNotBeforeHours}
|
||||
{@const top =
|
||||
focusAdjustment +
|
||||
curTimeIndicator.hour * hourSize +
|
||||
(curTimeIndicator.minute / 60) * hourSize -
|
||||
earliestStartHour * hourSize}
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 left-0 z-10 flex h-1 items-center transition-all duration-200 select-none"
|
||||
style:top={`${top}px`}
|
||||
>
|
||||
<div class="absolute right-0 left-0 h-px bg-red-500"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Hour markers -->
|
||||
{#each shownHours as hour (`hour-${hour}`)}
|
||||
<div
|
||||
class="relative z-0 flex w-full items-start justify-between transition-all duration-200 select-none"
|
||||
style:height={`${hourSize}px`}
|
||||
>
|
||||
{#if !isWeekView}
|
||||
<Text style="xs" class="text-muted-foreground -mt-2 w-16 shrink-0">
|
||||
{Intl.DateTimeFormat(getLocale(), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: getLocalTimeZone().toString(),
|
||||
}).format(toCalendarDateTime(day).set({ hour }).toDate(getLocalTimeZone()))}
|
||||
</Text>
|
||||
{/if}
|
||||
<Separator class="bg-muted-foreground -z-10 -ml-0.5 h-px w-auto! grow" />
|
||||
<Separator class="bg-secondary absolute top-1/2 right-0 left-16 h-px w-auto!" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { type TAppointmentFilter, type TCalendar } from "$lib/types/calendar";
|
||||
import { getWeekDays } from "$lib/utils/datetime";
|
||||
import { CalendarDate } from "@internationalized/date";
|
||||
import CalendarDay from "./CalendarDay.svelte";
|
||||
import CalendarLegend from "./CalendarLegend.svelte";
|
||||
import CalendarLines from "./CalendarLines.svelte";
|
||||
import type { CalendarView } from "../types";
|
||||
|
||||
let {
|
||||
selectedDate = $bindable(),
|
||||
day = $bindable(),
|
||||
calendar,
|
||||
shownAppointments,
|
||||
shownChannels,
|
||||
shownAgents,
|
||||
earliestStartHour,
|
||||
latestEndHour,
|
||||
view,
|
||||
scale = $bindable(),
|
||||
}: {
|
||||
selectedDate: CalendarDate;
|
||||
day: CalendarDate;
|
||||
calendar: TCalendar | undefined;
|
||||
shownAppointments: TAppointmentFilter;
|
||||
shownChannels: string[];
|
||||
shownAgents: string[];
|
||||
earliestStartHour: number;
|
||||
latestEndHour: number;
|
||||
view: CalendarView;
|
||||
scale: number;
|
||||
} = $props();
|
||||
|
||||
let weekDays = $derived(getWeekDays(day, view === "week-workdays"));
|
||||
</script>
|
||||
|
||||
<CalendarLegend
|
||||
{day}
|
||||
isLoading={calendar === undefined}
|
||||
{earliestStartHour}
|
||||
{latestEndHour}
|
||||
bind:scale
|
||||
/>
|
||||
{#each weekDays as weekDay (weekDay.toString())}
|
||||
<CalendarDay
|
||||
day={weekDay}
|
||||
{selectedDate}
|
||||
{calendar}
|
||||
{shownAppointments}
|
||||
{shownAgents}
|
||||
{shownChannels}
|
||||
{earliestStartHour}
|
||||
{latestEndHour}
|
||||
isWeekView={true}
|
||||
bind:scale
|
||||
/>
|
||||
{/each}
|
||||
<CalendarLines
|
||||
{day}
|
||||
{earliestStartHour}
|
||||
{latestEndHour}
|
||||
bind:scale
|
||||
isLoading={calendar === undefined}
|
||||
/>
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { CalendarDate } from "@internationalized/date";
|
||||
import { fetchCalendar } from "./utils";
|
||||
import { browser } from "$app/env";
|
||||
|
||||
export const calendarMonthQuery = (tenant: string | undefined | null, date: CalendarDate) => ({
|
||||
queryKey: ["calendar", `${date.year}-${date.month}`],
|
||||
queryFn: () => fetchCalendar({ tenant: tenant as string, selectedDate: date }),
|
||||
enabled: Boolean(tenant),
|
||||
enabled: Boolean(tenant) && browser,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
@@ -2,17 +2,28 @@ import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import type { AppointmentWithKeyShare } from "$lib/server/services/schedule-service";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { calendarStore } from "$lib/stores/calendar";
|
||||
import { staffCrypto } from "$lib/stores/staff-crypto";
|
||||
import type { TCalendar, TCalendarItem } from "$lib/types/calendar";
|
||||
import { localToUTCWithoutDST } from "$lib/utils/datetime";
|
||||
import { getLocalTimeZone, type CalendarDate } from "@internationalized/date";
|
||||
import type { AppointmentStatus, TCalendar, TCalendarItem } from "$lib/types/calendar";
|
||||
import type { TChannel } from "$lib/types/channel";
|
||||
import { getWeekStartsOn, localToUTCWithoutDST } from "$lib/utils/datetime";
|
||||
import {
|
||||
getLocalTimeZone,
|
||||
parseAbsoluteToLocal,
|
||||
toCalendarDate,
|
||||
type CalendarDate,
|
||||
} from "@internationalized/date";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export const CALENDAR_ZOOM_STEPS = [1, 2, 3, 4];
|
||||
|
||||
export const fetchCalendar = async (opts: { tenant: string; selectedDate: CalendarDate }) => {
|
||||
if (!browser) return;
|
||||
|
||||
const weekStartsOn = getWeekStartsOn();
|
||||
|
||||
const localStartDate = new Date(
|
||||
opts.selectedDate.year,
|
||||
opts.selectedDate.month - 1,
|
||||
@@ -22,6 +33,11 @@ export const fetchCalendar = async (opts: { tenant: string; selectedDate: Calend
|
||||
0,
|
||||
0,
|
||||
);
|
||||
// Roll back to the start of the week
|
||||
while (localStartDate.getDay() !== weekStartsOn) {
|
||||
localStartDate.setDate(localStartDate.getDate() - 1);
|
||||
}
|
||||
|
||||
const localEndDate = new Date(
|
||||
opts.selectedDate.year,
|
||||
opts.selectedDate.month,
|
||||
@@ -31,6 +47,11 @@ export const fetchCalendar = async (opts: { tenant: string; selectedDate: Calend
|
||||
59,
|
||||
999,
|
||||
);
|
||||
// Roll forward to the end of the week
|
||||
const weekEndDay = (weekStartsOn + 6) % 7;
|
||||
while (localEndDate.getDay() !== weekEndDay) {
|
||||
localEndDate.setDate(localEndDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
startDate: localStartDate.toISOString(),
|
||||
@@ -249,29 +270,57 @@ export const confirmAppointment = async (opts: {
|
||||
};
|
||||
|
||||
export const openAppointmentById = async (
|
||||
appointments: TCalendarItem[],
|
||||
calendar: TCalendar,
|
||||
channels: TChannel[],
|
||||
appointmentId: string,
|
||||
callback: () => void,
|
||||
) => {
|
||||
const appointment = appointments?.find((i) => i.id === appointmentId);
|
||||
const allAppointments: AppointmentWithKeyShare[] = calendar.calendar.reduce((all, item) => {
|
||||
return [...all, ...Object.values(item.channels).flatMap((it) => it.appointments)];
|
||||
}, [] as AppointmentWithKeyShare[]);
|
||||
const appointment = allAppointments?.find((i) => i.id === appointmentId);
|
||||
const staffCryptoStore = get(staffCrypto);
|
||||
if (
|
||||
staffCryptoStore.crypto &&
|
||||
appointment?.appointment?.encryptedPayload &&
|
||||
appointment.appointment.iv &&
|
||||
appointment.appointment.authTag &&
|
||||
appointment.appointment.staffKeyShare
|
||||
appointment?.encryptedPayload &&
|
||||
appointment.iv &&
|
||||
appointment.authTag &&
|
||||
appointment.staffKeyShare
|
||||
) {
|
||||
const decrypted = await staffCryptoStore.crypto.decryptStaffAppointment({
|
||||
encryptedAppointment: {
|
||||
encryptedPayload: appointment.appointment.encryptedPayload,
|
||||
iv: appointment.appointment.iv,
|
||||
authTag: appointment.appointment.authTag,
|
||||
encryptedPayload: appointment.encryptedPayload,
|
||||
iv: appointment.iv,
|
||||
authTag: appointment.authTag,
|
||||
},
|
||||
staffKeyShare: appointment.appointment.staffKeyShare,
|
||||
staffKeyShare: appointment.staffKeyShare,
|
||||
});
|
||||
calendarStore.setCurItem({ appointment, decrypted });
|
||||
const channel = channels.find((it) => it.id === appointment.channelId);
|
||||
const curCalendarItem: TCalendarItem = {
|
||||
date: appointment.appointmentDate.toString(),
|
||||
id: appointment.id,
|
||||
duration: appointment.duration,
|
||||
status: appointment.status as AppointmentStatus,
|
||||
start: "08:00",
|
||||
channelId: appointment.channelId,
|
||||
color: channel?.color || "",
|
||||
appointment: {
|
||||
...appointment,
|
||||
encryptedPayload: appointment.encryptedPayload,
|
||||
iv: appointment.iv,
|
||||
authTag: appointment.authTag,
|
||||
dateTime: new Date(appointment.appointmentDate),
|
||||
},
|
||||
// TODO: get proper values
|
||||
column: 0,
|
||||
};
|
||||
calendarStore.setCurItem({ appointment: curCalendarItem, decrypted });
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
export const convertDate = (dateStr: string) => {
|
||||
const zonedDateTime = parseAbsoluteToLocal(dateStr);
|
||||
return toCalendarDate(zonedDateTime);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CALENDAR_ZOOM_STEPS } from "./(components)/utils.js";
|
||||
import type { CalendarView } from "./types.js";
|
||||
|
||||
export const load = ({ cookies }) => {
|
||||
// zoom
|
||||
const cookieZoom = cookies.get("calendarZoom");
|
||||
const parsedCookieZoom = cookieZoom && parseInt(cookieZoom);
|
||||
const calendarZoom =
|
||||
parsedCookieZoom && CALENDAR_ZOOM_STEPS.includes(parsedCookieZoom) ? parsedCookieZoom : 1;
|
||||
|
||||
// view
|
||||
const cookieView = cookies.get("calendarView");
|
||||
const calendarView: CalendarView =
|
||||
cookieView && ["day", "week", "week-workdays"].includes(cookieView)
|
||||
? (cookieView as CalendarView)
|
||||
: "day";
|
||||
|
||||
return { calendarView, calendarZoom };
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
import { replaceState } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { m } from "$i18n/messages";
|
||||
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
|
||||
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
|
||||
@@ -12,31 +11,24 @@
|
||||
import { calendarStore } from "$lib/stores/calendar";
|
||||
import { channels as channelsStore } from "$lib/stores/channels";
|
||||
import { sidebar } from "$lib/stores/sidebar";
|
||||
import type { TAppointmentFilter, TCalendarItem } from "$lib/types/calendar";
|
||||
import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime";
|
||||
import type { TAppointmentFilter } from "$lib/types/calendar";
|
||||
import { timeUTCToLocalWithoutOffset } from "$lib/utils/datetime";
|
||||
import { getCurrentTranlslation } from "$lib/utils/localizations";
|
||||
import {
|
||||
getLocalTimeZone,
|
||||
parseAbsoluteToLocal,
|
||||
toCalendarDate,
|
||||
today,
|
||||
type CalendarDate,
|
||||
} from "@internationalized/date";
|
||||
import { getLocalTimeZone, today, type CalendarDate } from "@internationalized/date";
|
||||
import { SlidersHorizontal } from "@lucide/svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { onMount } from "svelte";
|
||||
import AddAppointment from "./(components)/add-appointment/AddAppointment.svelte";
|
||||
import Appointment from "./(components)/Appointment.svelte";
|
||||
import CalendarDay from "./(components)/CalendarDay.svelte";
|
||||
import CalendarFilters from "./(components)/CalendarFilters.svelte";
|
||||
import CalendarHeader from "./(components)/CalendarHeader.svelte";
|
||||
import { openAppointmentById } from "./(components)/utils";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import CalendarLegend from "./(components)/CalendarLegend.svelte";
|
||||
import CalendarLines from "./(components)/CalendarLines.svelte";
|
||||
import CalendarWeek from "./(components)/CalendarWeek.svelte";
|
||||
import { calendarMonthQuery } from "./(components)/queries";
|
||||
|
||||
const convertDate = (dateStr: string) => {
|
||||
const zonedDateTime = parseAbsoluteToLocal(dateStr);
|
||||
return toCalendarDate(zonedDateTime);
|
||||
};
|
||||
import { convertDate, openAppointmentById } from "./(components)/utils";
|
||||
import type { CalendarView } from "./types";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId = $derived($auth.user?.tenantId);
|
||||
@@ -54,6 +46,7 @@
|
||||
let shownAppointments: TAppointmentFilter = $state("all");
|
||||
let shownChannels: string[] = $state([]);
|
||||
let shownAgents: string[] = $state([]);
|
||||
let view: CalendarView = $state(page.data.calendarView);
|
||||
let hours = $derived.by(() => {
|
||||
const from = channels
|
||||
.map((c) => c.slotTemplates.map((t) => t.from))
|
||||
@@ -71,7 +64,7 @@
|
||||
});
|
||||
return { from: Math.min(...from), to: Math.max(...to) };
|
||||
});
|
||||
let scale = $state(1);
|
||||
let scale = $state(page.data.calendarZoom);
|
||||
|
||||
$effect(() => {
|
||||
const getMonth = (x: string | undefined) => (x ? new Date(x).getMonth() + 1 : undefined);
|
||||
@@ -81,24 +74,21 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (items && history.state["sveltekit:states"]?.appointmentId) {
|
||||
if (calendar && history.state["sveltekit:states"]?.appointmentId) {
|
||||
// Wait 100ms to ensure that the calendar items are rendered
|
||||
setTimeout(() => {
|
||||
openAppointmentById(items, history.state["sveltekit:states"].appointmentId, () => {
|
||||
replaceState("", {});
|
||||
});
|
||||
openAppointmentById(
|
||||
calendar,
|
||||
channels,
|
||||
history.state["sveltekit:states"].appointmentId,
|
||||
() => {
|
||||
replaceState("", {});
|
||||
},
|
||||
);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (history.state["sveltekit:states"]?.date) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { date, ...rest } = history.state["sveltekit:states"];
|
||||
replaceState("", rest);
|
||||
}
|
||||
});
|
||||
|
||||
// Navigating to appointment, if calendar is already mounted
|
||||
$effect(() => {
|
||||
if (
|
||||
@@ -111,15 +101,28 @@
|
||||
selectedDate = date;
|
||||
replaceState("", { appointmentId: page.state.appointmentId });
|
||||
} else {
|
||||
if (items) {
|
||||
openAppointmentById(items, history.state["sveltekit:states"].appointmentId, () => {
|
||||
replaceState("", {});
|
||||
});
|
||||
if (calendar) {
|
||||
openAppointmentById(
|
||||
calendar,
|
||||
channels,
|
||||
history.state["sveltekit:states"].appointmentId,
|
||||
() => {
|
||||
replaceState("", {});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (history.state["sveltekit:states"]?.date) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { date, ...rest } = history.state["sveltekit:states"];
|
||||
replaceState("", rest);
|
||||
}
|
||||
});
|
||||
|
||||
const updateCalendar = async () => {
|
||||
if (tenantId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -131,77 +134,6 @@
|
||||
const closeAppointmentDetail = () => {
|
||||
$calendarStore.curItem = null;
|
||||
};
|
||||
|
||||
let items: TCalendarItem[] | undefined = $derived.by(() => {
|
||||
if (!calendar) return undefined;
|
||||
const dayEntry = calendar.calendar.find((d) => d.date === selectedDate.toString());
|
||||
if (!dayEntry) return [];
|
||||
return Object.keys(dayEntry.channels).reduce<TCalendarItem[]>((allItems, channelId) => {
|
||||
const channelData = dayEntry.channels[channelId];
|
||||
const channelItems: TCalendarItem[] = [];
|
||||
|
||||
// Available slots
|
||||
if (["all", "available"].includes(shownAppointments)) {
|
||||
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
|
||||
channelData.availableSlots.forEach((slot) => {
|
||||
const isSlotInPast = utcToLocalWithoutDST(new Date(slot.to)) < new Date();
|
||||
if (!isSlotInPast) {
|
||||
if (
|
||||
shownAgents.length === 0 ||
|
||||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))
|
||||
) {
|
||||
channelItems.push({
|
||||
id: `${channelId}-${slot.from}`,
|
||||
date: dayEntry.date,
|
||||
start: slot.from,
|
||||
duration: slot.duration,
|
||||
channelId,
|
||||
color: channelData.channel.color,
|
||||
column: 0,
|
||||
status: "available",
|
||||
availableAgents: slot.availableAgents,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Appointments
|
||||
if (["all", "booked", "reserved"].includes(shownAppointments)) {
|
||||
channelData.appointments.forEach((appointment) => {
|
||||
const status = appointment.status === "CONFIRMED" ? "booked" : "reserved";
|
||||
if (shownAppointments === "all" || shownAppointments === status) {
|
||||
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
|
||||
if (shownAgents.length === 0 || shownAgents.includes(appointment.agentId)) {
|
||||
channelItems.push({
|
||||
id: appointment.id,
|
||||
date: dayEntry.date,
|
||||
start: new Date(appointment.appointmentDate).toISOString(),
|
||||
duration: appointment.duration,
|
||||
channelId,
|
||||
color: channelData.channel.color,
|
||||
column: 0,
|
||||
status,
|
||||
appointment: {
|
||||
dateTime: new Date(appointment.appointmentDate),
|
||||
encryptedPayload: appointment.encryptedPayload,
|
||||
tunnelId: appointment.tunnelId,
|
||||
agentId: appointment.agentId,
|
||||
staffKeyShare: appointment.staffKeyShare,
|
||||
iv: appointment.iv || undefined,
|
||||
authTag: appointment.authTag || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [...allItems, ...channelItems];
|
||||
}, []);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -209,20 +141,54 @@
|
||||
</svelte:head>
|
||||
|
||||
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}>
|
||||
<MaxPageWidth maxWidth="xl">
|
||||
<div class="flex flex-col gap-10">
|
||||
<CalendarHeader bind:selectedDate {shownAppointments} {shownAgents} {shownChannels} />
|
||||
<div>
|
||||
<CalendarDay
|
||||
<div class="flex flex-col gap-10">
|
||||
<CalendarHeader bind:selectedDate {view} {shownAppointments} {shownAgents} {shownChannels} />
|
||||
<div
|
||||
class="flex transition-all duration-200"
|
||||
style:min-height={`${(hours.to * 30 + 60) * scale}px`}
|
||||
>
|
||||
{#if view === "day"}
|
||||
<CalendarLegend
|
||||
day={selectedDate}
|
||||
{items}
|
||||
isLoading={calendar === undefined}
|
||||
earliestStartHour={hours.from}
|
||||
latestEndHour={hours.to}
|
||||
bind:scale
|
||||
/>
|
||||
</div>
|
||||
<CalendarDay
|
||||
day={selectedDate}
|
||||
{selectedDate}
|
||||
{calendar}
|
||||
{shownAppointments}
|
||||
{shownAgents}
|
||||
{shownChannels}
|
||||
earliestStartHour={hours.from}
|
||||
latestEndHour={hours.to}
|
||||
bind:scale
|
||||
/>
|
||||
<CalendarLines
|
||||
day={selectedDate}
|
||||
earliestStartHour={hours.from}
|
||||
latestEndHour={hours.to}
|
||||
isLoading={calendar === undefined}
|
||||
bind:scale
|
||||
/>
|
||||
{:else}
|
||||
<CalendarWeek
|
||||
day={selectedDate}
|
||||
{selectedDate}
|
||||
{calendar}
|
||||
{shownAppointments}
|
||||
{shownAgents}
|
||||
{shownChannels}
|
||||
{view}
|
||||
earliestStartHour={hours.from}
|
||||
latestEndHour={hours.to}
|
||||
bind:scale
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</MaxPageWidth>
|
||||
</div>
|
||||
{#snippet headerRight()}
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -239,6 +205,7 @@
|
||||
bind:shownChannels
|
||||
bind:shownAgents
|
||||
bind:scale
|
||||
bind:view
|
||||
bind:selectedDate
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type CalendarView = "day" | "week" | "week-workdays";
|
||||
Reference in New Issue
Block a user