chore: apply project-wide formatting and UI library updates
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+111
-111
@@ -1,138 +1,138 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.129 0.042 264.695);
|
--foreground: oklch(0.129 0.042 264.695);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.129 0.042 264.695);
|
--card-foreground: oklch(0.129 0.042 264.695);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||||
--primary: oklch(0.28 0.04 260);
|
--primary: oklch(0.28 0.04 260);
|
||||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--secondary: oklch(0.97 0.01 248);
|
--secondary: oklch(0.97 0.01 248);
|
||||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||||
--muted: oklch(0.968 0.007 247.896);
|
--muted: oklch(0.968 0.007 247.896);
|
||||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||||
--accent: oklch(0.968 0.007 247.896);
|
--accent: oklch(0.968 0.007 247.896);
|
||||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
--destructive: oklch(0.64 0.21 25);
|
--destructive: oklch(0.64 0.21 25);
|
||||||
--border: oklch(0.929 0.013 255.508);
|
--border: oklch(0.929 0.013 255.508);
|
||||||
--input: oklch(0.929 0.013 255.508);
|
--input: oklch(0.929 0.013 255.508);
|
||||||
--ring: oklch(0.704 0.04 256.788);
|
--ring: oklch(0.704 0.04 256.788);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.984 0.003 247.858);
|
--sidebar: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.129 0.042 264.695);
|
--background: oklch(0.129 0.042 264.695);
|
||||||
--foreground: oklch(0.984 0.003 247.858);
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
--card: oklch(0.208 0.042 265.755);
|
--card: oklch(0.208 0.042 265.755);
|
||||||
--card-foreground: oklch(0.984 0.003 247.858);
|
--card-foreground: oklch(0.984 0.003 247.858);
|
||||||
--popover: oklch(0.208 0.042 265.755);
|
--popover: oklch(0.208 0.042 265.755);
|
||||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||||
--primary: oklch(0.929 0.013 255.508);
|
--primary: oklch(0.929 0.013 255.508);
|
||||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||||
--secondary: oklch(0.279 0.041 260.031);
|
--secondary: oklch(0.279 0.041 260.031);
|
||||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--muted: oklch(0.279 0.041 260.031);
|
--muted: oklch(0.279 0.041 260.031);
|
||||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||||
--accent: oklch(0.279 0.041 260.031);
|
--accent: oklch(0.279 0.041 260.031);
|
||||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.551 0.027 264.364);
|
--ring: oklch(0.551 0.027 264.364);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.208 0.042 265.755);
|
--sidebar: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-container {
|
.calendar-container {
|
||||||
z-index: 49 !important;
|
z-index: 49 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-calendar] {
|
[data-calendar] {
|
||||||
z-index: 49 !important;
|
z-index: 49 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -10,5 +10,5 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { };
|
export {};
|
||||||
declare module 'swiper/svelte';
|
declare module 'swiper/svelte';
|
||||||
@@ -9,7 +9,11 @@
|
|||||||
import * as Label from '$lib/components/ui/label';
|
import * as Label from '$lib/components/ui/label';
|
||||||
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
import DatePicker from '$lib/components/booking/DatePicker.svelte';
|
||||||
import type { Booking, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
import type { Booking, Service, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||||
import { extractBookedSlots, getLunchProtectionForSlots, timeToMinutes } from '$lib/lunchProtection';
|
import {
|
||||||
|
extractBookedSlots,
|
||||||
|
getLunchProtectionForSlots,
|
||||||
|
timeToMinutes
|
||||||
|
} from '$lib/lunchProtection';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||||
@@ -94,32 +98,31 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let canSubmit = $derived(
|
let canSubmit = $derived(
|
||||||
submitting === false && (
|
submitting === false &&
|
||||||
(editMode === 'time' && !!newDate && newTime.length >= 4) ||
|
((editMode === 'time' && !!newDate && newTime.length >= 4) ||
|
||||||
(editMode === 'services' && selectedServices.length > 0) ||
|
(editMode === 'services' && selectedServices.length > 0) ||
|
||||||
(editMode === 'both-time' && !!newDate && newTime.length >= 4)
|
(editMode === 'both-time' && !!newDate && newTime.length >= 4))
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let notesChanged = $derived(notes.trim() !== originalNotes.trim());
|
let notesChanged = $derived(notes.trim() !== originalNotes.trim());
|
||||||
|
|
||||||
let modalTitle = $derived(() => {
|
let modalTitle = $derived(() => {
|
||||||
switch (editMode) {
|
switch (editMode) {
|
||||||
case 'select': return 'Edit Request';
|
case 'select':
|
||||||
case 'time': return 'Change Time';
|
return 'Edit Request';
|
||||||
case 'services': return 'Change Services';
|
case 'time':
|
||||||
case 'both-services': return 'Change Services';
|
return 'Change Time';
|
||||||
case 'both-time': return 'Choose Time';
|
case 'services':
|
||||||
|
return 'Change Services';
|
||||||
|
case 'both-services':
|
||||||
|
return 'Change Services';
|
||||||
|
case 'both-time':
|
||||||
|
return 'Choose Time';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const lunchProtection = $derived(() => {
|
const lunchProtection = $derived(() => {
|
||||||
if (
|
if (!newDate || !workingHours || !availableHours || slotDuration === 0) {
|
||||||
!newDate ||
|
|
||||||
!workingHours ||
|
|
||||||
!availableHours ||
|
|
||||||
slotDuration === 0
|
|
||||||
) {
|
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +134,7 @@
|
|||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingBookings = extractBookedSlots(
|
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
|
||||||
dayWH.startTime,
|
|
||||||
dayWH.endTime,
|
|
||||||
dayAH.slots
|
|
||||||
);
|
|
||||||
|
|
||||||
return getLunchProtectionForSlots(
|
return getLunchProtectionForSlots(
|
||||||
dayWH.startTime,
|
dayWH.startTime,
|
||||||
@@ -316,7 +315,11 @@
|
|||||||
|
|
||||||
const dayAH = availableHours?.[dateStr];
|
const dayAH = availableHours?.[dateStr];
|
||||||
if (dayAH?.slots) {
|
if (dayAH?.slots) {
|
||||||
const existingBookings = extractBookedSlots(dayHours.startTime, dayHours.endTime, dayAH.slots);
|
const existingBookings = extractBookedSlots(
|
||||||
|
dayHours.startTime,
|
||||||
|
dayHours.endTime,
|
||||||
|
dayAH.slots
|
||||||
|
);
|
||||||
const lunchProtection = getLunchProtectionForSlots(
|
const lunchProtection = getLunchProtectionForSlots(
|
||||||
dayHours.startTime,
|
dayHours.startTime,
|
||||||
dayHours.endTime,
|
dayHours.endTime,
|
||||||
@@ -390,7 +393,14 @@
|
|||||||
|
|
||||||
// ─── Auto-select ────────────────────────────────────────
|
// ─── Auto-select ────────────────────────────────────────
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!workingHours || !availableHours || newDate || userNavigatedCalendar || editRequestAutoSelectDone) return;
|
if (
|
||||||
|
!workingHours ||
|
||||||
|
!availableHours ||
|
||||||
|
newDate ||
|
||||||
|
userNavigatedCalendar ||
|
||||||
|
editRequestAutoSelectDone
|
||||||
|
)
|
||||||
|
return;
|
||||||
editRequestAutoSelectDone = true;
|
editRequestAutoSelectDone = true;
|
||||||
|
|
||||||
const currentDate = new SvelteDate();
|
const currentDate = new SvelteDate();
|
||||||
@@ -419,11 +429,7 @@
|
|||||||
if (!isDateUnavailable(calDate)) {
|
if (!isDateUnavailable(calDate)) {
|
||||||
newDate = calDate;
|
newDate = calDate;
|
||||||
if (!userNavigatedCalendar) {
|
if (!userNavigatedCalendar) {
|
||||||
placeholderDate = new CalendarDate(
|
placeholderDate = new CalendarDate(nextDate.getFullYear(), nextDate.getMonth() + 1, 1);
|
||||||
nextDate.getFullYear(),
|
|
||||||
nextDate.getMonth() + 1,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -432,11 +438,7 @@
|
|||||||
|
|
||||||
const tomorrow = new SvelteDate();
|
const tomorrow = new SvelteDate();
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
newDate = new CalendarDate(
|
newDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, tomorrow.getDate());
|
||||||
tomorrow.getFullYear(),
|
|
||||||
tomorrow.getMonth() + 1,
|
|
||||||
tomorrow.getDate()
|
|
||||||
);
|
|
||||||
if (!userNavigatedCalendar) {
|
if (!userNavigatedCalendar) {
|
||||||
placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
|
||||||
}
|
}
|
||||||
@@ -716,7 +718,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl">
|
<Modal.Content
|
||||||
|
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||||
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Modal.Title class="text-lg font-semibold">{modalTitle()}</Modal.Title>
|
<Modal.Title class="text-lg font-semibold">{modalTitle()}</Modal.Title>
|
||||||
@@ -734,7 +738,9 @@
|
|||||||
onclick={() => selectMode('time')}
|
onclick={() => selectMode('time')}
|
||||||
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50"
|
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"
|
||||||
|
>
|
||||||
<ClockIcon class="size-5" />
|
<ClockIcon class="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -749,9 +755,13 @@
|
|||||||
onclick={() => selectMode('services')}
|
onclick={() => selectMode('services')}
|
||||||
disabled={hasOverrides}
|
disabled={hasOverrides}
|
||||||
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50"
|
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
title={hasOverrides ? 'This booking has custom pricing. To change services, please contact the salon.' : ''}
|
title={hasOverrides
|
||||||
|
? 'This booking has custom pricing. To change services, please contact the salon.'
|
||||||
|
: ''}
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"
|
||||||
|
>
|
||||||
<RefreshCwIcon class="size-5" />
|
<RefreshCwIcon class="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -766,11 +776,15 @@
|
|||||||
onclick={() => selectMode('both-services')}
|
onclick={() => selectMode('both-services')}
|
||||||
disabled={hasOverrides}
|
disabled={hasOverrides}
|
||||||
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50"
|
class="flex w-full items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:border-fuchsia-200 hover:bg-fuchsia-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
title={hasOverrides ? 'This booking has custom pricing. To change services, please contact the salon.' : ''}
|
title={hasOverrides
|
||||||
|
? 'This booking has custom pricing. To change services, please contact the salon.'
|
||||||
|
: ''}
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600"
|
||||||
|
>
|
||||||
<ClockIcon class="size-5" />
|
<ClockIcon class="size-5" />
|
||||||
<RefreshCwIcon class="size-5 -ml-2" />
|
<RefreshCwIcon class="-ml-2 size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">Change Both</div>
|
<div class="font-medium">Change Both</div>
|
||||||
@@ -781,11 +795,11 @@
|
|||||||
|
|
||||||
{#if hasOverrides}
|
{#if hasOverrides}
|
||||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
This booking has custom pricing. To change services, please contact the salon. You can still request a time change.
|
This booking has custom pricing. To change services, please contact the salon. You can
|
||||||
|
still request a time change.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if editMode === 'time' || editMode === 'both-time'}
|
{:else if editMode === 'time' || editMode === 'both-time'}
|
||||||
<!-- ─── Time Selection ────────────────────────── -->
|
<!-- ─── Time Selection ────────────────────────── -->
|
||||||
{#if loadingHours && !workingHours}
|
{#if loadingHours && !workingHours}
|
||||||
@@ -799,7 +813,7 @@
|
|||||||
placeholder={placeholderDate}
|
placeholder={placeholderDate}
|
||||||
minValue={minDate}
|
minValue={minDate}
|
||||||
maxValue={maxCalendarDate}
|
maxValue={maxCalendarDate}
|
||||||
isDateUnavailable={isDateUnavailable}
|
{isDateUnavailable}
|
||||||
onchange={(d) => {
|
onchange={(d) => {
|
||||||
newDate = d;
|
newDate = d;
|
||||||
newTime = '';
|
newTime = '';
|
||||||
@@ -819,15 +833,15 @@
|
|||||||
<p class="text-sm text-gray-500">Loading times...</p>
|
<p class="text-sm text-gray-500">Loading times...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="no-scrollbar mt-2 flex min-h-[100px] max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4">
|
<div
|
||||||
|
class="no-scrollbar mt-2 flex max-h-40 min-h-[100px] w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
|
||||||
|
>
|
||||||
<div class="grid justify-center gap-2 text-sm text-gray-600">
|
<div class="grid justify-center gap-2 text-sm text-gray-600">
|
||||||
{newDate
|
{newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', {
|
||||||
.toDate(getLocalTimeZone())
|
weekday: 'long',
|
||||||
.toLocaleDateString('en-GB', {
|
day: 'numeric',
|
||||||
weekday: 'long',
|
month: 'short'
|
||||||
day: 'numeric',
|
})}
|
||||||
month: 'short'
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
{#if workingHours && !workingHours[newDate.toString()]?.isOpen}
|
{#if workingHours && !workingHours[newDate.toString()]?.isOpen}
|
||||||
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
|
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
|
||||||
@@ -844,7 +858,9 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => selectTime(slot.startTime)}
|
onclick={() => selectTime(slot.startTime)}
|
||||||
class="w-full hover:bg-fuchsia-50 {newTime === slot.startTime ? 'bg-fuchsia-100 border-fuchsia-200' : ''}"
|
class="w-full hover:bg-fuchsia-50 {newTime === slot.startTime
|
||||||
|
? 'border-fuchsia-200 bg-fuchsia-100'
|
||||||
|
: ''}"
|
||||||
>
|
>
|
||||||
{formatTime(slot.startTime)}
|
{formatTime(slot.startTime)}
|
||||||
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||||
@@ -878,7 +894,6 @@
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if editMode === 'services'}
|
{:else if editMode === 'services'}
|
||||||
<!-- ─── Services Only (with time restriction) ── -->
|
<!-- ─── Services Only (with time restriction) ── -->
|
||||||
{#if hasOverrides}
|
{#if hasOverrides}
|
||||||
@@ -897,9 +912,7 @@
|
|||||||
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Current Services
|
Current Services
|
||||||
{#if selectedServices.length > 0}
|
{#if selectedServices.length > 0}
|
||||||
<span class="ml-1 text-xs font-normal text-gray-400">
|
<span class="ml-1 text-xs font-normal text-gray-400"> (tap to remove) </span>
|
||||||
(tap to remove)
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</h4>
|
</h4>
|
||||||
{#if selectedServices.length === 0}
|
{#if selectedServices.length === 0}
|
||||||
@@ -912,16 +925,18 @@
|
|||||||
onclick={() => {
|
onclick={() => {
|
||||||
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||||
}}
|
}}
|
||||||
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50 bg-fuchsia-100"
|
class="cursor-pointer rounded-lg border border-input bg-background bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-between h-full min-h-[6rem]">
|
<div class="flex h-full min-h-[6rem] flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">{service.name}</h3>
|
<h3 class="font-semibold">{service.name}</h3>
|
||||||
<p class="text-sm text-gray-600">{service.description || ''}</p>
|
<p class="text-sm text-gray-600">{service.description || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
|
<div class="mt-2 flex items-center justify-between text-sm text-gray-500">
|
||||||
<span>{service.duration_minutes} mins</span>
|
<span>{service.duration_minutes} mins</span>
|
||||||
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
|
<span class="font-semibold text-foreground"
|
||||||
|
>£{service.price.toFixed(2)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -942,24 +957,26 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
{#each fittingServices as service (service.id)}
|
{#each fittingServices as service (service.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
selectedServices = [...selectedServices, service];
|
selectedServices = [...selectedServices, service];
|
||||||
}}
|
}}
|
||||||
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
|
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-between h-full min-h-[6rem]">
|
<div class="flex h-full min-h-[6rem] flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">{service.name}</h3>
|
<h3 class="font-semibold">{service.name}</h3>
|
||||||
<p class="text-sm text-gray-600">{service.description || ''}</p>
|
<p class="text-sm text-gray-600">{service.description || ''}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>{service.duration_minutes} mins</span>
|
||||||
|
<span class="font-semibold text-foreground"
|
||||||
|
>£{service.price.toFixed(2)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
|
</button>
|
||||||
<span>{service.duration_minutes} mins</span>
|
|
||||||
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -969,15 +986,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
<div
|
||||||
|
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
|
||||||
|
>
|
||||||
No remaining time available. Remove a service to free up time for additions.
|
No remaining time available. Remove a service to free up time for additions.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if selectedServices.length === 0}
|
{#if selectedServices.length === 0}
|
||||||
<p class="text-xs text-amber-600">
|
<p class="text-xs text-amber-600">Select at least one service to continue.</p>
|
||||||
Select at least one service to continue.
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -991,7 +1008,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if originalNotes}
|
{#if originalNotes}
|
||||||
<div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500">
|
<div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500">
|
||||||
<span class="font-medium">Original:</span> {originalNotes}
|
<span class="font-medium">Original:</span>
|
||||||
|
{originalNotes}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Textarea.Root
|
<Textarea.Root
|
||||||
@@ -1001,7 +1019,6 @@
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if editMode === 'both-services'}
|
{:else if editMode === 'both-services'}
|
||||||
<!-- ─── Both Step 1: Service Selection (no time restriction) ── -->
|
<!-- ─── Both Step 1: Service Selection (no time restriction) ── -->
|
||||||
{#if hasOverrides}
|
{#if hasOverrides}
|
||||||
@@ -1022,9 +1039,7 @@
|
|||||||
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h4 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Selected Services
|
Selected Services
|
||||||
{#if selectedServices.length > 0}
|
{#if selectedServices.length > 0}
|
||||||
<span class="ml-1 text-xs font-normal text-gray-400">
|
<span class="ml-1 text-xs font-normal text-gray-400"> (tap to remove) </span>
|
||||||
(tap to remove)
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</h4>
|
</h4>
|
||||||
{#if selectedServices.length === 0}
|
{#if selectedServices.length === 0}
|
||||||
@@ -1037,16 +1052,18 @@
|
|||||||
onclick={() => {
|
onclick={() => {
|
||||||
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||||
}}
|
}}
|
||||||
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50 bg-fuchsia-100"
|
class="cursor-pointer rounded-lg border border-input bg-background bg-fuchsia-100 p-4 text-left transition-colors hover:bg-fuchsia-50"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-between h-full min-h-[6rem]">
|
<div class="flex h-full min-h-[6rem] flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">{service.name}</h3>
|
<h3 class="font-semibold">{service.name}</h3>
|
||||||
<p class="text-sm text-gray-600">{service.description || ''}</p>
|
<p class="text-sm text-gray-600">{service.description || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
|
<div class="mt-2 flex items-center justify-between text-sm text-gray-500">
|
||||||
<span>{service.duration_minutes} mins</span>
|
<span>{service.duration_minutes} mins</span>
|
||||||
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
|
<span class="font-semibold text-foreground"
|
||||||
|
>£{service.price.toFixed(2)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -1069,14 +1086,16 @@
|
|||||||
}}
|
}}
|
||||||
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
|
class="cursor-pointer rounded-lg border border-input bg-background p-4 text-left transition-colors hover:bg-fuchsia-50"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-between h-full min-h-[6rem]">
|
<div class="flex h-full min-h-[6rem] flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">{service.name}</h3>
|
<h3 class="font-semibold">{service.name}</h3>
|
||||||
<p class="text-sm text-gray-600">{service.description || ''}</p>
|
<p class="text-sm text-gray-600">{service.description || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
|
<div class="mt-2 flex items-center justify-between text-sm text-gray-500">
|
||||||
<span>{service.duration_minutes} mins</span>
|
<span>{service.duration_minutes} mins</span>
|
||||||
<span class="font-semibold text-foreground">£{service.price.toFixed(2)}</span>
|
<span class="font-semibold text-foreground"
|
||||||
|
>£{service.price.toFixed(2)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -1086,9 +1105,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if selectedServices.length === 0}
|
{#if selectedServices.length === 0}
|
||||||
<p class="text-xs text-amber-600">
|
<p class="text-xs text-amber-600">Select at least one service to continue.</p>
|
||||||
Select at least one service to continue.
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1101,7 +1118,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if originalNotes}
|
{#if originalNotes}
|
||||||
<div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500">
|
<div class="rounded-md bg-gray-50 p-2 text-xs text-gray-500">
|
||||||
<span class="font-medium">Original:</span> {originalNotes}
|
<span class="font-medium">Original:</span>
|
||||||
|
{originalNotes}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Textarea.Root
|
<Textarea.Root
|
||||||
@@ -1122,13 +1140,8 @@
|
|||||||
<Button variant="ghost" size="sm" class="flex-1" onclick={() => (open = false)}>
|
<Button variant="ghost" size="sm" class="flex-1" onclick={() => (open = false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{:else if editMode === 'time' || editMode === 'services'}
|
{:else if editMode === 'time' || editMode === 'services'}
|
||||||
<Button
|
<Button variant="outline" size="sm" onclick={goBack}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onclick={goBack}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon class="size-4" />
|
<ArrowLeftIcon class="size-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1142,13 +1155,8 @@
|
|||||||
>
|
>
|
||||||
{submitting ? 'Submitting...' : 'Submit Request'}
|
{submitting ? 'Submitting...' : 'Submit Request'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{:else if editMode === 'both-services'}
|
{:else if editMode === 'both-services'}
|
||||||
<Button
|
<Button variant="outline" size="sm" onclick={goBack}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onclick={goBack}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon class="size-4" />
|
<ArrowLeftIcon class="size-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1165,13 +1173,8 @@
|
|||||||
Next: Choose Time
|
Next: Choose Time
|
||||||
<ArrowRightIcon class="size-4" />
|
<ArrowRightIcon class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{:else if editMode === 'both-time'}
|
{:else if editMode === 'both-time'}
|
||||||
<Button
|
<Button variant="outline" size="sm" onclick={goBack}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onclick={goBack}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon class="size-4" />
|
<ArrowLeftIcon class="size-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -45,9 +45,7 @@
|
|||||||
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
|
selectedBooking && isFutureBooking && ['pending', 'confirmed'].includes(selectedBooking.status)
|
||||||
);
|
);
|
||||||
|
|
||||||
let canEditBooking = $derived(
|
let canEditBooking = $derived(isCancellable && !hasPayments);
|
||||||
isCancellable && !hasPayments
|
|
||||||
);
|
|
||||||
|
|
||||||
let totalPaid = $derived(
|
let totalPaid = $derived(
|
||||||
selectedBooking?.payments
|
selectedBooking?.payments
|
||||||
@@ -249,7 +247,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl">
|
<Modal.Content
|
||||||
|
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||||
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -500,7 +500,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -516,14 +515,16 @@
|
|||||||
Cancel Booking
|
Cancel Booking
|
||||||
</Button>
|
</Button>
|
||||||
{#if canEditBooking}
|
{#if canEditBooking}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
onclick={() => { showEditModal = true; }}
|
onclick={() => {
|
||||||
>
|
showEditModal = true;
|
||||||
Edit Request
|
}}
|
||||||
</Button>
|
>
|
||||||
|
Edit Request
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,16 +66,20 @@
|
|||||||
function getBookingDateTime(): string {
|
function getBookingDateTime(): string {
|
||||||
if (!booking?.start_time) return '';
|
if (!booking?.start_time) return '';
|
||||||
const d = new SvelteDate(booking.start_time);
|
const d = new SvelteDate(booking.start_time);
|
||||||
return d.toLocaleDateString('en-GB', {
|
return (
|
||||||
weekday: 'long',
|
d.toLocaleDateString('en-GB', {
|
||||||
day: 'numeric',
|
weekday: 'long',
|
||||||
month: 'long',
|
day: 'numeric',
|
||||||
year: 'numeric'
|
month: 'long',
|
||||||
}) + ' at ' + d.toLocaleTimeString('en-GB', {
|
year: 'numeric'
|
||||||
hour: 'numeric',
|
}) +
|
||||||
minute: '2-digit',
|
' at ' +
|
||||||
hour12: true
|
d.toLocaleTimeString('en-GB', {
|
||||||
});
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTotalCost(): number {
|
function getTotalCost(): number {
|
||||||
@@ -447,36 +451,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Customer Contact Info -->
|
<!-- Customer Contact Info -->
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Customer Contact
|
Customer Contact
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div>
|
|
||||||
<div class="text-xs text-gray-500">Name</div>
|
|
||||||
<div class="font-medium">{booking.user?.full_name || '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Phone</div>
|
<div class="text-xs text-gray-500">Name</div>
|
||||||
<div class="font-medium">{booking.user?.phone || '—'}</div>
|
<div class="font-medium">{booking.user?.full_name || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<div class="text-xs text-gray-500">Email</div>
|
<div>
|
||||||
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
|
<div class="text-xs text-gray-500">Phone</div>
|
||||||
|
<div class="font-medium">{booking.user?.phone || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">Email</div>
|
||||||
|
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Booking Date & Time -->
|
<!-- Booking Date & Time -->
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Booking Date & Time
|
Booking Date & Time
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-lg font-medium">{getBookingDateTime()}</div>
|
<div class="text-lg font-medium">{getBookingDateTime()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Booking Notes -->
|
<!-- Booking Notes -->
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -74,7 +74,11 @@
|
|||||||
|
|
||||||
// Step 4: Date & Time
|
// Step 4: Date & Time
|
||||||
let placeholder = $state<CalendarDate>(
|
let placeholder = $state<CalendarDate>(
|
||||||
new CalendarDate(new SvelteDate().getFullYear(), new SvelteDate().getMonth() + 1, new SvelteDate().getDate())
|
new CalendarDate(
|
||||||
|
new SvelteDate().getFullYear(),
|
||||||
|
new SvelteDate().getMonth() + 1,
|
||||||
|
new SvelteDate().getDate()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||||
let selectedTime = $state<string | null>(null);
|
let selectedTime = $state<string | null>(null);
|
||||||
@@ -129,7 +133,7 @@
|
|||||||
|
|
||||||
// =============== Lunch Protection ===============
|
// =============== Lunch Protection ===============
|
||||||
const lunchProtection = $derived(
|
const lunchProtection = $derived(
|
||||||
(selectedDate && selectedServices.length > 0)
|
selectedDate && selectedServices.length > 0
|
||||||
? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true)
|
? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true)
|
||||||
: new Map()
|
: new Map()
|
||||||
);
|
);
|
||||||
@@ -197,7 +201,16 @@
|
|||||||
// Auto-select first available date once data loads (timing-safe, data-driven)
|
// Auto-select first available date once data loads (timing-safe, data-driven)
|
||||||
let bookingCreateAutoSelectDone = $state(false);
|
let bookingCreateAutoSelectDone = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && currentStep === 4 && workingHours && availableHours && !selectedDate && selectedServices.length > 0 && !userNavigatedCalendar && !bookingCreateAutoSelectDone) {
|
if (
|
||||||
|
open &&
|
||||||
|
currentStep === 4 &&
|
||||||
|
workingHours &&
|
||||||
|
availableHours &&
|
||||||
|
!selectedDate &&
|
||||||
|
selectedServices.length > 0 &&
|
||||||
|
!userNavigatedCalendar &&
|
||||||
|
!bookingCreateAutoSelectDone
|
||||||
|
) {
|
||||||
bookingCreateAutoSelectDone = true;
|
bookingCreateAutoSelectDone = true;
|
||||||
const now = new SvelteDate();
|
const now = new SvelteDate();
|
||||||
const maxDateJs = new SvelteDate(
|
const maxDateJs = new SvelteDate(
|
||||||
@@ -213,7 +226,11 @@
|
|||||||
const checkDate = new SvelteDate(now);
|
const checkDate = new SvelteDate(now);
|
||||||
checkDate.setDate(now.getDate() + i);
|
checkDate.setDate(now.getDate() + i);
|
||||||
const dateStr = checkDate.toISOString().split('T')[0];
|
const dateStr = checkDate.toISOString().split('T')[0];
|
||||||
const calDate = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, checkDate.getDate());
|
const calDate = new CalendarDate(
|
||||||
|
checkDate.getFullYear(),
|
||||||
|
checkDate.getMonth() + 1,
|
||||||
|
checkDate.getDate()
|
||||||
|
);
|
||||||
if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
|
if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
|
||||||
selectedDate = calDate;
|
selectedDate = calDate;
|
||||||
if (!userNavigatedCalendar) {
|
if (!userNavigatedCalendar) {
|
||||||
@@ -342,7 +359,9 @@
|
|||||||
const whMap: Record<string, any> = {};
|
const whMap: Record<string, any> = {};
|
||||||
const ahMap: Record<string, any> = {};
|
const ahMap: Record<string, any> = {};
|
||||||
|
|
||||||
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }));
|
whData.forEach(
|
||||||
|
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
|
||||||
|
);
|
||||||
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
||||||
|
|
||||||
if (gen !== hoursRangeGeneration) return; // Stale response, discard
|
if (gen !== hoursRangeGeneration) return; // Stale response, discard
|
||||||
@@ -435,7 +454,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
loadingWorkingHours = false;
|
loadingWorkingHours = false;
|
||||||
loadingAvailableHours = false;
|
loadingAvailableHours = false;
|
||||||
loadingMonthKeys = new Set([...loadingMonthKeys].filter(k => k !== monthKey));
|
loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,7 +594,13 @@
|
|||||||
|
|
||||||
const groupedTimeSlots = $derived(
|
const groupedTimeSlots = $derived(
|
||||||
currentStep === 4 && selectedServices.length > 0 && selectedDate
|
currentStep === 4 && selectedServices.length > 0 && selectedDate
|
||||||
? generateGroupedTimeSlots(selectedDate, workingHours, availableHours, getTotalDuration(), lunchProtection)
|
? generateGroupedTimeSlots(
|
||||||
|
selectedDate,
|
||||||
|
workingHours,
|
||||||
|
availableHours,
|
||||||
|
getTotalDuration(),
|
||||||
|
lunchProtection
|
||||||
|
)
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -599,8 +624,20 @@
|
|||||||
if (duration <= 0) return true;
|
if (duration <= 0) return true;
|
||||||
|
|
||||||
// Build lunch protection specifically for the date being checked
|
// Build lunch protection specifically for the date being checked
|
||||||
const dayProtection = buildLunchProtection(date as CalendarDate, workingHours, availableHours, duration, true);
|
const dayProtection = buildLunchProtection(
|
||||||
const slots = generateAvailableTimeSlots(date as CalendarDate, workingHours, availableHours, duration, dayProtection);
|
date as CalendarDate,
|
||||||
|
workingHours,
|
||||||
|
availableHours,
|
||||||
|
duration,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const slots = generateAvailableTimeSlots(
|
||||||
|
date as CalendarDate,
|
||||||
|
workingHours,
|
||||||
|
availableHours,
|
||||||
|
duration,
|
||||||
|
dayProtection
|
||||||
|
);
|
||||||
if (slots.length === 0) return true;
|
if (slots.length === 0) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -620,32 +657,32 @@
|
|||||||
try {
|
try {
|
||||||
let finalUserId = selectedUserId;
|
let finalUserId = selectedUserId;
|
||||||
|
|
||||||
if (userType === 'guest') {
|
if (userType === 'guest') {
|
||||||
const phone = guestPhone.trim() || '+447700900000';
|
const phone = guestPhone.trim() || '+447700900000';
|
||||||
const createRes = await fetch('/api/users/guest', {
|
const createRes = await fetch('/api/users/guest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${authStore.currentToken}`
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
firstName: guestName.trim().split(' ')[0] || 'Guest',
|
firstName: guestName.trim().split(' ')[0] || 'Guest',
|
||||||
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Customer',
|
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Customer',
|
||||||
phone: phone,
|
phone: phone,
|
||||||
email: `callin-${Date.now()}@guest.invalid`
|
email: `callin-${Date.now()}@guest.invalid`
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!createRes.ok) {
|
if (!createRes.ok) {
|
||||||
toast.error('Failed to create guest user');
|
toast.error('Failed to create guest user');
|
||||||
submitting = false;
|
submitting = false;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestUser = await createRes.json();
|
||||||
|
finalUserId = guestUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guestUser = await createRes.json();
|
|
||||||
finalUserId = guestUser.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finalUserId) throw new Error('User ID required');
|
if (!finalUserId) throw new Error('User ID required');
|
||||||
if (!selectedDate || !selectedTime) throw new Error('Date and time required');
|
if (!selectedDate || !selectedTime) throw new Error('Date and time required');
|
||||||
|
|
||||||
@@ -734,7 +771,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl">
|
<Modal.Content
|
||||||
|
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl"
|
||||||
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title>Create Admin Booking</Modal.Title>
|
<Modal.Title>Create Admin Booking</Modal.Title>
|
||||||
<Modal.Description>
|
<Modal.Description>
|
||||||
@@ -1096,17 +1135,30 @@
|
|||||||
<Card.Content class="space-y-4 p-0">
|
<Card.Content class="space-y-4 p-0">
|
||||||
<!-- Reservation Countdown Banner -->
|
<!-- Reservation Countdown Banner -->
|
||||||
{#if reservationId && reservationExpiresAt}
|
{#if reservationId && reservationExpiresAt}
|
||||||
<div class="mx-6 mt-4 rounded-lg bg-green-50 border border-green-200 p-3">
|
<div class="mx-6 mt-4 rounded-lg border border-green-200 bg-green-50 p-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<svg class="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
class="h-5 w-5 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm font-medium text-green-800">
|
<span class="text-sm font-medium text-green-800">
|
||||||
Slot reserved until {reservationExpiresAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
Slot reserved until {reservationExpiresAt.toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-mono font-semibold text-green-700">
|
<span class="font-mono text-sm font-semibold text-green-700">
|
||||||
({reservationCountdown} remaining)
|
({reservationCountdown} remaining)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1123,11 +1175,11 @@
|
|||||||
selectedDate = newDate;
|
selectedDate = newDate;
|
||||||
selectedTime = null;
|
selectedTime = null;
|
||||||
}}
|
}}
|
||||||
onPlaceholderChange={(newPlaceholder) => {
|
onPlaceholderChange={(newPlaceholder) => {
|
||||||
placeholder = newPlaceholder;
|
placeholder = newPlaceholder;
|
||||||
userNavigatedCalendar = true;
|
userNavigatedCalendar = true;
|
||||||
fetchHoursForMonth(newPlaceholder);
|
fetchHoursForMonth(newPlaceholder);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1139,14 +1191,18 @@
|
|||||||
<div class="border-t">
|
<div class="border-t">
|
||||||
<div class="max-h-64 overflow-y-auto p-6">
|
<div class="max-h-64 overflow-y-auto p-6">
|
||||||
{#if formattedSelectedDate}
|
{#if formattedSelectedDate}
|
||||||
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">{formattedSelectedDate}</div>
|
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">
|
||||||
|
{formattedSelectedDate}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<TimeSlotList
|
<TimeSlotList
|
||||||
slots={groupedTimeSlots}
|
slots={groupedTimeSlots}
|
||||||
{selectedTime}
|
{selectedTime}
|
||||||
duration={getTotalDuration()}
|
duration={getTotalDuration()}
|
||||||
protection={lunchProtection}
|
protection={lunchProtection}
|
||||||
onSelect={(time) => { selectedTime = time; }}
|
onSelect={(time) => {
|
||||||
|
selectedTime = time;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1177,7 +1233,11 @@
|
|||||||
onclick={submitBooking}
|
onclick={submitBooking}
|
||||||
class="bg-primary text-primary-foreground"
|
class="bg-primary text-primary-foreground"
|
||||||
>
|
>
|
||||||
{isReserving ? 'Reserving Slot...' : submitting ? 'Creating Booking...' : 'Create Booking'}
|
{isReserving
|
||||||
|
? 'Reserving Slot...'
|
||||||
|
: submitting
|
||||||
|
? 'Creating Booking...'
|
||||||
|
: 'Create Booking'}
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -25,8 +25,10 @@
|
|||||||
// customised at booking time (discounts, extended sessions). Showing base values
|
// customised at booking time (discounts, extended sessions). Showing base values
|
||||||
// misleads admins about what was actually booked.
|
// misleads admins about what was actually booked.
|
||||||
let totalDuration = $derived(
|
let totalDuration = $derived(
|
||||||
selectedBooking?.services?.reduce((sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0), 0) ||
|
selectedBooking?.services?.reduce(
|
||||||
|
(sum, service) => sum + (service.override_duration_minutes ?? service.duration_minutes ?? 0),
|
||||||
0
|
0
|
||||||
|
) || 0
|
||||||
);
|
);
|
||||||
|
|
||||||
async function fetchBookingDetails() {
|
async function fetchBookingDetails() {
|
||||||
@@ -135,7 +137,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl">
|
<Modal.Content
|
||||||
|
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||||
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -145,7 +149,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if selectedBooking}
|
{#if selectedBooking}
|
||||||
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)}
|
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(
|
||||||
|
selectedBooking.status
|
||||||
|
)}
|
||||||
|
|
||||||
{#if selectedBooking.status === 'pending'}
|
{#if selectedBooking.status === 'pending'}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -175,14 +181,14 @@
|
|||||||
Pending
|
Pending
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Deposit Badge for pending -->
|
<!-- Deposit Badge for pending -->
|
||||||
{#if selectedBooking.deposit_required}
|
{#if selectedBooking.deposit_required}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
|
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
|
||||||
>
|
>
|
||||||
Will Require Deposit
|
Will Require Deposit
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if isConfirmedOrLater && selectedBooking.deposit_required}
|
{:else if isConfirmedOrLater && selectedBooking.deposit_required}
|
||||||
<!-- Deposit Badge for confirmed+ -->
|
<!-- Deposit Badge for confirmed+ -->
|
||||||
@@ -197,14 +203,12 @@
|
|||||||
>
|
>
|
||||||
{selectedBooking.status.replace('_', ' ')}
|
{selectedBooking.status.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
||||||
{selectedBooking.deposit_paid
|
{selectedBooking.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
|
||||||
? 'bg-green-100 text-green-800'
|
>
|
||||||
: 'bg-orange-100 text-orange-800'}"
|
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
|
||||||
>
|
</span>
|
||||||
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -270,8 +274,14 @@
|
|||||||
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
|
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="mt-2 flex items-center justify-between text-sm">
|
<div class="mt-2 flex items-center justify-between text-sm">
|
||||||
<span class="text-gray-600">{formatDuration(service.override_duration_minutes ?? service.duration_minutes ?? 0)}</span>
|
<span class="text-gray-600"
|
||||||
<span class="font-semibold">£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span>
|
>{formatDuration(
|
||||||
|
service.override_duration_minutes ?? service.duration_minutes ?? 0
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<span class="font-semibold"
|
||||||
|
>£{(service.override_price ?? service.price ?? 0).toFixed(2)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -296,7 +306,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-lg font-semibold">{selectedBooking.user?.full_name || '—'}</div>
|
<div class="text-lg font-semibold">{selectedBooking.user?.full_name || '—'}</div>
|
||||||
{#if selectedBooking.user?.date_of_birth}
|
{#if selectedBooking.user?.date_of_birth}
|
||||||
<div class="text-sm text-gray-500">{calculateAge(selectedBooking.user.date_of_birth)} years old</div>
|
<div class="text-sm text-gray-500">
|
||||||
|
{calculateAge(selectedBooking.user.date_of_birth)} years old
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,7 +320,8 @@
|
|||||||
<a
|
<a
|
||||||
href="mailto:{selectedBooking.user.email}"
|
href="mailto:{selectedBooking.user.email}"
|
||||||
class="font-medium break-words text-blue-600 hover:underline"
|
class="font-medium break-words text-blue-600 hover:underline"
|
||||||
>{selectedBooking.user.email}</a>
|
>{selectedBooking.user.email}</a
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="font-medium">—</div>
|
<div class="font-medium">—</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -318,8 +331,8 @@
|
|||||||
{#if selectedBooking.user?.phone}
|
{#if selectedBooking.user?.phone}
|
||||||
<a
|
<a
|
||||||
href="tel:{selectedBooking.user.phone}"
|
href="tel:{selectedBooking.user.phone}"
|
||||||
class="font-medium text-blue-600 hover:underline"
|
class="font-medium text-blue-600 hover:underline">{selectedBooking.user.phone}</a
|
||||||
>{selectedBooking.user.phone}</a>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="font-medium">—</div>
|
<div class="font-medium">—</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -328,19 +341,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Referral Code</div>
|
<div class="text-xs text-gray-500">Referral Code</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<code class="rounded bg-gray-200 px-1.5 py-0.5 text-sm font-mono font-medium">
|
<code class="rounded bg-gray-200 px-1.5 py-0.5 font-mono text-sm font-medium">
|
||||||
{selectedBooking.user.referral_code}
|
{selectedBooking.user.referral_code}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Copy referral code"
|
title="Copy referral code"
|
||||||
class="inline-flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition-colors"
|
class="inline-flex items-center justify-center rounded p-1 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
navigator.clipboard.writeText(selectedBooking.user!.referral_code!);
|
navigator.clipboard.writeText(selectedBooking.user!.referral_code!);
|
||||||
toast.success('Referral code copied');
|
toast.success('Referral code copied');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -368,30 +388,30 @@
|
|||||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Financial Summary
|
Financial Summary
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
|
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
|
||||||
<!-- Deposit Info -->
|
<!-- Deposit Info -->
|
||||||
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
|
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
|
||||||
<span class="text-sm text-gray-600">Deposit Required</span>
|
<span class="text-sm text-gray-600">Deposit Required</span>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
|
<div class="font-semibold">
|
||||||
<div class="text-xs">
|
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
|
||||||
<span
|
</div>
|
||||||
class="{selectedBooking.deposit_paid
|
<div class="text-xs">
|
||||||
? 'text-green-600'
|
<span
|
||||||
: 'text-orange-600'}"
|
class={selectedBooking.deposit_paid ? 'text-green-600' : 'text-orange-600'}
|
||||||
>
|
>
|
||||||
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
|
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
|
||||||
|
</span>
|
||||||
|
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
|
||||||
|
<span class="text-gray-500">
|
||||||
|
• Due: {formatDateTime(selectedBooking.deposit_deadline)}
|
||||||
</span>
|
</span>
|
||||||
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
|
{/if}
|
||||||
<span class="text-gray-500">
|
|
||||||
• Due: {formatDateTime(selectedBooking.deposit_deadline)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-600">Total Amount</span>
|
<span class="text-sm text-gray-600">Total Amount</span>
|
||||||
@@ -494,10 +514,7 @@
|
|||||||
|
|
||||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||||
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit'].includes(selectedBooking.status)}
|
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit'].includes(selectedBooking.status)}
|
||||||
<Button
|
<Button variant="outline" onclick={() => (showRescheduleModal = true)}>
|
||||||
variant="outline"
|
|
||||||
onclick={() => (showRescheduleModal = true)}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="mr-2 h-4 w-4"
|
class="mr-2 h-4 w-4"
|
||||||
@@ -506,7 +523,9 @@
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
|
<path
|
||||||
|
d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Reschedule
|
Reschedule
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -246,35 +246,33 @@
|
|||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
{formatBookingDateTime(b.start_time)}
|
{formatBookingDateTime(b.start_time)}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span class={getStatusClasses(b.status)}>
|
<span class={getStatusClasses(b.status)}>
|
||||||
<span class={getStatusDotClasses(b.status)}></span>
|
<span class={getStatusDotClasses(b.status)}></span>
|
||||||
{b.status}
|
{b.status}
|
||||||
</span>
|
</span>
|
||||||
{#if b.deposit_required}
|
{#if b.deposit_required}
|
||||||
{#if b.status === 'pending'}
|
{#if b.status === 'pending'}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
||||||
>
|
>
|
||||||
Will Require Deposit
|
Will Require Deposit
|
||||||
</span>
|
</span>
|
||||||
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
|
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||||
{b.deposit_paid
|
{b.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
|
||||||
? 'bg-green-100 text-green-800'
|
>
|
||||||
: 'bg-orange-100 text-orange-800'}"
|
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
|
||||||
>
|
</span>
|
||||||
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
<span>• {b.user?.full_name || 'Unknown User'}</span>
|
{/if}
|
||||||
<span>
|
<span>• {b.user?.full_name || 'Unknown User'}</span>
|
||||||
- {formatServices(b.services)}
|
<span>
|
||||||
</span>
|
- {formatServices(b.services)}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { formatDuration } from '$lib/utils/format';
|
import { formatDuration } from '$lib/utils/format';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -172,7 +172,10 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
overrideOriginalPrice = service.price ?? 0;
|
overrideOriginalPrice = service.price ?? 0;
|
||||||
overrideOriginalDuration = service.duration_minutes ?? 0;
|
overrideOriginalDuration = service.duration_minutes ?? 0;
|
||||||
overridePrice = service.override_price !== undefined ? service.override_price.toFixed(2) : '';
|
overridePrice = service.override_price !== undefined ? service.override_price.toFixed(2) : '';
|
||||||
overrideDuration = service.override_duration_minutes !== undefined ? service.override_duration_minutes.toString() : '';
|
overrideDuration =
|
||||||
|
service.override_duration_minutes !== undefined
|
||||||
|
? service.override_duration_minutes.toString()
|
||||||
|
: '';
|
||||||
showOverrideModal = true;
|
showOverrideModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,8 +302,7 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
service_ids: services.map((s) => s.service_id),
|
service_ids: services.map((s) => s.service_id),
|
||||||
service_overrides: services
|
service_overrides: services
|
||||||
.filter(
|
.filter(
|
||||||
(s) =>
|
(s) => s.override_price !== undefined || s.override_duration_minutes !== undefined
|
||||||
s.override_price !== undefined || s.override_duration_minutes !== undefined
|
|
||||||
)
|
)
|
||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
service_id: s.service_id,
|
service_id: s.service_id,
|
||||||
@@ -391,7 +393,9 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl">
|
<Modal.Content
|
||||||
|
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||||
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -409,9 +413,7 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
<div class="space-y-6 px-4 pb-4">
|
<div class="space-y-6 px-4 pb-4">
|
||||||
<!-- Customer Info -->
|
<!-- Customer Info -->
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">Customer</h3>
|
||||||
Customer
|
|
||||||
</h3>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Name</div>
|
<div class="text-xs text-gray-500">Name</div>
|
||||||
@@ -419,7 +421,9 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Email</div>
|
<div class="text-xs text-gray-500">Email</div>
|
||||||
<div class="font-medium break-words" title={booking.user?.email}>{booking.user?.email || '—'}</div>
|
<div class="font-medium break-words" title={booking.user?.email}>
|
||||||
|
{booking.user?.email || '—'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Phone</div>
|
<div class="text-xs text-gray-500">Phone</div>
|
||||||
@@ -465,17 +469,23 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if services.length === 0}
|
{#if services.length === 0}
|
||||||
<div class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500">
|
<div
|
||||||
|
class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500"
|
||||||
|
>
|
||||||
No services added yet. Click "Add Service" to begin.
|
No services added yet. Click "Add Service" to begin.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each services as service, index (index)}
|
{#each services as service, index (index)}
|
||||||
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3">
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3"
|
||||||
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">{service.service_name}</div>
|
<div class="font-medium">{service.service_name}</div>
|
||||||
<div class="mt-1 text-sm text-gray-600">
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
{formatDuration(service.override_duration_minutes ?? service.duration_minutes ?? 0)} • £{(service.override_price ?? service.price ?? 0).toFixed(2)}
|
{formatDuration(
|
||||||
|
service.override_duration_minutes ?? service.duration_minutes ?? 0
|
||||||
|
)} • £{(service.override_price ?? service.price ?? 0).toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
{#if service.override_price !== undefined || service.override_duration_minutes !== undefined}
|
{#if service.override_price !== undefined || service.override_duration_minutes !== undefined}
|
||||||
<div class="text-xs text-amber-600">Modified</div>
|
<div class="text-xs text-amber-600">Modified</div>
|
||||||
@@ -523,7 +533,9 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 text-sm">
|
<div
|
||||||
|
class="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 text-sm"
|
||||||
|
>
|
||||||
<span class="text-gray-600">Total: {formatDuration(totalDuration)}</span>
|
<span class="text-gray-600">Total: {formatDuration(totalDuration)}</span>
|
||||||
<span class="font-semibold">£{totalPrice.toFixed(2)}</span>
|
<span class="font-semibold">£{totalPrice.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,10 +550,16 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each booking.payments as payment, index (index)}
|
{#each booking.payments as payment, index (index)}
|
||||||
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3">
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3"
|
||||||
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
{payment.payment_type === 'deposit' ? 'Deposit' : payment.payment_type === 'full' ? 'Full Payment' : payment.payment_type}
|
{payment.payment_type === 'deposit'
|
||||||
|
? 'Deposit'
|
||||||
|
: payment.payment_type === 'full'
|
||||||
|
? 'Full Payment'
|
||||||
|
: payment.payment_type}
|
||||||
{#if payment.payment_method}
|
{#if payment.payment_method}
|
||||||
<span class="text-gray-500"> via {payment.payment_method}</span>
|
<span class="text-gray-500"> via {payment.payment_method}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -550,7 +568,8 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
<span
|
<span
|
||||||
class:text-green-600={payment.status === 'completed'}
|
class:text-green-600={payment.status === 'completed'}
|
||||||
class:text-amber-600={payment.status === 'pending'}
|
class:text-amber-600={payment.status === 'pending'}
|
||||||
class:text-red-600={payment.status === 'failed' || payment.status === 'refunded'}
|
class:text-red-600={payment.status === 'failed' ||
|
||||||
|
payment.status === 'refunded'}
|
||||||
>
|
>
|
||||||
{payment.status}
|
{payment.status}
|
||||||
</span>
|
</span>
|
||||||
@@ -562,7 +581,11 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-gray-400">
|
<div class="mt-1 text-xs text-gray-400">
|
||||||
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if payment.status === 'completed'}
|
{#if payment.status === 'completed'}
|
||||||
@@ -592,9 +615,7 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
{#if notes.trim() !== (booking.notes || '')}
|
{#if notes.trim() !== (booking.notes || '')}
|
||||||
<div class="mt-1 text-xs text-emerald-600">
|
<div class="mt-1 text-xs text-emerald-600">Notes will be saved</div>
|
||||||
Notes will be saved
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -724,9 +745,7 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
|
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title class="text-lg font-semibold">Add Service</Modal.Title>
|
<Modal.Title class="text-lg font-semibold">Add Service</Modal.Title>
|
||||||
<Modal.Description>
|
<Modal.Description>Select a service to add to this booking.</Modal.Description>
|
||||||
Select a service to add to this booking.
|
|
||||||
</Modal.Description>
|
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|
||||||
<div class="space-y-4 px-4 pb-4">
|
<div class="space-y-4 px-4 pb-4">
|
||||||
@@ -742,7 +761,9 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
{#if loadingServices}
|
{#if loadingServices}
|
||||||
<div class="text-center text-sm text-gray-500">Loading services...</div>
|
<div class="text-center text-sm text-gray-500">Loading services...</div>
|
||||||
{:else if filteredServices.length === 0}
|
{:else if filteredServices.length === 0}
|
||||||
<div class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500">
|
<div
|
||||||
|
class="rounded-md border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500"
|
||||||
|
>
|
||||||
{#if availableServices.length === 0}
|
{#if availableServices.length === 0}
|
||||||
No services available.
|
No services available.
|
||||||
{:else}
|
{:else}
|
||||||
@@ -778,16 +799,12 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
|
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title>
|
<Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title>
|
||||||
<Modal.Description>
|
<Modal.Description>Enter the refund amount and reason.</Modal.Description>
|
||||||
Enter the refund amount and reason.
|
|
||||||
</Modal.Description>
|
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|
||||||
<div class="space-y-4 px-4 pb-4">
|
<div class="space-y-4 px-4 pb-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="refund-amount" class="mb-1 block text-xs text-gray-600">
|
<label for="refund-amount" class="mb-1 block text-xs text-gray-600"> Refund Amount </label>
|
||||||
Refund Amount
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<span class="text-gray-500">£</span>
|
<span class="text-gray-500">£</span>
|
||||||
@@ -819,7 +836,10 @@ import { formatDuration } from '$lib/utils/format';
|
|||||||
|
|
||||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" onclick={() => (showRefundModal = false)}>Cancel</Button>
|
<Button variant="outline" onclick={() => (showRefundModal = false)}>Cancel</Button>
|
||||||
<Button onclick={processRefund} disabled={refundLoading || !refundAmount || !refundReason.trim()}>
|
<Button
|
||||||
|
onclick={processRefund}
|
||||||
|
disabled={refundLoading || !refundAmount || !refundReason.trim()}
|
||||||
|
>
|
||||||
{refundLoading ? 'Processing...' : 'Confirm Refund'}
|
{refundLoading ? 'Processing...' : 'Confirm Refund'}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|||||||
@@ -221,7 +221,10 @@
|
|||||||
{formatDateLine1(editRequest.original.start_time)}
|
{formatDateLine1(editRequest.original.start_time)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
{formatDateLine2(editRequest.original.start_time, getDuration(editRequest.original.services))}
|
{formatDateLine2(
|
||||||
|
editRequest.original.start_time,
|
||||||
|
getDuration(editRequest.original.services)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if isTimeChanged()}
|
{#if isTimeChanged()}
|
||||||
@@ -231,11 +234,14 @@
|
|||||||
{formatDateLine1(editRequest.proposed.start_time!)}
|
{formatDateLine1(editRequest.proposed.start_time!)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
{formatDateLine2(editRequest.proposed.start_time!, getDuration(editRequest.proposed.services))}
|
{formatDateLine2(
|
||||||
|
editRequest.proposed.start_time!,
|
||||||
|
getDuration(editRequest.proposed.services)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-sm italic text-gray-500">No change</div>
|
<div class="text-sm text-gray-500 italic">No change</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +258,7 @@
|
|||||||
{#each editRequest.original.services as service (service.id)}
|
{#each editRequest.original.services as service (service.id)}
|
||||||
{#if serviceDiff.removed.some((s) => s.id === service.id)}
|
{#if serviceDiff.removed.some((s) => s.id === service.id)}
|
||||||
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||||
<span class="mt-0.5 text-red-600 font-mono text-sm">−</span>
|
<span class="mt-0.5 font-mono text-sm text-red-600">−</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-sm font-medium text-red-700 line-through">
|
<div class="text-sm font-medium text-red-700 line-through">
|
||||||
{service.name}
|
{service.name}
|
||||||
@@ -264,7 +270,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||||
<span class="mt-0.5 text-gray-400 font-mono text-sm"> </span>
|
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-sm font-medium">{service.name}</div>
|
<div class="text-sm font-medium">{service.name}</div>
|
||||||
<div class="text-xs text-gray-600">
|
<div class="text-xs text-gray-600">
|
||||||
@@ -281,8 +287,10 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each editRequest.proposed.services as service (service.id)}
|
{#each editRequest.proposed.services as service (service.id)}
|
||||||
{#if serviceDiff.added.some((s) => s.id === service.id)}
|
{#if serviceDiff.added.some((s) => s.id === service.id)}
|
||||||
<div class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2">
|
<div
|
||||||
<span class="mt-0.5 text-emerald-600 font-mono text-sm">+</span>
|
class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
|
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
|
||||||
<div class="text-xs text-emerald-600">
|
<div class="text-xs text-emerald-600">
|
||||||
@@ -292,7 +300,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
|
||||||
<span class="mt-0.5 text-gray-400 font-mono text-sm"> </span>
|
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-sm font-medium">{service.name}</div>
|
<div class="text-sm font-medium">{service.name}</div>
|
||||||
<div class="text-xs text-gray-600">
|
<div class="text-xs text-gray-600">
|
||||||
@@ -322,7 +330,7 @@
|
|||||||
{#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes}
|
{#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes}
|
||||||
<div class="text-sm">{editRequest.proposed.notes}</div>
|
<div class="text-sm">{editRequest.proposed.notes}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-sm italic text-gray-500">No change</div>
|
<div class="text-sm text-gray-500 italic">No change</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,11 +348,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button variant="destructive" onclick={() => (showDenyConfirm = true)} disabled={submitting}>
|
||||||
variant="destructive"
|
|
||||||
onclick={() => (showDenyConfirm = true)}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
Deny
|
Deny
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -754,24 +754,26 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if input.length && suggestions.length}
|
{#if input.length && suggestions.length}
|
||||||
<div class="absolute left-0 right-0 top-full z-10 mt-1 rounded-md border bg-white shadow-md">
|
|
||||||
{#each suggestions as s, i (s)}
|
|
||||||
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
|
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer px-3 py-3 text-sm touch-manipulation {isHighlighted
|
class="absolute top-full right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md"
|
||||||
? 'bg-primary/10 text-primary font-medium'
|
|
||||||
: 'hover:bg-gray-100'}"
|
|
||||||
onclick={() => selectSuggestion(s)}
|
|
||||||
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectSuggestion(s)}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
{s}
|
{#each suggestions as s, i (s)}
|
||||||
|
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
|
||||||
|
<div
|
||||||
|
class="cursor-pointer touch-manipulation px-3 py-3 text-sm {isHighlighted
|
||||||
|
? 'bg-primary/10 font-medium text-primary'
|
||||||
|
: 'hover:bg-gray-100'}"
|
||||||
|
onclick={() => selectSuggestion(s)}
|
||||||
|
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectSuggestion(s)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -82,12 +82,12 @@
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error(text || 'Failed to add patch test', { id: loadingToast });
|
toast.error(text || 'Failed to add patch test', { id: loadingToast });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error adding patch test:', err);
|
console.error('Error adding patch test:', err);
|
||||||
toast.error('Network error', { id: loadingToast });
|
toast.error('Network error', { id: loadingToast });
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -33,7 +33,11 @@
|
|||||||
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let placeholder = $state<CalendarDate>(
|
let placeholder = $state<CalendarDate>(
|
||||||
new CalendarDate(new SvelteDate().getFullYear(), new SvelteDate().getMonth() + 1, new SvelteDate().getDate())
|
new CalendarDate(
|
||||||
|
new SvelteDate().getFullYear(),
|
||||||
|
new SvelteDate().getMonth() + 1,
|
||||||
|
new SvelteDate().getDate()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||||
let selectedTime = $state<string | null>(null);
|
let selectedTime = $state<string | null>(null);
|
||||||
@@ -55,21 +59,42 @@
|
|||||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||||
const maxDate = new SvelteDate();
|
const maxDate = new SvelteDate();
|
||||||
maxDate.setMonth(today.getMonth() + 6);
|
maxDate.setMonth(today.getMonth() + 6);
|
||||||
const maxCalendarDate = new CalendarDate(maxDate.getFullYear(), maxDate.getMonth() + 1, maxDate.getDate());
|
const maxCalendarDate = new CalendarDate(
|
||||||
|
maxDate.getFullYear(),
|
||||||
|
maxDate.getMonth() + 1,
|
||||||
|
maxDate.getDate()
|
||||||
|
);
|
||||||
|
|
||||||
let bookingDuration = $derived(
|
let bookingDuration = $derived(
|
||||||
booking.services?.reduce((sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0), 0) ?? booking.duration_minutes ?? 0
|
booking.services?.reduce(
|
||||||
|
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
|
||||||
|
0
|
||||||
|
) ??
|
||||||
|
booking.duration_minutes ??
|
||||||
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
const lunchProtection = $derived(
|
const lunchProtection = $derived(
|
||||||
selectedDate ? buildLunchProtection(selectedDate, workingHours, availableHours, bookingDuration, true) : new Map()
|
selectedDate
|
||||||
|
? buildLunchProtection(selectedDate, workingHours, availableHours, bookingDuration, true)
|
||||||
|
: new Map()
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedTimeSlots = $derived(
|
const groupedTimeSlots = $derived(
|
||||||
selectedDate ? generateGroupedTimeSlots(selectedDate, workingHours, availableHours, bookingDuration, lunchProtection) : []
|
selectedDate
|
||||||
|
? generateGroupedTimeSlots(
|
||||||
|
selectedDate,
|
||||||
|
workingHours,
|
||||||
|
availableHours,
|
||||||
|
bookingDuration,
|
||||||
|
lunchProtection
|
||||||
|
)
|
||||||
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedSelectedDate = $derived(selectedDate ? getDayWithOrdinal(selectedDate) : undefined);
|
const formattedSelectedDate = $derived(
|
||||||
|
selectedDate ? getDayWithOrdinal(selectedDate) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
function isDateUnavailable(date: DateValue): boolean {
|
function isDateUnavailable(date: DateValue): boolean {
|
||||||
if (!(date instanceof CalendarDate)) return true;
|
if (!(date instanceof CalendarDate)) return true;
|
||||||
@@ -84,8 +109,20 @@
|
|||||||
// If booking duration is 0 (data missing), no valid slots can exist — mark unavailable
|
// If booking duration is 0 (data missing), no valid slots can exist — mark unavailable
|
||||||
if (bookingDuration <= 0) return true;
|
if (bookingDuration <= 0) return true;
|
||||||
// Build lunch protection specifically for the date being checked (not the selected date)
|
// Build lunch protection specifically for the date being checked (not the selected date)
|
||||||
const dayProtection = buildLunchProtection(date as CalendarDate, workingHours, availableHours, bookingDuration, true);
|
const dayProtection = buildLunchProtection(
|
||||||
const slots = generateAvailableTimeSlots(date as CalendarDate, workingHours, availableHours, bookingDuration, dayProtection);
|
date as CalendarDate,
|
||||||
|
workingHours,
|
||||||
|
availableHours,
|
||||||
|
bookingDuration,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const slots = generateAvailableTimeSlots(
|
||||||
|
date as CalendarDate,
|
||||||
|
workingHours,
|
||||||
|
availableHours,
|
||||||
|
bookingDuration,
|
||||||
|
dayProtection
|
||||||
|
);
|
||||||
if (slots.length === 0) return true;
|
if (slots.length === 0) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -130,7 +167,9 @@
|
|||||||
const whMap: Record<string, DayHours> = {};
|
const whMap: Record<string, DayHours> = {};
|
||||||
const ahMap: Record<string, DayAvailability> = {};
|
const ahMap: Record<string, DayAvailability> = {};
|
||||||
|
|
||||||
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }));
|
whData.forEach(
|
||||||
|
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
|
||||||
|
);
|
||||||
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
||||||
|
|
||||||
// Cache by month key
|
// Cache by month key
|
||||||
@@ -191,7 +230,11 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
||||||
const endOfMonth = new CalendarDate(date.year, date.month, date.calendar.getDaysInMonth(date));
|
const endOfMonth = new CalendarDate(
|
||||||
|
date.year,
|
||||||
|
date.month,
|
||||||
|
date.calendar.getDaysInMonth(date)
|
||||||
|
);
|
||||||
const [whRes, ahRes] = await Promise.all([
|
const [whRes, ahRes] = await Promise.all([
|
||||||
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, {
|
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, {
|
||||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
@@ -206,7 +249,9 @@
|
|||||||
if (gen !== hoursMonthGeneration) return;
|
if (gen !== hoursMonthGeneration) return;
|
||||||
const whMap: Record<string, DayHours> = {};
|
const whMap: Record<string, DayHours> = {};
|
||||||
const ahMap: Record<string, DayAvailability> = {};
|
const ahMap: Record<string, DayAvailability> = {};
|
||||||
whData.forEach((d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }));
|
whData.forEach(
|
||||||
|
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
|
||||||
|
);
|
||||||
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
ahData.forEach((d) => (ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }));
|
||||||
|
|
||||||
workingHoursCache[monthKey] = whMap;
|
workingHoursCache[monthKey] = whMap;
|
||||||
@@ -220,7 +265,7 @@
|
|||||||
toast.error('Failed to load availability');
|
toast.error('Failed to load availability');
|
||||||
} finally {
|
} finally {
|
||||||
delete loadingMonths[monthKey];
|
delete loadingMonths[monthKey];
|
||||||
loadingMonthKeys = new Set([...loadingMonthKeys].filter(k => k !== monthKey));
|
loadingMonthKeys = new Set([...loadingMonthKeys].filter((k) => k !== monthKey));
|
||||||
loadingHours = false;
|
loadingHours = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +282,11 @@
|
|||||||
loadingMonths = {};
|
loadingMonths = {};
|
||||||
loadingMonthKeys = new Set();
|
loadingMonthKeys = new Set();
|
||||||
rescheduleAutoSelectDone = false;
|
rescheduleAutoSelectDone = false;
|
||||||
placeholder = new CalendarDate(new SvelteDate().getFullYear(), new SvelteDate().getMonth() + 1, new SvelteDate().getDate());
|
placeholder = new CalendarDate(
|
||||||
|
new SvelteDate().getFullYear(),
|
||||||
|
new SvelteDate().getMonth() + 1,
|
||||||
|
new SvelteDate().getDate()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reschedule() {
|
async function reschedule() {
|
||||||
@@ -318,7 +367,14 @@
|
|||||||
|
|
||||||
// Auto-select first available date once data loads (timing-safe, data-driven)
|
// Auto-select first available date once data loads (timing-safe, data-driven)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && workingHours && availableHours && !selectedDate && !userNavigatedCalendar && !rescheduleAutoSelectDone) {
|
if (
|
||||||
|
open &&
|
||||||
|
workingHours &&
|
||||||
|
availableHours &&
|
||||||
|
!selectedDate &&
|
||||||
|
!userNavigatedCalendar &&
|
||||||
|
!rescheduleAutoSelectDone
|
||||||
|
) {
|
||||||
rescheduleAutoSelectDone = true;
|
rescheduleAutoSelectDone = true;
|
||||||
const now = new SvelteDate();
|
const now = new SvelteDate();
|
||||||
const maxDateJs = new SvelteDate(
|
const maxDateJs = new SvelteDate(
|
||||||
@@ -334,7 +390,11 @@
|
|||||||
const checkDate = new SvelteDate(now);
|
const checkDate = new SvelteDate(now);
|
||||||
checkDate.setDate(now.getDate() + i);
|
checkDate.setDate(now.getDate() + i);
|
||||||
const dateStr = checkDate.toISOString().split('T')[0];
|
const dateStr = checkDate.toISOString().split('T')[0];
|
||||||
const calDate = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, checkDate.getDate());
|
const calDate = new CalendarDate(
|
||||||
|
checkDate.getFullYear(),
|
||||||
|
checkDate.getMonth() + 1,
|
||||||
|
checkDate.getDate()
|
||||||
|
);
|
||||||
if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
|
if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
|
||||||
selectedDate = calDate;
|
selectedDate = calDate;
|
||||||
if (!userNavigatedCalendar) {
|
if (!userNavigatedCalendar) {
|
||||||
@@ -360,10 +420,21 @@
|
|||||||
<Card.Root class="mb-4">
|
<Card.Root class="mb-4">
|
||||||
<Card.Content class="pt-4">
|
<Card.Content class="pt-4">
|
||||||
<div class="text-sm font-medium">
|
<div class="text-sm font-medium">
|
||||||
Current: {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })} at {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit', hour12: true })}
|
Current: {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})} at {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
<div class="mt-1 text-sm text-gray-500">
|
||||||
{booking.user?.full_name || 'Unknown'} · {booking.services?.map((s) => s.service_name).join(', ') || 'No services'} · {bookingDuration} min
|
{booking.user?.full_name || 'Unknown'} · {booking.services
|
||||||
|
?.map((s) => s.service_name)
|
||||||
|
.join(', ') || 'No services'} · {bookingDuration} min
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
@@ -381,10 +452,10 @@
|
|||||||
selectedDate = newDate;
|
selectedDate = newDate;
|
||||||
selectedTime = null;
|
selectedTime = null;
|
||||||
}}
|
}}
|
||||||
onPlaceholderChange={(newPlaceholder) => {
|
onPlaceholderChange={(newPlaceholder) => {
|
||||||
placeholder = newPlaceholder;
|
placeholder = newPlaceholder;
|
||||||
userNavigatedCalendar = true;
|
userNavigatedCalendar = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -393,14 +464,18 @@
|
|||||||
<div class="border-t">
|
<div class="border-t">
|
||||||
<div class="max-h-64 overflow-y-auto p-6">
|
<div class="max-h-64 overflow-y-auto p-6">
|
||||||
{#if formattedSelectedDate}
|
{#if formattedSelectedDate}
|
||||||
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">{formattedSelectedDate}</div>
|
<div class="mb-3 grid justify-center gap-2 text-sm font-medium">
|
||||||
|
{formattedSelectedDate}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<TimeSlotList
|
<TimeSlotList
|
||||||
slots={groupedTimeSlots}
|
slots={groupedTimeSlots}
|
||||||
{selectedTime}
|
{selectedTime}
|
||||||
duration={bookingDuration}
|
duration={bookingDuration}
|
||||||
protection={lunchProtection}
|
protection={lunchProtection}
|
||||||
onSelect={(time) => { selectedTime = time; }}
|
onSelect={(time) => {
|
||||||
|
selectedTime = time;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +498,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" onclick={() => { open = false; resetForm(); }} disabled={saving}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
open = false;
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onclick={reschedule} disabled={saving || !selectedDate || !selectedTime}>
|
<Button onclick={reschedule} disabled={saving || !selectedDate || !selectedTime}>
|
||||||
|
|||||||
@@ -129,11 +129,21 @@
|
|||||||
if (!wh || !wh.isOpen) return [];
|
if (!wh || !wh.isOpen) return [];
|
||||||
const startMin = timeToMinutes(wh.startTime);
|
const startMin = timeToMinutes(wh.startTime);
|
||||||
const endMin = timeToMinutes(wh.endTime);
|
const endMin = timeToMinutes(wh.endTime);
|
||||||
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
|
const options: Array<{
|
||||||
|
hour: string;
|
||||||
|
minute: string;
|
||||||
|
period: 'AM' | 'PM';
|
||||||
|
totalMin: number;
|
||||||
|
label: string;
|
||||||
|
}> = [];
|
||||||
for (let m = startMin; m < endMin; m += 15) {
|
for (let m = startMin; m < endMin; m += 15) {
|
||||||
const t = minutesTo12h(m);
|
const t = minutesTo12h(m);
|
||||||
const isStart = m === startMin;
|
const isStart = m === startMin;
|
||||||
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isStart ? ' (start of day)' : ''}` });
|
options.push({
|
||||||
|
...t,
|
||||||
|
totalMin: m,
|
||||||
|
label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isStart ? ' (start of day)' : ''}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
});
|
});
|
||||||
@@ -144,17 +154,25 @@
|
|||||||
const startMin = timeToMinutes(wh.startTime);
|
const startMin = timeToMinutes(wh.startTime);
|
||||||
const endMin = timeToMinutes(wh.endTime);
|
const endMin = timeToMinutes(wh.endTime);
|
||||||
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
|
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
|
||||||
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
|
const options: Array<{
|
||||||
|
hour: string;
|
||||||
|
minute: string;
|
||||||
|
period: 'AM' | 'PM';
|
||||||
|
totalMin: number;
|
||||||
|
label: string;
|
||||||
|
}> = [];
|
||||||
for (let m = Math.max(startMin, currentStartMin + 15); m <= endMin; m += 15) {
|
for (let m = Math.max(startMin, currentStartMin + 15); m <= endMin; m += 15) {
|
||||||
const t = minutesTo12h(m);
|
const t = minutesTo12h(m);
|
||||||
const isEnd = m === endMin;
|
const isEnd = m === endMin;
|
||||||
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isEnd ? ' (end of day)' : ''}` });
|
options.push({
|
||||||
|
...t,
|
||||||
|
totalMin: m,
|
||||||
|
label: `${t.hour}:${t.minute} ${t.period}${t.period === 'PM' && t.hour === '12' && t.minute === '00' ? ' (noon)' : ''}${isEnd ? ' (end of day)' : ''}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function formatRelativeTime(iso: string): string {
|
function formatRelativeTime(iso: string): string {
|
||||||
const d = new SvelteDate(iso);
|
const d = new SvelteDate(iso);
|
||||||
const now = new SvelteDate();
|
const now = new SvelteDate();
|
||||||
@@ -174,7 +192,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sortedBlockers = $derived.by(() => {
|
let sortedBlockers = $derived.by(() => {
|
||||||
return [...blockers].sort((a, b) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime());
|
return [...blockers].sort(
|
||||||
|
(a, b) => new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
let totalPages = $derived.by(() => Math.max(1, Math.ceil(sortedBlockers.length / PAGE_SIZE)));
|
let totalPages = $derived.by(() => Math.max(1, Math.ceil(sortedBlockers.length / PAGE_SIZE)));
|
||||||
@@ -233,14 +253,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDateTime(date: string, hour: string, minute: string, period: 'AM' | 'PM'): string | null {
|
function buildDateTime(
|
||||||
|
date: string,
|
||||||
|
hour: string,
|
||||||
|
minute: string,
|
||||||
|
period: 'AM' | 'PM'
|
||||||
|
): string | null {
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
const t24 = to24h(hour, minute, period);
|
const t24 = to24h(hour, minute, period);
|
||||||
return `${date}T${t24}:00`;
|
return `${date}T${t24}:00`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let formComplete = $derived.by(() => {
|
let formComplete = $derived.by(() => {
|
||||||
return newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== '';
|
return (
|
||||||
|
newDescription.trim() !== '' && newStartDate !== '' && startHour !== '' && endHour !== ''
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
let canCreate = $derived.by(() => {
|
let canCreate = $derived.by(() => {
|
||||||
@@ -513,128 +540,135 @@
|
|||||||
<Skeleton class="h-16 w-full" />
|
<Skeleton class="h-16 w-full" />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if blockers.length === 0}
|
||||||
|
<p class="text-sm text-gray-500">No time blockers found.</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#if blockers.length === 0}
|
<div class="space-y-3">
|
||||||
<p class="text-sm text-gray-500">No time blockers found.</p>
|
{#each pagedBlockers as b (b.id)}
|
||||||
{:else}
|
<div
|
||||||
<div class="space-y-3">
|
class="flex items-center justify-between gap-3 rounded-lg border p-3 transition-all hover:shadow-sm sm:p-4"
|
||||||
{#each pagedBlockers as b (b.id)}
|
>
|
||||||
<div class="flex items-center justify-between gap-3 rounded-lg border p-3 sm:p-4 transition-all hover:shadow-sm">
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
<div class="hidden shrink-0 rounded-lg bg-gray-50 p-1.5 sm:block">
|
||||||
<div class="hidden shrink-0 rounded-lg bg-gray-50 p-1.5 sm:block">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4 text-gray-600"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="truncate font-medium text-gray-900">{b.description || 'Untitled'}</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-gray-600">
|
|
||||||
<span>
|
|
||||||
{new SvelteDate(b.start_time).toLocaleDateString('en-GB', {
|
|
||||||
weekday: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short'
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-400">·</span>
|
|
||||||
<span>
|
|
||||||
{new SvelteDate(b.start_time).toLocaleTimeString('en-GB', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-400">–</span>
|
|
||||||
<span>
|
|
||||||
{(() => {
|
|
||||||
const end = new SvelteDate(b.start_time);
|
|
||||||
end.setMinutes(end.getMinutes() + b.duration_minutes);
|
|
||||||
return end.toLocaleTimeString('en-GB', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
|
|
||||||
{formatDuration(b.duration_minutes)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-400">
|
|
||||||
{formatRelativeTime(b.start_time)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
class="shrink-0"
|
|
||||||
onclick={() => {
|
|
||||||
blockerToDelete = b;
|
|
||||||
showDeleteAlert = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-3 w-3"
|
class="h-4 w-4 text-gray-600"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate font-medium text-gray-900">{b.description || 'Untitled'}</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-gray-600">
|
||||||
|
<span>
|
||||||
|
{new SvelteDate(b.start_time).toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400">·</span>
|
||||||
|
<span>
|
||||||
|
{new SvelteDate(b.start_time).toLocaleTimeString('en-GB', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400">–</span>
|
||||||
|
<span>
|
||||||
|
{(() => {
|
||||||
|
const end = new SvelteDate(b.start_time);
|
||||||
|
end.setMinutes(end.getMinutes() + b.duration_minutes);
|
||||||
|
return end.toLocaleTimeString('en-GB', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
|
||||||
|
{formatDuration(b.duration_minutes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400">
|
||||||
|
{formatRelativeTime(b.start_time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<Button
|
||||||
</div>
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
{#if totalPages > 1}
|
class="shrink-0"
|
||||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between pt-2">
|
onclick={() => {
|
||||||
<p class="text-sm text-gray-500">
|
blockerToDelete = b;
|
||||||
{(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, sortedBlockers.length)} of {sortedBlockers.length}
|
showDeleteAlert = true;
|
||||||
</p>
|
}}
|
||||||
<div class="flex items-center gap-1">
|
>
|
||||||
<Button
|
<svg
|
||||||
variant="outline"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
size="sm"
|
class="h-3 w-3"
|
||||||
disabled={currentPage <= 1}
|
viewBox="0 0 24 24"
|
||||||
onclick={() => (currentPage -= 1)}
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
Prev
|
<polyline points="3 6 5 6 21 6" />
|
||||||
</Button>
|
<path
|
||||||
{#each Array(totalPages) as _, i (i)}
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||||
<Button
|
/>
|
||||||
variant={currentPage === i + 1 ? 'default' : 'outline'}
|
</svg>
|
||||||
size="sm"
|
</Button>
|
||||||
class="h-8 w-8 p-0"
|
|
||||||
onclick={() => (currentPage = i + 1)}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={currentPage >= totalPages}
|
|
||||||
onclick={() => (currentPage += 1)}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(
|
||||||
|
currentPage * PAGE_SIZE,
|
||||||
|
sortedBlockers.length
|
||||||
|
)} of {sortedBlockers.length}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onclick={() => (currentPage -= 1)}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
{#each Array(totalPages) as _, i (i)}
|
||||||
|
<Button
|
||||||
|
variant={currentPage === i + 1 ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
class="h-8 w-8 p-0"
|
||||||
|
onclick={() => (currentPage = i + 1)}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onclick={() => (currentPage += 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
@@ -668,14 +702,37 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label for="blocker-start-date" class="text-xs text-gray-600">Date</label>
|
<label for="blocker-start-date" class="text-xs text-gray-600">Date</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" class="text-xs flex-1" onclick={() => { newStartDate = new Date().toISOString().slice(0, 10); onStartDateChange(); }}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1 text-xs"
|
||||||
|
onclick={() => {
|
||||||
|
newStartDate = new Date().toISOString().slice(0, 10);
|
||||||
|
onStartDateChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Today
|
Today
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" size="sm" class="text-xs flex-1" onclick={() => { newStartDate = new Date(Date.now() + 86400000).toISOString().slice(0, 10); onStartDateChange(); }}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1 text-xs"
|
||||||
|
onclick={() => {
|
||||||
|
newStartDate = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
||||||
|
onStartDateChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Tomorrow
|
Tomorrow
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Input id="blocker-start-date" type="date" bind:value={newStartDate} onchange={onStartDateChange} />
|
<Input
|
||||||
|
id="blocker-start-date"
|
||||||
|
type="date"
|
||||||
|
bind:value={newStartDate}
|
||||||
|
onchange={onStartDateChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-xs text-gray-600">Time</label>
|
<label class="text-xs text-gray-600">Time</label>
|
||||||
@@ -684,7 +741,11 @@
|
|||||||
{:else if !selectedWorkingHours}
|
{:else if !selectedWorkingHours}
|
||||||
<p class="text-sm text-gray-400">Select a date first</p>
|
<p class="text-sm text-gray-400">Select a date first</p>
|
||||||
{:else if !selectedWorkingHours.isOpen}
|
{:else if !selectedWorkingHours.isOpen}
|
||||||
<p class="text-sm text-red-500">Closed on {new SvelteDate(newStartDate + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'long' })}</p>
|
<p class="text-sm text-red-500">
|
||||||
|
Closed on {new SvelteDate(newStartDate + 'T00:00:00').toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'long'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<select
|
<select
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
@@ -744,28 +805,51 @@
|
|||||||
{#if checkingOverlap}
|
{#if checkingOverlap}
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
<circle
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Checking for conflicting bookings…
|
Checking for conflicting bookings…
|
||||||
</div>
|
</div>
|
||||||
{:else if hasOverlap}
|
{:else if hasOverlap}
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="mb-3 flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4 text-amber-600"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||||
|
/>
|
||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm font-medium text-amber-800">
|
<span class="text-sm font-medium text-amber-800">
|
||||||
{overlappingBookings.length} booking{overlappingBookings.length > 1 ? 's' : ''} conflict{overlappingBookings.length > 1 ? '' : 's'} with this slot
|
{overlappingBookings.length} booking{overlappingBookings.length > 1 ? 's' : ''} conflict{overlappingBookings.length >
|
||||||
|
1
|
||||||
|
? ''
|
||||||
|
: 's'} with this slot
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each overlappingBookings as booking (booking.id)}
|
{#each overlappingBookings as booking (booking.id)}
|
||||||
<div class="rounded-md border border-amber-200 bg-white p-3">
|
<div class="rounded-md border border-amber-200 bg-white p-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-medium text-sm">{booking.user?.full_name || 'Unknown'}</div>
|
<div class="text-sm font-medium">{booking.user?.full_name || 'Unknown'}</div>
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-gray-500">
|
||||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
@@ -780,12 +864,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 mt-2">
|
<div class="mt-2 flex gap-2">
|
||||||
{#if openBookingModal}
|
{#if openBookingModal}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="text-xs h-7"
|
class="h-7 text-xs"
|
||||||
onclick={() => openBookingModal(booking.id)}
|
onclick={() => openBookingModal(booking.id)}
|
||||||
>
|
>
|
||||||
View Booking
|
View Booking
|
||||||
@@ -795,7 +879,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="text-xs h-7"
|
class="h-7 text-xs"
|
||||||
onclick={() => openUserModal(booking.user!.id)}
|
onclick={() => openUserModal(booking.user!.id)}
|
||||||
>
|
>
|
||||||
View Client
|
View Client
|
||||||
@@ -832,8 +916,8 @@
|
|||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
||||||
<AlertDialog.Description>
|
<AlertDialog.Description>
|
||||||
This will remove the "{blockerToDelete?.description}" time blocker. Bookings may become available
|
This will remove the "{blockerToDelete?.description}" time blocker. Bookings may become
|
||||||
during this period. This action cannot be undone.
|
available during this period. This action cannot be undone.
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
|
|||||||
@@ -230,7 +230,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl">
|
<Modal.Content
|
||||||
|
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
||||||
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -270,7 +272,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Email</div>
|
<div class="text-xs text-gray-500">Email</div>
|
||||||
<div class="font-medium break-words" title={selectedUser.email}>{selectedUser.email || '—'}</div>
|
<div class="font-medium break-words" title={selectedUser.email}>
|
||||||
|
{selectedUser.email || '—'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Phone</div>
|
<div class="text-xs text-gray-500">Phone</div>
|
||||||
@@ -334,7 +338,7 @@
|
|||||||
<img
|
<img
|
||||||
src={selectedUser.profilePicUrl}
|
src={selectedUser.profilePicUrl}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
class="mt-2 h-20 w-20 md:h-24 md:w-24 rounded-lg object-cover"
|
class="mt-2 h-20 w-20 rounded-lg object-cover md:h-24 md:w-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -381,50 +385,50 @@
|
|||||||
return `${dateStr} at ${timeStr}`;
|
return `${dateStr} at ${timeStr}`;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||||
{booking.status === 'confirmed' || booking.status === 'in_progress'
|
{booking.status === 'confirmed' || booking.status === 'in_progress'
|
||||||
? 'bg-emerald-100 text-emerald-800'
|
? 'bg-emerald-100 text-emerald-800'
|
||||||
: booking.status === 'pending'
|
: booking.status === 'pending'
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
: booking.status === 'completed'
|
: booking.status === 'completed'
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: booking.status === 'client_cancelled' ||
|
: booking.status === 'client_cancelled' ||
|
||||||
booking.status === 'we_cancelled' ||
|
booking.status === 'we_cancelled' ||
|
||||||
booking.status === 'no_deposit'
|
booking.status === 'no_deposit'
|
||||||
? 'bg-red-100 text-red-800'
|
? 'bg-red-100 text-red-800'
|
||||||
: 'bg-gray-100 text-gray-800'}"
|
: 'bg-gray-100 text-gray-800'}"
|
||||||
>
|
>
|
||||||
{booking.status.replace('_', ' ')}
|
{booking.status.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{#if booking.deposit_required}
|
{#if booking.deposit_required}
|
||||||
{#if booking.status === 'pending'}
|
{#if booking.status === 'pending'}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
||||||
>
|
>
|
||||||
Will Require Deposit
|
Will Require Deposit
|
||||||
</span>
|
</span>
|
||||||
{:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid}
|
{:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800"
|
class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800"
|
||||||
>
|
>
|
||||||
Deposit Due
|
Deposit Due
|
||||||
</span>
|
</span>
|
||||||
{:else if booking.deposit_paid}
|
{:else if booking.deposit_paid}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
|
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
|
||||||
>
|
>
|
||||||
Deposit Paid
|
Deposit Paid
|
||||||
</span>
|
</span>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="text-gray-500">
|
<span class="text-gray-500">
|
||||||
{booking.services.map((s) => s.service_name).join(', ')}
|
{booking.services.map((s) => s.service_name).join(', ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm font-semibold text-gray-900">
|
<div class="mt-1 text-sm font-semibold text-gray-900">
|
||||||
£{booking.total_amount.toFixed(2)}
|
£{booking.total_amount.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
@@ -484,19 +488,27 @@
|
|||||||
<div class="grid gap-3 md:grid-cols-3">
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Total Spend</div>
|
<div class="text-xs text-gray-500">Total Spend</div>
|
||||||
<div class="text-2xl font-bold text-emerald-600">£{customerRelationship.totalSpend.toFixed(2)}</div>
|
<div class="text-2xl font-bold text-emerald-600">
|
||||||
|
£{customerRelationship.totalSpend.toFixed(2)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Total Tips</div>
|
<div class="text-xs text-gray-500">Total Tips</div>
|
||||||
<div class="text-2xl font-bold text-amber-600">£{customerRelationship.totalTips.toFixed(2)}</div>
|
<div class="text-2xl font-bold text-amber-600">
|
||||||
|
£{customerRelationship.totalTips.toFixed(2)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Total Visits</div>
|
<div class="text-xs text-gray-500">Total Visits</div>
|
||||||
<div class="text-2xl font-bold text-blue-600">{customerRelationship.totalVisits}</div>
|
<div class="text-2xl font-bold text-blue-600">
|
||||||
|
{customerRelationship.totalVisits}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Customer For</div>
|
<div class="text-xs text-gray-500">Customer For</div>
|
||||||
<div class="text-2xl font-bold text-purple-600">{customerRelationship.customerFor || '—'}</div>
|
<div class="text-2xl font-bold text-purple-600">
|
||||||
|
{customerRelationship.customerFor || '—'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -505,7 +517,9 @@
|
|||||||
<div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div>
|
<div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each customerRelationship.topServices as service (service.name)}
|
{#each customerRelationship.topServices as service (service.name)}
|
||||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800">
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
|
||||||
|
>
|
||||||
{service.name}
|
{service.name}
|
||||||
<span class="ml-1 text-xs text-blue-600">({service.count})</span>
|
<span class="ml-1 text-xs text-blue-600">({service.count})</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -538,21 +552,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if hasEligiblePatchTests}
|
{#if hasEligiblePatchTests}
|
||||||
<!-- Patch Test Actions -->
|
<!-- Patch Test Actions -->
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Patch Tests
|
Patch Tests
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mb-3 text-sm text-gray-600">
|
<p class="mb-3 text-sm text-gray-600">
|
||||||
Record patch test completion to allow this user to book services requiring one.
|
Record patch test completion to allow this user to book services requiring one.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" onclick={() => (showPatchTestModal = true)}>
|
<Button variant="outline" onclick={() => (showPatchTestModal = true)}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</svg>
|
class="mr-2 h-4 w-4"
|
||||||
Record Patch Test
|
viewBox="0 0 24 24"
|
||||||
</Button>
|
fill="none"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
</svg>
|
||||||
|
Record Patch Test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -38,9 +38,7 @@
|
|||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const services: Service[] = await response.json();
|
const services: Service[] = await response.json();
|
||||||
const durations = services
|
const durations = services.map((s) => s.duration_minutes).filter((d) => d > 0);
|
||||||
.map((s) => s.duration_minutes)
|
|
||||||
.filter((d) => d > 0);
|
|
||||||
if (durations.length > 0) {
|
if (durations.length > 0) {
|
||||||
shortestServiceMinutes = Math.min(...durations);
|
shortestServiceMinutes = Math.min(...durations);
|
||||||
}
|
}
|
||||||
@@ -355,14 +353,19 @@
|
|||||||
|
|
||||||
{#if tooShortForService}
|
{#if tooShortForService}
|
||||||
<p class="mb-4 text-sm text-amber-600">
|
<p class="mb-4 text-sm text-amber-600">
|
||||||
Not enough time for any service{shortestServiceMinutes !== null ? ` (shortest service is ${formatDuration(shortestServiceMinutes)})` : ''}
|
Not enough time for any service{shortestServiceMinutes !== null
|
||||||
|
? ` (shortest service is ${formatDuration(shortestServiceMinutes)})`
|
||||||
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
onclick={handleStartWalkIn}
|
onclick={handleStartWalkIn}
|
||||||
disabled={noSlotsToday || isReserving || (slotInfo?.isAvailableNow && (getLiveRemainingMinutes() ?? 0) <= 0) || tooShortForService}
|
disabled={noSlotsToday ||
|
||||||
|
isReserving ||
|
||||||
|
(slotInfo?.isAvailableNow && (getLiveRemainingMinutes() ?? 0) <= 0) ||
|
||||||
|
tooShortForService}
|
||||||
>
|
>
|
||||||
{isReserving ? 'Reserving...' : 'Start Walk-In Session'}
|
{isReserving ? 'Reserving...' : 'Start Walk-In Session'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -374,7 +377,7 @@
|
|||||||
bind:open={showCreateModal}
|
bind:open={showCreateModal}
|
||||||
maxSlotDuration={reservedDuration}
|
maxSlotDuration={reservedDuration}
|
||||||
availableStartTime={undefined}
|
availableStartTime={undefined}
|
||||||
reservationExpiresAt={reservationExpiresAt}
|
{reservationExpiresAt}
|
||||||
onclose={handleModalClose}
|
onclose={handleModalClose}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -107,9 +107,7 @@
|
|||||||
|
|
||||||
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration);
|
const isOverDuration = $derived(getTotalDuration() > maxSlotDuration);
|
||||||
|
|
||||||
const canProceedStep1 = $derived(
|
const canProceedStep1 = $derived(userType === 'member' ? !!selectedUserId : !!guestName.trim());
|
||||||
userType === 'member' ? !!selectedUserId : !!guestName.trim()
|
|
||||||
);
|
|
||||||
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
|
const canProceedStep2 = $derived(selectedServices.length > 0 && !isOverDuration);
|
||||||
|
|
||||||
// =============== Effects ===============
|
// =============== Effects ===============
|
||||||
@@ -277,35 +275,35 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalUserId = selectedUserId;
|
let finalUserId = selectedUserId;
|
||||||
|
|
||||||
if (userType === 'guest') {
|
if (userType === 'guest') {
|
||||||
const phone = guestPhone.trim() || '+447700900000';
|
const phone = guestPhone.trim() || '+447700900000';
|
||||||
const createRes = await fetch('/api/users/guest', {
|
const createRes = await fetch('/api/users/guest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${authStore.currentToken}`
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
firstName: guestName.trim().split(' ')[0] || 'Walk-in',
|
firstName: guestName.trim().split(' ')[0] || 'Walk-in',
|
||||||
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Guest',
|
lastName: guestName.trim().split(' ').slice(1).join(' ') || 'Guest',
|
||||||
phone: phone,
|
phone: phone,
|
||||||
email: `walkin-${Date.now()}@guest.invalid`
|
email: `walkin-${Date.now()}@guest.invalid`
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!createRes.ok) {
|
if (!createRes.ok) {
|
||||||
toast.error('Failed to create guest user');
|
toast.error('Failed to create guest user');
|
||||||
submitting = false;
|
submitting = false;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestUser = await createRes.json();
|
||||||
|
finalUserId = guestUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guestUser = await createRes.json();
|
if (!finalUserId) throw new Error('User ID required');
|
||||||
finalUserId = guestUser.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finalUserId) throw new Error('User ID required');
|
|
||||||
|
|
||||||
// Use the available slot start time from the widget
|
// Use the available slot start time from the widget
|
||||||
let start: Date;
|
let start: Date;
|
||||||
@@ -414,7 +412,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal.Root bind:open>
|
<Modal.Root bind:open>
|
||||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl">
|
<Modal.Content
|
||||||
|
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl md:max-w-4xl"
|
||||||
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title>Walk-In Booking</Modal.Title>
|
<Modal.Title>Walk-In Booking</Modal.Title>
|
||||||
<Modal.Description>
|
<Modal.Description>
|
||||||
@@ -425,26 +425,39 @@
|
|||||||
<div class="px-6 pb-4">
|
<div class="px-6 pb-4">
|
||||||
<!-- Reservation Countdown Banner -->
|
<!-- Reservation Countdown Banner -->
|
||||||
{#if reservationExpiresAt && !isReservationExpired}
|
{#if reservationExpiresAt && !isReservationExpired}
|
||||||
<div class="mx-6 mt-4 rounded-lg bg-green-50 border border-green-200 p-3">
|
<div class="mx-6 mt-4 rounded-lg border border-green-200 bg-green-50 p-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<svg class="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
class="h-5 w-5 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm font-medium text-green-800">
|
<span class="text-sm font-medium text-green-800"> Slot held for </span>
|
||||||
Slot held for
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-mono font-semibold text-green-700">
|
<span class="font-mono text-sm font-semibold text-green-700">
|
||||||
{reservationCountdown}
|
{reservationCountdown}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if isReservationExpired}
|
{:else if isReservationExpired}
|
||||||
<div class="mx-6 mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm font-medium text-red-800">
|
<span class="text-sm font-medium text-red-800">
|
||||||
Slot released — please re-check availability
|
Slot released — please re-check availability
|
||||||
@@ -600,17 +613,17 @@
|
|||||||
oninput={(e) => (guestName = e.currentTarget.value)}
|
oninput={(e) => (guestName = e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="guest-phone">Phone Number</Label>
|
<Label for="guest-phone">Phone Number</Label>
|
||||||
<input
|
<input
|
||||||
id="guest-phone"
|
id="guest-phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
placeholder="07700 900000 (optional — helps us reach you if running late)"
|
placeholder="07700 900000 (optional — helps us reach you if running late)"
|
||||||
value={guestPhone}
|
value={guestPhone}
|
||||||
oninput={(e) => (guestPhone = e.currentTarget.value)}
|
oninput={(e) => (guestPhone = e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
|
<p class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-800">
|
||||||
Booking as a guest creates a temporary record. Encourage them to sign up for
|
Booking as a guest creates a temporary record. Encourage them to sign up for
|
||||||
loyalty benefits.
|
loyalty benefits.
|
||||||
|
|||||||
@@ -487,7 +487,9 @@
|
|||||||
>
|
>
|
||||||
{#each Array(24) as _, hour}
|
{#each Array(24) as _, hour}
|
||||||
{#each TIME15 as min (min)}
|
{#each TIME15 as min (min)}
|
||||||
<option value="{String(hour).padStart(2,'0')}:{min}">{String(hour).padStart(2,'0')}:{min}</option>
|
<option value="{String(hour).padStart(2, '0')}:{min}"
|
||||||
|
>{String(hour).padStart(2, '0')}:{min}</option
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -500,7 +502,9 @@
|
|||||||
>
|
>
|
||||||
{#each Array(24) as _, hour}
|
{#each Array(24) as _, hour}
|
||||||
{#each TIME15 as min (min)}
|
{#each TIME15 as min (min)}
|
||||||
<option value="{String(hour).padStart(2,'0')}:{min}">{String(hour).padStart(2,'0')}:{min}</option>
|
<option value="{String(hour).padStart(2, '0')}:{min}"
|
||||||
|
>{String(hour).padStart(2, '0')}:{min}</option
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1358,7 +1358,12 @@
|
|||||||
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StepIndicator {currentStep} steps={stepLabels} startAt={authStore.isAuthenticated ? 1 : 0} className="{currentStep === 0 ? 'md:hidden' : ''}" />
|
<StepIndicator
|
||||||
|
{currentStep}
|
||||||
|
steps={stepLabels}
|
||||||
|
startAt={authStore.isAuthenticated ? 1 : 0}
|
||||||
|
className={currentStep === 0 ? 'md:hidden' : ''}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if currentStep === 0}
|
{#if currentStep === 0}
|
||||||
<Card.Root class="border-fuchsia-200 bg-fuchsia-50">
|
<Card.Root class="border-fuchsia-200 bg-fuchsia-50">
|
||||||
@@ -1371,12 +1376,14 @@
|
|||||||
Guest checkout does not receive loyalty stamps or seasonal discounts.
|
Guest checkout does not receive loyalty stamps or seasonal discounts.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4">
|
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-4">
|
||||||
<Button onclick={() => goto('/login')} variant="outline" class="border-fuchsia-200 hover:bg-fuchsia-100">
|
<Button
|
||||||
|
onclick={() => goto('/login')}
|
||||||
|
variant="outline"
|
||||||
|
class="border-fuchsia-200 hover:bg-fuchsia-100"
|
||||||
|
>
|
||||||
Log In
|
Log In
|
||||||
</Button>
|
</Button>
|
||||||
<Button onclick={nextStep}>
|
<Button onclick={nextStep}>Continue as Guest</Button>
|
||||||
Continue as Guest
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<Calendar
|
<Calendar
|
||||||
type="single"
|
type="single"
|
||||||
value={date}
|
value={date}
|
||||||
placeholder={placeholder}
|
{placeholder}
|
||||||
// preventDeselect tells bits-ui to never deselect an already-selected date
|
// preventDeselect tells bits-ui to never deselect an already-selected date
|
||||||
// (which would fire onValueChange(undefined) and desync the $bindable() chain)
|
// (which would fire onValueChange(undefined) and desync the $bindable() chain)
|
||||||
preventDeselect={true}
|
preventDeselect={true}
|
||||||
|
|||||||
@@ -15,13 +15,28 @@
|
|||||||
|
|
||||||
<Separator class="my-4" />
|
<Separator class="my-4" />
|
||||||
{#if protection?.showWarning || protection?.isBlocked}
|
{#if protection?.showWarning || protection?.isBlocked}
|
||||||
<div class="rounded-lg border {protection?.isBlocked ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50'} p-4">
|
<div
|
||||||
|
class="rounded-lg border {protection?.isBlocked
|
||||||
|
? 'border-red-200 bg-red-50'
|
||||||
|
: 'border-amber-200 bg-amber-50'} p-4"
|
||||||
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 shrink-0 {protection?.isBlocked ? 'text-red-600' : 'text-amber-600'}" viewBox="0 0 20 20" fill="currentColor">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mt-0.5 h-4 w-4 shrink-0 {protection?.isBlocked ? 'text-red-600' : 'text-amber-600'}"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium {protection?.isBlocked ? 'text-red-800' : 'text-amber-800'}">
|
<div
|
||||||
|
class="text-sm font-medium {protection?.isBlocked ? 'text-red-800' : 'text-amber-800'}"
|
||||||
|
>
|
||||||
{protection?.isBlocked ? 'Lunch break conflict' : 'Lunch break warning'}
|
{protection?.isBlocked ? 'Lunch break conflict' : 'Lunch break warning'}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm {protection?.isBlocked ? 'text-red-700' : 'text-amber-700'}">
|
<div class="mt-1 text-sm {protection?.isBlocked ? 'text-red-700' : 'text-amber-700'}">
|
||||||
@@ -31,9 +46,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 {protection?.showWarning || protection?.isBlocked ? 'mt-3' : ''}">
|
<div
|
||||||
|
class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 {protection?.showWarning ||
|
||||||
|
protection?.isBlocked
|
||||||
|
? 'mt-3'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
<div class="text-sm font-medium text-emerald-800">
|
<div class="text-sm font-medium text-emerald-800">
|
||||||
{#if protection?.isBlocked}Booking conflict{:else if protection?.showWarning}Lunch warning{:else}Time selected{/if}
|
{#if protection?.isBlocked}Booking conflict{:else if protection?.showWarning}Lunch warning{:else}Time
|
||||||
|
selected{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm text-emerald-700">
|
<div class="mt-1 text-sm text-emerald-700">
|
||||||
{selectedDate} at {selectedTime}
|
{selectedDate} at {selectedTime}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
onclick={() => !isGrayedOut && onclick?.()}
|
onclick={() => !isGrayedOut && onclick?.()}
|
||||||
disabled={isGrayedOut}
|
disabled={isGrayedOut}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-between h-full min-h-[6rem]">
|
<div class="flex h-full min-h-[6rem] flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">{service.name}</h3>
|
<h3 class="font-semibold">{service.name}</h3>
|
||||||
{#if isGrayedOut}
|
{#if isGrayedOut}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<p class="text-sm text-gray-600">{service.description}</p>
|
<p class="text-sm text-gray-600">{service.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-2 text-sm text-gray-500">
|
<div class="mt-2 flex items-center justify-between text-sm text-gray-500">
|
||||||
<span>{service.duration_minutes} mins</span>
|
<span>{service.duration_minutes} mins</span>
|
||||||
<span class="font-semibold text-foreground">£{service.price}</span>
|
<span class="font-semibold text-foreground">£{service.price}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
const totalSteps = steps.length;
|
const totalSteps = steps.length;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-8 grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:flex lg:items-center lg:justify-center lg:space-x-4 {className}">
|
<div
|
||||||
|
class="mb-8 grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:flex lg:items-center lg:justify-center lg:space-x-4 {className}"
|
||||||
|
>
|
||||||
{#each steps as step, index (step)}
|
{#each steps as step, index (step)}
|
||||||
{@const displayNumber = startAt + index}
|
{@const displayNumber = startAt + index}
|
||||||
{@const isActive = displayNumber <= currentStep}
|
{@const isActive = displayNumber <= currentStep}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
title={p.warningMessage || 'Lunch protection'}
|
title={p.warningMessage || 'Lunch protection'}
|
||||||
>
|
>
|
||||||
{formatTime(slot.startTime)}
|
{formatTime(slot.startTime)}
|
||||||
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span>
|
<span class="ml-2 text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||||
<span class="ml-auto text-xs text-gray-400">{duration} min</span>
|
<span class="ml-auto text-xs text-gray-400">{duration} min</span>
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -36,20 +36,33 @@
|
|||||||
onclick={() => onSelect(slot.startTime)}
|
onclick={() => onSelect(slot.startTime)}
|
||||||
class={`w-full hover:bg-fuchsia-50 ${
|
class={`w-full hover:bg-fuchsia-50 ${
|
||||||
slot.startTime === selectedTime
|
slot.startTime === selectedTime
|
||||||
? (p?.showWarning ? 'bg-fuchsia-200 border-amber-500' : 'bg-fuchsia-100')
|
? p?.showWarning
|
||||||
: (p?.showWarning ? 'border-amber-400 bg-amber-100' : '')
|
? 'border-amber-500 bg-fuchsia-200'
|
||||||
|
: 'bg-fuchsia-100'
|
||||||
|
: p?.showWarning
|
||||||
|
? 'border-amber-400 bg-amber-100'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
title={p?.warningMessage}
|
title={p?.warningMessage}
|
||||||
>
|
>
|
||||||
{#if p?.showWarning}
|
{#if p?.showWarning}
|
||||||
<span class="mr-1 text-amber-500">
|
<span class="mr-1 text-amber-500">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
<svg
|
||||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{formatTime(slot.startTime)}
|
{formatTime(slot.startTime)}
|
||||||
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span>
|
<span class="ml-2 text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||||
<span class="ml-auto text-xs text-gray-400">{duration} min</span>
|
<span class="ml-auto text-xs text-gray-400">{duration} min</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -60,11 +73,11 @@
|
|||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
{formatTime(slot.startTime)}
|
{formatTime(slot.startTime)}
|
||||||
<span class="text-gray-500 ml-2">- {formatTime(slot.endTime)}</span>
|
<span class="ml-2 text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-center text-sm text-gray-500 py-4">No available slots for this date</p>
|
<p class="py-4 text-center text-sm text-gray-500">No available slots for this date</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -15,7 +15,17 @@
|
|||||||
|
|
||||||
let { booking, onClose, onComplete }: Props = $props();
|
let { booking, onClose, onComplete }: Props = $props();
|
||||||
|
|
||||||
type PaymentStatus = 'idle' | 'selecting' | 'card-processing' | 'card-polling' | 'cash-entering' | 'cash-confirming' | 'gift-entering' | 'gift-confirming' | 'success' | 'error';
|
type PaymentStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'selecting'
|
||||||
|
| 'card-processing'
|
||||||
|
| 'card-polling'
|
||||||
|
| 'cash-entering'
|
||||||
|
| 'cash-confirming'
|
||||||
|
| 'gift-entering'
|
||||||
|
| 'gift-confirming'
|
||||||
|
| 'success'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
type PaymentResult = {
|
type PaymentResult = {
|
||||||
checkout_id: string;
|
checkout_id: string;
|
||||||
@@ -84,9 +94,9 @@
|
|||||||
let tipPercentages = $derived.by(() => {
|
let tipPercentages = $derived.by(() => {
|
||||||
if (subtotal <= 0) return [];
|
if (subtotal <= 0) return [];
|
||||||
return [
|
return [
|
||||||
{ pct: 10, amount: Math.round(subtotal * 0.10 * 100) / 100 },
|
{ pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 },
|
||||||
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
|
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
|
||||||
{ pct: 20, amount: Math.round(subtotal * 0.20 * 100) / 100 },
|
{ pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 }
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,9 +134,7 @@
|
|||||||
return service.override_price ?? service.price ?? 0;
|
return service.override_price ?? service.price ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let subtotal = $derived(
|
let subtotal = $derived((booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0));
|
||||||
(booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
let tipMultiplier = $derived(
|
let tipMultiplier = $derived(
|
||||||
selectedTipPercent !== null
|
selectedTipPercent !== null
|
||||||
@@ -415,8 +423,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()} onOpenAutoFocus={(e) => e.preventDefault()}>
|
<Dialog.Root
|
||||||
<Dialog.Content class="max-w-lg max-h-[90vh] overflow-y-auto">
|
open={true}
|
||||||
|
onOpenChange={(open) => !open && handleClose()}
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Dialog.Content class="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
@@ -425,10 +437,16 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
|
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||||
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
|
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
|
||||||
<div class="grid grid-cols-1 {(booking.services?.length ?? 0) > 1 ? 'sm:grid-cols-2' : ''} gap-3">
|
<div
|
||||||
|
class="grid grid-cols-1 {(booking.services?.length ?? 0) > 1
|
||||||
|
? 'sm:grid-cols-2'
|
||||||
|
: ''} gap-3"
|
||||||
|
>
|
||||||
{#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)}
|
{#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)}
|
||||||
<div class="rounded-lg border bg-white p-3">
|
<div class="rounded-lg border bg-white p-3">
|
||||||
<div class="mb-2 font-medium text-sm">{service.service_name || 'Unknown Service'}</div>
|
<div class="mb-2 text-sm font-medium">
|
||||||
|
{service.service_name || 'Unknown Service'}
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs text-gray-500">£</span>
|
<span class="text-xs text-gray-500">£</span>
|
||||||
<input
|
<input
|
||||||
@@ -436,7 +454,9 @@
|
|||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
class="flex h-8 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
value={serviceOverrides[service.service_id]?.price ?? (service.price?.toFixed(2) ?? '0.00')}
|
value={serviceOverrides[service.service_id]?.price ??
|
||||||
|
service.price?.toFixed(2) ??
|
||||||
|
'0.00'}
|
||||||
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
|
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
|
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
|
||||||
@@ -469,10 +489,22 @@
|
|||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod === 'card' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
|
||||||
onclick={() => { selectedMethod = 'card'; status = 'selecting'; }}
|
'card'
|
||||||
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
selectedMethod = 'card';
|
||||||
|
status = 'selecting';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="mx-auto mb-2 h-8 w-8"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||||
<line x1="1" y1="10" x2="23" y2="10" />
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -480,10 +512,22 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod === 'cash' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
|
||||||
onclick={() => { selectedMethod = 'cash'; status = 'cash-entering'; }}
|
'cash'
|
||||||
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
selectedMethod = 'cash';
|
||||||
|
status = 'cash-entering';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="mx-auto mb-2 h-8 w-8"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="12" y1="1" x2="12" y2="23" />
|
<line x1="12" y1="1" x2="12" y2="23" />
|
||||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -491,10 +535,22 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod === 'giftcard' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
|
||||||
onclick={() => { selectedMethod = 'giftcard'; status = 'gift-entering'; }}
|
'giftcard'
|
||||||
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
selectedMethod = 'giftcard';
|
||||||
|
status = 'gift-entering';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg class="mx-auto mb-2 h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="mx-auto mb-2 h-8 w-8"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<polyline points="20 12 20 22 4 22 4 12" />
|
<polyline points="20 12 20 22 4 22 4 12" />
|
||||||
<rect x="2" y="7" width="20" height="5" />
|
<rect x="2" y="7" width="20" height="5" />
|
||||||
<line x1="12" y1="22" x2="12" y2="7" />
|
<line x1="12" y1="22" x2="12" y2="7" />
|
||||||
@@ -509,19 +565,19 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-sm text-gray-600 underline hover:text-gray-900"
|
class="text-sm text-gray-600 underline hover:text-gray-900"
|
||||||
onclick={() => { selectedMethod = 'giftcard'; status = 'gift-entering'; }}
|
onclick={() => {
|
||||||
|
selectedMethod = 'giftcard';
|
||||||
|
status = 'gift-entering';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Pay with Gift Card
|
Pay with Gift Card
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={handleClose} class="flex-1">
|
<Button variant="ghost" onclick={handleClose} class="flex-1">Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if status === 'selecting'}
|
{:else if status === 'selecting'}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
||||||
@@ -537,8 +593,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
|
||||||
tip.pct
|
tip.pct
|
||||||
? 'border-input bg-fuchsia-100 text-foreground'
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
: 'border-input'}"
|
: 'border-input'}"
|
||||||
onclick={() => selectTipPercent(tip.pct)}
|
onclick={() => selectTipPercent(tip.pct)}
|
||||||
>
|
>
|
||||||
<div>{tip.pct}%</div>
|
<div>{tip.pct}%</div>
|
||||||
@@ -547,7 +603,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
|
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
@@ -572,25 +628,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||||
Back
|
<Button onclick={handleCardPayment} class="flex-1 bg-green-600 hover:bg-green-700">
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onclick={handleCardPayment}
|
|
||||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Charge Card
|
Charge Card
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if status === 'card-processing' || status === 'card-polling'}
|
{:else if status === 'card-processing' || status === 'card-polling'}
|
||||||
<div class="flex flex-col items-center justify-center py-8">
|
<div class="flex flex-col items-center justify-center py-8">
|
||||||
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div>
|
<div
|
||||||
|
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
|
||||||
|
></div>
|
||||||
<p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p>
|
<p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p>
|
||||||
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
|
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if status === 'cash-entering'}
|
{:else if status === 'cash-entering'}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
||||||
@@ -599,11 +650,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="cash-amount" class="text-sm font-medium text-gray-700">
|
<label for="cash-amount" class="text-sm font-medium text-gray-700"> Cash Received </label>
|
||||||
Cash Received
|
|
||||||
</label>
|
|
||||||
<div class="relative mt-1">
|
<div class="relative mt-1">
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
|
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
|
||||||
<Input
|
<Input
|
||||||
id="cash-amount"
|
id="cash-amount"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -624,10 +673,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if changeDue > 0}
|
{#if changeDue > 0}
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="mt-2 flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox id="keep-change" bind:checked={extraAsTip} />
|
||||||
id="keep-change"
|
|
||||||
bind:checked={extraAsTip}
|
|
||||||
/>
|
|
||||||
<label for="keep-change" class="text-sm text-green-700">
|
<label for="keep-change" class="text-sm text-green-700">
|
||||||
Keep {formatCurrency(changeDue)} as tip
|
Keep {formatCurrency(changeDue)} as tip
|
||||||
</label>
|
</label>
|
||||||
@@ -637,9 +683,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleCashPayment}
|
onclick={handleCashPayment}
|
||||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
class="flex-1 bg-green-600 hover:bg-green-700"
|
||||||
@@ -649,13 +693,13 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if status === 'cash-confirming'}
|
{:else if status === 'cash-confirming'}
|
||||||
<div class="flex flex-col items-center justify-center py-8">
|
<div class="flex flex-col items-center justify-center py-8">
|
||||||
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div>
|
<div
|
||||||
|
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
|
||||||
|
></div>
|
||||||
<p class="text-lg font-medium text-gray-700">Processing cash payment...</p>
|
<p class="text-lg font-medium text-gray-700">Processing cash payment...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if status === 'gift-entering'}
|
{:else if status === 'gift-entering'}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
||||||
@@ -664,9 +708,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="gift-card-id" class="text-sm font-medium text-gray-700">
|
<label for="gift-card-id" class="text-sm font-medium text-gray-700"> Gift Card ID </label>
|
||||||
Gift Card ID
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
id="gift-card-id"
|
id="gift-card-id"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -682,9 +724,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onclick={handleGiftCardPayment}
|
onclick={handleGiftCardPayment}
|
||||||
class="flex-1 bg-green-600 hover:bg-green-700"
|
class="flex-1 bg-green-600 hover:bg-green-700"
|
||||||
@@ -694,28 +734,23 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if status === 'gift-confirming'}
|
{:else if status === 'gift-confirming'}
|
||||||
<div class="flex flex-col items-center justify-center py-8">
|
<div class="flex flex-col items-center justify-center py-8">
|
||||||
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div>
|
<div
|
||||||
|
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
|
||||||
|
></div>
|
||||||
<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
|
<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if status === 'error' && error}
|
{:else if status === 'error' && error}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="rounded-md border border-red-200 bg-red-50 p-3">
|
<div class="rounded-md border border-red-200 bg-red-50 p-3">
|
||||||
<p class="text-sm text-red-800">{error}</p>
|
<p class="text-sm text-red-800">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={handleClose} class="flex-1">
|
<Button variant="ghost" onclick={handleClose} class="flex-1">Close</Button>
|
||||||
Close
|
<Button onclick={resetToSelect} class="flex-1">Try Again</Button>
|
||||||
</Button>
|
|
||||||
<Button onclick={resetToSelect} class="flex-1">
|
|
||||||
Try Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if status === 'success' && paymentResult}
|
{:else if status === 'success' && paymentResult}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex flex-col items-center justify-center py-4">
|
<div class="flex flex-col items-center justify-center py-4">
|
||||||
@@ -759,9 +794,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onclick={handleClose} class="w-full">
|
<Button onclick={handleClose} class="w-full">Done</Button>
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -92,32 +92,31 @@
|
|||||||
let expiryParts = $derived(parseExpiryParts(newCardExpiry));
|
let expiryParts = $derived(parseExpiryParts(newCardExpiry));
|
||||||
|
|
||||||
let isExpiryInPast = $derived(
|
let isExpiryInPast = $derived(
|
||||||
expiryParts !== null && (() => {
|
expiryParts !== null &&
|
||||||
const expiryDate = new SvelteDate(expiryParts.year, expiryParts.month);
|
(() => {
|
||||||
return expiryDate < new SvelteDate();
|
const expiryDate = new SvelteDate(expiryParts.year, expiryParts.month);
|
||||||
})()
|
return expiryDate < new SvelteDate();
|
||||||
|
})()
|
||||||
);
|
);
|
||||||
|
|
||||||
let hasInvalidMonth = $derived(
|
let hasInvalidMonth = $derived(/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null);
|
||||||
/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null
|
|
||||||
);
|
|
||||||
|
|
||||||
let cardFormValid = $derived(
|
let cardFormValid = $derived(
|
||||||
newCardNumber.replace(/\s/g, '').length >= 13 &&
|
newCardNumber.replace(/\s/g, '').length >= 13 &&
|
||||||
expiryParts !== null &&
|
expiryParts !== null &&
|
||||||
newCardCVC.length >= 3 &&
|
newCardCVC.length >= 3 &&
|
||||||
!isExpiryInPast
|
!isExpiryInPast
|
||||||
);
|
);
|
||||||
|
|
||||||
let cardSelected = $derived(
|
let cardSelected = $derived(
|
||||||
(selectedPaymentMethod !== null && paymentMethods.length > 0) ||
|
(selectedPaymentMethod !== null && paymentMethods.length > 0) ||
|
||||||
(showNewCardForm && cardFormValid) ||
|
(showNewCardForm && cardFormValid) ||
|
||||||
(paymentMethods.length === 0 && cardFormValid)
|
(paymentMethods.length === 0 && cardFormValid)
|
||||||
);
|
);
|
||||||
|
|
||||||
let cardValidationError = $derived(
|
let cardValidationError = $derived(
|
||||||
!cardSelected ? (
|
!cardSelected
|
||||||
selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
|
? selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
|
||||||
? 'Please select a card'
|
? 'Please select a card'
|
||||||
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
|
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
|
||||||
? 'Card number too short'
|
? 'Card number too short'
|
||||||
@@ -132,7 +131,7 @@
|
|||||||
: paymentMethods.length === 0 && !showNewCardForm && newCardNumber.length === 0
|
: paymentMethods.length === 0 && !showNewCardForm && newCardNumber.length === 0
|
||||||
? 'Please enter card details'
|
? 'Please enter card details'
|
||||||
: 'Please complete all card fields'
|
: 'Please complete all card fields'
|
||||||
) : null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Partial payment amount (in pounds, user enters)
|
// Partial payment amount (in pounds, user enters)
|
||||||
@@ -143,9 +142,7 @@
|
|||||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
// Derived values
|
// Derived values
|
||||||
let depositOutstanding = $derived(
|
let depositOutstanding = $derived(booking.deposit_required && !booking.deposit_paid);
|
||||||
booking.deposit_required && !booking.deposit_paid
|
|
||||||
);
|
|
||||||
|
|
||||||
let totalPaid = $derived(
|
let totalPaid = $derived(
|
||||||
booking.payments
|
booking.payments
|
||||||
@@ -157,23 +154,21 @@
|
|||||||
|
|
||||||
let isScenarioA = $derived(depositOutstanding);
|
let isScenarioA = $derived(depositOutstanding);
|
||||||
|
|
||||||
let isScenarioB = $derived(
|
let isScenarioB = $derived(!depositOutstanding && totalPaid < booking.total_amount);
|
||||||
!depositOutstanding && totalPaid < booking.total_amount
|
|
||||||
);
|
|
||||||
|
|
||||||
let partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
|
let partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
|
||||||
let partialAmountValid = $derived(
|
let partialAmountValid = $derived(
|
||||||
paymentType === 'partial' &&
|
paymentType === 'partial' &&
|
||||||
partialAmount !== '' &&
|
partialAmount !== '' &&
|
||||||
!isNaN(partialAmountNum) &&
|
!isNaN(partialAmountNum) &&
|
||||||
partialAmountNum > 0 &&
|
partialAmountNum > 0 &&
|
||||||
partialAmountNum <= amountRemaining &&
|
partialAmountNum <= amountRemaining &&
|
||||||
/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
||||||
);
|
);
|
||||||
|
|
||||||
let partialValidationError = $derived(
|
let partialValidationError = $derived(
|
||||||
paymentType === 'partial' && !partialAmountValid ? (
|
paymentType === 'partial' && !partialAmountValid
|
||||||
partialAmount === ''
|
? partialAmount === ''
|
||||||
? 'Enter an amount'
|
? 'Enter an amount'
|
||||||
: !/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
: !/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
||||||
? 'Invalid amount format'
|
? 'Invalid amount format'
|
||||||
@@ -182,7 +177,7 @@
|
|||||||
: partialAmountNum > amountRemaining
|
: partialAmountNum > amountRemaining
|
||||||
? 'Amount exceeds balance'
|
? 'Amount exceeds balance'
|
||||||
: 'Invalid amount'
|
: 'Invalid amount'
|
||||||
) : null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
let payButtonDisabled = $derived(
|
let payButtonDisabled = $derived(
|
||||||
@@ -190,9 +185,13 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let payButtonError = $derived(
|
let payButtonError = $derived(
|
||||||
status === 'processing' ? null : (
|
status === 'processing'
|
||||||
!cardSelected ? cardValidationError : (paymentType === 'partial' ? partialValidationError : null)
|
? null
|
||||||
)
|
: !cardSelected
|
||||||
|
? cardValidationError
|
||||||
|
: paymentType === 'partial'
|
||||||
|
? partialValidationError
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
function formatCurrency(pence: number): string {
|
function formatCurrency(pence: number): string {
|
||||||
@@ -388,7 +387,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||||
<Dialog.Content class="max-w-md max-h-[90vh] overflow-y-auto">
|
<Dialog.Content class="max-h-[90vh] max-w-md overflow-y-auto">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
|
||||||
{#if booking.id}
|
{#if booking.id}
|
||||||
@@ -406,7 +405,11 @@
|
|||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
|
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
|
||||||
<span class="font-medium">
|
<span class="font-medium">
|
||||||
{service.override_price ? formatCurrency(Math.round(service.override_price * 100)) : service.price ? formatCurrency(Math.round(service.price * 100)) : '-'}
|
{service.override_price
|
||||||
|
? formatCurrency(Math.round(service.override_price * 100))
|
||||||
|
: service.price
|
||||||
|
? formatCurrency(Math.round(service.price * 100))
|
||||||
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -417,7 +420,8 @@
|
|||||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
|
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="text-gray-600">Total</span>
|
<span class="text-gray-600">Total</span>
|
||||||
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span>
|
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="text-gray-600">Amount Paid</span>
|
<span class="text-gray-600">Amount Paid</span>
|
||||||
@@ -441,9 +445,13 @@
|
|||||||
{#if selectedPaymentMethod}
|
{#if selectedPaymentMethod}
|
||||||
{#each paymentMethods as method (method.id)}
|
{#each paymentMethods as method (method.id)}
|
||||||
{#if method.id === selectedPaymentMethod}
|
{#if method.id === selectedPaymentMethod}
|
||||||
<div class="flex items-center justify-between rounded-lg border border-input bg-fuchsia-100 p-3">
|
<div
|
||||||
|
class="flex items-center justify-between rounded-lg border border-input bg-fuchsia-100 p-3"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium">
|
<div
|
||||||
|
class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"
|
||||||
|
>
|
||||||
{method.brand}
|
{method.brand}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -474,7 +482,7 @@
|
|||||||
{#if canSaveCards}
|
{#if canSaveCards}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 hover:border-gray-300 transition-colors"
|
class="flex w-full items-center justify-between rounded-lg border p-3 transition-colors hover:border-gray-300"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
showNewCardForm = true;
|
showNewCardForm = true;
|
||||||
showCardList = false;
|
showCardList = false;
|
||||||
@@ -482,7 +490,13 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="text-sm font-medium text-gray-700">Enter card details</div>
|
<div class="text-sm font-medium text-gray-700">Enter card details</div>
|
||||||
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="h-4 w-4 text-gray-400"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<path d="M9 18l6-6-6-6" />
|
<path d="M9 18l6-6-6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -490,7 +504,10 @@
|
|||||||
{#each paymentMethods as method (method.id)}
|
{#each paymentMethods as method (method.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 {selectedPaymentMethod === method.id ? 'border-input bg-fuchsia-100' : 'border-input hover:bg-fuchsia-50'}"
|
class="flex w-full items-center justify-between rounded-lg border p-3 {selectedPaymentMethod ===
|
||||||
|
method.id
|
||||||
|
? 'border-input bg-fuchsia-100'
|
||||||
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
selectedPaymentMethod = method.id;
|
selectedPaymentMethod = method.id;
|
||||||
showNewCardForm = false;
|
showNewCardForm = false;
|
||||||
@@ -498,7 +515,9 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium">
|
<div
|
||||||
|
class="flex h-10 min-w-14 items-center justify-center rounded bg-gray-100 px-2 text-xs font-medium"
|
||||||
|
>
|
||||||
{method.brand}
|
{method.brand}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -522,7 +541,9 @@
|
|||||||
<h4 class="mb-3 text-sm font-medium text-gray-700">Card Details</h4>
|
<h4 class="mb-3 text-sm font-medium text-gray-700">Card Details</h4>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="cardNumber" class="text-sm font-medium text-gray-700">Card Number</label>
|
<label for="cardNumber" class="text-sm font-medium text-gray-700"
|
||||||
|
>Card Number</label
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="cardNumber"
|
id="cardNumber"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -536,7 +557,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="cardExpiry" class="text-sm font-medium text-gray-700">Expiry (MM/YY)</label>
|
<label for="cardExpiry" class="text-sm font-medium text-gray-700"
|
||||||
|
>Expiry (MM/YY)</label
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="cardExpiry"
|
id="cardExpiry"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -582,14 +605,20 @@
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'deposit' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
|
||||||
|
'deposit'
|
||||||
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
onclick={() => (paymentType = 'deposit')}
|
onclick={() => (paymentType = 'deposit')}
|
||||||
>
|
>
|
||||||
Pay Deposit
|
Pay Deposit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'full' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
|
||||||
|
'full'
|
||||||
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
onclick={() => (paymentType = 'full')}
|
onclick={() => (paymentType = 'full')}
|
||||||
>
|
>
|
||||||
Pay in Full
|
Pay in Full
|
||||||
@@ -601,13 +630,17 @@
|
|||||||
<p class="text-sm text-red-600">{payButtonError}</p>
|
<p class="text-sm text-red-600">{payButtonError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
onclick={() => paymentType === 'deposit' ? handlePayDeposit() : handlePayFull()}
|
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
loading={status === 'processing'}
|
loading={status === 'processing'}
|
||||||
disabled={payButtonDisabled}
|
disabled={payButtonDisabled}
|
||||||
>
|
>
|
||||||
{#if paymentType === 'deposit'}
|
{#if paymentType === 'deposit'}
|
||||||
Pay Deposit ({formatCurrency(booking.deposit_amount ? Math.round(booking.deposit_amount * 100) : Math.round(booking.total_amount * 0.2 * 100))})
|
Pay Deposit ({formatCurrency(
|
||||||
|
booking.deposit_amount
|
||||||
|
? Math.round(booking.deposit_amount * 100)
|
||||||
|
: Math.round(booking.total_amount * 0.2 * 100)
|
||||||
|
)})
|
||||||
{:else}
|
{:else}
|
||||||
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
|
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -622,14 +655,20 @@
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'full' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
|
||||||
|
'full'
|
||||||
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
onclick={() => (paymentType = 'full')}
|
onclick={() => (paymentType = 'full')}
|
||||||
>
|
>
|
||||||
Pay in Full
|
Pay in Full
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'partial' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
|
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
|
||||||
|
'partial'
|
||||||
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
onclick={() => (paymentType = 'partial')}
|
onclick={() => (paymentType = 'partial')}
|
||||||
>
|
>
|
||||||
Pay Part
|
Pay Part
|
||||||
@@ -643,7 +682,7 @@
|
|||||||
Partial Payment Amount
|
Partial Payment Amount
|
||||||
</label>
|
</label>
|
||||||
<div class="relative mt-1">
|
<div class="relative mt-1">
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
|
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
|
||||||
<Input
|
<Input
|
||||||
id="partial-amount"
|
id="partial-amount"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -664,13 +703,15 @@
|
|||||||
<p class="text-sm text-red-600">{payButtonError}</p>
|
<p class="text-sm text-red-600">{payButtonError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
onclick={() => paymentType === 'partial' ? handlePayPartial() : handlePayFull()}
|
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
loading={status === 'processing'}
|
loading={status === 'processing'}
|
||||||
disabled={payButtonDisabled}
|
disabled={payButtonDisabled}
|
||||||
>
|
>
|
||||||
{#if paymentType === 'partial'}
|
{#if paymentType === 'partial'}
|
||||||
Pay {partialAmountValid ? formatCurrency(Math.round(partialAmountNum * 100)) : 'Part'}
|
Pay {partialAmountValid
|
||||||
|
? formatCurrency(Math.round(partialAmountNum * 100))
|
||||||
|
: 'Part'}
|
||||||
{:else}
|
{:else}
|
||||||
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
|
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -685,19 +726,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Close button -->
|
<!-- Close button -->
|
||||||
<Button variant="ghost" onclick={handleClose} class="w-full" disabled={status === 'processing'}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onclick={handleClose}
|
||||||
|
class="w-full"
|
||||||
|
disabled={status === 'processing'}
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if status === 'polling'}
|
{:else if status === 'polling'}
|
||||||
<!-- Polling State -->
|
<!-- Polling State -->
|
||||||
<div class="flex flex-col items-center justify-center py-8">
|
<div class="flex flex-col items-center justify-center py-8">
|
||||||
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div>
|
<div
|
||||||
|
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
|
||||||
|
></div>
|
||||||
<p class="text-lg font-medium text-gray-700">Processing payment...</p>
|
<p class="text-lg font-medium text-gray-700">Processing payment...</p>
|
||||||
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
|
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
|
||||||
<Button variant="ghost" onclick={handleClose} class="mt-6">
|
<Button variant="ghost" onclick={handleClose} class="mt-6">Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if status === 'success' && paymentResult}
|
{:else if status === 'success' && paymentResult}
|
||||||
<!-- Success State -->
|
<!-- Success State -->
|
||||||
@@ -750,9 +796,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onclick={handleClose} class="w-full">
|
<Button onclick={handleClose} class="w-full">Done</Button>
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -89,8 +89,18 @@
|
|||||||
if (closingTime) {
|
if (closingTime) {
|
||||||
const [ch, cm] = closingTime.split(':').map(Number);
|
const [ch, cm] = closingTime.split(':').map(Number);
|
||||||
const today = new SvelteDate();
|
const today = new SvelteDate();
|
||||||
const closing = new SvelteDate(today.getFullYear(), today.getMonth() + 1, today.getDate(), ch, cm, 0);
|
const closing = new SvelteDate(
|
||||||
const minutesToClosing = Math.max(0, Math.floor((closing.getTime() - endTime.getTime()) / 60000));
|
today.getFullYear(),
|
||||||
|
today.getMonth() + 1,
|
||||||
|
today.getDate(),
|
||||||
|
ch,
|
||||||
|
cm,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const minutesToClosing = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((closing.getTime() - endTime.getTime()) / 60000)
|
||||||
|
);
|
||||||
|
|
||||||
if (!nextAppointment) {
|
if (!nextAppointment) {
|
||||||
// No next appointment — show time until closing
|
// No next appointment — show time until closing
|
||||||
@@ -213,7 +223,7 @@
|
|||||||
{#if activeAppointment}
|
{#if activeAppointment}
|
||||||
{#if isInProgress}
|
{#if isInProgress}
|
||||||
<div class="flex flex-row flex-wrap gap-2 sm:flex-col sm:items-end">
|
<div class="flex flex-row flex-wrap gap-2 sm:flex-col sm:items-end">
|
||||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap">
|
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
||||||
<span class="relative mr-2 flex h-2 w-2">
|
<span class="relative mr-2 flex h-2 w-2">
|
||||||
<span
|
<span
|
||||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
|
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
|
||||||
@@ -224,18 +234,19 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
{#if timeRemaining > 29}
|
{#if timeRemaining > 29}
|
||||||
{#if freeTimeCapped && freeTimeAfter === 0}
|
{#if freeTimeCapped && freeTimeAfter === 0}
|
||||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap">
|
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
||||||
closing after
|
closing after
|
||||||
</Badge>
|
</Badge>
|
||||||
{:else if freeTimeAfter > 0}
|
{:else if freeTimeAfter > 0}
|
||||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap">
|
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
|
||||||
{freeTimeAfter} min free afterwards{#if freeTimeCapped} (closing after){/if}
|
{freeTimeAfter} min free afterwards{#if freeTimeCapped}
|
||||||
|
(closing after){/if}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Badge class="bg-amber-100 px-3 py-1 text-sm text-amber-800 whitespace-nowrap">
|
<Badge class="bg-amber-100 px-3 py-1 text-sm whitespace-nowrap text-amber-800">
|
||||||
Starts in {timeRemaining} min
|
Starts in {timeRemaining} min
|
||||||
</Badge>
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -246,7 +257,7 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<Card.Content class="space-y-4">
|
<Card.Content class="space-y-4">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<Skeleton class="h-16 w-16 md:h-20 md:w-20 rounded-full" />
|
<Skeleton class="h-16 w-16 rounded-full md:h-20 md:w-20" />
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<Skeleton class="h-6 w-48" />
|
<Skeleton class="h-6 w-48" />
|
||||||
<Skeleton class="h-4 w-32" />
|
<Skeleton class="h-4 w-32" />
|
||||||
@@ -280,23 +291,29 @@
|
|||||||
<Card.Content>
|
<Card.Content>
|
||||||
<div class="grid gap-6 md:grid-cols-3">
|
<div class="grid gap-6 md:grid-cols-3">
|
||||||
<!-- Customer Info -->
|
<!-- Customer Info -->
|
||||||
<div class="flex items-center gap-3 sm:gap-4 min-w-0">
|
<div class="flex min-w-0 items-center gap-3 sm:gap-4">
|
||||||
{#if activeAppointment.user?.profile_pic_url}
|
{#if activeAppointment.user?.profile_pic_url}
|
||||||
<img
|
<img
|
||||||
src={activeAppointment.user.profile_pic_url}
|
src={activeAppointment.user.profile_pic_url}
|
||||||
alt={activeAppointment.user.full_name}
|
alt={activeAppointment.user.full_name}
|
||||||
class="h-14 w-14 sm:h-16 sm:w-16 md:h-20 md:w-20 shrink-0 rounded-full object-cover ring-2 sm:ring-4 ring-blue-200"
|
class="h-14 w-14 shrink-0 rounded-full object-cover ring-2 ring-blue-200 sm:h-16 sm:w-16 sm:ring-4 md:h-20 md:w-20"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'}
|
{@const initials =
|
||||||
|
activeAppointment.user?.full_name
|
||||||
|
?.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('') || '?'}
|
||||||
<div
|
<div
|
||||||
class="flex h-14 w-14 sm:h-16 sm:w-16 md:h-20 md:w-20 shrink-0 items-center justify-center rounded-full bg-gray-200 text-xl sm:text-2xl font-bold text-gray-600 ring-2 sm:ring-4 ring-blue-200"
|
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-gray-200 text-xl font-bold text-gray-600 ring-2 ring-blue-200 sm:h-16 sm:w-16 sm:text-2xl sm:ring-4 md:h-20 md:w-20"
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate text-lg font-semibold">{activeAppointment.user?.full_name || 'Guest'}</div>
|
<div class="truncate text-lg font-semibold">
|
||||||
|
{activeAppointment.user?.full_name || 'Guest'}
|
||||||
|
</div>
|
||||||
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
|
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
|
||||||
{#if activeAppointment.user}
|
{#if activeAppointment.user}
|
||||||
<button
|
<button
|
||||||
@@ -321,7 +338,11 @@
|
|||||||
<div class="text-xs text-gray-600">{service.service_description}</div>
|
<div class="text-xs text-gray-600">{service.service_description}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="mt-1 flex items-center justify-between text-xs text-gray-500">
|
<div class="mt-1 flex items-center justify-between text-xs text-gray-500">
|
||||||
<span>{formatDuration(service.override_duration_minutes ?? service.duration_minutes ?? 0)}</span>
|
<span
|
||||||
|
>{formatDuration(
|
||||||
|
service.override_duration_minutes ?? service.duration_minutes ?? 0
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -413,7 +434,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onclick={handleTakePayment} class="col-span-2 bg-green-600 hover:bg-green-700">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={handleTakePayment}
|
||||||
|
class="col-span-2 bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="mr-2 h-4 w-4"
|
class="mr-2 h-4 w-4"
|
||||||
@@ -437,5 +462,9 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
{#if showPaymentModal}
|
{#if showPaymentModal}
|
||||||
<PaymentModal booking={activeAppointment} onClose={() => showPaymentModal = false} onComplete={handlePaymentComplete} />
|
<PaymentModal
|
||||||
|
booking={activeAppointment}
|
||||||
|
onClose={() => (showPaymentModal = false)}
|
||||||
|
onComplete={handlePaymentComplete}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -93,7 +93,9 @@
|
|||||||
let selectedEditRequest = $state<EditRequest | null>(null);
|
let selectedEditRequest = $state<EditRequest | null>(null);
|
||||||
|
|
||||||
let _hasItems = $derived(pendingApprovals.length > 0 || pendingEditRequests.length > 0);
|
let _hasItems = $derived(pendingApprovals.length > 0 || pendingEditRequests.length > 0);
|
||||||
$effect(() => { hasItems = _hasItems; });
|
$effect(() => {
|
||||||
|
hasItems = _hasItems;
|
||||||
|
});
|
||||||
|
|
||||||
// Helper function to format date nicely
|
// Helper function to format date nicely
|
||||||
function formatDateTime(dateTimeString: string): string {
|
function formatDateTime(dateTimeString: string): string {
|
||||||
@@ -396,7 +398,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium">{er.user?.full_name || 'Unknown'}</span>
|
<span class="font-medium">{er.user?.full_name || 'Unknown'}</span>
|
||||||
<span class="text-xs text-amber-600 font-medium">Edit Request</span>
|
<span class="text-xs font-medium text-amber-600">Edit Request</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm text-gray-600">
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
{getEditRequestSummary(er)}
|
{getEditRequestSummary(er)}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import { calculateMiddleWindow, extractBookedSlots, findAllLunchGaps } from '$lib/lunchProtection';
|
import {
|
||||||
|
calculateMiddleWindow,
|
||||||
|
extractBookedSlots,
|
||||||
|
findAllLunchGaps
|
||||||
|
} from '$lib/lunchProtection';
|
||||||
import { formatDuration } from '$lib/utils/format';
|
import { formatDuration } from '$lib/utils/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -125,8 +129,6 @@
|
|||||||
return { hour, minute, period };
|
return { hour, minute, period };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function isAutoGenerated(blocker: TimeBlocker): boolean {
|
function isAutoGenerated(blocker: TimeBlocker): boolean {
|
||||||
return blocker.description?.startsWith('RESERVATION:') ?? false;
|
return blocker.description?.startsWith('RESERVATION:') ?? false;
|
||||||
}
|
}
|
||||||
@@ -136,7 +138,13 @@
|
|||||||
if (!wh || !wh.isOpen) return [];
|
if (!wh || !wh.isOpen) return [];
|
||||||
const startMin = timeToMinutes(wh.startTime);
|
const startMin = timeToMinutes(wh.startTime);
|
||||||
const endMin = timeToMinutes(wh.endTime);
|
const endMin = timeToMinutes(wh.endTime);
|
||||||
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
|
const options: Array<{
|
||||||
|
hour: string;
|
||||||
|
minute: string;
|
||||||
|
period: 'AM' | 'PM';
|
||||||
|
totalMin: number;
|
||||||
|
label: string;
|
||||||
|
}> = [];
|
||||||
for (let m = startMin; m < endMin; m += 15) {
|
for (let m = startMin; m < endMin; m += 15) {
|
||||||
const t = minutesTo12h(m);
|
const t = minutesTo12h(m);
|
||||||
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
|
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
|
||||||
@@ -149,8 +157,18 @@
|
|||||||
if (!wh || !wh.isOpen) return [];
|
if (!wh || !wh.isOpen) return [];
|
||||||
const endMin = timeToMinutes(wh.endTime);
|
const endMin = timeToMinutes(wh.endTime);
|
||||||
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
|
const currentStartMin = timeToMinutes(to24h(startHour, startMinute, startPeriod));
|
||||||
const options: Array<{ hour: string; minute: string; period: 'AM' | 'PM'; totalMin: number; label: string }> = [];
|
const options: Array<{
|
||||||
for (let m = Math.max(timeToMinutes(wh.startTime), currentStartMin + 15); m <= endMin; m += 15) {
|
hour: string;
|
||||||
|
minute: string;
|
||||||
|
period: 'AM' | 'PM';
|
||||||
|
totalMin: number;
|
||||||
|
label: string;
|
||||||
|
}> = [];
|
||||||
|
for (
|
||||||
|
let m = Math.max(timeToMinutes(wh.startTime), currentStartMin + 15);
|
||||||
|
m <= endMin;
|
||||||
|
m += 15
|
||||||
|
) {
|
||||||
const t = minutesTo12h(m);
|
const t = minutesTo12h(m);
|
||||||
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
|
options.push({ ...t, totalMin: m, label: `${t.hour}:${t.minute} ${t.period}` });
|
||||||
}
|
}
|
||||||
@@ -195,7 +213,13 @@
|
|||||||
const largestGap = gapInfos[0];
|
const largestGap = gapInfos[0];
|
||||||
const startTimeStr = `${minutesTo12h(largestGap.start).hour}:${minutesTo12h(largestGap.start).minute} ${minutesTo12h(largestGap.start).period}`;
|
const startTimeStr = `${minutesTo12h(largestGap.start).hour}:${minutesTo12h(largestGap.start).minute} ${minutesTo12h(largestGap.start).period}`;
|
||||||
const endTimeStr = `${minutesTo12h(largestGap.end).hour}:${minutesTo12h(largestGap.end).minute} ${minutesTo12h(largestGap.end).period}`;
|
const endTimeStr = `${minutesTo12h(largestGap.end).hour}:${minutesTo12h(largestGap.end).minute} ${minutesTo12h(largestGap.end).period}`;
|
||||||
return { startMin: largestGap.start, endMin: largestGap.end, duration: largestGap.duration, startLabel: startTimeStr, endLabel: endTimeStr };
|
return {
|
||||||
|
startMin: largestGap.start,
|
||||||
|
endMin: largestGap.end,
|
||||||
|
duration: largestGap.duration,
|
||||||
|
startLabel: startTimeStr,
|
||||||
|
endLabel: endTimeStr
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
type AppointmentTimelineItem = {
|
type AppointmentTimelineItem = {
|
||||||
@@ -257,7 +281,11 @@
|
|||||||
startMinutes: suggestedLunch.startMin,
|
startMinutes: suggestedLunch.startMin,
|
||||||
endMinutes: suggestedLunch.endMin,
|
endMinutes: suggestedLunch.endMin,
|
||||||
type: 'lunch',
|
type: 'lunch',
|
||||||
data: { duration: suggestedLunch.duration, startLabel: suggestedLunch.startLabel, endLabel: suggestedLunch.endLabel }
|
data: {
|
||||||
|
duration: suggestedLunch.duration,
|
||||||
|
startLabel: suggestedLunch.startLabel,
|
||||||
|
endLabel: suggestedLunch.endLabel
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,15 +336,33 @@
|
|||||||
try {
|
try {
|
||||||
const dateParam = today;
|
const dateParam = today;
|
||||||
const [blockersRes, whRes, ahRes] = await Promise.all([
|
const [blockersRes, whRes, ahRes] = await Promise.all([
|
||||||
fetch(`/api/admin/time-blockers?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
|
fetch(
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
|
`/api/admin/time-blockers?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
|
||||||
}),
|
{
|
||||||
fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
|
headers: {
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
|
'Content-Type': 'application/json',
|
||||||
}),
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
fetch(`/api/scheduling/available-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
|
}
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
|
}
|
||||||
})
|
),
|
||||||
|
fetch(
|
||||||
|
`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`/api/scheduling/available-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (blockersRes.ok) {
|
if (blockersRes.ok) {
|
||||||
@@ -357,33 +403,58 @@
|
|||||||
async function checkOverlappingBookings() {
|
async function checkOverlappingBookings() {
|
||||||
const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
|
const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
|
||||||
const endIso = buildDateTime(today, endHour, endMinute, endPeriod);
|
const endIso = buildDateTime(today, endHour, endMinute, endPeriod);
|
||||||
if (!startIso || !endIso) { overlappingBookings = []; hasOverlap = false; return; }
|
if (!startIso || !endIso) {
|
||||||
|
overlappingBookings = [];
|
||||||
|
hasOverlap = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const blockerStart = new SvelteDate(startIso);
|
const blockerStart = new SvelteDate(startIso);
|
||||||
const blockerEnd = new SvelteDate(endIso);
|
const blockerEnd = new SvelteDate(endIso);
|
||||||
if (blockerEnd.getTime() <= blockerStart.getTime()) { overlappingBookings = []; hasOverlap = false; return; }
|
if (blockerEnd.getTime() <= blockerStart.getTime()) {
|
||||||
|
overlappingBookings = [];
|
||||||
|
hasOverlap = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkingOverlap = true;
|
checkingOverlap = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`, {
|
const response = await fetch(
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
|
`/api/admin/bookings/by-date-range?start=${encodeURIComponent(today)}&end=${encodeURIComponent(today)}`,
|
||||||
});
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const allBookings: OverlappingBooking[] = data.bookings || [];
|
const allBookings: OverlappingBooking[] = data.bookings || [];
|
||||||
const filtered = allBookings.filter((b) => {
|
const filtered = allBookings.filter((b) => {
|
||||||
const bStart = new SvelteDate(b.start_time);
|
const bStart = new SvelteDate(b.start_time);
|
||||||
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
|
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
|
||||||
return bStart < blockerEnd && bEnd > blockerStart;
|
return bStart < blockerEnd && bEnd > blockerStart;
|
||||||
});
|
});
|
||||||
overlappingBookings = filtered;
|
overlappingBookings = filtered;
|
||||||
hasOverlap = overlappingBookings.length > 0;
|
hasOverlap = overlappingBookings.length > 0;
|
||||||
} else { overlappingBookings = []; hasOverlap = false; }
|
} else {
|
||||||
|
overlappingBookings = [];
|
||||||
|
hasOverlap = false;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error checking overlapping bookings:', err);
|
console.error('Error checking overlapping bookings:', err);
|
||||||
overlappingBookings = []; hasOverlap = false;
|
overlappingBookings = [];
|
||||||
} finally { checkingOverlap = false; }
|
hasOverlap = false;
|
||||||
|
} finally {
|
||||||
|
checkingOverlap = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDateTime(date: string, hour: string, minute: string, period: 'AM' | 'PM'): string | null {
|
function buildDateTime(
|
||||||
|
date: string,
|
||||||
|
hour: string,
|
||||||
|
minute: string,
|
||||||
|
period: 'AM' | 'PM'
|
||||||
|
): string | null {
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
const t24 = to24h(hour, minute, period);
|
const t24 = to24h(hour, minute, period);
|
||||||
return `${date}T${t24}:00`;
|
return `${date}T${t24}:00`;
|
||||||
@@ -404,8 +475,15 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/time-blockers', {
|
const response = await fetch('/api/admin/time-blockers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` },
|
headers: {
|
||||||
body: JSON.stringify({ start_time: start.toISOString(), duration_minutes: durationMinutes, description: newDescription.trim() || 'break' })
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
start_time: start.toISOString(),
|
||||||
|
duration_minutes: durationMinutes,
|
||||||
|
description: newDescription.trim() || 'break'
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success('Time blocker created!', { id: loadingToast });
|
toast.success('Time blocker created!', { id: loadingToast });
|
||||||
@@ -419,7 +497,9 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating time blocker:', err);
|
console.error('Error creating time blocker:', err);
|
||||||
toast.error('Network error creating time blocker', { id: loadingToast });
|
toast.error('Network error creating time blocker', { id: loadingToast });
|
||||||
} finally { creating = false; }
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteBlocker() {
|
async function confirmDeleteBlocker() {
|
||||||
@@ -447,9 +527,14 @@
|
|||||||
|
|
||||||
function resetCreateForm() {
|
function resetCreateForm() {
|
||||||
newDescription = '';
|
newDescription = '';
|
||||||
startHour = '9'; startMinute = '00'; startPeriod = 'AM';
|
startHour = '9';
|
||||||
endHour = '10'; endMinute = '00'; endPeriod = 'AM';
|
startMinute = '00';
|
||||||
overlappingBookings = []; hasOverlap = false;
|
startPeriod = 'AM';
|
||||||
|
endHour = '10';
|
||||||
|
endMinute = '00';
|
||||||
|
endPeriod = 'AM';
|
||||||
|
overlappingBookings = [];
|
||||||
|
hasOverlap = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
@@ -457,8 +542,12 @@
|
|||||||
if (workingHours && workingHours.isOpen) {
|
if (workingHours && workingHours.isOpen) {
|
||||||
const startOpt = minutesTo12h(timeToMinutes(workingHours.startTime));
|
const startOpt = minutesTo12h(timeToMinutes(workingHours.startTime));
|
||||||
const endOpt = minutesTo12h(timeToMinutes(workingHours.startTime) + 60);
|
const endOpt = minutesTo12h(timeToMinutes(workingHours.startTime) + 60);
|
||||||
startHour = startOpt.hour; startMinute = startOpt.minute; startPeriod = startOpt.period;
|
startHour = startOpt.hour;
|
||||||
endHour = endOpt.hour; endMinute = endOpt.minute; endPeriod = endOpt.period;
|
startMinute = startOpt.minute;
|
||||||
|
startPeriod = startOpt.period;
|
||||||
|
endHour = endOpt.hour;
|
||||||
|
endMinute = endOpt.minute;
|
||||||
|
endPeriod = endOpt.period;
|
||||||
}
|
}
|
||||||
showCreateModal = true;
|
showCreateModal = true;
|
||||||
}
|
}
|
||||||
@@ -551,7 +640,14 @@
|
|||||||
<Card.Description>Timeline view of all bookings</Card.Description>
|
<Card.Description>Timeline view of all bookings</Card.Description>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onclick={openCreateModal}>
|
<Button variant="outline" size="sm" onclick={openCreateModal}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mr-1 h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -563,15 +659,15 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each Array(5) as _, i (i)}
|
{#each Array(5) as _, i (i)}
|
||||||
<div class="flex items-center gap-3 sm:gap-4 rounded-lg border p-3">
|
<div class="flex items-center gap-3 rounded-lg border p-3 sm:gap-4">
|
||||||
<Skeleton class="h-4 w-[45px] sm:w-[60px] lg:w-[80px]" />
|
<Skeleton class="h-4 w-[45px] sm:w-[60px] lg:w-[80px]" />
|
||||||
<Skeleton class="h-10 w-1 shrink-0" />
|
<Skeleton class="h-10 w-1 shrink-0" />
|
||||||
<div class="flex-1 space-y-2 min-w-0">
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
<Skeleton class="h-4 w-32" />
|
<Skeleton class="h-4 w-32" />
|
||||||
<Skeleton class="h-3 w-48" />
|
<Skeleton class="h-3 w-48" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton class="h-6 w-20 hidden sm:block shrink-0" />
|
<Skeleton class="hidden h-6 w-20 shrink-0 sm:block" />
|
||||||
<Skeleton class="h-8 w-9 sm:w-16 shrink-0" />
|
<Skeleton class="h-8 w-9 shrink-0 sm:w-16" />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -599,38 +695,51 @@
|
|||||||
{#each timeline as item (item.id)}
|
{#each timeline as item (item.id)}
|
||||||
{#if item.type === 'appointment'}
|
{#if item.type === 'appointment'}
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3 sm:gap-4 rounded-lg border p-3 transition-all hover:shadow-md
|
class="flex items-center gap-3 rounded-lg border p-3 transition-all hover:shadow-md sm:gap-4
|
||||||
{isPastAppointment(item.data.start_time, item.data.duration_minutes) ? 'line-through opacity-50' : ''}"
|
{isPastAppointment(item.data.start_time, item.data.duration_minutes)
|
||||||
|
? 'line-through opacity-50'
|
||||||
|
: ''}"
|
||||||
>
|
>
|
||||||
<div class="min-w-[45px] sm:min-w-[60px] lg:min-w-[80px] text-xs sm:text-sm font-semibold text-gray-700 shrink-0">
|
<div
|
||||||
|
class="min-w-[45px] shrink-0 text-xs font-semibold text-gray-700 sm:min-w-[60px] sm:text-sm lg:min-w-[80px]"
|
||||||
|
>
|
||||||
{formatTime(item.data.start_time)}
|
{formatTime(item.data.start_time)}
|
||||||
</div>
|
</div>
|
||||||
<div class="h-10 w-1 shrink-0 rounded {getStatusBarColor(item.data.status)}"></div>
|
<div class="h-10 w-1 shrink-0 rounded {getStatusBarColor(item.data.status)}"></div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="truncate font-medium hover:text-blue-600 hover:underline max-w-full block"
|
class="block max-w-full truncate font-medium hover:text-blue-600 hover:underline"
|
||||||
onclick={() => openUserModal(item.data.user_id)}
|
onclick={() => openUserModal(item.data.user_id)}
|
||||||
>
|
>
|
||||||
{item.data.user_name}
|
{item.data.user_name}
|
||||||
</button>
|
</button>
|
||||||
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
|
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
|
||||||
<span class="truncate">{item.data.services.join(', ')}</span>
|
<span class="truncate">{item.data.services.join(', ')}</span>
|
||||||
<span class="shrink-0 rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium">
|
<span
|
||||||
|
class="shrink-0 rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium"
|
||||||
|
>
|
||||||
{formatDuration(item.data.duration_minutes)}
|
{formatDuration(item.data.duration_minutes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge class="{getStatusColor(item.data.status)} hidden sm:inline-flex shrink-0">
|
<Badge class="{getStatusColor(item.data.status)} hidden shrink-0 sm:inline-flex">
|
||||||
{formatStatus(item.data.status)}
|
{formatStatus(item.data.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="shrink-0 sm:px-3 h-9 w-9 sm:w-auto"
|
class="h-9 w-9 shrink-0 sm:w-auto sm:px-3"
|
||||||
onclick={() => openBookingModal(item.data.id)}
|
onclick={() => openBookingModal(item.data.id)}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 sm:mr-1.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4 sm:mr-1.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -638,39 +747,77 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if item.type === 'blocker'}
|
{:else if item.type === 'blocker'}
|
||||||
<div class="flex items-center gap-4 rounded-lg border border-amber-200 bg-amber-50/40 p-3 transition-all hover:shadow-sm">
|
<div
|
||||||
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-gray-700">
|
class="flex items-center gap-4 rounded-lg border border-amber-200 bg-amber-50/40 p-3 transition-all hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="min-w-[60px] text-sm font-semibold text-gray-700 sm:min-w-[80px]">
|
||||||
{formatTime(item.data.start_time)}
|
{formatTime(item.data.start_time)}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-amber-100">
|
<div
|
||||||
<svg class="h-4 w-4 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-amber-100"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-amber-600"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-1">
|
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-1">
|
||||||
<div class="truncate text-sm font-medium text-gray-900">{item.data.description || 'Untitled'}</div>
|
<div class="truncate text-sm font-medium text-gray-900">
|
||||||
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
{item.data.description || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600"
|
||||||
|
>
|
||||||
{formatDuration(item.data.duration_minutes)}
|
{formatDuration(item.data.duration_minutes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive" size="sm" class="shrink-0 h-9 w-9 p-0"
|
variant="destructive"
|
||||||
onclick={() => { blockerToDelete = item.data; showDeleteAlert = true; }}
|
size="sm"
|
||||||
|
class="h-9 w-9 shrink-0 p-0"
|
||||||
|
onclick={() => {
|
||||||
|
blockerToDelete = item.data;
|
||||||
|
showDeleteAlert = true;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-3 w-3"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<polyline points="3 6 5 6 21 6" />
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
<path
|
||||||
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if item.type === 'lunch'}
|
{:else if item.type === 'lunch'}
|
||||||
<div class="flex items-center gap-4 rounded-lg border border-green-200 bg-green-50 p-3">
|
<div
|
||||||
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-green-700">
|
class="flex items-center gap-4 rounded-lg border border-green-200 bg-green-50 p-3"
|
||||||
|
>
|
||||||
|
<div class="min-w-[60px] text-sm font-semibold text-green-700 sm:min-w-[80px]">
|
||||||
{item.data.startLabel}
|
{item.data.startLabel}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-green-100">
|
<div
|
||||||
<svg class="h-4 w-4 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
class="flex h-10 w-6 shrink-0 items-center justify-center rounded bg-green-100"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-green-600"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<path d="M18 8h1a4 4 0 0 1 0 8h-1" />
|
<path d="M18 8h1a4 4 0 0 1 0 8h-1" />
|
||||||
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" />
|
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z" />
|
||||||
<line x1="6" y1="1" x2="6" y2="4" />
|
<line x1="6" y1="1" x2="6" y2="4" />
|
||||||
@@ -705,7 +852,12 @@
|
|||||||
<div class="space-y-5 px-4 pb-4">
|
<div class="space-y-5 px-4 pb-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label for="blocker-desc" class="text-sm font-medium">Description</label>
|
<label for="blocker-desc" class="text-sm font-medium">Description</label>
|
||||||
<Input id="blocker-desc" type="text" placeholder="e.g., Lunch, Appointment, Break" bind:value={newDescription} />
|
<Input
|
||||||
|
id="blocker-desc"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Lunch, Appointment, Break"
|
||||||
|
bind:value={newDescription}
|
||||||
|
/>
|
||||||
<p class="text-xs text-gray-400">Defaults to "break" if left empty</p>
|
<p class="text-xs text-gray-400">Defaults to "break" if left empty</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -718,7 +870,17 @@
|
|||||||
{:else if !workingHours.isOpen}
|
{:else if !workingHours.isOpen}
|
||||||
<p class="text-sm text-red-500">Closed today</p>
|
<p class="text-sm text-red-500">Closed today</p>
|
||||||
{:else}
|
{:else}
|
||||||
<select class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" bind:value={startSelectValue} onchange={() => { const p = parseSelectValue(startSelectValue); startHour = p.hour; startMinute = p.minute; startPeriod = p.period; checkOverlappingBookings(); }}>
|
<select
|
||||||
|
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
bind:value={startSelectValue}
|
||||||
|
onchange={() => {
|
||||||
|
const p = parseSelectValue(startSelectValue);
|
||||||
|
startHour = p.hour;
|
||||||
|
startMinute = p.minute;
|
||||||
|
startPeriod = p.period;
|
||||||
|
checkOverlappingBookings();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{#each availableStartOptions as opt}
|
{#each availableStartOptions as opt}
|
||||||
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
|
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -736,7 +898,17 @@
|
|||||||
{:else if availableEndOptions.length === 0}
|
{:else if availableEndOptions.length === 0}
|
||||||
<p class="text-sm text-gray-400">No available end time after selected start</p>
|
<p class="text-sm text-gray-400">No available end time after selected start</p>
|
||||||
{:else}
|
{:else}
|
||||||
<select class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm" bind:value={endSelectValue} onchange={() => { const p = parseSelectValue(endSelectValue); endHour = p.hour; endMinute = p.minute; endPeriod = p.period; checkOverlappingBookings(); }}>
|
<select
|
||||||
|
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||||
|
bind:value={endSelectValue}
|
||||||
|
onchange={() => {
|
||||||
|
const p = parseSelectValue(endSelectValue);
|
||||||
|
endHour = p.hour;
|
||||||
|
endMinute = p.minute;
|
||||||
|
endPeriod = p.period;
|
||||||
|
checkOverlappingBookings();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{#each availableEndOptions as opt}
|
{#each availableEndOptions as opt}
|
||||||
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
|
<option value={`${opt.hour}:${opt.minute}:${opt.period}`}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -746,16 +918,36 @@
|
|||||||
{#if checkingOverlap}
|
{#if checkingOverlap}
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
<circle
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Checking for conflicting bookings…
|
Checking for conflicting bookings…
|
||||||
</div>
|
</div>
|
||||||
{:else if hasOverlap}
|
{:else if hasOverlap}
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="mb-2 flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4 shrink-0 text-amber-600"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||||
|
/>
|
||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -765,17 +957,32 @@
|
|||||||
{#each overlappingBookings as booking (booking.id)}
|
{#each overlappingBookings as booking (booking.id)}
|
||||||
<div class="rounded-md border border-amber-200 bg-white p-2">
|
<div class="rounded-md border border-amber-200 bg-white p-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-medium text-sm">{booking.user?.full_name || 'Unknown'}</div>
|
<div class="text-sm font-medium">{booking.user?.full_name || 'Unknown'}</div>
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-gray-500">
|
||||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit', hour12: true })}
|
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
})}
|
||||||
{' · '}{formatDuration(booking.duration_minutes)}
|
{' · '}{formatDuration(booking.duration_minutes)}
|
||||||
{#if booking.services?.length} {' · '}{booking.services.join(', ')}{/if}
|
{#if booking.services?.length}
|
||||||
|
{' · '}{booking.services.join(', ')}{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 mt-1.5">
|
<div class="mt-1.5 flex gap-2">
|
||||||
<Button variant="outline" size="sm" class="text-xs h-6" onclick={() => openBookingModal(booking.id)}>View Booking</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 text-xs"
|
||||||
|
onclick={() => openBookingModal(booking.id)}>View Booking</Button
|
||||||
|
>
|
||||||
{#if booking.user?.id}
|
{#if booking.user?.id}
|
||||||
<Button variant="outline" size="sm" class="text-xs h-6" onclick={() => openUserModal(booking.user!.id)}>View Client</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 text-xs"
|
||||||
|
onclick={() => openUserModal(booking.user!.id)}>View Client</Button
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -785,7 +992,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" onclick={() => { showCreateModal = false; resetCreateForm(); }} disabled={creating}>Cancel</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
showCreateModal = false;
|
||||||
|
resetCreateForm();
|
||||||
|
}}
|
||||||
|
disabled={creating}>Cancel</Button
|
||||||
|
>
|
||||||
<Button onclick={createBlocker} disabled={!canCreate || creating || checkingOverlap}>
|
<Button onclick={createBlocker} disabled={!canCreate || creating || checkingOverlap}>
|
||||||
{creating ? 'Creating…' : 'Create Blocker'}
|
{creating ? 'Creating…' : 'Create Blocker'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -797,11 +1011,21 @@
|
|||||||
<AlertDialog.Content class="z-[60]">
|
<AlertDialog.Content class="z-[60]">
|
||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
<AlertDialog.Title>Delete time blocker?</AlertDialog.Title>
|
||||||
<AlertDialog.Description>This will remove the "{blockerToDelete?.description}" time blocker. This action cannot be undone.</AlertDialog.Description>
|
<AlertDialog.Description
|
||||||
|
>This will remove the "{blockerToDelete?.description}" time blocker. This action cannot be
|
||||||
|
undone.</AlertDialog.Description
|
||||||
|
>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
<AlertDialog.Cancel onclick={() => { showDeleteAlert = false; blockerToDelete = null; }}>Cancel</AlertDialog.Cancel>
|
<AlertDialog.Cancel
|
||||||
<AlertDialog.Action onclick={confirmDeleteBlocker} class="bg-red-600 hover:bg-red-700">Delete</AlertDialog.Action>
|
onclick={() => {
|
||||||
|
showDeleteAlert = false;
|
||||||
|
blockerToDelete = null;
|
||||||
|
}}>Cancel</AlertDialog.Cancel
|
||||||
|
>
|
||||||
|
<AlertDialog.Action onclick={confirmDeleteBlocker} class="bg-red-600 hover:bg-red-700"
|
||||||
|
>Delete</AlertDialog.Action
|
||||||
|
>
|
||||||
</AlertDialog.Footer>
|
</AlertDialog.Footer>
|
||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
|
|||||||
@@ -57,9 +57,15 @@
|
|||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
const res = await fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateStr)}&end=${encodeURIComponent(dateStr)}`, {
|
const res = await fetch(
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
|
`/api/scheduling/working-hours?start=${encodeURIComponent(dateStr)}&end=${encodeURIComponent(dateStr)}`,
|
||||||
});
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: DayWorkingHours[] = await res.json();
|
const data: DayWorkingHours[] = await res.json();
|
||||||
@@ -88,11 +94,20 @@
|
|||||||
const dateParam = today;
|
const dateParam = today;
|
||||||
const [apptsRes, whRes] = await Promise.all([
|
const [apptsRes, whRes] = await Promise.all([
|
||||||
fetch(`/api/admin/today/appointments`, {
|
fetch(`/api/admin/today/appointments`, {
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
fetch(`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`, {
|
fetch(
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
|
`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
|
||||||
})
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let appointments: TodayAppointment[] = [];
|
let appointments: TodayAppointment[] = [];
|
||||||
@@ -111,34 +126,60 @@
|
|||||||
const total = appointments.length;
|
const total = appointments.length;
|
||||||
const completed = appointments.filter((a) => a.status === 'completed').length;
|
const completed = appointments.filter((a) => a.status === 'completed').length;
|
||||||
const inProgress = appointments.filter((a) => a.status === 'in_progress').length;
|
const inProgress = appointments.filter((a) => a.status === 'in_progress').length;
|
||||||
const upcoming = appointments.filter((a) => a.status === 'confirmed' || a.status === 'pending').length;
|
const upcoming = appointments.filter(
|
||||||
const cancelled = appointments.filter((a) => a.status === 'client_cancelled' || a.status === 'we_cancelled').length;
|
(a) => a.status === 'confirmed' || a.status === 'pending'
|
||||||
|
).length;
|
||||||
|
const cancelled = appointments.filter(
|
||||||
|
(a) => a.status === 'client_cancelled' || a.status === 'we_cancelled'
|
||||||
|
).length;
|
||||||
const noShow = appointments.filter((a) => a.status === 'no_show').length;
|
const noShow = appointments.filter((a) => a.status === 'no_show').length;
|
||||||
|
|
||||||
let occupancyPercent = 0;
|
let occupancyPercent = 0;
|
||||||
if (workingHours?.isOpen) {
|
if (workingHours?.isOpen) {
|
||||||
const workingMinutes = timeToMinutes(workingHours.endTime) - timeToMinutes(workingHours.startTime);
|
const workingMinutes =
|
||||||
|
timeToMinutes(workingHours.endTime) - timeToMinutes(workingHours.startTime);
|
||||||
const bookedMinutes = appointments
|
const bookedMinutes = appointments
|
||||||
.filter((a) => a.status !== 'client_cancelled' && a.status !== 'we_cancelled' && a.status !== 'no_show')
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.status !== 'client_cancelled' &&
|
||||||
|
a.status !== 'we_cancelled' &&
|
||||||
|
a.status !== 'no_show'
|
||||||
|
)
|
||||||
.reduce((sum, a) => sum + a.duration_minutes, 0);
|
.reduce((sum, a) => sum + a.duration_minutes, 0);
|
||||||
occupancyPercent = workingMinutes > 0 ? Math.round((bookedMinutes / workingMinutes) * 100) : 0;
|
occupancyPercent =
|
||||||
|
workingMinutes > 0 ? Math.round((bookedMinutes / workingMinutes) * 100) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cutoff, spansClosed } = await findLastWorkingDayClose();
|
const { cutoff, spansClosed } = await findLastWorkingDayClose();
|
||||||
const nowISO = new SvelteDate().toISOString();
|
const nowISO = new SvelteDate().toISOString();
|
||||||
let bookingsMade = 0;
|
let bookingsMade = 0;
|
||||||
try {
|
try {
|
||||||
const bmRes = await fetch(`/api/admin/bookings/by-created-range?start=${encodeURIComponent(cutoff)}&end=${encodeURIComponent(nowISO)}`, {
|
const bmRes = await fetch(
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }
|
`/api/admin/bookings/by-created-range?start=${encodeURIComponent(cutoff)}&end=${encodeURIComponent(nowISO)}`,
|
||||||
});
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
if (bmRes.ok) {
|
if (bmRes.ok) {
|
||||||
const data = await bmRes.json();
|
const data = await bmRes.json();
|
||||||
bookingsMade = (data.bookings || []).length;
|
bookingsMade = (data.bookings || []).length;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
}
|
|
||||||
|
|
||||||
const newStats: Stats = { total, completed, inProgress, upcoming, cancelled, noShow, occupancyPercent, bookingsMade, bookingsMadeOverClosed: spansClosed };
|
const newStats: Stats = {
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
inProgress,
|
||||||
|
upcoming,
|
||||||
|
cancelled,
|
||||||
|
noShow,
|
||||||
|
occupancyPercent,
|
||||||
|
bookingsMade,
|
||||||
|
bookingsMadeOverClosed: spansClosed
|
||||||
|
};
|
||||||
const newJson = JSON.stringify(newStats);
|
const newJson = JSON.stringify(newStats);
|
||||||
if (newJson !== prevJson) {
|
if (newJson !== prevJson) {
|
||||||
stats = newStats;
|
stats = newStats;
|
||||||
@@ -223,7 +264,9 @@
|
|||||||
{#if stats.bookingsMade > 0}
|
{#if stats.bookingsMade > 0}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-600">
|
<span class="text-sm text-gray-600">
|
||||||
Bookings Made{#if stats.bookingsMadeOverClosed}<sup class="text-xs text-gray-400">*</sup>{/if}
|
Bookings Made{#if stats.bookingsMadeOverClosed}<sup class="text-xs text-gray-400"
|
||||||
|
>*</sup
|
||||||
|
>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-semibold">{stats.bookingsMade}</span>
|
<span class="font-semibold">{stats.bookingsMade}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,6 +13,6 @@
|
|||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-cancel"
|
data-slot="alert-dialog-cancel"
|
||||||
class={cn(buttonVariants({ variant: "outline" }), className)}
|
class={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 mm:max-w-lg fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=closed]:duration-[130ms]',
|
'mm:max-w-lg fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,6 +12,6 @@
|
|||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-description"
|
data-slot="alert-dialog-description"
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn('text-sm text-muted-foreground', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="alert-dialog-header"
|
data-slot="alert-dialog-header"
|
||||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
class={cn(
|
class={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:duration-[130ms]",
|
'fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,6 +12,6 @@
|
|||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="alert-dialog-title"
|
data-slot="alert-dialog-title"
|
||||||
class={cn("text-lg font-semibold", className)}
|
class={cn('text-lg font-semibold', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||||
import Trigger from "./alert-dialog-trigger.svelte";
|
import Trigger from './alert-dialog-trigger.svelte';
|
||||||
import Title from "./alert-dialog-title.svelte";
|
import Title from './alert-dialog-title.svelte';
|
||||||
import Action from "./alert-dialog-action.svelte";
|
import Action from './alert-dialog-action.svelte';
|
||||||
import Cancel from "./alert-dialog-cancel.svelte";
|
import Cancel from './alert-dialog-cancel.svelte';
|
||||||
import Footer from "./alert-dialog-footer.svelte";
|
import Footer from './alert-dialog-footer.svelte';
|
||||||
import Header from "./alert-dialog-header.svelte";
|
import Header from './alert-dialog-header.svelte';
|
||||||
import Overlay from "./alert-dialog-overlay.svelte";
|
import Overlay from './alert-dialog-overlay.svelte';
|
||||||
import Content from "./alert-dialog-content.svelte";
|
import Content from './alert-dialog-content.svelte';
|
||||||
import Description from "./alert-dialog-description.svelte";
|
import Description from './alert-dialog-description.svelte';
|
||||||
|
|
||||||
const Root = AlertDialogPrimitive.Root;
|
const Root = AlertDialogPrimitive.Root;
|
||||||
const Portal = AlertDialogPrimitive.Portal;
|
const Portal = AlertDialogPrimitive.Portal;
|
||||||
@@ -35,5 +35,5 @@ export {
|
|||||||
Trigger as AlertDialogTrigger,
|
Trigger as AlertDialogTrigger,
|
||||||
Overlay as AlertDialogOverlay,
|
Overlay as AlertDialogOverlay,
|
||||||
Content as AlertDialogContent,
|
Content as AlertDialogContent,
|
||||||
Description as AlertDialogDescription,
|
Description as AlertDialogDescription
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
class={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
|
class={cn('flex size-full items-center justify-center rounded-full bg-muted', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,36 +1,35 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
export const badgeVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
|
||||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
|
||||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href,
|
href,
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
<svelte:element
|
||||||
this={href ? "a" : "span"}
|
this={href ? 'a' : 'span'}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
{href}
|
{href}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as Badge } from "./badge.svelte";
|
export { default as Badge } from './badge.svelte';
|
||||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
export { badgeVariants, type BadgeVariant } from './badge.svelte';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from 'svelte';
|
||||||
import type Calendar from "./calendar.svelte";
|
import type Calendar from './calendar.svelte';
|
||||||
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
import CalendarMonthSelect from './calendar-month-select.svelte';
|
||||||
import CalendarYearSelect from "./calendar-year-select.svelte";
|
import CalendarYearSelect from './calendar-year-select.svelte';
|
||||||
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
import { DateFormatter, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
captionLayout,
|
captionLayout,
|
||||||
@@ -14,13 +14,13 @@
|
|||||||
month,
|
month,
|
||||||
locale,
|
locale,
|
||||||
placeholder = $bindable(),
|
placeholder = $bindable(),
|
||||||
monthIndex = 0,
|
monthIndex = 0
|
||||||
}: {
|
}: {
|
||||||
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
|
||||||
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
months: ComponentProps<typeof CalendarMonthSelect>['months'];
|
||||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
|
||||||
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
years: ComponentProps<typeof CalendarYearSelect>['years'];
|
||||||
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
|
||||||
month: DateValue;
|
month: DateValue;
|
||||||
placeholder: DateValue | undefined;
|
placeholder: DateValue | undefined;
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -29,13 +29,13 @@
|
|||||||
|
|
||||||
function formatYear(date: DateValue) {
|
function formatYear(date: DateValue) {
|
||||||
const dateObj = date.toDate(getLocalTimeZone());
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
if (typeof yearFormat === 'function') return yearFormat(dateObj.getFullYear());
|
||||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMonth(date: DateValue) {
|
function formatMonth(date: DateValue) {
|
||||||
const dateObj = date.toDate(getLocalTimeZone());
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
if (typeof monthFormat === 'function') return monthFormat(dateObj.getMonth() + 1);
|
||||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -58,15 +58,15 @@
|
|||||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if captionLayout === "dropdown"}
|
{#if captionLayout === 'dropdown'}
|
||||||
{@render MonthSelect()}
|
{@render MonthSelect()}
|
||||||
{@render YearSelect()}
|
{@render YearSelect()}
|
||||||
{:else if captionLayout === "dropdown-months"}
|
{:else if captionLayout === 'dropdown-months'}
|
||||||
{@render MonthSelect()}
|
{@render MonthSelect()}
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
{formatYear(placeholder)}
|
{formatYear(placeholder)}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if captionLayout === "dropdown-years"}
|
{:else if captionLayout === 'dropdown-years'}
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
{formatMonth(placeholder)}
|
{formatMonth(placeholder)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<CalendarPrimitive.Cell
|
<CalendarPrimitive.Cell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md",
|
'relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,24 +13,24 @@
|
|||||||
<CalendarPrimitive.Day
|
<CalendarPrimitive.Day
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: 'ghost' }),
|
||||||
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
|
'flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none',
|
||||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground',
|
||||||
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
'data-[selected]:bg-primary data-[selected]:text-primary-foreground dark:data-[selected]:hover:bg-accent/50',
|
||||||
// Outside months
|
// Outside months
|
||||||
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
'[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground',
|
||||||
// Disabled
|
// Disabled
|
||||||
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
'data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||||
// Unavailable
|
// Unavailable
|
||||||
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through',
|
||||||
// Today dot (::after pseudo-element on [data-today])
|
// Today dot (::after pseudo-element on [data-today])
|
||||||
"[&[data-today]]:after:block [&[data-today]]:after:content-[''] [&[data-today]]:after:w-1 [&[data-today]]:after:h-1 [&[data-today]]:after:bg-current [&[data-today]]:after:rounded-full [&[data-today]]:after:mx-auto [&[data-today]]:after:mt-[-2px] [&[data-today]]:after:opacity-70",
|
"[&[data-today]]:after:mx-auto [&[data-today]]:after:mt-[-2px] [&[data-today]]:after:block [&[data-today]]:after:h-1 [&[data-today]]:after:w-1 [&[data-today]]:after:rounded-full [&[data-today]]:after:bg-current [&[data-today]]:after:opacity-70 [&[data-today]]:after:content-['']",
|
||||||
// hover
|
// hover
|
||||||
"dark:hover:text-accent-foreground",
|
'dark:hover:text-accent-foreground',
|
||||||
// focus
|
// focus
|
||||||
"focus:border-ring focus:ring-ring/50 focus:relative",
|
'focus:relative focus:border-ring focus:ring-ring/50',
|
||||||
// inner spans
|
// inner spans
|
||||||
"[&>span]:text-xs [&>span]:opacity-70",
|
'[&>span]:text-xs [&>span]:opacity-70',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -9,4 +9,4 @@
|
|||||||
}: CalendarPrimitive.GridRowProps = $props();
|
}: CalendarPrimitive.GridRowProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
<CalendarPrimitive.GridRow bind:ref class={cn('flex', className)} {...restProps} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
|
|
||||||
<CalendarPrimitive.Grid
|
<CalendarPrimitive.Grid
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
class={cn('mt-4 flex w-full border-collapse flex-col gap-1', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<CalendarPrimitive.HeadCell
|
<CalendarPrimitive.HeadCell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
'w-(--cell-size) rounded-md text-[0.8rem] font-normal text-muted-foreground',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<CalendarPrimitive.Header
|
<CalendarPrimitive.Header
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
|
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
|
|
||||||
<CalendarPrimitive.Heading
|
<CalendarPrimitive.Heading
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
class={cn('px-(--cell-size) text-sm font-medium', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
'relative flex rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
|
class="flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type WithElementRef, cn } from "$lib/utils.js";
|
import { type WithElementRef, cn } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -10,6 +10,6 @@
|
|||||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
<div {...restProps} bind:this={ref} class={cn('flex flex-col', className)}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
class={cn('relative flex flex-col gap-4 md:flex-row', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<nav
|
<nav
|
||||||
{...restProps}
|
{...restProps}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
class={cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', className)}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
variant = "ghost",
|
variant = 'ghost',
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.NextButtonProps & {
|
}: CalendarPrimitive.NextButtonProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant }),
|
buttonVariants({ variant }),
|
||||||
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
|
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
children={children || Fallback}
|
children={children || Fallback}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
variant = "ghost",
|
variant = 'ghost',
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.PrevButtonProps & {
|
}: CalendarPrimitive.PrevButtonProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant }),
|
buttonVariants({ variant }),
|
||||||
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
|
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
children={children || Fallback}
|
children={children || Fallback}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
'relative flex rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
|
class="flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ get along, so we shut typescript up by casting `value` to `never`.
|
|||||||
class={cn(
|
class={cn(
|
||||||
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||||
// Add fuchsia theme styles for selected date
|
// Add fuchsia theme styles for selected date
|
||||||
'[&_[data-selected]]:border-primary [&_[data-selected]]:text-primary [&_[data-selected]]:rounded-md [&_[data-selected]]:bg-fuchsia-100 [&_[data-selected]]:shadow-sm',
|
'[&_[data-selected]]:rounded-md [&_[data-selected]]:border-primary [&_[data-selected]]:bg-fuchsia-100 [&_[data-selected]]:text-primary [&_[data-selected]]:shadow-sm',
|
||||||
'[&_[data-selected]:hover]:bg-fuchsia-50',
|
'[&_[data-selected]:hover]:bg-fuchsia-50',
|
||||||
// Ensure today indicator works with fuchsia theme
|
// Ensure today indicator works with fuchsia theme
|
||||||
'[&_[data-today]:not([data-selected])]:bg-accent [&_[data-today]:not([data-selected])]:text-accent-foreground',
|
'[&_[data-today]:not([data-selected])]:bg-accent [&_[data-today]:not([data-selected])]:text-accent-foreground',
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import Root from "./calendar.svelte";
|
import Root from './calendar.svelte';
|
||||||
import Cell from "./calendar-cell.svelte";
|
import Cell from './calendar-cell.svelte';
|
||||||
import Day from "./calendar-day.svelte";
|
import Day from './calendar-day.svelte';
|
||||||
import Grid from "./calendar-grid.svelte";
|
import Grid from './calendar-grid.svelte';
|
||||||
import Header from "./calendar-header.svelte";
|
import Header from './calendar-header.svelte';
|
||||||
import Months from "./calendar-months.svelte";
|
import Months from './calendar-months.svelte';
|
||||||
import GridRow from "./calendar-grid-row.svelte";
|
import GridRow from './calendar-grid-row.svelte';
|
||||||
import Heading from "./calendar-heading.svelte";
|
import Heading from './calendar-heading.svelte';
|
||||||
import GridBody from "./calendar-grid-body.svelte";
|
import GridBody from './calendar-grid-body.svelte';
|
||||||
import GridHead from "./calendar-grid-head.svelte";
|
import GridHead from './calendar-grid-head.svelte';
|
||||||
import HeadCell from "./calendar-head-cell.svelte";
|
import HeadCell from './calendar-head-cell.svelte';
|
||||||
import NextButton from "./calendar-next-button.svelte";
|
import NextButton from './calendar-next-button.svelte';
|
||||||
import PrevButton from "./calendar-prev-button.svelte";
|
import PrevButton from './calendar-prev-button.svelte';
|
||||||
import MonthSelect from "./calendar-month-select.svelte";
|
import MonthSelect from './calendar-month-select.svelte';
|
||||||
import YearSelect from "./calendar-year-select.svelte";
|
import YearSelect from './calendar-year-select.svelte';
|
||||||
import Month from "./calendar-month.svelte";
|
import Month from './calendar-month.svelte';
|
||||||
import Nav from "./calendar-nav.svelte";
|
import Nav from './calendar-nav.svelte';
|
||||||
import Caption from "./calendar-caption.svelte";
|
import Caption from './calendar-caption.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Day,
|
Day,
|
||||||
@@ -36,5 +36,5 @@ export {
|
|||||||
MonthSelect,
|
MonthSelect,
|
||||||
Caption,
|
Caption,
|
||||||
//
|
//
|
||||||
Root as Calendar,
|
Root as Calendar
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -10,6 +10,6 @@
|
|||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
<div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<p
|
<p
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn('text-sm text-muted-foreground', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
class={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
class={cn(
|
class={cn(
|
||||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
class={cn("font-semibold leading-none", className)}
|
class={cn('leading-none font-semibold', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
class={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6', className)}
|
class={cn('flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Root from "./card.svelte";
|
import Root from './card.svelte';
|
||||||
import Content from "./card-content.svelte";
|
import Content from './card-content.svelte';
|
||||||
import Description from "./card-description.svelte";
|
import Description from './card-description.svelte';
|
||||||
import Footer from "./card-footer.svelte";
|
import Footer from './card-footer.svelte';
|
||||||
import Header from "./card-header.svelte";
|
import Header from './card-header.svelte';
|
||||||
import Title from "./card-title.svelte";
|
import Title from './card-title.svelte';
|
||||||
import Action from "./card-action.svelte";
|
import Action from './card-action.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -21,5 +21,5 @@ export {
|
|||||||
Footer as CardFooter,
|
Footer as CardFooter,
|
||||||
Header as CardHeader,
|
Header as CardHeader,
|
||||||
Title as CardTitle,
|
Title as CardTitle,
|
||||||
Action as CardAction,
|
Action as CardAction
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
import { Checkbox as CheckboxPrimitive } from 'bits-ui';
|
||||||
import CheckIcon from "@lucide/svelte/icons/check";
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
import MinusIcon from "@lucide/svelte/icons/minus";
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
class={cn(
|
class={cn(
|
||||||
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
'peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
bind:checked
|
bind:checked
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Root from "./checkbox.svelte";
|
import Root from './checkbox.svelte';
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Checkbox,
|
Root as Checkbox
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=closed]:duration-[130ms] sm:max-w-lg',
|
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if !hideClose}
|
{#if !hideClose}
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
class="ring-offset-background focus:ring-ring absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
class="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">Close</span>
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
class={cn('text-muted-foreground text-sm', className)}
|
class={cn('text-sm text-muted-foreground', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
class={cn(
|
class={cn(
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:duration-[130ms]',
|
'fixed inset-0 z-50 bg-black/50 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Button from "$lib/components/ui/button/index.js";
|
import * as Button from '$lib/components/ui/button/index.js';
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps }: Button.Props = $props();
|
let { ref = $bindable(null), ...restProps }: Button.Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as FormPrimitive from "formsnap";
|
import * as FormPrimitive from 'formsnap';
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,6 +12,6 @@
|
|||||||
<FormPrimitive.Description
|
<FormPrimitive.Description
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="form-description"
|
data-slot="form-description"
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn('text-sm text-muted-foreground', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
|
||||||
import * as FormPrimitive from "formsnap";
|
import * as FormPrimitive from 'formsnap';
|
||||||
import type { FormPathLeaves } from "sveltekit-superforms";
|
import type { FormPathLeaves } from 'sveltekit-superforms';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<FormPrimitive.ElementField {form} {name}>
|
<FormPrimitive.ElementField {form} {name}>
|
||||||
{#snippet children({ constraints, errors, tainted, value })}
|
{#snippet children({ constraints, errors, tainted, value })}
|
||||||
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}>
|
<div bind:this={ref} class={cn('space-y-2', className)} {...restProps}>
|
||||||
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
|
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as FormPrimitive from "formsnap";
|
import * as FormPrimitive from 'formsnap';
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<FormPrimitive.FieldErrors
|
<FormPrimitive.FieldErrors
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("text-destructive text-sm font-medium", className)}
|
class={cn('text-sm font-medium text-destructive', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet children({ errors, errorProps })}
|
{#snippet children({ errors, errorProps })}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
|
||||||
import * as FormPrimitive from "formsnap";
|
import * as FormPrimitive from 'formsnap';
|
||||||
import type { FormPath } from "sveltekit-superforms";
|
import type { FormPath } from 'sveltekit-superforms';
|
||||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -17,12 +17,7 @@
|
|||||||
|
|
||||||
<FormPrimitive.Field {form} {name}>
|
<FormPrimitive.Field {form} {name}>
|
||||||
{#snippet children({ constraints, errors, tainted, value })}
|
{#snippet children({ constraints, errors, tainted, value })}
|
||||||
<div
|
<div bind:this={ref} data-slot="form-item" class={cn('space-y-2', className)} {...restProps}>
|
||||||
bind:this={ref}
|
|
||||||
data-slot="form-item"
|
|
||||||
class={cn("space-y-2", className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
|
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
|
||||||
import * as FormPrimitive from "formsnap";
|
import * as FormPrimitive from 'formsnap';
|
||||||
import type { FormPath } from "sveltekit-superforms";
|
import type { FormPath } from 'sveltekit-superforms';
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -12,4 +12,4 @@
|
|||||||
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
|
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FormPrimitive.Fieldset bind:ref {form} {name} class={cn("space-y-2", className)} {...restProps} />
|
<FormPrimitive.Fieldset bind:ref {form} {name} class={cn('space-y-2', className)} {...restProps} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as FormPrimitive from "formsnap";
|
import * as FormPrimitive from 'formsnap';
|
||||||
import { Label } from "$lib/components/ui/label/index.js";
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<Label
|
<Label
|
||||||
{...props}
|
{...props}
|
||||||
data-slot="form-label"
|
data-slot="form-label"
|
||||||
class={cn("data-[fs-error]:text-destructive", className)}
|
class={cn('data-[fs-error]:text-destructive', className)}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</Label>
|
</Label>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as FormPrimitive from "formsnap";
|
import * as FormPrimitive from 'formsnap';
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
|
|
||||||
<FormPrimitive.Legend
|
<FormPrimitive.Legend
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn("data-[fs-error]:text-destructive text-sm font-medium leading-none", className)}
|
class={cn('text-sm leading-none font-medium data-[fs-error]:text-destructive', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as FormPrimitive from "formsnap";
|
import * as FormPrimitive from 'formsnap';
|
||||||
import Description from "./form-description.svelte";
|
import Description from './form-description.svelte';
|
||||||
import Label from "./form-label.svelte";
|
import Label from './form-label.svelte';
|
||||||
import FieldErrors from "./form-field-errors.svelte";
|
import FieldErrors from './form-field-errors.svelte';
|
||||||
import Field from "./form-field.svelte";
|
import Field from './form-field.svelte';
|
||||||
import Fieldset from "./form-fieldset.svelte";
|
import Fieldset from './form-fieldset.svelte';
|
||||||
import Legend from "./form-legend.svelte";
|
import Legend from './form-legend.svelte';
|
||||||
import ElementField from "./form-element-field.svelte";
|
import ElementField from './form-element-field.svelte';
|
||||||
import Button from "./form-button.svelte";
|
import Button from './form-button.svelte';
|
||||||
|
|
||||||
const Control = FormPrimitive.Control;
|
const Control = FormPrimitive.Control;
|
||||||
|
|
||||||
@@ -29,5 +29,5 @@ export {
|
|||||||
Fieldset as FormFieldset,
|
Fieldset as FormFieldset,
|
||||||
Legend as FormLegend,
|
Legend as FormLegend,
|
||||||
ElementField as FormElementField,
|
ElementField as FormElementField,
|
||||||
Button as FormButton,
|
Button as FormButton
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
{@render child({ src: previewState.rootState.src })}
|
{@render child({ src: previewState.rootState.src })}
|
||||||
{:else}
|
{:else}
|
||||||
<Avatar.Root
|
<Avatar.Root
|
||||||
class={cn('ring-accent ring-offset-background size-20 ring-2 ring-offset-2', className)}
|
class={cn('size-20 ring-2 ring-accent ring-offset-2 ring-offset-background', className)}
|
||||||
>
|
>
|
||||||
<Avatar.Image src={previewState.rootState.src} />
|
<Avatar.Image src={previewState.rootState.src} />
|
||||||
<Avatar.Fallback>
|
<Avatar.Fallback>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Root from "./input.svelte";
|
import Root from './input.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Input,
|
Root as Input
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
|
||||||
|
|
||||||
type Props = WithElementRef<
|
type Props = WithElementRef<
|
||||||
Omit<HTMLInputAttributes, "type"> &
|
Omit<HTMLInputAttributes, 'type'> &
|
||||||
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
|
||||||
>;
|
>;
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -15,19 +15,19 @@
|
|||||||
type,
|
type,
|
||||||
files = $bindable(),
|
files = $bindable(),
|
||||||
class: className,
|
class: className,
|
||||||
"data-slot": dataSlot = "input",
|
'data-slot': dataSlot = 'input',
|
||||||
...restProps
|
...restProps
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if type === "file"}
|
{#if type === 'file'}
|
||||||
<input
|
<input
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot={dataSlot}
|
data-slot={dataSlot}
|
||||||
class={cn(
|
class={cn(
|
||||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
|
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30',
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot={dataSlot}
|
data-slot={dataSlot}
|
||||||
class={cn(
|
class={cn(
|
||||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{type}
|
{type}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Root from "./label.svelte";
|
import Root from './label.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Label,
|
Root as Label
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label as LabelPrimitive } from "bits-ui";
|
import { Label as LabelPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
import Group from "./select-group.svelte";
|
import Group from './select-group.svelte';
|
||||||
import Label from "./select-label.svelte";
|
import Label from './select-label.svelte';
|
||||||
import Item from "./select-item.svelte";
|
import Item from './select-item.svelte';
|
||||||
import Content from "./select-content.svelte";
|
import Content from './select-content.svelte';
|
||||||
import Trigger from "./select-trigger.svelte";
|
import Trigger from './select-trigger.svelte';
|
||||||
import Separator from "./select-separator.svelte";
|
import Separator from './select-separator.svelte';
|
||||||
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
import ScrollDownButton from './select-scroll-down-button.svelte';
|
||||||
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
import ScrollUpButton from './select-scroll-up-button.svelte';
|
||||||
import GroupHeading from "./select-group-heading.svelte";
|
import GroupHeading from './select-group-heading.svelte';
|
||||||
|
|
||||||
const Root = SelectPrimitive.Root;
|
const Root = SelectPrimitive.Root;
|
||||||
|
|
||||||
@@ -33,5 +33,5 @@ export {
|
|||||||
Separator as SelectSeparator,
|
Separator as SelectSeparator,
|
||||||
ScrollDownButton as SelectScrollDownButton,
|
ScrollDownButton as SelectScrollDownButton,
|
||||||
ScrollUpButton as SelectScrollUpButton,
|
ScrollUpButton as SelectScrollUpButton,
|
||||||
GroupHeading as SelectGroupHeading,
|
GroupHeading as SelectGroupHeading
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
import SelectScrollUpButton from './select-scroll-up-button.svelte';
|
||||||
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
import SelectScrollDownButton from './select-scroll-down-button.svelte';
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
{sideOffset}
|
{sideOffset}
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
'relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport
|
||||||
class={cn(
|
class={cn(
|
||||||
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
|
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<SelectPrimitive.GroupHeading
|
<SelectPrimitive.GroupHeading
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="select-group-heading"
|
data-slot="select-group-heading"
|
||||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CheckIcon from "@lucide/svelte/icons/check";
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
{value}
|
{value}
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
class={cn(
|
class={cn(
|
||||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
class={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon class="size-4" />
|
<ChevronDownIcon class="size-4" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
class={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon class="size-4" />
|
<ChevronUpIcon class="size-4" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
import type { Separator as SeparatorPrimitive } from 'bits-ui';
|
||||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -13,6 +13,6 @@
|
|||||||
<Separator
|
<Separator
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
class={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Select as SelectPrimitive } from "bits-ui";
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
size = "default",
|
size = 'default',
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||||
size?: "sm" | "default";
|
size?: 'sm' | 'default';
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
class={cn(
|
class={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Root from "./separator.svelte";
|
import Root from './separator.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Separator,
|
Root as Separator
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
import { Separator as SeparatorPrimitive } from 'bits-ui';
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
"data-slot": dataSlot = "separator",
|
'data-slot': dataSlot = 'separator',
|
||||||
...restProps
|
...restProps
|
||||||
}: SeparatorPrimitive.RootProps = $props();
|
}: SeparatorPrimitive.RootProps = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot={dataSlot}
|
data-slot={dataSlot}
|
||||||
class={cn(
|
class={cn(
|
||||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user