diff --git a/README.md b/README.md index 81d597e..13a8e48 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,16 @@ Nexus 6 represents the culmination of lessons learned from five previous iterati ``` ┌─────────────────────────────────────────────────────────────┐ -│ Clients │ -│ (Browser / Mobile / API Consumers) │ +│ Clients │ +│ (Browser / Mobile / API Consumers) │ └─────────────────────────┬───────────────────────────────────┘ │ ┌─────────────────────────▼───────────────────────────────────┐ -│ Ory Oathkeeper │ -│ (API Gateway / Zero Trust) │ -│ - Route-based authentication │ -│ - JWT token injection │ -│ - CORS handling │ +│ Ory Oathkeeper │ +│ (API Gateway / Zero Trust) │ +│ - Route-based authentication │ +│ - JWT token injection │ +│ - CORS handling │ └─────────────────────────┬───────────────────────────────────┘ │ ┌────────────────┼────────────────┐ @@ -39,14 +39,14 @@ Nexus 6 represents the culmination of lessons learned from five previous iterati │ - Customer │ │ - Register │ │ - Media handling │ │ - Public │ │ - Settings │ │ - Notifications │ └─────────────┘ └─────────────┘ └──────────┬──────────┘ - │ │ - │ │ - ┌────────────────┼───────────────────┘ + │ │ + │ │ + ┌────────────────┼──────────────────┘ │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ │ Ory Kratos │ │ PostgreSQL │ -│ (Identity) │ │ (via │ +│ (Identity) │ │ (via │ │ │ │ PgBouncer) │ │ - Sessions │ │ │ │ - Recovery │ │ - App data │ diff --git a/frontend/src/lib/components/forms/CustomerForm.svelte b/frontend/src/lib/components/forms/CustomerForm.svelte index ae11264..f678b01 100644 --- a/frontend/src/lib/components/forms/CustomerForm.svelte +++ b/frontend/src/lib/components/forms/CustomerForm.svelte @@ -10,13 +10,20 @@ } from '$lib/graphql/mutations/customer'; import type { Customer } from '$lib/graphql/queries/customer'; + interface WaveCustomer { + id: string; + name: string; + email?: string; + } + interface Props { customer: Customer; + waveCustomers?: WaveCustomer[]; onSuccess: () => void; onCancel: () => void; } - let { customer, onSuccess, onCancel }: Props = $props(); + let { customer, waveCustomers = [], onSuccess, onCancel }: Props = $props(); // Get active billing address let activeAddress = $derived(customer.addresses?.find((a) => a.isActive) ?? null); @@ -195,16 +202,21 @@
- - Wave Customer + +

Wave accounting customer for invoicing

@@ -274,12 +286,12 @@ class="border-theme-muted mt-0.5 h-4 w-4 rounded text-primary-600 focus:ring-primary-500" disabled={loading} /> -
+ This is a new address -

+ Check this if the customer moved. The previous address will be kept in history. -

-
+ + {/if} diff --git a/frontend/src/lib/components/forms/WaveLinkForm.svelte b/frontend/src/lib/components/forms/WaveLinkForm.svelte new file mode 100644 index 0000000..f9a9756 --- /dev/null +++ b/frontend/src/lib/components/forms/WaveLinkForm.svelte @@ -0,0 +1,115 @@ + + +
+ {#if error} +
+ {error} +
+ {/if} + +
+ + +

+ {#if selectedProductId} + This {type} will be linked to "{getSelectedProductName()}" for Wave invoicing. + {:else} + No Wave product linked. This {type} won't sync to Wave. + {/if} +

+
+ +
+ + +
+
diff --git a/frontend/src/lib/graphql/queries/accounts.ts b/frontend/src/lib/graphql/queries/accounts.ts index 2623c60..914c957 100644 --- a/frontend/src/lib/graphql/queries/accounts.ts +++ b/frontend/src/lib/graphql/queries/accounts.ts @@ -18,6 +18,10 @@ export const ACCOUNTS_QUERY = gql` state isPrimary } + revenues { + waveServiceId + isActive + } } } `; @@ -42,6 +46,11 @@ export interface AccountListCustomer { name: string; } +export interface AccountListRevenue { + waveServiceId: string | null; + isActive: boolean; +} + export interface AccountListItem { id: string; customerId: string; @@ -50,6 +59,7 @@ export interface AccountListItem { isActive: boolean; customer: AccountListCustomer | null; addresses: AccountListAddress[]; + revenues: AccountListRevenue[]; } export interface AccountsQueryResult { diff --git a/frontend/src/lib/graphql/queries/invoices.ts b/frontend/src/lib/graphql/queries/invoices.ts index a26f1ad..519cf9a 100644 --- a/frontend/src/lib/graphql/queries/invoices.ts +++ b/frontend/src/lib/graphql/queries/invoices.ts @@ -118,6 +118,7 @@ export const ELIGIBLE_PROJECTS_QUERY = gql` date amount formattedAddress + waveServiceId } } } @@ -162,6 +163,7 @@ export interface InvoiceProjectEntry { date: string; amount: number | null; formattedAddress: string | null; + waveServiceId: string | null; } | null; } @@ -236,6 +238,7 @@ export interface EligibleInvoiceProject { date: string; amount: number | null; formattedAddress: string | null; + waveServiceId: string | null; } | null; } diff --git a/frontend/src/routes/admin/accounts/+page.svelte b/frontend/src/routes/admin/accounts/+page.svelte index 0ae5450..6f8b5a4 100644 --- a/frontend/src/routes/admin/accounts/+page.svelte +++ b/frontend/src/routes/admin/accounts/+page.svelte @@ -48,11 +48,9 @@ } // Status filter - if (statusFilter !== 'ALL' && a.status !== statusFilter) { - return false; - } + return !(statusFilter !== 'ALL' && a.status !== statusFilter); + - return true; }) ); @@ -81,6 +79,10 @@ function getPrimaryAddress(account: (typeof accounts)[number]) { return account.addresses.find((a) => a.isPrimary) ?? account.addresses[0] ?? null; } + + function hasWaveLinkedRevenue(account: (typeof accounts)[number]): boolean { + return account.revenues.some((r) => r.isActive && r.waveServiceId); + } @@ -197,7 +199,7 @@ {getPrimaryAddress(account).city}, {getPrimaryAddress(account).state}

