mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-31 12:19:55 +02:00
Added dummy flow to add appointments using the calendar
This commit is contained in:
@@ -946,6 +946,34 @@
|
||||
"action": "Termin bestätigen",
|
||||
"success": "Termin erfolgreich bestätigt",
|
||||
"error": "Fehler beim Bestätigen des Termins"
|
||||
},
|
||||
"addAppointment": {
|
||||
"preview": "Neuer Termin {time} Uhr {channel}",
|
||||
"steps": {
|
||||
"selectClient": {
|
||||
"hasNoEmail": "Klient hat keine E-Mail Adresse",
|
||||
"action": "Klient suchen"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Akteur auswählen"
|
||||
},
|
||||
"clientData": {
|
||||
"shareEmail": "Klient wünscht Benachrichtigungen per E-Mail",
|
||||
"phoneHint": "Format: +1234567890 (optional)",
|
||||
"action": "Daten hinzufügen"
|
||||
},
|
||||
"summary": {
|
||||
"action": "Termin hinzufügen"
|
||||
},
|
||||
"error": {
|
||||
"title": "Fehler beim Hinzufügen des Termins",
|
||||
"description": "Das Hinzufügen des Termins ist fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"success": {
|
||||
"title": "Termin hinzugefügt",
|
||||
"description": "Der Termin wurde erfolgreich hinzugefügt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -955,6 +955,34 @@
|
||||
"action": "Confirm Appointment",
|
||||
"success": "Appointment confirmed successfully",
|
||||
"error": "Failed to confirm appointment"
|
||||
},
|
||||
"addAppointment": {
|
||||
"preview": "Add {time} {channel}",
|
||||
"steps": {
|
||||
"selectClient": {
|
||||
"hasNoEmail": "Client has no e-mail",
|
||||
"action": "Search Client"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Select an Agent"
|
||||
},
|
||||
"clientData": {
|
||||
"shareEmail": "Client wishes updates via E-Mail",
|
||||
"phoneHint": "Format: +1234567890 (optional)",
|
||||
"action": "Add Client Details"
|
||||
},
|
||||
"summary": {
|
||||
"action": "Add Appointment"
|
||||
},
|
||||
"error": {
|
||||
"title": "Error adding Appointment",
|
||||
"description": "Adding an appointment failed. Please try again."
|
||||
},
|
||||
"success": {
|
||||
"title": "Appointment added",
|
||||
"description": "This appointment was added successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
|
||||
@@ -25,16 +25,25 @@
|
||||
onclick?.();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
if (!disabledStates.includes(state)) {
|
||||
onclick?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={onClick}
|
||||
class={cn(
|
||||
"flex h-[40px] w-full justify-start",
|
||||
"flex h-10 w-full justify-start",
|
||||
state === "error" ? "dark:border-destructive border-destructive text-destructive" : "",
|
||||
className,
|
||||
)}
|
||||
onkeydown={onKeyDown}
|
||||
disabled={disabledStates.includes(state)}
|
||||
>
|
||||
{#if state === "initial"}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<a class="button" {href}>{@render children?.()}</a>
|
||||
</div>
|
||||
<span class="html-only md">
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
{m["emails.buttonAlternativeLink"]()}: <a {href}>{href}</a>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AppointmentData } from "$lib/client/appointment-crypto";
|
||||
import { openDialog } from "$lib/components/ui/responsive-dialog";
|
||||
import type { TCalendarItem } from "$lib/types/calendar";
|
||||
import type { TCalendarItem, TCalendarSlot } from "$lib/types/calendar";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export type CurAppointmentItem = {
|
||||
@@ -10,11 +10,13 @@ export type CurAppointmentItem = {
|
||||
|
||||
interface CalendarState {
|
||||
curItem: CurAppointmentItem | null;
|
||||
curEmptySlot: TCalendarSlot | null;
|
||||
}
|
||||
|
||||
const createCalendarStore = () => {
|
||||
const store = writable<CalendarState>({
|
||||
curItem: null,
|
||||
curEmptySlot: null,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -28,6 +30,15 @@ const createCalendarStore = () => {
|
||||
openDialog("current-calendar-item");
|
||||
}
|
||||
},
|
||||
setCurSlot: (curEmptySlot: TCalendarSlot | null) => {
|
||||
store.update((state) => {
|
||||
return { ...state, curEmptySlot };
|
||||
});
|
||||
|
||||
if (curEmptySlot) {
|
||||
openDialog("current-calendar-slot");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SelectAgent } from "$lib/server/db/tenant-schema";
|
||||
import type { DaySchedule } from "$lib/server/services/schedule-service";
|
||||
|
||||
export type AppointmentStatus = "available" | "booked" | "reserved";
|
||||
@@ -11,15 +12,7 @@ export type TCalendar = {
|
||||
calendar: DaySchedule[];
|
||||
};
|
||||
|
||||
export type TCalendarItem = {
|
||||
date: string; // YYYY-MM-DD
|
||||
id: string;
|
||||
start: string; // HH:mm
|
||||
duration: number; // in minutes
|
||||
status: AppointmentStatus;
|
||||
color: string | null;
|
||||
column: number;
|
||||
channelId: string;
|
||||
export type TCalendarItem = TCalendarSlot & {
|
||||
appointment?: {
|
||||
dateTime: Date;
|
||||
encryptedPayload: string | null;
|
||||
@@ -30,3 +23,15 @@ export type TCalendarItem = {
|
||||
authTag?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TCalendarSlot = {
|
||||
date: string; // YYYY-MM-DD
|
||||
id: string;
|
||||
start: string; // HH:mm
|
||||
duration: number; // in minutes
|
||||
status: AppointmentStatus;
|
||||
color: string | null;
|
||||
column: number;
|
||||
channelId: string;
|
||||
availableAgents?: SelectAgent[];
|
||||
};
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import type { TCalendarSlot } from "$lib/types/calendar";
|
||||
import { parseAbsoluteToLocal, toCalendarDateTime } from "@internationalized/date";
|
||||
|
||||
export const toDisplayDateTime = (date: Date) => {
|
||||
export const toDisplayDateTime = (date: Date, opts?: Intl.DateTimeFormatOptions) => {
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const formatter = new Intl.DateTimeFormat(navigator.language, {
|
||||
dateStyle: "short",
|
||||
...(hours !== 0 || minutes !== 0 ? { timeStyle: "short" } : {}),
|
||||
});
|
||||
const formatter = new Intl.DateTimeFormat(
|
||||
getLocale(),
|
||||
opts ?? {
|
||||
dateStyle: "short",
|
||||
...(hours !== 0 || minutes !== 0 ? { timeStyle: "short" } : {}),
|
||||
},
|
||||
);
|
||||
return formatter.format(date);
|
||||
};
|
||||
|
||||
export const calendarItemToDate = (item: TCalendarSlot) => {
|
||||
const [year, month, day] = item.date.split("-").map(Number);
|
||||
const [hours, minutes] = item.start.split(":").map((it) => parseInt(it));
|
||||
const date = new Date(year, month - 1, day);
|
||||
date.setHours(hours, minutes, 0, 0);
|
||||
return date;
|
||||
};
|
||||
|
||||
export const toInputDateTime = (dateStr: string) => {
|
||||
return toCalendarDateTime(parseAbsoluteToLocal(dateStr));
|
||||
};
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ export const createFormSchema = (requirePhone?: boolean) => {
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()),
|
||||
email: z.string().email(m["form.errors.email"]()),
|
||||
email: z.email(m["form.errors.email"]()),
|
||||
phone: z.e164(m["form.errors.phoneNoInvalid"]()).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { type CurAppointmentItem } from "$lib/stores/calendar";
|
||||
@@ -8,6 +7,7 @@
|
||||
import { cancelAppointment, denyAppointment, confirmAppointment } from "./utils";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { m } from "$i18n/messages";
|
||||
import { toDisplayDateTime } from "$lib/utils/datetime";
|
||||
|
||||
let {
|
||||
tenantId,
|
||||
@@ -117,7 +117,7 @@
|
||||
<div class="flex gap-2 p-1">
|
||||
<Calendar class="size-4 " />
|
||||
<Text style="sm">
|
||||
{Intl.DateTimeFormat(getLocale(), {
|
||||
{toDisplayDateTime(item.appointment.appointment.dateTime, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -125,7 +125,7 @@
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: getLocalTimeZone().toString(),
|
||||
}).format(item.appointment.appointment.dateTime)}
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
<div class="mt-5 flex w-full flex-col gap-4">
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { positionItems } from "./utils";
|
||||
import AppointmentPreview from "./AppointmentPreview.svelte";
|
||||
import { getContrastColor } from "$lib/utils/color";
|
||||
import SlotPreview from "./SlotPreview.svelte";
|
||||
|
||||
let {
|
||||
day = $bindable(),
|
||||
@@ -117,6 +118,8 @@
|
||||
>
|
||||
{#if ["booked", "reserved"].includes(item.status)}
|
||||
<AppointmentPreview {item} />
|
||||
{:else if item.status === "available"}
|
||||
<SlotPreview {item} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
<div
|
||||
class="flex flex-col items-start justify-between gap-2 min-[500px]:flex-row min-[500px]:items-center"
|
||||
>
|
||||
<div class="-ml-1 flex w-[350px] items-center justify-between gap-5">
|
||||
<Button size="sm" variant="ghost" class="h-6 !p-1" onclick={prev}>
|
||||
<div class="-ml-1 flex w-87.5 items-center justify-between gap-5">
|
||||
<Button size="sm" variant="ghost" class="h-6 p-1!" onclick={prev}>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<div>
|
||||
@@ -46,7 +46,7 @@
|
||||
timeZone: getLocalTimeZone().toString(),
|
||||
}).format(startDate.toDate(getLocalTimeZone()))}
|
||||
</div>
|
||||
<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 />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { calendarStore } from "$lib/stores/calendar";
|
||||
import { channels as channelsStore } from "$lib/stores/channels";
|
||||
import { type TCalendarItem } from "$lib/types/calendar";
|
||||
import { calendarItemToDate, toDisplayDateTime } from "$lib/utils/datetime";
|
||||
import { getCurrentTranlslation } from "$lib/utils/localizations";
|
||||
|
||||
let {
|
||||
item,
|
||||
}: {
|
||||
item: TCalendarItem;
|
||||
} = $props();
|
||||
|
||||
let channel = $derived(
|
||||
$channelsStore.channels.filter((x) => !x.archived).find((x) => x.id === item.channelId),
|
||||
);
|
||||
|
||||
const setCalendarItem = () => {
|
||||
calendarStore.setCurSlot(item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<Button
|
||||
class="m-0 h-full w-full cursor-pointer justify-start rounded-none px-1 leading-none text-(--channel-color-contrast) hover:bg-transparent! hover:text-(--channel-color-contrast) focus:ring-1"
|
||||
variant="ghost"
|
||||
onclick={setCalendarItem}
|
||||
>
|
||||
<span class="sr-only">
|
||||
{m["calendar.addAppointment.preview"]({
|
||||
time: toDisplayDateTime(calendarItemToDate(item), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
channel: channel ? getCurrentTranlslation(channel.names) : "unkown channel",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { CenterState } from "$lib/components/templates/empty-state";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import type { TCalendarSlot } from "$lib/types/calendar";
|
||||
import { calendarItemToDate } from "$lib/utils/datetime";
|
||||
import { BanIcon, Check } from "@lucide/svelte";
|
||||
import { SearchClientForm } from "./search-client-form";
|
||||
import SelectAgent from "./SelectAgent.svelte";
|
||||
import Summary from "./Summary.svelte";
|
||||
import type { TAddAppointment, TAddAppointmentStep } from "./types";
|
||||
import { ClientDataForm } from "./client-data-form";
|
||||
|
||||
let {
|
||||
tenantId,
|
||||
item = $bindable(),
|
||||
updateCalendar,
|
||||
}: {
|
||||
tenantId: string;
|
||||
item: TCalendarSlot;
|
||||
updateCalendar: () => void;
|
||||
} = $props();
|
||||
|
||||
let step: TAddAppointmentStep = $state("email");
|
||||
let newAppointment: TAddAppointment = $state({ dateTime: calendarItemToDate(item) });
|
||||
|
||||
const proceed = (data: TAddAppointment) => {
|
||||
switch (true) {
|
||||
case Boolean(data.name): {
|
||||
newAppointment = {
|
||||
...data,
|
||||
};
|
||||
step = "summary";
|
||||
break;
|
||||
}
|
||||
case Boolean(data.agentId): {
|
||||
newAppointment = {
|
||||
...data,
|
||||
};
|
||||
step = "client";
|
||||
break;
|
||||
}
|
||||
case Boolean(data.email) || data.hasNoEmail: {
|
||||
newAppointment = {
|
||||
...data,
|
||||
};
|
||||
step = "agent";
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addAppointment = async () => {
|
||||
console.log("adding appointment", newAppointment);
|
||||
updateCalendar();
|
||||
step = "success";
|
||||
};
|
||||
</script>
|
||||
|
||||
<Summary {step} {newAppointment} />
|
||||
{#if step === "email"}
|
||||
<SearchClientForm {tenantId} {newAppointment} {proceed} />
|
||||
{:else if step === "agent" && item.availableAgents}
|
||||
<SelectAgent availableAgents={item.availableAgents} {newAppointment} {proceed} />
|
||||
{:else if step === "summary"}
|
||||
<Button onclick={addAppointment} class="w-full">
|
||||
{m["calendar.addAppointment.steps.summary.action"]()}
|
||||
</Button>
|
||||
{:else if step === "client"}
|
||||
<ClientDataForm {newAppointment} {proceed} />
|
||||
{:else if step === "success"}
|
||||
<CenterState
|
||||
headline={m["calendar.addAppointment.steps.success.title"]()}
|
||||
description={m["calendar.addAppointment.steps.success.description"]()}
|
||||
Icon={Check}
|
||||
size="sm"
|
||||
/>
|
||||
{:else}
|
||||
<CenterState
|
||||
headline={m["calendar.addAppointment.steps.error.title"]()}
|
||||
description={m["calendar.addAppointment.steps.error.description"]()}
|
||||
Icon={BanIcon}
|
||||
size="sm"
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import type { SelectAgent } from "$lib/server/db/tenant-schema";
|
||||
import type { TAddAppointment } from "./types";
|
||||
import UnknownItemIcon from "@lucide/svelte/icons/user-star";
|
||||
|
||||
let {
|
||||
newAppointment,
|
||||
availableAgents,
|
||||
proceed,
|
||||
}: {
|
||||
newAppointment: TAddAppointment;
|
||||
availableAgents: SelectAgent[];
|
||||
proceed: (data: TAddAppointment) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Text style="sm" class="block font-medium">
|
||||
{m["calendar.addAppointment.steps.agents.title"]()}
|
||||
</Text>
|
||||
<ul class="flex w-full flex-col gap-2">
|
||||
{#each availableAgents as agent (agent.id)}
|
||||
<li>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex h-auto w-full cursor-pointer items-center gap-4 px-2 py-1.5 text-left"
|
||||
onclick={() => proceed({ ...newAppointment, agentId: agent.id })}
|
||||
>
|
||||
{#if agent.image}
|
||||
<img
|
||||
src={agent.image}
|
||||
alt={agent.name}
|
||||
class="size-8 rounded-full border object-cover object-center"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<UnknownItemIcon
|
||||
class="bg-muted text-muted-foreground size-15 rounded-full border stroke-1 p-1"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex grow flex-col gap-1">
|
||||
<Text style="md" class="font-medium">
|
||||
{agent.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import { toDisplayDateTime } from "$lib/utils/datetime";
|
||||
import { Calendar, Mail, Phone, User, UserStar } from "@lucide/svelte";
|
||||
import type { TAddAppointment, TAddAppointmentStep } from "./types";
|
||||
|
||||
let {
|
||||
step,
|
||||
newAppointment,
|
||||
}: {
|
||||
step: TAddAppointmentStep;
|
||||
newAppointment: TAddAppointment;
|
||||
} = $props();
|
||||
|
||||
let agent = $derived($agentsStore.agents.find((a) => a.id === newAppointment.agentId));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<div class="flex gap-2">
|
||||
<Calendar class="size-4 " />
|
||||
<Text style="sm">
|
||||
{toDisplayDateTime(newAppointment.dateTime, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
{#if newAppointment.name}
|
||||
<div class="flex gap-2">
|
||||
<User class="size-4 " />
|
||||
<Text style="sm">
|
||||
{newAppointment.name}
|
||||
</Text>
|
||||
</div>
|
||||
{/if}
|
||||
{#if newAppointment.email}
|
||||
<Button
|
||||
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
|
||||
variant="link"
|
||||
href={`mailto:${newAppointment.email}`}
|
||||
>
|
||||
<Mail class="size-4 " />
|
||||
{newAppointment.email}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if newAppointment.phone}
|
||||
<Button
|
||||
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
|
||||
variant="link"
|
||||
href={`tel:${newAppointment.phone}`}
|
||||
>
|
||||
<Phone class="size-4 " />
|
||||
{newAppointment.phone}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if agent}
|
||||
<div class="flex gap-2">
|
||||
<UserStar class="size-4 " />
|
||||
<Text style="sm">
|
||||
{agent.name}
|
||||
</Text>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="my-5">
|
||||
{#if step !== "summary"}
|
||||
<Separator />
|
||||
{/if}
|
||||
</div>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages.js";
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import type { TAddAppointment } from "../types";
|
||||
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
|
||||
|
||||
let {
|
||||
newAppointment,
|
||||
proceed,
|
||||
}: {
|
||||
newAppointment: TAddAppointment;
|
||||
proceed: (data: TAddAppointment) => void;
|
||||
} = $props();
|
||||
|
||||
const form = superForm(
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
{ name: "", phone: true ? "" : undefined, shareEmail: false },
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onSubmit: async ({ cancel }) => {
|
||||
if ($formData.phone === "") {
|
||||
$formData.phone = undefined;
|
||||
}
|
||||
|
||||
const validation = await validateForm();
|
||||
if (validation.valid) {
|
||||
cancel();
|
||||
proceed({
|
||||
...newAppointment,
|
||||
name: $formData.name,
|
||||
phone: $formData.phone,
|
||||
shareEmail: $formData.shareEmail,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { form: formData, enhance, validateForm } = form;
|
||||
</script>
|
||||
|
||||
<Form.Root {enhance} class="w-full">
|
||||
<Form.Field {form} name="name">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.name"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.name} type="name" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="phone">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.phone"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.phone} type="phone" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description class="mt-1">
|
||||
{m["calendar.addAppointment.steps.clientData.phoneHint"]()}
|
||||
</Form.Description>
|
||||
</Form.Field>
|
||||
{#if newAppointment.email}
|
||||
<Form.Field {form} name="shareEmail">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<CheckboxWithLabel
|
||||
{...props}
|
||||
bind:value={$formData.shareEmail}
|
||||
label={m["calendar.addAppointment.steps.clientData.shareEmail"]()}
|
||||
onCheckedChange={(v) => {
|
||||
$formData.shareEmail = v;
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<div class="mt-0 flex flex-col gap-4">
|
||||
<Form.Button size="lg" type="submit">
|
||||
{m["calendar.addAppointment.steps.clientData.action"]()}
|
||||
</Form.Button>
|
||||
</div>
|
||||
</Form.Root>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import ClientDataForm from "./client-data-form.svelte";
|
||||
|
||||
export { ClientDataForm };
|
||||
export { formSchema } from "./schema";
|
||||
export type { FormSchema } from "./schema";
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { m } from "$i18n/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
shareEmail: z.boolean(),
|
||||
name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()),
|
||||
phone: z.e164(m["form.errors.phoneNoInvalid"]()).optional(),
|
||||
});
|
||||
|
||||
export type FormSchema = typeof formSchema;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import SearchClientForm from "./search-client-form.svelte";
|
||||
|
||||
export { SearchClientForm };
|
||||
export { formSchema } from "./schema";
|
||||
export type { FormSchema } from "./schema";
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { m } from "$i18n/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
email: z.email(m["form.errors.email"]()),
|
||||
});
|
||||
|
||||
export type FormSchema = typeof formSchema;
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages.js";
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import type { TAddAppointment } from "../types";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
|
||||
let {
|
||||
tenantId,
|
||||
newAppointment,
|
||||
proceed,
|
||||
}: {
|
||||
tenantId: string;
|
||||
newAppointment: TAddAppointment;
|
||||
proceed: (data: TAddAppointment) => void;
|
||||
} = $props();
|
||||
|
||||
const form = superForm(
|
||||
{ email: "" },
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onSubmit: async ({ cancel }) => {
|
||||
const validation = await validateForm();
|
||||
if (validation.valid) {
|
||||
cancel();
|
||||
isSubmitting = true;
|
||||
console.log("tenantId", tenantId);
|
||||
console.log("email", $formData.email);
|
||||
const hashedEmail = await hashEmail($formData.email);
|
||||
console.log("hashed", hashedEmail);
|
||||
proceed({ ...newAppointment, email: $formData.email, hasNoEmail: false });
|
||||
// const success = await confirmAppointment({
|
||||
// tenant: tenantId,
|
||||
// appointment: item.appointment.id,
|
||||
// email: item.decrypted.shareEmail ? item.decrypted.email : undefined,
|
||||
// locale: "de", // TODO: Use client language as soon as available in appointment
|
||||
// });
|
||||
// if (success) {
|
||||
// toast.success(m["calendar.confirmAppointment.success"]());
|
||||
// updateCalendar();
|
||||
// close();
|
||||
// } else {
|
||||
// toast.error(m["calendar.confirmAppointment.error"]());
|
||||
// }
|
||||
|
||||
isSubmitting = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const { form: formData, enhance, validateForm } = form;
|
||||
|
||||
// TODO: Use the version from appointment-crypto
|
||||
const hashEmail = async (email: string): Promise<string> => {
|
||||
const emailNormalized = email.toLowerCase().trim();
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(emailNormalized);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
};
|
||||
|
||||
const proceedWithoutEmail = () => {
|
||||
proceed({ ...newAppointment, email: undefined, hasNoEmail: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<Form.Root {enhance} class="w-full">
|
||||
<Form.Field {form} name="email">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.email"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.email} type="email" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description>
|
||||
<Button size="sm" variant="link" onclick={proceedWithoutEmail}>
|
||||
{m["calendar.addAppointment.steps.selectClient.hasNoEmail"]()}
|
||||
</Button>
|
||||
</Form.Description>
|
||||
</Form.Field>
|
||||
<div class="mt-0 flex flex-col gap-4">
|
||||
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
|
||||
{m["calendar.addAppointment.steps.selectClient.action"]()}
|
||||
</Form.Button>
|
||||
</div>
|
||||
</Form.Root>
|
||||
@@ -0,0 +1,11 @@
|
||||
export type TAddAppointment = {
|
||||
dateTime: Date;
|
||||
agentId?: string;
|
||||
email?: string;
|
||||
hasNoEmail?: boolean;
|
||||
shareEmail?: boolean;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
export type TAddAppointmentStep = "email" | "agent" | "client" | "summary" | "success" | "error";
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { replaceState } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { m } from "$i18n/messages";
|
||||
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
|
||||
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
|
||||
@@ -23,12 +24,12 @@
|
||||
} from "@internationalized/date";
|
||||
import { Funnel } from "@lucide/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import AddAppointment from "./(components)/add-appointment/AddAppointment.svelte";
|
||||
import AppointmentDetail from "./(components)/AppointmentDetail.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 { page } from "$app/state";
|
||||
|
||||
const convertDate = (dateStr: string) => {
|
||||
const zonedDateTime = parseAbsoluteToLocal(dateStr);
|
||||
@@ -36,6 +37,7 @@
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -84,6 +86,7 @@
|
||||
|
||||
onMount(() => {
|
||||
if (history.state["sveltekit:states"]?.date) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { date, ...rest } = history.state["sveltekit:states"];
|
||||
replaceState("", rest);
|
||||
}
|
||||
@@ -146,6 +149,7 @@
|
||||
color: channelData.channel.color,
|
||||
column: 0,
|
||||
status: "available",
|
||||
availableAgents: slot.availableAgents,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -237,3 +241,15 @@
|
||||
<AppointmentDetail {tenantId} item={curItem} {updateCalendar} close={closeAppointmentDetail} />
|
||||
</ResponsiveDialog>
|
||||
{/if}
|
||||
|
||||
{#if curEmptySlot && tenantId}
|
||||
{@const channel = channels.find((c) => c.id === curEmptySlot.channelId)}
|
||||
<ResponsiveDialog
|
||||
id="current-calendar-slot"
|
||||
title="Add Appointment"
|
||||
description={channel ? getCurrentTranlslation(channel.names) : undefined}
|
||||
triggerHidden={true}
|
||||
>
|
||||
<AddAppointment {tenantId} item={curEmptySlot} {updateCalendar} />
|
||||
</ResponsiveDialog>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user