Damien Coles 33c4edd67e improved visual indicators for wave link;
improved scope handling - import directly from json
2026-01-29 10:05:12 -05:00

1404 lines
42 KiB
Svelte

<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { client } from '$lib/graphql/client';
import type { PageData } from './$types';
import { ContentContainer } from '$lib/components/layout';
import { PageHeader, SectionHeader } from '$lib/components/admin';
import FormDrawer from '$lib/components/drawers/FormDrawer.svelte';
import type {
InvoiceDetail,
InvoiceStatus,
EligibleRevenue,
EligibleInvoiceProject
} from '$lib/graphql/queries/invoices';
import {
UPDATE_INVOICE,
DELETE_INVOICE,
ADD_REVENUE_TO_INVOICE,
ADD_PROJECT_TO_INVOICE,
REMOVE_REVENUE_FROM_INVOICE,
REMOVE_PROJECT_FROM_INVOICE,
ADD_ALL_ELIGIBLE_REVENUES,
ADD_ALL_ELIGIBLE_PROJECTS
} from '$lib/graphql/mutations/invoices';
import {
WAVE_CUSTOMERS,
WAVE_INVOICE,
WAVE_PRODUCTS,
type WaveInvoiceReadiness,
type WaveCustomer,
type WaveInvoice,
type WaveProduct
} from '$lib/graphql/queries/wave';
import { CREATE_WAVE_INVOICE, LINK_CUSTOMER_TO_WAVE } from '$lib/graphql/mutations/wave';
import WaveLinkForm from '$lib/components/forms/WaveLinkForm.svelte';
let { data }: { data: PageData } = $props();
let invoice = $derived(data.invoice as InvoiceDetail);
let eligibleRevenues = $derived(data.eligibleRevenues as EligibleRevenue[]);
let eligibleProjects = $derived(data.eligibleProjects as EligibleInvoiceProject[]);
let waveReadiness = $derived(data.waveReadiness as WaveInvoiceReadiness | null);
// State
let isUpdating = $state(false);
let isDeleting = $state(false);
let showDeleteConfirm = $state(false);
let showAddRevenuesModal = $state(false);
let showAddProjectsModal = $state(false);
let addingRevenueId = $state<string | null>(null);
let addingProjectId = $state<string | null>(null);
let removingId = $state<string | null>(null);
// Wave state
let waveCustomers = $state<WaveCustomer[]>([]);
let waveInvoice = $state<WaveInvoice | null>(null);
let isSyncingWave = $state(false);
let waveError = $state<string | null>(null);
let showLinkCustomerModal = $state(false);
let selectedWaveCustomerId = $state<string>('');
// Wave product linking state
let waveProducts = $state<WaveProduct[]>([]);
let showWaveLinkDrawer = $state(false);
let waveLinkTarget = $state<{
type: 'revenue' | 'project';
id: string;
currentWaveServiceId: string | null;
itemName: string;
} | null>(null);
// Fetch Wave invoice details when synced
$effect(() => {
if (invoice?.waveInvoiceId) {
client
.query<{ waveInvoice: WaveInvoice }>({
query: WAVE_INVOICE,
variables: { invoiceId: invoice.waveInvoiceId }
})
.then((result) => {
waveInvoice = result.data?.waveInvoice ?? null;
})
.catch((err) => {
console.error('Failed to fetch Wave invoice:', err);
});
}
});
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2
}).format(amount);
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function formatDateRange(startDate: string, endDate: string): string {
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
}
function getStatusBadgeClass(status: InvoiceStatus): string {
switch (status) {
case 'DRAFT':
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
case 'SENT':
return 'bg-accent-100 text-accent-800 dark:bg-accent-900/30 dark:text-accent-400';
case 'PAID':
return 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400';
case 'OVERDUE':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
case 'CANCELLED':
return 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
}
async function updateStatus(newStatus: InvoiceStatus) {
isUpdating = true;
try {
await client.mutate({
mutation: UPDATE_INVOICE,
variables: { id: invoice.id, input: { status: newStatus } }
});
await invalidateAll();
} catch (err) {
console.error('Failed to update status:', err);
} finally {
isUpdating = false;
}
}
async function handleDelete() {
isDeleting = true;
try {
await client.mutate({
mutation: DELETE_INVOICE,
variables: { id: invoice.id }
});
await goto('/admin/invoices');
} catch (err) {
console.error('Failed to delete invoice:', err);
} finally {
isDeleting = false;
showDeleteConfirm = false;
}
}
async function addRevenue(revenueId: string) {
addingRevenueId = revenueId;
try {
await client.mutate({
mutation: ADD_REVENUE_TO_INVOICE,
variables: { invoiceId: invoice.id, revenueId }
});
await invalidateAll();
} catch (err) {
console.error('Failed to add revenue:', err);
} finally {
addingRevenueId = null;
}
}
async function addProject(projectId: string) {
addingProjectId = projectId;
try {
await client.mutate({
mutation: ADD_PROJECT_TO_INVOICE,
variables: { invoiceId: invoice.id, projectId }
});
await invalidateAll();
} catch (err) {
console.error('Failed to add project:', err);
} finally {
addingProjectId = null;
}
}
async function removeRevenue(id: string) {
removingId = id;
try {
await client.mutate({
mutation: REMOVE_REVENUE_FROM_INVOICE,
variables: { id }
});
await invalidateAll();
} catch (err) {
console.error('Failed to remove revenue:', err);
} finally {
removingId = null;
}
}
async function removeProject(id: string) {
removingId = id;
try {
await client.mutate({
mutation: REMOVE_PROJECT_FROM_INVOICE,
variables: { id }
});
await invalidateAll();
} catch (err) {
console.error('Failed to remove project:', err);
} finally {
removingId = null;
}
}
async function addAllRevenues() {
isUpdating = true;
try {
await client.mutate({
mutation: ADD_ALL_ELIGIBLE_REVENUES,
variables: { invoiceId: invoice.id }
});
await invalidateAll();
showAddRevenuesModal = false;
} catch (err) {
console.error('Failed to add all revenues:', err);
} finally {
isUpdating = false;
}
}
async function addAllProjects() {
isUpdating = true;
try {
await client.mutate({
mutation: ADD_ALL_ELIGIBLE_PROJECTS,
variables: { invoiceId: invoice.id }
});
await invalidateAll();
showAddProjectsModal = false;
} catch (err) {
console.error('Failed to add all projects:', err);
} finally {
isUpdating = false;
}
}
// Wave functions
async function loadWaveCustomers() {
try {
const result = await client.query<{ waveCustomers: WaveCustomer[] }>({
query: WAVE_CUSTOMERS,
fetchPolicy: 'cache-first'
});
waveCustomers = result.data?.waveCustomers ?? [];
} catch (err) {
console.error('Failed to load Wave customers:', err);
}
}
async function loadWaveProducts() {
if (waveProducts.length > 0) return;
try {
const result = await client.query<{ waveProducts: WaveProduct[] }>({
query: WAVE_PRODUCTS,
fetchPolicy: 'cache-first'
});
waveProducts = result.data?.waveProducts ?? [];
} catch (err) {
console.error('Failed to load Wave products:', err);
}
}
function handleWaveLinkClick(
type: 'revenue' | 'project',
id: string,
currentWaveServiceId: string | null,
itemName: string
) {
loadWaveProducts();
waveLinkTarget = { type, id, currentWaveServiceId, itemName };
showWaveLinkDrawer = true;
}
async function handleWaveLinkSuccess() {
showWaveLinkDrawer = false;
waveLinkTarget = null;
await invalidateAll();
}
interface CreateWaveInvoiceResult {
createWaveInvoice: {
success: boolean;
error?: string;
};
}
async function syncToWave() {
isSyncingWave = true;
waveError = null;
try {
const result = await client.mutate<CreateWaveInvoiceResult>({
mutation: CREATE_WAVE_INVOICE,
variables: {
input: {
invoiceId: invoice.id
}
}
});
if (result.data?.createWaveInvoice.success) {
await invalidateAll();
} else {
waveError = result.data?.createWaveInvoice.error || 'Failed to create Wave invoice';
}
} catch (err: unknown) {
console.error('Failed to sync to Wave:', err);
waveError = err instanceof Error ? err.message : 'Failed to sync to Wave';
} finally {
isSyncingWave = false;
}
}
async function linkCustomerToWave() {
if (!selectedWaveCustomerId) return;
isUpdating = true;
try {
await client.mutate({
mutation: LINK_CUSTOMER_TO_WAVE,
variables: {
customerId: invoice.customer?.id,
waveCustomerId: selectedWaveCustomerId
}
});
await invalidateAll();
showLinkCustomerModal = false;
} catch (err) {
console.error('Failed to link customer:', err);
} finally {
isUpdating = false;
}
}
function handleWaveCustomerSelect(e: Event) {
const target = e.target as HTMLSelectElement;
selectedWaveCustomerId = target.value;
}
</script>
<svelte:head>
<title>{invoice.customer?.name ?? 'Invoice'} - Invoices - Admin - Nexus</title>
</svelte:head>
<div class="py-8">
<ContentContainer>
<PageHeader
title={invoice.customer?.name ?? 'Unknown'}
subtitle={formatDateRange(invoice.startDate, invoice.endDate)}
colorScheme="accent6"
backHref="/admin/invoices"
>
<span
class="rounded-full px-3 py-1 text-sm font-semibold {getStatusBadgeClass(invoice.status)}"
>
{invoice.status}
</span>
</PageHeader>
<!-- Status Actions -->
<div class="mb-6 flex flex-wrap gap-2">
{#if invoice.status === 'DRAFT'}
<button
type="button"
class="btn-accent"
onclick={() => updateStatus('SENT')}
disabled={isUpdating}
>
Mark as Sent
</button>
<button
type="button"
class="btn-ghost text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
onclick={() => (showDeleteConfirm = true)}
>
Delete
</button>
{:else if invoice.status === 'SENT'}
<button
type="button"
class="btn-primary"
onclick={() => updateStatus('PAID')}
disabled={isUpdating}
>
Mark as Paid
</button>
<button
type="button"
class="btn-ghost text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20"
onclick={() => updateStatus('OVERDUE')}
disabled={isUpdating}
>
Mark as Overdue
</button>
<button
type="button"
class="btn-ghost"
onclick={() => updateStatus('DRAFT')}
disabled={isUpdating}
>
Revert to Draft
</button>
{:else if invoice.status === 'OVERDUE'}
<button
type="button"
class="btn-primary"
onclick={() => updateStatus('PAID')}
disabled={isUpdating}
>
Mark as Paid
</button>
<button
type="button"
class="btn-ghost"
onclick={() => updateStatus('SENT')}
disabled={isUpdating}
>
Revert to Sent
</button>
{:else if invoice.status === 'PAID'}
<button
type="button"
class="btn-ghost"
onclick={() => updateStatus('SENT')}
disabled={isUpdating}
>
Revert to Sent
</button>
{:else if invoice.status === 'CANCELLED'}
<button
type="button"
class="btn-ghost"
onclick={() => updateStatus('DRAFT')}
disabled={isUpdating}
>
Revert to Draft
</button>
<button
type="button"
class="btn-ghost text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
onclick={() => (showDeleteConfirm = true)}
>
Delete
</button>
{/if}
{#if invoice.status !== 'CANCELLED' && invoice.status !== 'PAID'}
<button
type="button"
class="btn-ghost text-gray-500 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800"
onclick={() => updateStatus('CANCELLED')}
disabled={isUpdating}
>
Cancel
</button>
{/if}
</div>
<div class="space-y-6">
<!-- Summary Section -->
<section
class="overflow-hidden rounded-xl border border-accent6-500/20 bg-theme-card shadow-theme-md"
>
<div class="section-header-accent6 px-5 py-3">
<h2 class="text-lg font-semibold text-theme">Summary</h2>
</div>
<div class="grid grid-cols-2 gap-4 p-5 sm:grid-cols-4">
<div>
<p class="text-sm font-medium text-theme-muted">Revenues</p>
<p class="mt-1 text-xl font-bold text-secondary-600 dark:text-secondary-400">
{invoice.revenueCount}
</p>
<p class="text-sm text-theme-muted">{formatCurrency(invoice.revenuesTotal)}</p>
</div>
<div>
<p class="text-sm font-medium text-theme-muted">Projects</p>
<p class="mt-1 text-xl font-bold text-accent-600 dark:text-accent-400">
{invoice.projectCount}
</p>
<p class="text-sm text-theme-muted">{formatCurrency(invoice.projectsTotal)}</p>
</div>
<div class="col-span-2">
<p class="text-sm font-medium text-theme-muted">Total Amount</p>
<p class="mt-1 text-2xl font-bold text-accent6-600 dark:text-accent6-400">
{formatCurrency(invoice.totalAmount)}
</p>
</div>
</div>
</section>
<!-- Revenues Section -->
<section
class="overflow-hidden rounded-xl border border-accent6-500/20 bg-theme-card shadow-theme-md"
>
<div class="section-header-accent6 px-5 py-3">
{#if invoice.status === 'DRAFT' && eligibleRevenues.length > 0}
<SectionHeader
title="Revenues ({invoice.revenues.length})"
buttonText="Add ({eligibleRevenues.length})"
onButtonClick={() => (showAddRevenuesModal = true)}
/>
{:else}
<h2 class="text-lg font-semibold text-theme">Revenues ({invoice.revenues.length})</h2>
{/if}
</div>
<div class="px-5 py-5">
{#if invoice.revenues.length > 0}
<!-- Mobile: Card view -->
<div class="space-y-3 sm:hidden">
{#each invoice.revenues as entry (entry.id)}
<div class="bg-theme-secondary/50 rounded-lg border border-theme p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="font-medium text-theme">
{entry.account?.name ?? 'Unknown Account'}
</p>
<p class="mt-1 text-sm text-theme-secondary">
{entry.revenue?.startDate ? formatDate(entry.revenue.startDate) : ''} - {entry
.revenue?.endDate
? formatDate(entry.revenue.endDate)
: 'Ongoing'}
</p>
<div class="mt-2">
{#if entry.revenue?.waveServiceId}
<span class="badge-success">Wave Linked</span>
{:else if invoice.status === 'DRAFT'}
<button
type="button"
class="badge-warning cursor-pointer hover:opacity-80"
onclick={() => handleWaveLinkClick('revenue', entry.revenueId, entry.revenue?.waveServiceId ?? null, entry.account?.name ?? 'Revenue')}
>
Link to Wave
</button>
{:else}
<span class="badge-warning">Not Linked</span>
{/if}
</div>
</div>
<div class="flex items-center gap-3">
<span class="font-medium text-accent6-600 dark:text-accent6-400">
{formatCurrency(entry.amount)}
</span>
{#if invoice.status === 'DRAFT'}
<button
type="button"
class="text-red-600 hover:text-red-800 disabled:opacity-50 dark:text-red-400"
onclick={() => removeRevenue(entry.id)}
disabled={removingId === entry.id}
aria-label="Remove revenue"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
<!-- Desktop: Table view -->
<div class="hidden overflow-x-auto rounded-lg border border-theme sm:block">
<table class="w-full">
<thead class="bg-theme-secondary">
<tr
class="text-left text-xs font-medium tracking-wider text-theme-muted uppercase"
>
<th class="px-4 py-2">Account</th>
<th class="px-4 py-2">Period</th>
<th class="px-4 py-2 text-right">Amount</th>
<th class="px-4 py-2">Wave</th>
{#if invoice.status === 'DRAFT'}
<th class="w-10 px-4 py-2"></th>
{/if}
</tr>
</thead>
<tbody class="divide-theme divide-y">
{#each invoice.revenues as entry (entry.id)}
<tr>
<td class="px-4 py-2 text-sm text-theme">
{entry.account?.name ?? 'Unknown Account'}
</td>
<td class="px-4 py-2 text-sm whitespace-nowrap text-theme-secondary">
{entry.revenue?.startDate ? formatDate(entry.revenue.startDate) : ''} - {entry
.revenue?.endDate
? formatDate(entry.revenue.endDate)
: 'Ongoing'}
</td>
<td
class="px-4 py-2 text-right text-sm font-medium whitespace-nowrap text-accent6-600 dark:text-accent6-400"
>
{formatCurrency(entry.amount)}
</td>
<td class="px-4 py-2">
{#if entry.revenue?.waveServiceId}
<span class="badge-success">Linked</span>
{:else if invoice.status === 'DRAFT'}
<button
type="button"
class="badge-warning cursor-pointer hover:opacity-80"
onclick={() => handleWaveLinkClick('revenue', entry.revenueId, entry.revenue?.waveServiceId ?? null, entry.account?.name ?? 'Revenue')}
>
Link
</button>
{:else}
<span class="badge-warning">Not Linked</span>
{/if}
</td>
{#if invoice.status === 'DRAFT'}
<td class="px-4 py-2 text-right">
<button
type="button"
class="text-red-600 hover:text-red-800 disabled:opacity-50 dark:text-red-400"
onclick={() => removeRevenue(entry.id)}
disabled={removingId === entry.id}
aria-label="Remove revenue"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="py-6 text-center text-theme-muted">
{#if invoice.status === 'DRAFT'}
{#if eligibleRevenues.length > 0}
No revenues added yet.
<button
type="button"
class="mx-auto mt-2 block text-secondary-600 hover:text-secondary-700 dark:text-secondary-400"
onclick={() => (showAddRevenuesModal = true)}
>
Add revenues
</button>
{:else}
<p>No eligible revenues for this customer and invoice period.</p>
<a
href="/admin/customers/{invoice.customerId}"
class="mt-2 inline-block text-secondary-600 hover:text-secondary-700 dark:text-secondary-400"
>
Go to customer page to manage accounts
</a>
{/if}
{:else}
No revenues in this invoice.
{/if}
</div>
{/if}
</div>
</section>
<!-- Projects Section -->
<section
class="overflow-hidden rounded-xl border border-accent6-500/20 bg-theme-card shadow-theme-md"
>
<div class="section-header-accent6 px-5 py-3">
{#if invoice.status === 'DRAFT' && eligibleProjects.length > 0}
<SectionHeader
title="Projects ({invoice.projects.length})"
buttonText="Add ({eligibleProjects.length})"
onButtonClick={() => (showAddProjectsModal = true)}
/>
{:else}
<h2 class="text-lg font-semibold text-theme">Projects ({invoice.projects.length})</h2>
{/if}
</div>
<div class="px-5 py-5">
{#if invoice.projects.length > 0}
<!-- Mobile: Card view -->
<div class="space-y-3 sm:hidden">
{#each invoice.projects as entry (entry.id)}
<div class="bg-theme-secondary/50 rounded-lg border border-theme p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="font-medium text-theme">{entry.project?.name ?? 'Unknown'}</p>
<p class="mt-1 text-sm text-theme-secondary">
{formatDate(entry.project?.date ?? '')}
{#if entry.account?.name}
· {entry.account.name}
{/if}
</p>
{#if entry.project?.formattedAddress}
<p class="mt-1 text-sm text-theme-muted">
{entry.project.formattedAddress}
</p>
{/if}
<div class="mt-2">
{#if entry.project?.waveServiceId}
<span class="badge-success">Wave Linked</span>
{:else if invoice.status === 'DRAFT'}
<button
type="button"
class="badge-warning cursor-pointer hover:opacity-80"
onclick={() => handleWaveLinkClick('project', entry.projectId, entry.project?.waveServiceId ?? null, entry.project?.name ?? 'Project')}
>
Link to Wave
</button>
{:else}
<span class="badge-warning">Not Linked</span>
{/if}
</div>
</div>
<div class="flex items-center gap-3">
<span class="font-medium text-accent6-600 dark:text-accent6-400">
{formatCurrency(entry.amount)}
</span>
{#if invoice.status === 'DRAFT'}
<button
type="button"
class="text-red-600 hover:text-red-800 disabled:opacity-50 dark:text-red-400"
onclick={() => removeProject(entry.id)}
disabled={removingId === entry.id}
aria-label="Remove project"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
<!-- Desktop: Table view -->
<div class="hidden overflow-x-auto rounded-lg border border-theme sm:block">
<table class="w-full">
<thead class="bg-theme-secondary">
<tr
class="text-left text-xs font-medium tracking-wider text-theme-muted uppercase"
>
<th class="px-4 py-2">Date</th>
<th class="px-4 py-2">Project</th>
<th class="px-4 py-2">Account</th>
<th class="hidden px-4 py-2 md:table-cell">Location</th>
<th class="px-4 py-2 text-right">Amount</th>
<th class="px-4 py-2">Wave</th>
{#if invoice.status === 'DRAFT'}
<th class="w-10 px-4 py-2"></th>
{/if}
</tr>
</thead>
<tbody class="divide-theme divide-y">
{#each invoice.projects as entry (entry.id)}
<tr>
<td class="px-4 py-2 text-sm whitespace-nowrap text-theme">
{formatDate(entry.project?.date ?? '')}
</td>
<td class="px-4 py-2 text-sm text-theme">
{entry.project?.name ?? 'Unknown'}
</td>
<td class="px-4 py-2 text-sm text-theme-secondary">
{entry.account?.name ?? '-'}
</td>
<td class="hidden px-4 py-2 text-sm text-theme-secondary md:table-cell">
{entry.project?.formattedAddress ?? ''}
</td>
<td
class="px-4 py-2 text-right text-sm font-medium whitespace-nowrap text-accent6-600 dark:text-accent6-400"
>
{formatCurrency(entry.amount)}
</td>
<td class="px-4 py-2">
{#if entry.project?.waveServiceId}
<span class="badge-success">Linked</span>
{:else if invoice.status === 'DRAFT'}
<button
type="button"
class="badge-warning cursor-pointer hover:opacity-80"
onclick={() => handleWaveLinkClick('project', entry.projectId, entry.project?.waveServiceId ?? null, entry.project?.name ?? 'Project')}
>
Link
</button>
{:else}
<span class="badge-warning">Not Linked</span>
{/if}
</td>
{#if invoice.status === 'DRAFT'}
<td class="px-4 py-2 text-right">
<button
type="button"
class="text-red-600 hover:text-red-800 disabled:opacity-50 dark:text-red-400"
onclick={() => removeProject(entry.id)}
disabled={removingId === entry.id}
aria-label="Remove project"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="py-6 text-center text-theme-muted">
{#if invoice.status === 'DRAFT'}
{#if eligibleProjects.length > 0}
No projects added yet.
<button
type="button"
class="mx-auto mt-2 block text-accent-600 hover:text-accent-700 dark:text-accent-400"
onclick={() => (showAddProjectsModal = true)}
>
Add projects
</button>
{:else}
<p>No eligible projects for this customer and invoice period.</p>
<a
href="/admin/projects"
class="mt-2 inline-block text-accent-600 hover:text-accent-700 dark:text-accent-400"
>
View all projects
</a>
{/if}
{:else}
No projects in this invoice.
{/if}
</div>
{/if}
</div>
</section>
<!-- Wave Integration Section -->
<section
class="overflow-hidden rounded-xl border border-accent6-500/20 bg-theme-card shadow-theme-md"
>
<div class="section-header-accent6 px-5 py-3">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-theme">Wave Accounting</h2>
<span
class="rounded bg-accent6-100 px-2 py-0.5 text-xs font-medium text-accent6-700 dark:bg-accent6-900/30 dark:text-accent6-400"
>
Integration
</span>
</div>
</div>
<div class="px-5 py-5">
{#if invoice.waveInvoiceId}
<!-- Already synced to Wave -->
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-success-100 dark:bg-success-900/30"
>
<svg
class="h-5 w-5 text-success-600 dark:text-success-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<p class="font-medium text-theme">Synced to Wave</p>
<p class="text-sm text-theme-secondary">
{#if waveInvoice?.invoiceNumber}
Wave #{waveInvoice.invoiceNumber}
{:else}
Loading...
{/if}
</p>
</div>
</div>
{#if waveInvoice?.pdfUrl}
<a
href={waveInvoice.pdfUrl}
target="_blank"
rel="noopener noreferrer"
class="btn-secondary inline-flex items-center gap-2 self-start text-sm sm:self-auto"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Download PDF
</a>
{/if}
</div>
{:else if invoice.status === 'CANCELLED'}
<!-- Cancelled - cannot sync -->
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800"
>
<svg
class="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
<div>
<p class="font-medium text-theme-muted">Not synced</p>
<p class="text-sm text-theme-muted">Cancelled invoices cannot be synced to Wave</p>
</div>
</div>
{:else if invoice.status !== 'DRAFT'}
<!-- Sent/Paid/Overdue without sync - missed opportunity -->
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800"
>
<svg
class="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p class="font-medium text-theme-muted">Not synced</p>
<p class="text-sm text-theme-muted">
This invoice was not synced to Wave before being sent
</p>
</div>
</div>
{:else if waveError}
<!-- Sync error state -->
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30"
>
<svg
class="h-5 w-5 text-red-600 dark:text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<div>
<p class="font-medium text-theme">Wave Sync Failed</p>
<p class="text-sm text-red-600 dark:text-red-400">{waveError}</p>
</div>
</div>
<button
type="button"
class="btn-ghost self-start text-sm sm:self-auto"
onclick={() => {
waveError = null;
}}
>
Dismiss
</button>
</div>
{:else if waveReadiness}
<!-- Readiness check result -->
{#if waveReadiness.ready}
<!-- Ready to sync -->
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-success-100 dark:bg-success-900/30"
>
<svg
class="h-5 w-5 text-success-600 dark:text-success-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<p class="font-medium text-theme">Ready to sync</p>
<p class="text-sm text-theme-secondary">
{waveReadiness.readyItemCount} items totaling {formatCurrency(
parseFloat(waveReadiness.totalAmount)
)}
</p>
</div>
</div>
<button
type="button"
class="btn-primary self-start sm:self-auto"
onclick={syncToWave}
disabled={isSyncingWave}
>
{isSyncingWave ? 'Syncing...' : 'Create Wave Invoice'}
</button>
</div>
{:else}
<!-- Not ready - show issues -->
<div>
<div class="mb-3 flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30"
>
<svg
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<p class="font-medium text-theme">Not ready to sync</p>
<p class="text-sm text-theme-secondary">Please resolve the following issues:</p>
</div>
</div>
<ul class="ml-13 space-y-2 text-sm">
{#each waveReadiness.issues as issue}
<li class="flex flex-col gap-1">
<span class="flex items-start gap-2">
<span class="text-yellow-600 dark:text-yellow-400"></span>
<span class="text-theme-secondary">{issue}</span>
</span>
{#if issue.includes('Customer is not linked')}
<button
type="button"
class="ml-4 text-left text-accent6-600 hover:text-accent6-700 dark:text-accent6-400"
onclick={() => {
loadWaveCustomers();
showLinkCustomerModal = true;
}}
>
Link now →
</button>
{/if}
</li>
{/each}
</ul>
{#if waveReadiness.readyItemCount > 0}
<p class="mt-3 text-sm text-theme-muted">
{waveReadiness.readyItemCount} of {waveReadiness.readyItemCount +
waveReadiness.missingWaveLinkCount} items are linked to Wave products
</p>
{/if}
</div>
{/if}
{/if}
</div>
</section>
</div>
</ContentContainer>
</div>
<!-- Add Revenues Drawer -->
<FormDrawer
open={showAddRevenuesModal}
title="Add Revenues"
onClose={() => (showAddRevenuesModal = false)}
>
{#if eligibleRevenues.length > 0}
<div class="space-y-3">
{#each eligibleRevenues as revenue (revenue.revenueId)}
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme p-4">
<div>
<p class="font-medium text-theme">
{revenue.accountName}
</p>
<p class="text-sm text-theme-secondary">
{formatCurrency(revenue.amount)}/month
</p>
</div>
<button
type="button"
class="btn-secondary text-sm"
onclick={() => addRevenue(revenue.revenueId)}
disabled={addingRevenueId === revenue.revenueId}
>
{addingRevenueId === revenue.revenueId ? 'Adding...' : 'Add'}
</button>
</div>
{/each}
</div>
{:else}
<div class="py-8 text-center">
<p class="mb-4 text-theme-muted">No eligible revenues available for this invoice period.</p>
<a
href="/admin/customers/{invoice.customerId}"
class="btn-secondary inline-flex items-center gap-2 text-sm"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
Go to Customer Page
</a>
</div>
{/if}
{#snippet footer()}
{#if eligibleRevenues.length > 0}
<div class="flex gap-3">
<button
type="button"
class="btn-ghost flex-1"
onclick={() => (showAddRevenuesModal = false)}
>
Close
</button>
<button
type="button"
class="btn-primary flex-1"
onclick={addAllRevenues}
disabled={isUpdating}
>
{isUpdating ? 'Adding...' : `Add All (${eligibleRevenues.length})`}
</button>
</div>
{:else}
<button type="button" class="btn-ghost w-full" onclick={() => (showAddRevenuesModal = false)}>
Close
</button>
{/if}
{/snippet}
</FormDrawer>
<!-- Add Projects Drawer -->
<FormDrawer
open={showAddProjectsModal}
title="Add Projects"
onClose={() => (showAddProjectsModal = false)}
>
{#if eligibleProjects.length > 0}
<div class="space-y-3">
{#each eligibleProjects as project (project.projectId)}
<div class="flex items-center justify-between rounded-lg border border-theme bg-theme p-4">
<div>
<p class="font-medium text-theme">
{project.name}
</p>
<p class="text-sm text-theme-secondary">
{formatDate(project.date)} - {formatCurrency(project.amount)}
</p>
</div>
<button
type="button"
class="btn-secondary text-sm"
onclick={() => addProject(project.projectId)}
disabled={addingProjectId === project.projectId}
>
{addingProjectId === project.projectId ? 'Adding...' : 'Add'}
</button>
</div>
{/each}
</div>
{:else}
<div class="py-8 text-center">
<p class="mb-4 text-theme-muted">No eligible projects available for this invoice period.</p>
<a href="/admin/projects" class="btn-secondary inline-flex items-center gap-2 text-sm">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
View Projects
</a>
</div>
{/if}
{#snippet footer()}
{#if eligibleProjects.length > 0}
<div class="flex gap-3">
<button
type="button"
class="btn-ghost flex-1"
onclick={() => (showAddProjectsModal = false)}
>
Close
</button>
<button
type="button"
class="btn-primary flex-1"
onclick={addAllProjects}
disabled={isUpdating}
>
{isUpdating ? 'Adding...' : `Add All (${eligibleProjects.length})`}
</button>
</div>
{:else}
<button type="button" class="btn-ghost w-full" onclick={() => (showAddProjectsModal = false)}>
Close
</button>
{/if}
{/snippet}
</FormDrawer>
<!-- Delete Confirmation Modal -->
{#if showDeleteConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
class="absolute inset-0 bg-black/50"
onclick={() => (showDeleteConfirm = false)}
aria-label="Close modal"
></button>
<div
class="relative w-full max-w-md rounded-xl border border-theme bg-theme-card p-6 shadow-xl"
>
<h2 class="mb-4 text-lg font-semibold text-theme">Delete Invoice?</h2>
<p class="mb-6 text-theme-secondary">
Are you sure you want to delete this invoice? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
type="button"
class="btn-ghost"
onclick={() => (showDeleteConfirm = false)}
disabled={isDeleting}
>
Cancel
</button>
<button
type="button"
class="btn-primary bg-red-600 hover:bg-red-700"
onclick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Invoice'}
</button>
</div>
</div>
</div>
{/if}
<!-- Link Customer to Wave Drawer -->
<FormDrawer
open={showLinkCustomerModal}
title="Link Customer to Wave"
onClose={() => (showLinkCustomerModal = false)}
>
<p class="mb-6 text-theme-secondary">
Select the Wave customer that corresponds to <strong class="text-theme"
>{invoice.customer?.name}</strong
>:
</p>
<label class="block">
<span class="form-label">Wave Customer</span>
<select class="mt-1 select-base" onchange={handleWaveCustomerSelect}>
<option value="">Select a customer...</option>
{#each waveCustomers as customer}
<option value={customer.id} selected={selectedWaveCustomerId === customer.id}>
{customer.name}
{customer.email ? `(${customer.email})` : ''}
</option>
{/each}
</select>
</label>
{#snippet footer()}
<div class="flex gap-3">
<button
type="button"
class="btn-ghost flex-1"
onclick={() => (showLinkCustomerModal = false)}
disabled={isUpdating}
>
Cancel
</button>
<button
type="button"
class="btn-primary flex-1"
onclick={linkCustomerToWave}
disabled={isUpdating || !selectedWaveCustomerId}
>
{isUpdating ? 'Linking...' : 'Link Customer'}
</button>
</div>
{/snippet}
</FormDrawer>
<!-- Link Item to Wave Product Drawer -->
<FormDrawer
open={showWaveLinkDrawer}
title="Link {waveLinkTarget?.type === 'revenue' ? 'Revenue' : 'Project'} to Wave"
onClose={() => {
showWaveLinkDrawer = false;
waveLinkTarget = null;
}}
>
{#if waveLinkTarget}
<p class="mb-4 text-theme-secondary">
Select a Wave product to link <strong class="text-theme">{waveLinkTarget.itemName}</strong> for
invoicing:
</p>
<WaveLinkForm
type={waveLinkTarget.type}
itemId={waveLinkTarget.id}
currentWaveServiceId={waveLinkTarget.currentWaveServiceId}
{waveProducts}
onSuccess={handleWaveLinkSuccess}
onCancel={() => {
showWaveLinkDrawer = false;
waveLinkTarget = null;
}}
/>
{/if}
</FormDrawer>