{/if} -
+
Wave Linked + {/if}
{ const { user } = await parent(); @@ -21,24 +22,36 @@ export const load: PageServerLoad = async ({ locals, parent, params }) => { const client = createServerClient(locals.cookie); - const { data } = await client - .query({ - query: CUSTOMER_QUERY, - variables: { id: params.customer } - }) - .catch((err) => { - console.error('Failed to fetch customer:', err); - throw error(500, 'Failed to load customer'); - }); + const [customerResult, waveCustomersResult] = await Promise.all([ + client + .query({ + query: CUSTOMER_QUERY, + variables: { id: params.customer } + }) + .catch((err) => { + console.error('Failed to fetch customer:', err); + throw error(500, 'Failed to load customer'); + }), + client + .query<{ waveCustomers: WaveCustomer[] }>({ + query: WAVE_CUSTOMERS + }) + .catch((err) => { + console.error('Failed to fetch Wave customers:', err); + // Non-fatal: return empty array if Wave fetch fails + return { data: { waveCustomers: [] } }; + }) + ]); - if (!data?.customer) { + if (!customerResult.data?.customer) { throw error(404, 'Customer not found'); } return { - customer: data.customer, - contacts: data.customer.contacts, - addresses: data.customer.addresses, - accounts: data.customer.accounts + customer: customerResult.data.customer, + contacts: customerResult.data.customer.contacts, + addresses: customerResult.data.customer.addresses, + accounts: customerResult.data.customer.accounts, + waveCustomers: waveCustomersResult.data?.waveCustomers ?? [] }; }; diff --git a/frontend/src/routes/admin/customers/[customer]/+page.svelte b/frontend/src/routes/admin/customers/[customer]/+page.svelte index 5f02772..545c069 100644 --- a/frontend/src/routes/admin/customers/[customer]/+page.svelte +++ b/frontend/src/routes/admin/customers/[customer]/+page.svelte @@ -18,6 +18,7 @@ let contacts = $derived(data.contacts ?? []); let addresses = $derived(data.addresses ?? []); let accounts = $derived(data.accounts ?? []); + let waveCustomers = $derived(data.waveCustomers ?? []); // Split addresses into active (current billing) and inactive (history) let activeAddress = $derived(addresses.find((a) => a.isActive) ?? null); @@ -422,7 +423,7 @@ {#if drawerMode?.type === 'customer' && customer} - + {:else if drawerMode?.type === 'contact' && drawerMode.mode === 'add' && customer} (''); + // Wave product linking state + let waveProducts = $state([]); + 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) { @@ -247,6 +260,36 @@ } } + 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; @@ -488,6 +531,21 @@ ? formatDate(entry.revenue.endDate) : 'Ongoing'}

+
+ {#if entry.revenue?.waveServiceId} + Wave Linked + {:else if invoice.status === 'DRAFT'} + + {:else} + Not Linked + {/if} +
@@ -532,6 +590,7 @@ Account Period Amount + Wave {#if invoice.status === 'DRAFT'} {/if} @@ -554,6 +613,21 @@ > {formatCurrency(entry.amount)} + + {#if entry.revenue?.waveServiceId} + Linked + {:else if invoice.status === 'DRAFT'} + + {:else} + Not Linked + {/if} + {#if invoice.status === 'DRAFT'} + {:else} + Not Linked + {/if} +
@@ -694,6 +783,7 @@ Account Location Amount + Wave {#if invoice.status === 'DRAFT'} {/if} @@ -719,6 +809,21 @@ > {formatCurrency(entry.amount)} + + {#if entry.project?.waveServiceId} + Linked + {:else if invoice.status === 'DRAFT'} + + {:else} + Not Linked + {/if} + {#if invoice.status === 'DRAFT'} +
+
+ Upload JSON File + +
+ +
+
+
+
+
+ or paste JSON +
+
+ diff --git a/src/graphql/queries/wave.rs b/src/graphql/queries/wave.rs index a5db16b..286cf1d 100644 --- a/src/graphql/queries/wave.rs +++ b/src/graphql/queries/wave.rs @@ -130,12 +130,12 @@ impl WaveQuery { } // Count revenues with and without wave_service_id - // Empty string '' means unlinked (backend uses '' to clear fields, not NULL) + // Use COALESCE to handle NULL as unlinked (same as empty string) let revenue_stats: (i64, i64) = sqlx::query_as( r#" SELECT - COUNT(*) FILTER (WHERE r.wave_service_id != '') as linked, - COUNT(*) FILTER (WHERE r.wave_service_id = '') as unlinked + COUNT(*) FILTER (WHERE COALESCE(r.wave_service_id, '') != '') as linked, + COUNT(*) FILTER (WHERE COALESCE(r.wave_service_id, '') = '') as unlinked FROM invoice_revenues ir JOIN revenues r ON r.id = ir.revenue_id WHERE ir.invoice_id = $1 @@ -146,12 +146,12 @@ impl WaveQuery { .await?; // Count projects with and without wave_service_id - // Empty string '' means unlinked (backend uses '' to clear fields, not NULL) + // Use COALESCE to handle NULL as unlinked (same as empty string) let project_stats: (i64, i64) = sqlx::query_as( r#" SELECT - COUNT(*) FILTER (WHERE p.wave_service_id != '') as linked, - COUNT(*) FILTER (WHERE p.wave_service_id = '') as unlinked + COUNT(*) FILTER (WHERE COALESCE(p.wave_service_id, '') != '') as linked, + COUNT(*) FILTER (WHERE COALESCE(p.wave_service_id, '') = '') as unlinked FROM invoice_projects ip JOIN projects p ON p.id = ip.project_id WHERE ip.invoice_id = $1