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",
|
||||
"@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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+18
-10
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user