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",
"@simplewebauthn/server": "^11.0.0",
"@sveltejs/adapter-node": "^5.5.1",
"@tanstack/svelte-query": "^6.1.34",
"argon2": "^0.43.0",
"argon2-browser": "^1.18.0",
"cropperjs": "^2.0.1",
@@ -2887,6 +2888,32 @@
"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": {
"version": "10.4.0",
"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",
"@simplewebauthn/server": "^11.0.0",
"@sveltejs/adapter-node": "^5.5.1",
"@tanstack/svelte-query": "^6.1.34",
"argon2": "^0.43.0",
"argon2-browser": "^1.18.0",
"cropperjs": "^2.0.1",
+6
View File
@@ -357,6 +357,7 @@
"weekdays": "Wochentage"
}
},
"cancel": "Abbrechen",
"actions": "Aktionen",
"edit": "Editieren",
"pause": "Deaktivieren",
@@ -941,7 +942,12 @@
},
"calendar": {
"title": "Kalender",
"selectDate": "Datum auswählen",
"today": "Heute",
"thisMonth": "Aktueller Monat",
"addThreeMonths": "+3",
"addSixMonths": "+6",
"addOneYear": "+12",
"loading": "Kalender wird geladen",
"decrypting": "wird entschlüsselt...",
"decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu laden/anmelden.",
+5
View File
@@ -950,7 +950,12 @@
},
"calendar": {
"title": "Calendar",
"selectDate": "Select a date",
"today": "Today",
"thisMonth": "Current month",
"addThreeMonths": "+3",
"addSixMonths": "+6",
"addOneYear": "+12",
"loading": "Loading calendar",
"decrypting": "decrypting...",
"decryptingError": "Unable to decrypt data. Please reload/log-in again",
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import * as Sidebar from "$lib/components/ui/sidebar";
@@ -8,19 +9,26 @@
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
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 EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import UnplugIcon from "@lucide/svelte/icons/unplug";
import UnknownTenantIcon from "@lucide/svelte/icons/landmark";
import Loader from "@lucide/svelte/icons/loader-2";
import { cn } from "$lib/utils";
import { resolve } from "$app/paths";
import UnplugIcon from "@lucide/svelte/icons/unplug";
const sidebar = useSidebar();
const maxTenantsToShow = 5;
let activeTenantId = $derived($auth.user?.tenantId);
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>
<Sidebar.Menu>
@@ -95,11 +103,12 @@
<DropdownMenu.Label class="text-muted-foreground text-xs">
{m["nav.tenants"]()}
</DropdownMenu.Label>
{#each $tenants?.tenants.slice(0, maxTenantsToShow) as tenant (tenant.id)}
{#each tenantList.slice(0, maxTenantsToShow) as tenant (tenant.id)}
<DropdownMenu.Item
onSelect={() => tenants.setCurrentTenant(tenant.id)}
class="gap-2 p-2"
class="justify-between gap-2 p-2"
>
<div class="flex items-center gap-2">
<div class="flex size-6 items-center justify-center rounded-md border">
{#if tenant.logo}
<img
@@ -113,9 +122,13 @@
{/if}
</div>
{tenant.shortName}
</div>
{#if tenant.id === activeTenantId}
<PlugZap />
{/if}
</DropdownMenu.Item>
{/each}
{#if $tenants?.tenants.length > maxTenantsToShow}
{#if tenantList.length > maxTenantsToShow}
<DropdownMenu.Separator />
<DropdownMenu.Item
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">
import { Calendar as CalendarPrimitive } from "bits-ui";
import * as Calendar from "./index.js";
@@ -5,6 +11,7 @@
import type { ButtonVariant } from "../button/button.svelte";
import { isEqualMonth, type DateValue } from "@internationalized/date";
import type { Snippet } from "svelte";
import { getWeekStartsOn } from "$lib/utils/datetime.js";
let {
ref = $bindable(null),
@@ -56,6 +63,7 @@ get along, so we shut typescript up by casting `value` to `never`.
{locale}
{monthFormat}
{yearFormat}
weekStartsOn={getWeekStartsOn()}
{...restProps}
>
{#snippet children({ months, weekdays })}
+8
View File
@@ -21,3 +21,11 @@ export function normalizeEmail(value?: string | null): string | undefined {
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;
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 { 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 Card from "$lib/components/ui/card";
import { ScrollArea } from "$lib/components/ui/scroll-area";
import { Text } from "$lib/components/ui/typography";
@@ -28,6 +29,7 @@
import type { DateMatcher } from "bits-ui";
import type { OnChangeFn } from "vaul-svelte";
import { fetchSchedule } from "./utils";
import { debounce } from "$lib/utils";
const {
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 = () => {
if (browser) {
const todayDate = today(getLocalTimeZone());
@@ -189,21 +199,19 @@
<Calendar.Calendar
type="single"
locale={getLocale()}
calendarLabel="Select a date"
calendarLabel={m["public.steps.slot.selectDate"]()}
bind:value={selectedDate}
{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}
disableDaysOutsideMonth={true}
onValueChange={onSelectDay}
onPlaceholderChange={(placeholder) => {
if (!placeholder) return;
if (placeholder.year === year && placeholder.month === month) return;
year = placeholder.year;
month = placeholder.month;
schedule = undefined;
}}
/>
onPlaceholderChange={debouncedPlaceholderChange}
>
{#snippet day({ day, outsideMonth })}
<Day {day} {outsideMonth} />
{/snippet}
</Calendar.Calendar>
<div class="flex grow items-center justify-center p-4 px-6">
{#if slots === null}
<Text style="md" class="text-muted-foreground text-center">
@@ -7,12 +7,22 @@
import { staffCrypto } from "$lib/stores/staff-crypto";
import { notifications } from "$lib/stores/notifications";
import { staff } from "$lib/stores/staff";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { browser } from "$app/environment";
let { data, children }: LayoutProps = $props();
let intervalSession: ReturnType<typeof setInterval> | null = null;
let intervalData: ReturnType<typeof setInterval> | null = null;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
},
},
});
onMount(() => {
if (data?.user) {
auth.setUser(data.user);
@@ -76,4 +86,6 @@
};
</script>
<QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>
@@ -14,14 +14,17 @@
import { sidebar as sidebarStore } from "$lib/stores/sidebar";
import type { TAppointmentFilter } from "$lib/types/calendar";
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 CalendarMonth from "./CalendarMonth.svelte";
let {
shownAppointments = $bindable(),
shownChannels = $bindable(),
shownAgents = $bindable(),
scale = $bindable(),
selectedDate = $bindable(),
ref = $bindable(null),
...restProps
}: ComponentProps<typeof Sidebar.Root> & {
@@ -29,6 +32,7 @@
shownChannels: string[];
shownAgents: string[];
scale: number;
selectedDate: CalendarDate;
} = $props();
const appointmentStates: { value: TAppointmentFilter; label: string }[] = [
@@ -49,6 +53,12 @@
const nextIndex = currentIndex + direction;
scale = zoomSteps[nextIndex];
};
const clearFilters = () => {
shownAppointments = "all";
shownChannels = [];
shownAgents = [];
};
</script>
<Sidebar.Root
@@ -92,7 +102,23 @@
</Sidebar.Header>
<Sidebar.Content>
<HorizontalPagePadding class="my-3">
<div class="flex flex-col gap-4">
<div>
<div class="flex items-center justify-between">
<Text style="sm">{m["calendar.shownAppointments.title"]()}</Text>
<Button
variant="ghost"
size="sm"
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>
<RadioGroup.Root bind:value={shownAppointments} class="mt-2 mb-1">
{#each appointmentStates as state (state.value)}
<div class="flex items-center space-x-2">
@@ -103,9 +129,7 @@
</div>
{/each}
</RadioGroup.Root>
</HorizontalPagePadding>
<Sidebar.Separator class="mx-0" />
<HorizontalPagePadding class="flex flex-col gap-4">
</div>
{#if channels.length > 1}
<div>
<Text style="sm">{m["channels.title"]()}</Text>
@@ -150,6 +174,17 @@
{/each}
</div>
{/if}
</div>
</HorizontalPagePadding>
<Sidebar.Separator class="mx-0 my-1" />
<HorizontalPagePadding class="pt-2">
<CalendarMonth
bind:selectedDate
{shownAppointments}
{shownAgents}
{shownChannels}
onSelectDay={() => sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded)}
/>
</HorizontalPagePadding>
</Sidebar.Content>
</Sidebar.Root>
@@ -1,32 +1,53 @@
<script lang="ts">
import { m } from "$i18n/messages";
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 { 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 {
startDate = $bindable(),
selectedDate = $bindable(),
shownAppointments,
shownChannels,
shownAgents,
}: {
startDate: CalendarDate;
selectedDate: CalendarDate;
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
} = $props();
let open = $state(false);
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,
});
startDate = nextDate;
selectedDate = nextDate;
};
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,
});
startDate = nextDate;
selectedDate = nextDate;
};
const setToToday = () => {
startDate = today(getLocalTimeZone());
selectedDate = today(getLocalTimeZone());
};
const onSelectDay: OnChangeFn<unknown> = () => {
open = false;
};
</script>
@@ -37,15 +58,28 @@
<Button size="sm" variant="ghost" class="h-6 p-1!" onclick={prev}>
<ChevronLeft />
</Button>
<div>
<Popover.Root bind:open>
<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(startDate.toDate(getLocalTimeZone()))}
</div>
}).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}>
<ChevronRight />
</Button>
@@ -54,7 +88,7 @@
size="sm"
variant="outline"
onclick={setToToday}
disabled={startDate.toString() === today(getLocalTimeZone()).toString()}
disabled={selectedDate.toString() === today(getLocalTimeZone()).toString()}
class="-order-1 ml-auto min-[500px]:order-0"
>
{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 { 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;
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(
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({
@@ -12,7 +12,7 @@
import { calendarStore } from "$lib/stores/calendar";
import { channels as channelsStore } from "$lib/stores/channels";
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 { getCurrentTranlslation } from "$lib/utils/localizations";
import {
@@ -22,31 +22,35 @@
today,
type CalendarDate,
} from "@internationalized/date";
import { Funnel } from "@lucide/svelte";
import { SlidersHorizontal } from "@lucide/svelte";
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 { 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 zonedDateTime = parseAbsoluteToLocal(dateStr);
return toCalendarDate(zonedDateTime);
};
const queryClient = useQueryClient();
const tenantId = $derived($auth.user?.tenantId);
const curEmptySlot = $derived($calendarStore.curEmptySlot);
const curItem = $derived($calendarStore.curItem);
const channels = $derived($channelsStore.channels);
const agents = $derived($agentsStore.agents);
let startDate: CalendarDate = $state(
let selectedDate: CalendarDate = $state(
"date" in page.state
? convertDate(history?.state["sveltekit:states"].date)
: 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 shownChannels: string[] = $state([]);
let shownAgents: string[] = $state([]);
@@ -70,7 +74,10 @@
let scale = $state(1);
$effect(() => {
const getMonth = (x: string | undefined) => (x ? new Date(x).getMonth() + 1 : undefined);
if (!calendar || getMonth(calendar?.period.startDate) !== getMonth(selectedDate.toString())) {
updateCalendar();
}
});
$effect(() => {
@@ -100,8 +107,8 @@
"appointmentId" in page.state
) {
const date = convertDate(page.state.date as string);
if (date.toString() !== startDate.toString()) {
startDate = date;
if (date.toString() !== selectedDate.toString()) {
selectedDate = date;
replaceState("", { appointmentId: page.state.appointmentId });
} else {
if (items) {
@@ -115,8 +122,9 @@
const updateCalendar = async () => {
if (tenantId) {
calender = undefined;
calender = await fetchCalendar({ startDate, tenant: tenantId });
queryClient.invalidateQueries({
queryKey: ["calendar"],
});
}
};
@@ -125,8 +133,8 @@
};
let items: TCalendarItem[] | undefined = $derived.by(() => {
if (!calender) return undefined;
const dayEntry = calender.calendar.find((d) => d.date === startDate.toString());
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];
@@ -203,10 +211,10 @@
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}>
<MaxPageWidth maxWidth="xl">
<div class="flex flex-col gap-10">
<CalendarHeader bind:startDate />
<CalendarHeader bind:selectedDate {shownAppointments} {shownAgents} {shownChannels} />
<div>
<CalendarDay
day={startDate}
day={selectedDate}
{items}
earliestStartHour={hours.from}
latestEndHour={hours.to}
@@ -222,11 +230,17 @@
onclick={() => sidebar.setCalendarExpanded(!$sidebar.isCalendarExpanded)}
class="lg:hidden"
>
<Funnel />
<SlidersHorizontal />
</Button>
{/snippet}
{#snippet sidebarRight()}
<CalendarFilters bind:shownAppointments bind:shownChannels bind:shownAgents bind:scale />
<CalendarFilters
bind:shownAppointments
bind:shownChannels
bind:shownAgents
bind:scale
bind:selectedDate
/>
{/snippet}
</SidebarLayout>