nexus-5-frontend-3/src/lib/components/admin/services/GenerateServicesModal.svelte
2026-01-26 11:30:40 -05:00

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}