improved visual indicators for wave link;

improved scope handling - import directly from json
This commit is contained in:
Damien Coles 2026-01-29 10:05:12 -05:00
parent fa0767e456
commit 33c4edd67e
11 changed files with 497 additions and 53 deletions

View File

@ -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 │

View File

@ -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 @@
</div>
<div>
<label for="waveCustomerId" class="form-label">Wave Customer ID</label>
<input
<label for="waveCustomerId" class="form-label">Wave Customer</label>
<select
id="waveCustomerId"
type="text"
bind:value={waveCustomerId}
class="input-base"
placeholder="Enter Wave customer ID..."
class="select-base"
disabled={loading}
/>
<p class="mt-1 text-xs text-theme-muted">Wave accounting customer ID for invoicing</p>
onchange={(e) => (waveCustomerId = e.currentTarget.value)}
>
<option value="" selected={!waveCustomerId}>None</option>
{#each waveCustomers as wc (wc.id)}
<option value={wc.id} selected={waveCustomerId === wc.id}>
{wc.name}{wc.email ? ` (${wc.email})` : ''}
</option>
{/each}
</select>
<p class="mt-1 text-xs text-theme-muted">Wave accounting customer for invoicing</p>
</div>
<!-- Billing Address -->
@ -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}
/>
<div>
<span>
<span class="text-sm font-medium text-theme">This is a new address</span>
<p class="text-xs text-theme-muted">
<span class="text-xs text-theme-muted">
Check this if the customer moved. The previous address will be kept in history.
</p>
</div>
</span>
</span>
</label>
{/if}
</div>

View File

