524 lines
15 KiB
Svelte
524 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { fade, scale } from 'svelte/transition';
|
|
import { GenerateServicesByMonthStore } from '$houdini';
|
|
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
|
import IconSpinner from '$lib/components/icons/IconSpinner.svelte';
|
|
|
|
interface Schedule {
|
|
id: string;
|
|
name: string | null;
|
|
startDate: string;
|
|
endDate: string | null;
|
|
mondayService: boolean;
|
|
tuesdayService: boolean;
|
|
wednesdayService: boolean;
|
|
thursdayService: boolean;
|
|
fridayService: boolean;
|
|
saturdayService: boolean;
|
|
sundayService: boolean;
|
|
weekendService: boolean;
|
|
}
|
|
|
|
interface Address {
|
|
id: string;
|
|
name: string | null;
|
|
streetAddress: string;
|
|
city: string;
|
|
state: string;
|
|
zipCode: string;
|
|
isActive: boolean;
|
|
schedules: Schedule[];
|
|
}
|
|
|
|
interface Account {
|
|
id: string;
|
|
name: string;
|
|
addresses: Address[];
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
accounts: Account[];
|
|
preselectedAddressId?: string;
|
|
preselectedScheduleId?: string;
|
|
initialMonth?: number;
|
|
initialYear?: number;
|
|
onClose: () => void;
|
|
onSuccess: (totalCount: number) => void;
|
|
}
|
|
|
|
let {
|
|
open = $bindable(false),
|
|
accounts,
|
|
preselectedAddressId,
|
|
preselectedScheduleId,
|
|
initialMonth,
|
|
initialYear,
|
|
onClose,
|
|
onSuccess
|
|
}: Props = $props();
|
|
|
|
// State
|
|
let now = new Date();
|
|
// Default to next month if no initial values provided
|
|
let defaultMonth = now.getMonth() + 2 > 12 ? 1 : now.getMonth() + 2;
|
|
let defaultYear = now.getMonth() + 2 > 12 ? now.getFullYear() + 1 : now.getFullYear();
|
|
let selectedMonth = $state(initialMonth ?? defaultMonth);
|
|
let selectedYear = $state(initialYear ?? defaultYear);
|
|
|
|
// Update selected month/year when initial values change
|
|
$effect(() => {
|
|
if (initialMonth !== undefined) {
|
|
selectedMonth = initialMonth;
|
|
}
|
|
if (initialYear !== undefined) {
|
|
selectedYear = initialYear;
|
|
}
|
|
});
|
|
let selectedItems = new SvelteSet<string>(); // "addressId:scheduleId"
|
|
let generating = $state(false);
|
|
let results = $state<
|
|
{ key: string; accountName: string; success: boolean; count?: number; error?: string }[]
|
|
>([]);
|
|
let showResults = $state(false);
|
|
|
|
const generateStore = new GenerateServicesByMonthStore();
|
|
|
|
// Filter accounts to only show those with active schedules
|
|
// A schedule is active if it has no endDate OR if the endDate is in the future
|
|
let accountsWithActiveSchedules = $derived(
|
|
accounts
|
|
.map((account) => ({
|
|
...account,
|
|
addresses: account.addresses
|
|
.filter((addr) => addr.isActive)
|
|
.map((addr) => ({
|
|
...addr,
|
|
activeSchedules: addr.schedules.filter((s) => {
|
|
if (!s.endDate) return true;
|
|
// Compare endDate with today
|
|
const endDate = new Date(s.endDate + 'T00:00:00');
|
|
const today = new SvelteDate();
|
|
today.setHours(0, 0, 0, 0);
|
|
return endDate >= today;
|
|
})
|
|
}))
|
|
.filter((addr) => addr.activeSchedules.length > 0)
|
|
}))
|
|
.filter((account) => account.addresses.length > 0)
|
|
);
|
|
|
|
// Initialize preselection
|
|
$effect(() => {
|
|
if (open && preselectedAddressId && preselectedScheduleId) {
|
|
selectedItems.add(`${preselectedAddressId}:${preselectedScheduleId}`);
|
|
}
|
|
});
|
|
|
|
// Reset state when modal opens
|
|
$effect(() => {
|
|
if (open) {
|
|
results = [];
|
|
showResults = false;
|
|
if (!preselectedAddressId) {
|
|
selectedItems.clear();
|
|
}
|
|
}
|
|
});
|
|
|
|
function toggleSelection(addressId: string, scheduleId: string) {
|
|
const key = `${addressId}:${scheduleId}`;
|
|
if (selectedItems.has(key)) {
|
|
selectedItems.delete(key);
|
|
} else {
|
|
selectedItems.add(key);
|
|
}
|
|
}
|
|
|
|
function selectAll() {
|
|
for (const account of accountsWithActiveSchedules) {
|
|
for (const address of account.addresses) {
|
|
for (const schedule of address.activeSchedules) {
|
|
selectedItems.add(`${address.id}:${schedule.id}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function selectNone() {
|
|
selectedItems.clear();
|
|
}
|
|
|
|
function getScheduleDays(schedule: Schedule): string[] {
|
|
const days: string[] = [];
|
|
if (schedule.mondayService) days.push('Mon');
|
|
if (schedule.tuesdayService) days.push('Tue');
|
|
if (schedule.wednesdayService) days.push('Wed');
|
|
if (schedule.thursdayService) days.push('Thu');
|
|
if (schedule.fridayService) days.push('Fri');
|
|
if (schedule.saturdayService) days.push('Sat');
|
|
if (schedule.sundayService) days.push('Sun');
|
|
if (schedule.weekendService && !days.includes('Fri')) days.push('Weekend');
|
|
return days;
|
|
}
|
|
|
|
// Calculate estimated service count for a schedule
|
|
// Mirrors backend logic: Mon-Thu use day flags, Fri uses weekend_service OR friday flag,
|
|
// Sat-Sun use day flags only if weekend_service is false
|
|
function estimateServiceCount(schedule: Schedule): number {
|
|
const year = selectedYear;
|
|
const month = selectedMonth;
|
|
const firstDay = new Date(year, month - 1, 1);
|
|
const lastDay = new Date(year, month, 0);
|
|
|
|
// Map JS getDay() (Sun=0..Sat=6) to day flags
|
|
// Index: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
|
const dayFlags = [
|
|
schedule.sundayService,
|
|
schedule.mondayService,
|
|
schedule.tuesdayService,
|
|
schedule.wednesdayService,
|
|
schedule.thursdayService,
|
|
schedule.fridayService,
|
|
schedule.saturdayService
|
|
];
|
|
|
|
let count = 0;
|
|
for (let d = new SvelteDate(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) {
|
|
const jsDay = d.getDay(); // Sun=0, Mon=1, ..., Sat=6
|
|
|
|
let scheduleToday = false;
|
|
|
|
if (jsDay >= 1 && jsDay <= 4) {
|
|
// Mon-Thu: use the day flag
|
|
scheduleToday = dayFlags[jsDay];
|
|
} else if (jsDay === 5) {
|
|
// Friday: weekend_service takes precedence, otherwise use friday flag
|
|
scheduleToday = schedule.weekendService || dayFlags[5];
|
|
} else {
|
|
// Sat (6) or Sun (0): only use day flag if weekend_service is OFF
|
|
if (!schedule.weekendService) {
|
|
scheduleToday = dayFlags[jsDay];
|
|
}
|
|
}
|
|
|
|
if (scheduleToday) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
async function handleGenerate() {
|
|
if (selectedItems.size === 0) return;
|
|
|
|
generating = true;
|
|
results = [];
|
|
|
|
for (const key of selectedItems) {
|
|
const [addressId, scheduleId] = key.split(':');
|
|
|
|
// Find account name for display
|
|
let accountName = 'Unknown';
|
|
for (const account of accounts) {
|
|
if (account.addresses.some((a) => a.id === addressId)) {
|
|
accountName = account.name;
|
|
break;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const res = await generateStore.mutate({
|
|
input: {
|
|
accountAddressId: addressId,
|
|
scheduleId: scheduleId,
|
|
year: selectedYear,
|
|
month: selectedMonth
|
|
}
|
|
});
|
|
|
|
const count = res.data?.generateServicesByMonth?.length ?? 0;
|
|
results = [...results, { key, accountName, success: true, count }];
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Failed to generate';
|
|
results = [...results, { key, accountName, success: false, error: errorMessage }];
|
|
}
|
|
}
|
|
|
|
generating = false;
|
|
showResults = true;
|
|
|
|
// Calculate total and notify
|
|
const totalCount = results.reduce((sum, r) => sum + (r.count ?? 0), 0);
|
|
if (totalCount > 0) {
|
|
onSuccess(totalCount);
|
|
}
|
|
}
|
|
|
|
function handleClose() {
|
|
if (generating) return;
|
|
open = false;
|
|
onClose();
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape' && open && !generating) {
|
|
handleClose();
|
|
}
|
|
}
|
|
|
|
function handleBackdropClick(event: MouseEvent) {
|
|
if (event.target === event.currentTarget && !generating) {
|
|
handleClose();
|
|
}
|
|
}
|
|
|
|
// Month names
|
|
const months = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December'
|
|
];
|
|
|
|
// Year options (current year and next 2)
|
|
let yearOptions = $derived([now.getFullYear(), now.getFullYear() + 1, now.getFullYear() + 2]);
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
{#if open}
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
transition:fade={{ duration: 150 }}
|
|
onclick={handleBackdropClick}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
class="flex max-h-[90vh] w-full max-w-2xl flex-col rounded-xl border border-theme bg-theme shadow-theme-lg"
|
|
transition:scale={{ duration: 150, start: 0.95 }}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-title"
|
|
>
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
|
<h2 id="modal-title" class="text-lg font-semibold text-theme">Generate Services</h2>
|
|
<button
|
|
onclick={handleClose}
|
|
disabled={generating}
|
|
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme disabled:opacity-50 dark:hover:bg-white/10"
|
|
aria-label="Close"
|
|
>
|
|
<svg class="h-5 w-5" style="fill: none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1 overflow-y-auto p-6">
|
|
{#if showResults}
|
|
<!-- Results View -->
|
|
<div class="space-y-4">
|
|
<h3 class="font-medium text-theme">Generation Results</h3>
|
|
<div class="space-y-2">
|
|
{#each results as result (result.key)}
|
|
<div class="flex items-center justify-between rounded-lg border border-theme p-3">
|
|
<span class="text-theme">{result.accountName}</span>
|
|
{#if result.success}
|
|
<span
|
|
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
>
|
|
{result.count} service{result.count !== 1 ? 's' : ''} created
|
|
</span>
|
|
{:else}
|
|
<span
|
|
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
|
>
|
|
{result.error}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{#if results.length > 0}
|
|
{@const successCount = results.filter((r) => r.success).length}
|
|
{@const totalServices = results.reduce((sum, r) => sum + (r.count ?? 0), 0)}
|
|
<div class="rounded-lg bg-theme-card p-4 text-center">
|
|
<p class="text-lg font-semibold text-theme">
|
|
{totalServices} service{totalServices !== 1 ? 's' : ''} generated
|
|
</p>
|
|
<p class="text-sm text-theme-muted">
|
|
{successCount} of {results.length} address{results.length !== 1 ? 'es' : ''} succeeded
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- Selection View -->
|
|
<div class="space-y-6">
|
|
<!-- Month/Year Selection -->
|
|
<div class="flex gap-4">
|
|
<div class="flex-1">
|
|
<label for="month-select" class="mb-1 block text-sm font-medium text-theme"
|
|
>Month</label
|
|
>
|
|
<select
|
|
id="month-select"
|
|
bind:value={selectedMonth}
|
|
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme"
|
|
>
|
|
{#each months as month, i (month)}
|
|
<option value={i + 1}>{month}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
<div class="w-32">
|
|
<label for="year-select" class="mb-1 block text-sm font-medium text-theme"
|
|
>Year</label
|
|
>
|
|
<select
|
|
id="year-select"
|
|
bind:value={selectedYear}
|
|
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme"
|
|
>
|
|
{#each yearOptions as year (year)}
|
|
<option value={year}>{year}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selection Controls -->
|
|
<div class="flex items-center justify-between">
|
|
<p class="text-sm text-theme-muted">
|
|
{selectedItems.size} address{selectedItems.size !== 1 ? 'es' : ''} selected
|
|
</p>
|
|
<div class="flex gap-2">
|
|
<button
|
|
onclick={selectAll}
|
|
class="text-sm text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
|
>
|
|
Select All
|
|
</button>
|
|
<span class="text-theme-muted">|</span>
|
|
<button
|
|
onclick={selectNone}
|
|
class="text-sm text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
|
>
|
|
Select None
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Address List -->
|
|
<div class="space-y-3">
|
|
{#if accountsWithActiveSchedules.length === 0}
|
|
<div class="rounded-lg border border-theme bg-theme-card p-8 text-center">
|
|
<p class="text-theme-muted">No accounts with active schedules found</p>
|
|
</div>
|
|
{:else}
|
|
{#each accountsWithActiveSchedules as account (account.id)}
|
|
<div class="rounded-lg border border-theme">
|
|
<div class="border-b border-theme bg-theme-card px-4 py-2">
|
|
<h4 class="font-medium text-theme">{account.name}</h4>
|
|
</div>
|
|
<div class="divide-theme divide-y">
|
|
{#each account.addresses as address (address.id)}
|
|
{#each address.activeSchedules as schedule (schedule.id)}
|
|
{@const key = `${address.id}:${schedule.id}`}
|
|
{@const isSelected = selectedItems.has(key)}
|
|
{@const days = getScheduleDays(schedule)}
|
|
{@const estimatedCount = estimateServiceCount(schedule)}
|
|
<label
|
|
class="flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onchange={() => toggleSelection(address.id, schedule.id)}
|
|
class="mt-1 h-4 w-4 rounded border-theme text-primary-500"
|
|
/>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="text-sm font-medium text-theme">
|
|
{address.name || address.streetAddress}
|
|
</span>
|
|
<span class="text-xs text-theme-muted">
|
|
{address.streetAddress}, {address.city}, {address.state}
|
|
</span>
|
|
<span class="mt-1 flex flex-wrap items-center gap-1">
|
|
{#each days as day (day)}
|
|
<span
|
|
class="rounded bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-400"
|
|
>
|
|
{day}
|
|
</span>
|
|
{/each}
|
|
<span class="ml-2 text-xs text-theme-muted">
|
|
~{estimatedCount} service{estimatedCount !== 1 ? 's' : ''}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
</label>
|
|
{/each}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
|
|
{#if showResults}
|
|
<button
|
|
onclick={handleClose}
|
|
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600"
|
|
>
|
|
Done
|
|
</button>
|
|
{:else}
|
|
<button
|
|
onclick={handleClose}
|
|
disabled={generating}
|
|
class="rounded-lg border border-theme bg-theme px-4 py-2 text-sm font-medium text-theme transition-colors hover:bg-black/5 disabled:opacity-50 dark:hover:bg-white/10"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onclick={handleGenerate}
|
|
disabled={generating || selectedItems.size === 0}
|
|
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{#if generating}
|
|
<span class="flex items-center gap-2">
|
|
<IconSpinner class="h-4 w-4" />
|
|
Generating...
|
|
</span>
|
|
{:else}
|
|
Generate Services
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|