mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Merge pull request #301 from open-reception/feat/calendar-month-filter
Feat/calendar month filter
This commit is contained in:
Generated
+27
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
+18
-10
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user