mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
* Notification shows who canceled an appointment -wip * Added solution arcitecture for decrypting notification payload * Use encrypted tunnel key to decrypt encrpyted notification payload * Fix tests --------- Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
160 lines
5.2 KiB
Svelte
160 lines
5.2 KiB
Svelte
<script lang="ts">
|
|
import { resolve } from "$app/paths";
|
|
import { m } from "$i18n/messages.js";
|
|
import type { DecryptedAppointment } from "$lib/client/appointment-crypto";
|
|
import * as Alert from "$lib/components/ui/alert";
|
|
import { AppointmentDetails } from "$lib/components/ui/appointment-details";
|
|
import { Badge } from "$lib/components/ui/badge";
|
|
import { Button } from "$lib/components/ui/button";
|
|
import * as Item from "$lib/components/ui/item";
|
|
import { LocalizedText } from "$lib/components/ui/public";
|
|
import { openDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
|
|
import { Skeleton } from "$lib/components/ui/skeleton";
|
|
import { Headline } from "$lib/components/ui/typography";
|
|
import { ROUTES } from "$lib/const/routes";
|
|
import { publicStore } from "$lib/stores/public";
|
|
import { toDisplayDateTime } from "$lib/utils/datetime";
|
|
import { getCurrentTranlslation } from "$lib/utils/localizations";
|
|
import { CircleAlert } from "@lucide/svelte";
|
|
import { onMount } from "svelte";
|
|
import { toast } from "svelte-sonner";
|
|
import { appointmentStatusToBadge } from "./utils";
|
|
|
|
let channels = $derived($publicStore.channels);
|
|
let tenant = $derived($publicStore.tenant);
|
|
let step: "loading" | "list" | "error" = $state("loading");
|
|
let appointments: DecryptedAppointment[] = $state([]);
|
|
let cancelling: DecryptedAppointment | undefined = $state();
|
|
let isCancelling = $state(false);
|
|
|
|
onMount(() => {
|
|
getAppointments();
|
|
});
|
|
|
|
const getAppointments = async () => {
|
|
if ($publicStore.crypto?.isClientAuthenticated() && tenant) {
|
|
appointments = await $publicStore.crypto.getMyAppointments(tenant.id);
|
|
step = "list";
|
|
}
|
|
};
|
|
|
|
const cancelAppointment = async () => {
|
|
if (!cancelling || !tenant) return;
|
|
|
|
isCancelling = true;
|
|
await $publicStore.crypto
|
|
?.cancelAppointmentByClient({
|
|
appointment: cancelling.id,
|
|
tenant: tenant.id,
|
|
name: cancelling.name,
|
|
phone: cancelling.phone,
|
|
email: cancelling.email,
|
|
})
|
|
.then(() => {
|
|
getAppointments();
|
|
toast.success(m["clients.appointments.cancel.success"]());
|
|
cancelling = undefined;
|
|
})
|
|
.catch(() => {
|
|
toast.error(m["clients.appointments.cancel.error"]());
|
|
})
|
|
.finally(() => {
|
|
isCancelling = false;
|
|
});
|
|
};
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-3">
|
|
<Headline level="h2" style="h3">{m["clients.appointments.title"]()}</Headline>
|
|
{#if step === "loading"}
|
|
<div class="flex flex-col gap-1">
|
|
<Skeleton class="h-20 opacity-60" />
|
|
<Skeleton class="h-20 opacity-40" />
|
|
<Skeleton class="h-20 opacity-20" />
|
|
</div>
|
|
<Skeleton class="h-8 w-30 self-end" />
|
|
{:else if step === "list"}
|
|
{#if appointments.length > 0}
|
|
<div class="flex flex-col gap-1">
|
|
{#each appointments as item (item)}
|
|
{@const channel = channels?.find((it) => it.id === item.channelId)}
|
|
<Item.Root variant="outline">
|
|
<Item.Content>
|
|
<Item.Title>
|
|
{#if channel}
|
|
<LocalizedText translations={channel.names} />
|
|
{/if}
|
|
{@const badge = appointmentStatusToBadge(item.status)}
|
|
{#if badge}
|
|
<Badge>{badge.label.toLocaleUpperCase()}</Badge>
|
|
{/if}
|
|
</Item.Title>
|
|
<Item.Description>
|
|
{toDisplayDateTime(new Date(item.appointmentDate))}
|
|
</Item.Description>
|
|
</Item.Content>
|
|
<Item.Actions>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={() => {
|
|
cancelling = item;
|
|
openDialog("cancel-appointment");
|
|
}}
|
|
>
|
|
{m["clients.appointments.cancel.action"]()}
|
|
</Button>
|
|
</Item.Actions>
|
|
</Item.Root>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
{m["clients.appointments.empty"]()}
|
|
{/if}
|
|
<Button href={resolve(ROUTES.MAIN)} class="self-end">
|
|
{m["public.bookAppointment"]()}
|
|
</Button>
|
|
{:else if step === "error"}
|
|
<Alert.Root variant="destructive">
|
|
<CircleAlert />
|
|
<Alert.Title>{m["clients.appointments.error.title"]()}</Alert.Title>
|
|
<Alert.Description>
|
|
{m["clients.appointments.error.description"]()}
|
|
</Alert.Description>
|
|
</Alert.Root>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if cancelling}
|
|
{@const channel = channels?.find((it) => it.id === cancelling?.channelId)}
|
|
<ResponsiveDialog
|
|
id="cancel-appointment"
|
|
title={m["clients.appointments.cancel.title"]()}
|
|
description={m["clients.appointments.cancel.description"]()}
|
|
triggerHidden={true}
|
|
>
|
|
<AppointmentDetails
|
|
items={[
|
|
{
|
|
type: "channel",
|
|
value: channel && getCurrentTranlslation(channel.names),
|
|
},
|
|
{
|
|
type: "date",
|
|
value: new Date(cancelling.appointmentDate),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Button
|
|
variant="destructive"
|
|
onclick={cancelAppointment}
|
|
isLoading={isCancelling}
|
|
disabled={isCancelling}
|
|
class="mt-5 w-full"
|
|
>
|
|
{m["clients.appointments.cancel.action"]()}
|
|
</Button>
|
|
</ResponsiveDialog>
|
|
{/if}
|