1404 lines
42 KiB
Svelte
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>
|