Merge pull request #301 from open-reception/feat/calendar-month-filter

Feat/calendar month filter
This commit is contained in:
Karl Ludwig Weise
2026-06-25 20:50:43 +02:00
committed by GitHub
17 changed files with 583 additions and 123 deletions
+27
View File
@@ -13,6 +13,7 @@
"@noble/post-quantum": "^0.4.1", "@noble/post-quantum": "^0.4.1",
"@simplewebauthn/server": "^11.0.0", "@simplewebauthn/server": "^11.0.0",
"@sveltejs/adapter-node": "^5.5.1", "@sveltejs/adapter-node": "^5.5.1",
"@tanstack/svelte-query": "^6.1.34",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"argon2-browser": "^1.18.0", "argon2-browser": "^1.18.0",
"cropperjs": "^2.0.1", "cropperjs": "^2.0.1",
@@ -2887,6 +2888,32 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/svelte-query": {
"version": "6.1.34",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-6.1.34.tgz",
"integrity": "sha512-b/j6OXHd285NLPTH31SqJ8+XfHKoDPV8+ZGxXjg4pSAWW2aVo/f5gbu5m32QjfdTSumib3+d1dJBv5Yupv7HKw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^5.25.0"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
+1
View File
@@ -88,6 +88,7 @@
"@noble/post-quantum": "^0.4.1", "@noble/post-quantum": "^0.4.1",
"@simplewebauthn/server": "^11.0.0", "@simplewebauthn/server": "^11.0.0",
"@sveltejs/adapter-node": "^5.5.1", "@sveltejs/adapter-node": "^5.5.1",
"@tanstack/svelte-query": "^6.1.34",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"argon2-browser": "^1.18.0", "argon2-browser": "^1.18.0",
"cropperjs": "^2.0.1", "cropperjs": "^2.0.1",
+6
View File
@@ -357,6 +357,7 @@
"weekdays": "Wochentage" "weekdays": "Wochentage"
} }
}, },
"cancel": "Abbrechen",
"actions": "Aktionen", "actions": "Aktionen",
"edit": "Editieren", "edit": "Editieren",
"pause": "Deaktivieren", "pause": "Deaktivieren",
@@ -941,7 +942,12 @@
}, },
"calendar": { "calendar": {
"title": "Kalender", "title": "Kalender",
"selectDate": "Datum auswählen",
"today": "Heute", "today": "Heute",
"thisMonth": "Aktueller Monat",
"addThreeMonths": "+3",
"addSixMonths": "+6",
"addOneYear": "+12",
"loading": "Kalender wird geladen", "loading": "Kalender wird geladen",
"decrypting": "wird entschlüsselt...", "decrypting": "wird entschlüsselt...",
"decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu laden/anmelden.", "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu laden/anmelden.",
+5
View File
@@ -950,7 +950,12 @@
}, },
"calendar": { "calendar": {
"title": "Calendar", "title": "Calendar",
"selectDate": "Select a date",
"today": "Today", "today": "Today",
"thisMonth": "Current month",
"addThreeMonths": "+3",
"addSixMonths": "+6",
"addOneYear": "+12",
"loading": "Loading calendar", "loading": "Loading calendar",
"decrypting": "decrypting...", "decrypting": "decrypting...",
"decryptingError": "Unable to decrypt data. Please reload/log-in again", "decryptingError": "Unable to decrypt data. Please reload/log-in again",
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { m } from "$i18n/messages"; import { m } from "$i18n/messages";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import * as Sidebar from "$lib/components/ui/sidebar"; import * as Sidebar from "$lib/components/ui/sidebar";
@@ -8,19 +9,26 @@
import { ROUTES } from "$lib/const/routes"; import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants"; import { tenants } from "$lib/stores/tenants";
import { cn } from "$lib/utils";
import { PlugZap } from "@lucide/svelte";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down"; import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import EllipsisIcon from "@lucide/svelte/icons/ellipsis"; import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import UnplugIcon from "@lucide/svelte/icons/unplug";
import UnknownTenantIcon from "@lucide/svelte/icons/landmark"; import UnknownTenantIcon from "@lucide/svelte/icons/landmark";
import Loader from "@lucide/svelte/icons/loader-2"; import Loader from "@lucide/svelte/icons/loader-2";
import { cn } from "$lib/utils"; import UnplugIcon from "@lucide/svelte/icons/unplug";
import { resolve } from "$app/paths";
const sidebar = useSidebar(); const sidebar = useSidebar();
const maxTenantsToShow = 5; const maxTenantsToShow = 5;
let activeTenantId = $derived($auth.user?.tenantId); let activeTenantId = $derived($auth.user?.tenantId);
let activeTenant = $derived($tenants.tenants.find((t) => t.id === activeTenantId)); let activeTenant = $derived($tenants.tenants.find((t) => t.id === activeTenantId));
let tenantList = $derived(
$tenants.tenants.sort((a, b) => {
if (a.id === activeTenantId) return -1;
if (b.id === activeTenantId) return 1;
return 0;
}),
);
</script> </script>
<Sidebar.Menu> <Sidebar.Menu>
@@ -95,27 +103,32 @@
<DropdownMenu.Label class="text-muted-foreground text-xs"> <DropdownMenu.Label class="text-muted-foreground text-xs">
{m["nav.tenants"]()} {m["nav.tenants"]()}
</DropdownMenu.Label> </DropdownMenu.Label>
{#each $tenants?.tenants.slice(0, maxTenantsToShow) as tenant (tenant.id)} {#each tenantList.slice(0, maxTenantsToShow) as tenant (tenant.id)}
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => tenants.setCurrentTenant(tenant.id)} onSelect={() => tenants.setCurrentTenant(tenant.id)}
class="gap-2 p-2" class="justify-between gap-2 p-2"
> >
<div class="flex size-6 items-center justify-center rounded-md border"> <div class="flex items-center gap-2">
{#if tenant.logo} <div class="flex size-6 items-center justify-center rounded-md border">
<img {#if tenant.logo}
src={tenant.logo} <img
alt={tenant.shortName} src={tenant.logo}
class="border-dark h-full w-full rounded-sm border object-cover object-center" alt={tenant.shortName}
loading="lazy" class="border-dark h-full w-full rounded-sm border object-cover object-center"
/> loading="lazy"
{:else} />
<UnknownTenantIcon class="size-3.5 shrink-0" /> {:else}
{/if} <UnknownTenantIcon class="size-3.5 shrink-0" />
{/if}
</div>
{tenant.shortName}
</div> </div>
{tenant.shortName} {#if tenant.id === activeTenantId}
<PlugZap />
{/if}
</DropdownMenu.Item> </DropdownMenu.Item>
{/each} {/each}
{#if $tenants?.tenants.length > maxTenantsToShow} {#if tenantList.length > maxTenantsToShow}
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
class="gap-2 p-2" class="gap-2 p-2"
@@ -0,0 +1,35 @@
<script lang="ts">
import * as Calendar from "$lib/components/ui/calendar";
import * as Sidebar from "$lib/components/ui/sidebar";
import { cn } from "$lib/utils";
import { type DateValue } from "@internationalized/date";
import { type ComponentProps } from "svelte";
let {
day,
outsideMonth,
isDateEmpty,
isDateHighlighted,
}: ComponentProps<typeof Sidebar.Root> & {
day: DateValue;
outsideMonth: boolean;
isDateEmpty?: (day: DateValue) => boolean;
isDateHighlighted?: (day: DateValue) => boolean;
} = $props();
</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!"
>
{#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>
{/if}
</Calendar.Day>
@@ -1,3 +1,9 @@
<!--
Custom changes:
* usage of `weekStartsOn={getWeekStartsOn()}`
-->
<script lang="ts"> <script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui"; import { Calendar as CalendarPrimitive } from "bits-ui";
import * as Calendar from "./index.js"; import * as Calendar from "./index.js";
@@ -5,6 +11,7 @@
import type { ButtonVariant } from "../button/button.svelte"; import type { ButtonVariant } from "../button/button.svelte";
import { isEqualMonth, type DateValue } from "@internationalized/date"; import { isEqualMonth, type DateValue } from "@internationalized/date";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { getWeekStartsOn } from "$lib/utils/datetime.js";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -56,6 +63,7 @@ get along, so we shut typescript up by casting `value` to `never`.
{locale} {locale}
{monthFormat} {monthFormat}
{yearFormat} {yearFormat}
weekStartsOn={getWeekStartsOn()}
{...restProps} {...restProps}
> >
{#snippet children({ months, weekdays })} {#snippet children({ months, weekdays })}
+8
View File
@@ -21,3 +21,11 @@ export function normalizeEmail(value?: string | null): string | undefined {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
} }
export const debounce = <T extends (...args: never[]) => void>(fn: T, ms: number): T => {
let timeout: ReturnType<typeof setTimeout>;
return ((...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), ms);
}) as T;
};
+44
View File
@@ -184,3 +184,47 @@ export const utcToLocal = (utcDate: Date): Date => {
const currentOffsetMs = utcDate.getTimezoneOffset() * 60 * 1000; const currentOffsetMs = utcDate.getTimezoneOffset() * 60 * 1000;
return new Date(utcDate.getTime() - currentOffsetMs); return new Date(utcDate.getTime() - currentOffsetMs);
}; };
const sundayStartLocales = new Set([
"en-US",
"en-CA",
"ja",
"ja-JP",
"ko",
"ko-KR",
"zh",
"zh-TW",
"zh-CN",
"he",
"he-IL",
"ar-SA",
"th",
"th-TH",
"pt-BR",
]);
const saturdayStartLocales = new Set([
"ar",
"ar-AE",
"ar-EG",
"ar-BH",
"ar-DZ",
"ar-IQ",
"ar-JO",
"ar-KW",
"ar-LY",
"ar-MA",
"ar-OM",
"ar-QA",
"ar-SY",
"ar-YE",
"fa",
"fa-IR",
]);
export const getWeekStartsOn = (): 0 | 1 | 2 | 3 | 4 | 5 | 6 => {
const locale = navigator.language;
if (saturdayStartLocales.has(locale)) return 6;
if (sundayStartLocales.has(locale)) return 0;
return 1;
};
@@ -4,6 +4,7 @@
import { getLocale } from "$i18n/runtime"; import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import * as Calendar from "$lib/components/ui/calendar"; import * as Calendar from "$lib/components/ui/calendar";
import Day from "$lib/components/ui/calendar-custom/day.svelte";
import * as Card from "$lib/components/ui/card"; import * as Card from "$lib/components/ui/card";
import { ScrollArea } from "$lib/components/ui/scroll-area"; import { ScrollArea } from "$lib/components/ui/scroll-area";
import { Text } from "$lib/components/ui/typography"; import { Text } from "$lib/components/ui/typography";
@@ -28,6 +29,7 @@
import type { DateMatcher } from "bits-ui"; import type { DateMatcher } from "bits-ui";
import type { OnChangeFn } from "vaul-svelte"; import type { OnChangeFn } from "vaul-svelte";
import { fetchSchedule } from "./utils"; import { fetchSchedule } from "./utils";
import { debounce } from "$lib/utils";
const { const {
channel, channel,
@@ -81,6 +83,14 @@
}); });
}; };
const debouncedPlaceholderChange = debounce((placeholder: DateValue | undefined) => {
if (!placeholder) return;
if (placeholder.year === year && placeholder.month === month) return;
year = placeholder.year;
month = placeholder.month;
schedule = undefined;
}, 300);
const setToToday = () => { const setToToday = () => {
if (browser) { if (browser) {
const todayDate = today(getLocalTimeZone()); const todayDate = today(getLocalTimeZone());
@@ -189,21 +199,19 @@
<Calendar.Calendar <Calendar.Calendar
type="single" type="single"
locale={getLocale()} locale={getLocale()}
calendarLabel="Select a date" calendarLabel={m["public.steps.slot.selectDate"]()}
bind:value={selectedDate} bind:value={selectedDate}
{isDateUnavailable} {isDateUnavailable}
class="rounded-lg p-0 [&_td]:grow [&_td_*]:mx-auto [&_th]:grow" class="min-h-80 rounded-lg p-0 [&_td]:grow [&_td_*]:mx-auto [&_th]:grow"
preventDeselect={true} preventDeselect={true}
disableDaysOutsideMonth={true} disableDaysOutsideMonth={true}
onValueChange={onSelectDay} onValueChange={onSelectDay}
onPlaceholderChange={(placeholder) => { onPlaceholderChange={debouncedPlaceholderChange}
if (!placeholder) return; >
if (placeholder.year === year && placeholder.month === month) return; {#snippet day({ day, outsideMonth })}
year = placeholder.year; <Day {day} {outsideMonth} />
month = placeholder.month; {/snippet}
schedule = undefined; </Calendar.Calendar>
}}
/>
<div class="flex grow items-center justify-center p-4 px-6"> <div class="flex grow items-center justify-center p-4 px-6">
{#if slots === null} {#if slots === null}
<Text style="md" class="text-muted-foreground text-center"> <Text style="md" class="text-muted-foreground text-center">
+13 -1
View File
@@ -7,12 +7,22 @@
import { staffCrypto } from "$lib/stores/staff-crypto"; import { staffCrypto } from "$lib/stores/staff-crypto";
import { notifications } from "$lib/stores/notifications"; import { notifications } from "$lib/stores/notifications";
import { staff } from "$lib/stores/staff"; import { staff } from "$lib/stores/staff";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { browser } from "$app/environment";
let { data, children }: LayoutProps = $props(); let { data, children }: LayoutProps = $props();
let intervalSession: ReturnType<typeof setInterval> | null = null; let intervalSession: ReturnType<typeof setInterval> | null = null;
let intervalData: ReturnType<typeof setInterval> | null = null; let intervalData: ReturnType<typeof setInterval> | null = null;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
},
},
});
onMount(() => { onMount(() => {
if (data?.user) { if (data?.user) {
auth.setUser(data.user); auth.setUser(data.user);
@@ -76,4 +86,6 @@
}; };
</script> </script>
{@render children()} <QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>
@@ -14,14 +14,17 @@
import { sidebar as sidebarStore } from "$lib/stores/sidebar"; import { sidebar as sidebarStore } from "$lib/stores/sidebar";
import type { TAppointmentFilter } from "$lib/types/calendar"; import type { TAppointmentFilter } from "$lib/types/calendar";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import { PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte"; import { CalendarDate } from "@internationalized/date";
import { FunnelX, PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte";
import { type ComponentProps } from "svelte"; import { type ComponentProps } from "svelte";
import CalendarMonth from "./CalendarMonth.svelte";
let { let {
shownAppointments = $bindable(), shownAppointments = $bindable(),
shownChannels = $bindable(), shownChannels = $bindable(),
shownAgents = $bindable(), shownAgents = $bindable(),
scale = $bindable(), scale = $bindable(),
selectedDate = $bindable(),
ref = $bindable(null), ref = $bindable(null),
...restProps ...restProps
}: ComponentProps<typeof Sidebar.Root> & { }: ComponentProps<typeof Sidebar.Root> & {
@@ -29,6 +32,7 @@
shownChannels: string[]; shownChannels: string[];
shownAgents: string[]; shownAgents: string[];
scale: number; scale: number;
selectedDate: CalendarDate;
} = $props(); } = $props();
const appointmentStates: { value: TAppointmentFilter; label: string }[] = [ const appointmentStates: { value: TAppointmentFilter; label: string }[] = [
@@ -49,6 +53,12 @@
const nextIndex = currentIndex + direction; const nextIndex = currentIndex + direction;
scale = zoomSteps[nextIndex]; scale = zoomSteps[nextIndex];
}; };
const clearFilters = () => {
shownAppointments = "all";
shownChannels = [];
shownAgents = [];
};
</script> </script>
<Sidebar.Root <Sidebar.Root
@@ -92,64 +102,89 @@
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Content> <Sidebar.Content>
<HorizontalPagePadding class="my-3"> <HorizontalPagePadding class="my-3">
<Text style="sm">{m["calendar.shownAppointments.title"]()}</Text> <div class="flex flex-col gap-4">
<RadioGroup.Root bind:value={shownAppointments} class="mt-2 mb-1"> <div>
{#each appointmentStates as state (state.value)} <div class="flex items-center justify-between">
<div class="flex items-center space-x-2"> <Text style="sm">{m["calendar.shownAppointments.title"]()}</Text>
<RadioGroup.Item value={state.value} id={state.value} /> <Button
<Label for={state.value}> variant="ghost"
{state.label} size="sm"
</Label> class="h-auto px-2 py-1"
onclick={clearFilters}
disabled={shownAppointments === "all" &&
shownChannels.length === 0 &&
shownAgents.length === 0}
>
<FunnelX />
<span class="sr-only">Reset</span>
</Button>
</div> </div>
{/each} <RadioGroup.Root bind:value={shownAppointments} class="mt-2 mb-1">
</RadioGroup.Root> {#each appointmentStates as state (state.value)}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={state.value} id={state.value} />
<Label for={state.value}>
{state.label}
</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
{#if channels.length > 1}
<div>
<Text style="sm">{m["channels.title"]()}</Text>
{#each channels as channel (channel.id)}
{@const locale = getLocale()}
{@const name = channel.names[locale] || Object.values(channel.names)[0]}
<div>
<CheckboxWithLabel
value={shownChannels.includes(channel.id)}
label={name}
onCheckedChange={(v) => {
if (v) {
shownChannels = [...shownChannels, channel.id];
} else {
shownChannels = shownChannels.filter((id) => id !== channel.id);
}
}}
class="mt-2 mb-1"
/>
</div>
{/each}
</div>
{/if}
{#if agents.length > 1}
<div>
<Text style="sm">{m["agents.title"]()}</Text>
{#each agents as agent (agent.id)}
<div>
<CheckboxWithLabel
value={shownAgents.includes(agent.id)}
label={agent.name}
onCheckedChange={(v) => {
if (v) {
shownAgents = [...shownAgents, agent.id];
} else {
shownAgents = shownAgents.filter((id) => id !== agent.id);
}
}}
class="mt-2 mb-1"
/>
</div>
{/each}
</div>
{/if}
</div>
</HorizontalPagePadding> </HorizontalPagePadding>
<Sidebar.Separator class="mx-0" /> <Sidebar.Separator class="mx-0 my-1" />
<HorizontalPagePadding class="flex flex-col gap-4"> <HorizontalPagePadding class="pt-2">
{#if channels.length > 1} <CalendarMonth
<div> bind:selectedDate
<Text style="sm">{m["channels.title"]()}</Text> {shownAppointments}
{#each channels as channel (channel.id)} {shownAgents}
{@const locale = getLocale()} {shownChannels}
{@const name = channel.names[locale] || Object.values(channel.names)[0]} onSelectDay={() => sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded)}
<div> />
<CheckboxWithLabel
value={shownChannels.includes(channel.id)}
label={name}
onCheckedChange={(v) => {
if (v) {
shownChannels = [...shownChannels, channel.id];
} else {
shownChannels = shownChannels.filter((id) => id !== channel.id);
}
}}
class="mt-2 mb-1"
/>
</div>
{/each}
</div>
{/if}
{#if agents.length > 1}
<div>
<Text style="sm">{m["agents.title"]()}</Text>
{#each agents as agent (agent.id)}
<div>
<CheckboxWithLabel
value={shownAgents.includes(agent.id)}
label={agent.name}
onCheckedChange={(v) => {
if (v) {
shownAgents = [...shownAgents, agent.id];
} else {
shownAgents = shownAgents.filter((id) => id !== agent.id);
}
}}
class="mt-2 mb-1"
/>
</div>
{/each}
</div>
{/if}
</HorizontalPagePadding> </HorizontalPagePadding>
</Sidebar.Content> </Sidebar.Content>
</Sidebar.Root> </Sidebar.Root>
@@ -1,32 +1,53 @@
<script lang="ts"> <script lang="ts">
import { m } from "$i18n/messages"; import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime"; import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button"; import { Button, buttonVariants } from "$lib/components/ui/button";
import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date"; import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import { ChevronLeft, ChevronRight } from "@lucide/svelte"; import { ChevronLeft, ChevronRight } from "@lucide/svelte";
import * as Popover from "$lib/components/ui/popover/index.js";
import { cn } from "$lib/utils";
import CalendarMonth from "./CalendarMonth.svelte";
import type { TAppointmentFilter } from "$lib/types/calendar";
import type { OnChangeFn } from "vaul-svelte";
let { let {
startDate = $bindable(), selectedDate = $bindable(),
shownAppointments,
shownChannels,
shownAgents,
}: { }: {
startDate: CalendarDate; selectedDate: CalendarDate;
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
} = $props(); } = $props();
let open = $state(false);
const prev = () => { const prev = () => {
const nextDate = new CalendarDate(startDate.year, startDate.month, startDate.day).subtract({ const nextDate = new CalendarDate(
selectedDate.year,
selectedDate.month,
selectedDate.day,
).subtract({
days: 1, days: 1,
}); });
startDate = nextDate; selectedDate = nextDate;
}; };
const next = () => { const next = () => {
const nextDate = new CalendarDate(startDate.year, startDate.month, startDate.day).add({ const nextDate = new CalendarDate(selectedDate.year, selectedDate.month, selectedDate.day).add({
days: 1, days: 1,
}); });
startDate = nextDate; selectedDate = nextDate;
}; };
const setToToday = () => { const setToToday = () => {
startDate = today(getLocalTimeZone()); selectedDate = today(getLocalTimeZone());
};
const onSelectDay: OnChangeFn<unknown> = () => {
open = false;
}; };
</script> </script>
@@ -37,15 +58,28 @@
<Button size="sm" variant="ghost" class="h-6 p-1!" onclick={prev}> <Button size="sm" variant="ghost" class="h-6 p-1!" onclick={prev}>
<ChevronLeft /> <ChevronLeft />
</Button> </Button>
<div> <Popover.Root bind:open>
{Intl.DateTimeFormat(getLocale(), { <Popover.Trigger
year: "numeric", class={cn(buttonVariants({ variant: "ghost" }), "h-auto py-1 leading-none font-normal")}
month: "long", >
day: "numeric", {Intl.DateTimeFormat(getLocale(), {
weekday: "short", year: "numeric",
timeZone: getLocalTimeZone().toString(), month: "long",
}).format(startDate.toDate(getLocalTimeZone()))} day: "numeric",
</div> weekday: "short",
timeZone: getLocalTimeZone().toString(),
}).format(selectedDate.toDate(getLocalTimeZone()))}
</Popover.Trigger>
<Popover.Content class="w-64">
<CalendarMonth
bind:selectedDate
{shownAppointments}
{shownAgents}
{shownChannels}
{onSelectDay}
/>
</Popover.Content>
</Popover.Root>
<Button size="sm" variant="ghost" class="h-6 p-1!" onclick={next}> <Button size="sm" variant="ghost" class="h-6 p-1!" onclick={next}>
<ChevronRight /> <ChevronRight />
</Button> </Button>
@@ -54,7 +88,7 @@
size="sm" size="sm"
variant="outline" variant="outline"
onclick={setToToday} onclick={setToToday}
disabled={startDate.toString() === today(getLocalTimeZone()).toString()} disabled={selectedDate.toString() === today(getLocalTimeZone()).toString()}
class="-order-1 ml-auto min-[500px]:order-0" class="-order-1 ml-auto min-[500px]:order-0"
> >
{m["calendar.today"]()} {m["calendar.today"]()}
@@ -0,0 +1,189 @@
<script lang="ts">
import { browser } from "$app/environment";
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button";
import * as Calendar from "$lib/components/ui/calendar";
import Day from "$lib/components/ui/calendar-custom/day.svelte";
import * as Sidebar from "$lib/components/ui/sidebar";
import { auth } from "$lib/stores/auth";
import type { TAppointmentFilter } from "$lib/types/calendar";
import {
getLocalTimeZone,
today,
type CalendarDate,
type DateValue,
} from "@internationalized/date";
import { createQuery } from "@tanstack/svelte-query";
import type { DateMatcher } from "bits-ui";
import { type ComponentProps } from "svelte";
import type { OnChangeFn } from "vaul-svelte";
import { calendarMonthQuery } from "./queries";
let {
selectedDate = $bindable(),
shownAppointments,
shownChannels,
shownAgents,
onSelectDay,
ref = $bindable(null),
}: ComponentProps<typeof Sidebar.Root> & {
selectedDate: CalendarDate;
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
onSelectDay?: OnChangeFn<DateValue | undefined>;
} = $props();
const tenantId = $derived($auth.user?.tenantId);
let placeholder = $state(selectedDate);
let isFiltering = $derived(
shownAppointments !== "all" || shownChannels.length > 0 || shownAgents.length > 0,
);
const query = createQuery(() => calendarMonthQuery(tenantId, placeholder));
const month = $derived(query.data?.calendar);
const isDateEmpty = (date: DateValue) => {
const day = month?.filter((it) => it.date === date.toString())[0];
if (!day) {
return true;
}
const slots = Object.values(day.channels).flatMap((it) => it.availableSlots);
const appointments = Object.values(day.channels).flatMap((it) => it.appointments);
if (slots.length === 0 && appointments.length == 0) {
return true;
}
return false;
};
const isDateHighlighted: DateMatcher = (date) => {
// Return early, if not filtering is happening
if (!isFiltering) return false;
const day = month?.filter((it) => it.date === date.toString())[0];
if (!day) {
return false;
}
let matches = Object.keys(day.channels).map((it) => day.channels[it]);
// Filter by appointment state
if (shownAppointments !== "all") {
switch (shownAppointments) {
case "available":
matches = matches
.filter((it) => it.availableSlots.length > 0)
.filter((it) => it.availableSlots.some((s) => s.from >= new Date().toISOString()));
break;
case "reserved":
matches = matches.filter(
(it) => it.appointments.length > 0 && it.appointments.some((x) => x.status === "NEW"),
);
break;
case "booked":
matches = matches.filter(
(it) =>
it.appointments.length > 0 && it.appointments.some((x) => x.status === "CONFIRMED"),
);
break;
}
}
// Filter by channel
if (shownChannels.length > 0) {
matches = matches.filter(
(it) =>
shownChannels.includes(it.channel.id) &&
(it.appointments.length > 0 || it.availableSlots.length > 0),
);
if (matches.length === 0) return false;
}
// Filter by agent
if (shownAgents.length > 0) {
matches = matches.filter((it) => {
const availableAgents = [
...new Set([
...it.availableSlots.flatMap((x) => x.availableAgents.flatMap((y) => y.id)),
...it.appointments.map((x) => x.agentId),
]),
];
return availableAgents.some((it) => shownAgents.includes(it));
});
if (matches.length === 0) return false;
}
if (matches.length === 0) return false;
return true;
};
const setToCurrentMonth = () => {
if (browser) {
const todayDate = today(getLocalTimeZone());
placeholder = todayDate;
}
};
const addMonths = (months: number) => {
if (browser) {
const nextDate = placeholder.add({ months });
placeholder = nextDate;
}
};
</script>
<div class="flex flex-col gap-2">
<div class="flex h-5 items-center justify-between gap-x-2">
<Button
size="xs"
variant="outline"
onclick={setToCurrentMonth}
class="h-auto rounded-md px-2 py-1"
>
{m["calendar.thisMonth"]()}
</Button>
<div class="flex items-center justify-between gap-x-1">
<Button
size="xs"
variant="outline"
onclick={() => addMonths(3)}
class="h-auto rounded-md px-2 py-1"
>
{m["calendar.addThreeMonths"]()}
</Button>
<Button
size="xs"
variant="outline"
onclick={() => addMonths(6)}
class="h-auto rounded-md px-2 py-1"
>
{m["calendar.addSixMonths"]()}
</Button>
<Button
size="xs"
variant="outline"
onclick={() => addMonths(12)}
class="h-auto rounded-md px-2 py-1"
>
{m["calendar.addOneYear"]()}
</Button>
</div>
</div>
<Calendar.Calendar
type="single"
locale={getLocale()}
calendarLabel={m["calendar.selectDate"]()}
class="bg-transparent p-0 [&_td]:grow [&_td_*]:mx-auto [&_th]:grow"
preventDeselect={true}
bind:value={selectedDate}
bind:placeholder
onValueChange={onSelectDay}
>
{#snippet day({ day, outsideMonth })}
<Day {day} {outsideMonth} {isDateEmpty} {isDateHighlighted} />
{/snippet}
</Calendar.Calendar>
</div>
@@ -0,0 +1,9 @@
import type { CalendarDate } from "@internationalized/date";
import { fetchCalendar } from "./utils";
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),
staleTime: 5000,
});
@@ -10,14 +10,26 @@ import { localToUTCWithoutDST } from "$lib/utils/datetime";
import { getLocalTimeZone, type CalendarDate } from "@internationalized/date"; import { getLocalTimeZone, type CalendarDate } from "@internationalized/date";
import { get } from "svelte/store"; import { get } from "svelte/store";
export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarDate }) => { export const fetchCalendar = async (opts: { tenant: string; selectedDate: CalendarDate }) => {
if (!browser) return; if (!browser) return;
const localStartDate = new Date( const localStartDate = new Date(
Date.UTC(opts.startDate.year, opts.startDate.month - 1, opts.startDate.day, 0, 0, 0, 0), opts.selectedDate.year,
opts.selectedDate.month - 1,
1,
0,
0,
0,
0,
); );
const localEndDate = new Date( const localEndDate = new Date(
Date.UTC(opts.startDate.year, opts.startDate.month - 1, opts.startDate.day, 23, 59, 59, 999), opts.selectedDate.year,
opts.selectedDate.month,
0,
23,
59,
59,
999,
); );
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -12,7 +12,7 @@
import { calendarStore } from "$lib/stores/calendar"; import { calendarStore } from "$lib/stores/calendar";
import { channels as channelsStore } from "$lib/stores/channels"; import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar } from "$lib/stores/sidebar"; import { sidebar } from "$lib/stores/sidebar";
import type { TAppointmentFilter, TCalendar, TCalendarItem } from "$lib/types/calendar"; import type { TAppointmentFilter, TCalendarItem } from "$lib/types/calendar";
import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime"; import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations"; import { getCurrentTranlslation } from "$lib/utils/localizations";
import { import {
@@ -22,31 +22,35 @@
today, today,
type CalendarDate, type CalendarDate,
} from "@internationalized/date"; } from "@internationalized/date";
import { Funnel } from "@lucide/svelte"; import { SlidersHorizontal } from "@lucide/svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import AddAppointment from "./(components)/add-appointment/AddAppointment.svelte"; import AddAppointment from "./(components)/add-appointment/AddAppointment.svelte";
import Appointment from "./(components)/Appointment.svelte"; import Appointment from "./(components)/Appointment.svelte";
import CalendarDay from "./(components)/CalendarDay.svelte"; import CalendarDay from "./(components)/CalendarDay.svelte";
import CalendarFilters from "./(components)/CalendarFilters.svelte"; import CalendarFilters from "./(components)/CalendarFilters.svelte";
import CalendarHeader from "./(components)/CalendarHeader.svelte"; import CalendarHeader from "./(components)/CalendarHeader.svelte";
import { fetchCalendar, openAppointmentById } from "./(components)/utils"; import { openAppointmentById } from "./(components)/utils";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { calendarMonthQuery } from "./(components)/queries";
const convertDate = (dateStr: string) => { const convertDate = (dateStr: string) => {
const zonedDateTime = parseAbsoluteToLocal(dateStr); const zonedDateTime = parseAbsoluteToLocal(dateStr);
return toCalendarDate(zonedDateTime); return toCalendarDate(zonedDateTime);
}; };
const queryClient = useQueryClient();
const tenantId = $derived($auth.user?.tenantId); const tenantId = $derived($auth.user?.tenantId);
const curEmptySlot = $derived($calendarStore.curEmptySlot); const curEmptySlot = $derived($calendarStore.curEmptySlot);
const curItem = $derived($calendarStore.curItem); const curItem = $derived($calendarStore.curItem);
const channels = $derived($channelsStore.channels); const channels = $derived($channelsStore.channels);
const agents = $derived($agentsStore.agents); const agents = $derived($agentsStore.agents);
let startDate: CalendarDate = $state( let selectedDate: CalendarDate = $state(
"date" in page.state "date" in page.state
? convertDate(history?.state["sveltekit:states"].date) ? convertDate(history?.state["sveltekit:states"].date)
: today(getLocalTimeZone()), : today(getLocalTimeZone()),
); );
let calender: TCalendar | undefined = $state(); const query = createQuery(() => calendarMonthQuery(tenantId as string, selectedDate));
const calendar = $derived(query.data);
let shownAppointments: TAppointmentFilter = $state("all"); let shownAppointments: TAppointmentFilter = $state("all");
let shownChannels: string[] = $state([]); let shownChannels: string[] = $state([]);
let shownAgents: string[] = $state([]); let shownAgents: string[] = $state([]);
@@ -70,7 +74,10 @@
let scale = $state(1); let scale = $state(1);
$effect(() => { $effect(() => {
updateCalendar(); const getMonth = (x: string | undefined) => (x ? new Date(x).getMonth() + 1 : undefined);
if (!calendar || getMonth(calendar?.period.startDate) !== getMonth(selectedDate.toString())) {
updateCalendar();
}
}); });
$effect(() => { $effect(() => {
@@ -100,8 +107,8 @@
"appointmentId" in page.state "appointmentId" in page.state
) { ) {
const date = convertDate(page.state.date as string); const date = convertDate(page.state.date as string);
if (date.toString() !== startDate.toString()) { if (date.toString() !== selectedDate.toString()) {
startDate = date; selectedDate = date;
replaceState("", { appointmentId: page.state.appointmentId }); replaceState("", { appointmentId: page.state.appointmentId });
} else { } else {
if (items) { if (items) {
@@ -115,8 +122,9 @@
const updateCalendar = async () => { const updateCalendar = async () => {
if (tenantId) { if (tenantId) {
calender = undefined; queryClient.invalidateQueries({
calender = await fetchCalendar({ startDate, tenant: tenantId }); queryKey: ["calendar"],
});
} }
}; };
@@ -125,8 +133,8 @@
}; };
let items: TCalendarItem[] | undefined = $derived.by(() => { let items: TCalendarItem[] | undefined = $derived.by(() => {
if (!calender) return undefined; if (!calendar) return undefined;
const dayEntry = calender.calendar.find((d) => d.date === startDate.toString()); const dayEntry = calendar.calendar.find((d) => d.date === selectedDate.toString());
if (!dayEntry) return []; if (!dayEntry) return [];
return Object.keys(dayEntry.channels).reduce<TCalendarItem[]>((allItems, channelId) => { return Object.keys(dayEntry.channels).reduce<TCalendarItem[]>((allItems, channelId) => {
const channelData = dayEntry.channels[channelId]; const channelData = dayEntry.channels[channelId];
@@ -203,10 +211,10 @@
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}> <SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}>
<MaxPageWidth maxWidth="xl"> <MaxPageWidth maxWidth="xl">
<div class="flex flex-col gap-10"> <div class="flex flex-col gap-10">
<CalendarHeader bind:startDate /> <CalendarHeader bind:selectedDate {shownAppointments} {shownAgents} {shownChannels} />
<div> <div>
<CalendarDay <CalendarDay
day={startDate} day={selectedDate}
{items} {items}
earliestStartHour={hours.from} earliestStartHour={hours.from}
latestEndHour={hours.to} latestEndHour={hours.to}
@@ -222,11 +230,17 @@
onclick={() => sidebar.setCalendarExpanded(!$sidebar.isCalendarExpanded)} onclick={() => sidebar.setCalendarExpanded(!$sidebar.isCalendarExpanded)}
class="lg:hidden" class="lg:hidden"
> >
<Funnel /> <SlidersHorizontal />
</Button> </Button>
{/snippet} {/snippet}
{#snippet sidebarRight()} {#snippet sidebarRight()}
<CalendarFilters bind:shownAppointments bind:shownChannels bind:shownAgents bind:scale /> <CalendarFilters
bind:shownAppointments
bind:shownChannels
bind:shownAgents
bind:scale
bind:selectedDate
/>
{/snippet} {/snippet}
</SidebarLayout> </SidebarLayout>