improved visual indicators for wave link;
improved scope handling - import directly from json
This commit is contained in:
parent
fa0767e456
commit
33c4edd67e
22
README.md
22
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 │
|
||||
|
||||
@ -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>
|
||||
|
||||
115
frontend/src/lib/components/forms/WaveLinkForm.svelte
Normal file
115
frontend/src/lib/components/forms/WaveLinkForm.svelte
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ?? []
|
||||
};
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 & 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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user