@ -0,0 +1,115 @@
<script lang="ts">
import { client } from '$lib/graphql/client';
import { UPDATE_REVENUE, type UpdateRevenueInput } from '$lib/graphql/mutations/account';
import { UPDATE_PROJECT, type UpdateProjectInput } from '$lib/graphql/mutations/project';
interface WaveProduct {
id: string;
name: string;
unitPrice: number;
isArchived: boolean;
}
interface Props {
type: 'revenue' | 'project';
itemId: string;
currentWaveServiceId: string | null;
waveProducts: WaveProduct[];
onSuccess: () => void;
onCancel: () => void;
}
let { type, itemId, currentWaveServiceId, waveProducts, onSuccess, onCancel }: Props = $props();
let activeProducts = $derived(waveProducts.filter((p) => !p.isArchived));
let selectedProductId = $derived('');
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
selectedProductId = currentWaveServiceId ?? '';
});
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
loading = true;
error = null;
try {
// Empty string means clear the link
const waveServiceId = selectedProductId || undefined;
if (type === 'revenue') {
const input: UpdateRevenueInput = { waveServiceId };
await client.mutate({
mutation: UPDATE_REVENUE,
variables: { id: itemId, input }
});
} else {
const input: UpdateProjectInput = { waveServiceId };
await client.mutate({
mutation: UPDATE_PROJECT,
variables: { id: itemId, input }
});
}
onSuccess();
} catch (err) {
console.error('Failed to update Wave link:', err);
error = err instanceof Error ? err.message : 'Failed to update Wave link';
} finally {
loading = false;
}
}
function getSelectedProductName(): string | null {
if (!selectedProductId) return null;
const product = waveProducts.find((p) => p.id === selectedProductId);
return product?.name ?? null;
}
</script>
<form onsubmit={handleSubmit} class="space-y-6">
{#if error}
<div class="rounded-lg bg-error-50 p-4 text-error-700 dark:bg-error-900/30 dark:text-error-300">
{error}
</div>
{/if}
<div>
<label for="waveProduct" class="form-label">Wave Product</label>
<select
id="waveProduct"
class="select-base"
disabled={loading}
onchange={(e) => (selectedProductId = e.currentTarget.value)}
>
<option value="" selected={!selectedProductId}>None</option>
{#each activeProducts as product (product.id)}
<option value={product.id} selected={selectedProductId === product.id}>
{product.name} (${product.unitPrice.toFixed(2)})
</option>
{/each}
</select>
<p class="mt-1 text-xs text-theme-muted">
{#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}
</p>
</div>
<div class="flex justify-end gap-3">
<button type="button" class="btn-secondary" onclick={onCancel} disabled={loading}>
Cancel
</button>
<button type="submit" class="btn-primary" disabled={loading}>
{#if loading}
Saving...
{:else}
Save
{/if}
</button>
</div>
</form>

View File

@ -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 {

View File

@ -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;
}

View File

@ -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);
}
</script>
<svelte:head>
@ -197,7 +199,7 @@
{getPrimaryAddress(account).city}, {getPrimaryAddress(account).state}
</p>
{/if}
<div class="mt-2">
<div class="mt-2 flex flex-wrap items-center gap-2">
<span
class="inline-flex rounded-full px-2 py-0.5 text-xs font-semibold {getStatusBadgeClass(
account.status
@ -205,6 +207,9 @@
>
{account.status}
</span>
{#if hasWaveLinkedRevenue(account)}
<span class="badge-primary">Wave Linked</span>
{/if}
</div>
</div>
<svg

View File

@ -2,6 +2,7 @@ import type { PageServerLoad } from './$types';
import { createServerClient } from '$lib/graphql/client';
import { redirect, error } from '@sveltejs/kit';
import { CUSTOMER_QUERY, type CustomerQueryResult } from '$lib/graphql/queries/customer';
import { WAVE_CUSTOMERS, type WaveCustomer } from '$lib/graphql/queries/wave';
export const load: PageServerLoad = async ({ locals, parent, params }) => {
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<CustomerQueryResult>({
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<CustomerQueryResult>({
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 ?? []
};
};

View File

@ -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 @@
<!-- Form Drawer -->
<FormDrawer open={drawerOpen} title={drawerTitle()} onClose={closeDrawer}>
{#if drawerMode?.type === 'customer' && customer}
<CustomerForm {customer} onSuccess={handleFormSuccess} onCancel={closeDrawer} />
<CustomerForm {customer} {waveCustomers} onSuccess={handleFormSuccess} onCancel={closeDrawer} />
{:else if drawerMode?.type === 'contact' && drawerMode.mode === 'add' && customer}
<ContactForm
customerId={customer.id}

View File

@ -24,11 +24,14 @@
import {
WAVE_CUSTOMERS,
WAVE_INVOICE,
WAVE_PRODUCTS,
type WaveInvoiceReadiness,
type WaveCustomer,
type WaveInvoice
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();
@ -55,6 +58,16 @@
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) {
@ -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'}
</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">
@ -532,6 +590,7 @@
<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}
@ -554,6 +613,21 @@
>
{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
@ -648,6 +722,21 @@
{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">
@ -694,6 +783,7 @@
<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}
@ -719,6 +809,21 @@
>
{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
@ -1268,3 +1373,31 @@
</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>

View File

@ -87,6 +87,22 @@
let importReplace = $state(false);
let importError = $state<string | null>(null);
function handleImportFileChange(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result;
importJson = typeof result === 'string' ? result : '';
};
reader.onerror = () => {
importError = 'Failed to read file';
};
reader.readAsText(file);
}
}
// Derived values
let templates = $derived(activeTab === 'service' ? serviceTemplates : projectTemplates);
let selectedTemplateId = $derived(
@ -508,6 +524,68 @@
}
}
async function moveTask(sectionId: string, taskId: string, direction: 'up' | 'down') {
if (activeTab === 'service' && selectedServiceTemplate) {
const areaIndex = selectedServiceTemplate.areas.findIndex((a) => a.id === sectionId);
if (areaIndex === -1) return;
const tasks = [...selectedServiceTemplate.areas[areaIndex].tasks];
const taskIndex = tasks.findIndex((t) => t.id === taskId);
if (taskIndex === -1) return;
if (direction === 'up' && taskIndex === 0) return;
if (direction === 'down' && taskIndex === tasks.length - 1) return;
const newIndex = direction === 'up' ? taskIndex - 1 : taskIndex + 1;
[tasks[taskIndex], tasks[newIndex]] = [tasks[newIndex], tasks[taskIndex]];
try {
await Promise.all(
tasks.map((task, i) =>
client.mutate({
mutation: UPDATE_SERVICE_SCOPE_TEMPLATE_TASK,
variables: { id: task.id, input: { order: i } }
})
)
);
const updatedAreas = selectedServiceTemplate.areas.map((a, i) =>
i === areaIndex ? { ...a, tasks } : a
);
selectedServiceTemplate = { ...selectedServiceTemplate, areas: updatedAreas };
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to reorder tasks';
}
} else if (activeTab === 'project' && selectedProjectTemplate) {
const catIndex = selectedProjectTemplate.categories.findIndex((c) => c.id === sectionId);
if (catIndex === -1) return;
const tasks = [...selectedProjectTemplate.categories[catIndex].tasks];
const taskIndex = tasks.findIndex((t) => t.id === taskId);
if (taskIndex === -1) return;
if (direction === 'up' && taskIndex === 0) return;
if (direction === 'down' && taskIndex === tasks.length - 1) return;
const newIndex = direction === 'up' ? taskIndex - 1 : taskIndex + 1;
[tasks[taskIndex], tasks[newIndex]] = [tasks[newIndex], tasks[taskIndex]];
try {
await Promise.all(
tasks.map((task, i) =>
client.mutate({
mutation: UPDATE_PROJECT_SCOPE_TEMPLATE_TASK,
variables: { id: task.id, input: { order: i } }
})
)
);
const updatedCategories = selectedProjectTemplate.categories.map((c, i) =>
i === catIndex ? { ...c, tasks } : c
);
selectedProjectTemplate = { ...selectedProjectTemplate, categories: updatedCategories };
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to reorder tasks';
}
}
}
// Task CRUD
async function createTask(sectionId: string) {
try {
@ -1301,7 +1379,7 @@
<p class="py-2 text-center text-sm text-theme-muted">No tasks yet</p>
{:else}
<div class="space-y-1">
{#each section.tasks as task (task.id)}
{#each section.tasks as task, taskIndex (task.id)}
<div class="rounded bg-gray-50 p-2 dark:bg-gray-800/50">
{#if editingTaskId === task.id}
<!-- Edit mode -->
@ -1438,6 +1516,48 @@
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
onclick={() => moveTask(section.id, task.id, 'up')}
disabled={taskIndex === 0}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme disabled:opacity-30 disabled:cursor-not-allowed dark:hover:bg-white/10"
aria-label="Move task up"
>
<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="M5 15l7-7 7 7"
/>
</svg>
</button>
<button
type="button"
onclick={() => moveTask(section.id, task.id, 'down')}
disabled={taskIndex === section.tasks.length - 1}
class="rounded p-1 text-theme-muted hover:bg-black/5 hover:text-theme disabled:opacity-30 disabled:cursor-not-allowed dark:hover:bg-white/10"
aria-label="Move task down"
>
<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="M19 9l-7 7-7-7"
/>
</svg>
</button>
<button
type="button"
onclick={() => (editingTaskId = task.id)}
@ -1615,12 +1735,44 @@
</div>
</details>
<div class="mb-4">
<span class="form-label">Upload JSON File</span>
<label
class="mt-1 flex cursor-pointer items-center justify-center gap-2 rounded-lg border-2 border-dashed border-theme px-4 py-3 text-sm text-theme-muted transition-colors hover:border-primary-500 hover:text-primary-500"
>
<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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<span>Choose file or drag &amp; drop</span>
<input
type="file"
accept=".json,application/json"
class="hidden"
onchange={handleImportFileChange}
/>
</label>
</div>
<div class="relative mb-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-theme"></div>
</div>
<div class="relative flex justify-center">
<span class="bg-theme-card px-2 text-xs text-theme-muted">or paste JSON</span>
</div>
</div>
<label class="mb-4 block">
<span class="form-label">JSON Data</span>
<textarea
bind:value={importJson}
placeholder="Paste JSON here..."
rows="10"
rows="8"
class="textarea-base font-mono text-sm"
></textarea>
</label>

View File

@ -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