Split admin dashboard, implement user and booking search
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
// =============== Types ===============
|
||||
type WorkingHourRow = {
|
||||
weekday: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_open: boolean;
|
||||
};
|
||||
|
||||
// =============== State ===============
|
||||
let defaultHours = $state<WorkingHourRow[]>([]);
|
||||
let defaultHoursIsLoading = $state(true);
|
||||
let defaultHoursDraft = $state<WorkingHourRow[]>([]);
|
||||
let showDefaultHoursModal = $state(false);
|
||||
let showSaveDefaultHoursAlert = $state(false);
|
||||
let savingHours = $state(false);
|
||||
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
// =============== Helper Functions ===============
|
||||
/** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */
|
||||
function formatTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
|
||||
// Special case for 12:00
|
||||
if (hours === 12 && minutes === 0) {
|
||||
return 'Noon';
|
||||
} else if (hours === 0 && minutes === 0) {
|
||||
return 'Midnight';
|
||||
}
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
/** Convert formatted time back to HH:MM for input fields */
|
||||
function timeToInputValue(time: string): string {
|
||||
// Handle special cases
|
||||
if (time === 'Noon') return '12:00';
|
||||
if (time === 'Midnight') return '00:00';
|
||||
|
||||
// Parse 12-hour format
|
||||
const match = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
||||
if (!match) return time; // Return as-is if not in expected format
|
||||
|
||||
let hours = parseInt(match[1]);
|
||||
const minutes = match[2];
|
||||
const period = match[3].toUpperCase();
|
||||
|
||||
if (period === 'PM' && hours !== 12) hours += 12;
|
||||
if (period === 'AM' && hours === 12) hours = 0;
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes}`;
|
||||
}
|
||||
|
||||
function weekdayLabel(i: number): string {
|
||||
return dayNames[i];
|
||||
}
|
||||
|
||||
/** Calculate hours between start and end time */
|
||||
function calculateHours(startTime: string, endTime: string): string {
|
||||
// Convert formatted times to 24-hour format for calculation
|
||||
const start = timeToInputValue(startTime);
|
||||
const end = timeToInputValue(endTime);
|
||||
|
||||
const [startHours, startMinutes] = start.split(':').map(Number);
|
||||
const [endHours, endMinutes] = end.split(':').map(Number);
|
||||
|
||||
const startTotalMinutes = startHours * 60 + startMinutes;
|
||||
const endTotalMinutes = endHours * 60 + endMinutes;
|
||||
|
||||
const diffMinutes = endTotalMinutes - startTotalMinutes;
|
||||
const hours = Math.floor(diffMinutes / 60);
|
||||
const minutes = diffMinutes % 60;
|
||||
|
||||
if (minutes === 0) {
|
||||
return `${hours}`;
|
||||
}
|
||||
return `${hours}.${minutes === 30 ? '5' : Math.round((minutes / 60) * 10)}`;
|
||||
}
|
||||
|
||||
// =============== API Functions ===============
|
||||
async function fetchDefaultHours() {
|
||||
if (!browser) return;
|
||||
|
||||
defaultHoursIsLoading = true;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scheduling/default-hours', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
defaultHours = data.map((hour) => ({
|
||||
weekday: hour.weekday,
|
||||
start_time: formatTime(hour.startTime),
|
||||
end_time: formatTime(hour.endTime),
|
||||
is_open: hour.isOpen
|
||||
}));
|
||||
} else {
|
||||
const text = await response.text();
|
||||
error = 'Failed to load working hours: ' + text;
|
||||
console.error('Error fetching default hours:', text);
|
||||
}
|
||||
} catch (err) {
|
||||
error = 'Network error: ' + (err instanceof Error ? err.message : 'Unknown error');
|
||||
console.error('Error fetching default hours:', err);
|
||||
} finally {
|
||||
if (error) toast.error(error);
|
||||
defaultHoursIsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the modal and creates a deep copy of current hours for editing. */
|
||||
function prepareDefaultHoursEdit() {
|
||||
// Deep copy the current default hours into the draft state
|
||||
defaultHoursDraft = JSON.parse(JSON.stringify(defaultHours));
|
||||
// Convert display format back to input format
|
||||
defaultHoursDraft = defaultHoursDraft.map((row) => ({
|
||||
...row,
|
||||
start_time: timeToInputValue(row.start_time),
|
||||
end_time: timeToInputValue(row.end_time)
|
||||
}));
|
||||
showDefaultHoursModal = true;
|
||||
}
|
||||
|
||||
/** Saves the default hours draft after confirmation. */
|
||||
async function confirmSaveDefaultHours() {
|
||||
savingHours = true;
|
||||
const loadingToast = toast.loading('Saving default hours...');
|
||||
|
||||
try {
|
||||
// Map snake_case to camelCase for API
|
||||
const payload = defaultHoursDraft.map((hour) => ({
|
||||
weekday: hour.weekday,
|
||||
startTime: hour.start_time,
|
||||
endTime: hour.end_time,
|
||||
isOpen: hour.is_open
|
||||
}));
|
||||
|
||||
const response = await fetch('/api/scheduling/default-hours', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update the main state from the draft state if successful
|
||||
defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft));
|
||||
showDefaultHoursModal = false;
|
||||
showSaveDefaultHoursAlert = false;
|
||||
toast.success('Default hours saved successfully!', { id: loadingToast });
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
toast.error('Unauthorized. Please log in again.', { id: loadingToast });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to save: ' + text, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('save default hours', err);
|
||||
toast.error('Network error saving hours', { id: loadingToast });
|
||||
} finally {
|
||||
savingHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Effects ===============
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
fetchDefaultHours();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Default Hours Card -->
|
||||
<Card.Root>
|
||||
{#if !defaultHoursIsLoading}
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Weekly Schedule</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Your standard operating hours for each day of the week
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={prepareDefaultHoursEdit} disabled={savingHours}>
|
||||
<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"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit Schedule
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Desktop Table -->
|
||||
<div class="hidden w-full overflow-x-auto sm:block">
|
||||
<table class="w-full table-auto border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="px-4 py-3">Day</th>
|
||||
<th class="px-4 py-3 text-center">Status</th>
|
||||
<th class="px-4 py-3">Opening Time</th>
|
||||
<th class="px-4 py-3">Closing Time</th>
|
||||
<th class="px-4 py-3 text-right">Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{#each defaultHours as row (row.weekday)}
|
||||
<tr class="transition-colors hover:bg-gray-50">
|
||||
<td class="p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium"
|
||||
>{weekdayLabel(row.weekday) === 'Mon'
|
||||
? 'Monday'
|
||||
: weekdayLabel(row.weekday) === 'Tue'
|
||||
? 'Tuesday'
|
||||
: weekdayLabel(row.weekday) === 'Wed'
|
||||
? 'Wednesday'
|
||||
: weekdayLabel(row.weekday) === 'Thu'
|
||||
? 'Thursday'
|
||||
: weekdayLabel(row.weekday) === 'Fri'
|
||||
? 'Friday'
|
||||
: weekdayLabel(row.weekday) === 'Sat'
|
||||
? 'Saturday'
|
||||
: 'Sunday'}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-center">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium {row.is_open
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
<span
|
||||
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
|
||||
? 'bg-emerald-600'
|
||||
: 'bg-gray-600'}"
|
||||
></span>
|
||||
{row.is_open ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
{#if row.is_open}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{row.start_time}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
{#if row.is_open}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{row.end_time}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-4 text-right">
|
||||
{#if row.is_open}
|
||||
<span class="inline-flex items-center gap-1 text-sm font-medium text-gray-700">
|
||||
{calculateHours(row.start_time, row.end_time)}
|
||||
<span class="text-xs text-gray-500">hrs</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards -->
|
||||
<div class="space-y-3 sm:hidden">
|
||||
{#each defaultHours as row (row.weekday)}
|
||||
<div class="rounded-lg border p-4 transition-colors hover:bg-gray-50">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-900">
|
||||
{weekdayLabel(row.weekday) === 'Mon'
|
||||
? 'Monday'
|
||||
: weekdayLabel(row.weekday) === 'Tue'
|
||||
? 'Tuesday'
|
||||
: weekdayLabel(row.weekday) === 'Wed'
|
||||
? 'Wednesday'
|
||||
: weekdayLabel(row.weekday) === 'Thu'
|
||||
? 'Thursday'
|
||||
: weekdayLabel(row.weekday) === 'Fri'
|
||||
? 'Friday'
|
||||
: weekdayLabel(row.weekday) === 'Sat'
|
||||
? 'Saturday'
|
||||
: 'Sunday'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium {row.is_open
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
<span
|
||||
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
|
||||
? 'bg-emerald-600'
|
||||
: 'bg-gray-600'}"
|
||||
></span>
|
||||
{row.is_open ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if row.is_open}
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Opening</div>
|
||||
<div class="font-medium text-gray-900">{row.start_time}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Closing</div>
|
||||
<div class="font-medium text-gray-900">{row.end_time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 border-t pt-3 text-xs text-gray-600">
|
||||
Total: <span class="font-medium text-gray-900"
|
||||
>{calculateHours(row.start_time, row.end_time)} hours</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-gray-500">No hours scheduled for this day</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
{:else}
|
||||
<!-- Skeleton loading -->
|
||||
<Card.Content class="space-y-4">
|
||||
<!-- Desktop Skeleton -->
|
||||
<div class="hidden w-full overflow-x-auto md:block">
|
||||
<table class="w-full table-auto border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
class="border-b bg-gray-50 text-left text-xs font-medium tracking-wider text-gray-600 uppercase"
|
||||
>
|
||||
<th class="px-4 py-3">Day</th>
|
||||
<th class="px-4 py-3 text-center">Status</th>
|
||||
<th class="px-4 py-3">Opening Time</th>
|
||||
<th class="px-4 py-3">Closing Time</th>
|
||||
<th class="px-4 py-3 text-right">Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{#each Array(7) as _, i (i)}
|
||||
<tr>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-8 w-8 rounded-full" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-center">
|
||||
<Skeleton class="mx-auto h-6 w-16 rounded-full" />
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</td>
|
||||
<td class="px-4 py-4 text-right">
|
||||
<Skeleton class="ml-auto h-4 w-12" />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Skeleton -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
{#each Array(7) as _, i (i)}
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<Skeleton class="h-5 w-24" />
|
||||
</div>
|
||||
<Skeleton class="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Skeleton class="h-12 w-full" />
|
||||
<Skeleton class="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<!-- Default Hours Modal -->
|
||||
<Modal.Root bind:open={showDefaultHoursModal}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Edit Default Working Hours</Modal.Title>
|
||||
<Modal.Description>
|
||||
Set the standard open and close times for your business.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="px-4 pb-4">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full table-auto">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="py-2">Day</th>
|
||||
<th class="py-2">Open</th>
|
||||
<th class="py-2">Start</th>
|
||||
<th class="py-2">End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each defaultHoursDraft as row (row.weekday)}
|
||||
<tr class="border-t">
|
||||
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
|
||||
<td class="py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={row.is_open}
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-primary focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<Input
|
||||
type="time"
|
||||
bind:value={row.start_time}
|
||||
disabled={!row.is_open}
|
||||
class="max-w-[70px] text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<Input
|
||||
type="time"
|
||||
bind:value={row.end_time}
|
||||
disabled={!row.is_open}
|
||||
class="max-w-[70px] text-sm"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showDefaultHoursModal = false;
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={() => (showSaveDefaultHoursAlert = true)} disabled={savingHours}>
|
||||
Save Defaults
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Save Default Hours Confirmation -->
|
||||
<AlertDialog.Root bind:open={showSaveDefaultHoursAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Save default hours?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to save these default hours? This will affect future bookings.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmSaveDefaultHours}>Continue</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
Reference in New Issue
Block a user