@@ -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'}
|
+
+ {#if entry.project?.waveServiceId}
+ Linked
+ {:else if invoice.status === 'DRAFT'}
+ handleWaveLinkClick('project', entry.projectId, entry.project?.waveServiceId ?? null, entry.project?.name ?? 'Project')}
+ >
+ Link
+
+ {:else}
+ Not Linked
+ {/if}
+ |
{#if invoice.status === 'DRAFT'}
{/snippet}
+
+
+ {
+ showWaveLinkDrawer = false;
+ waveLinkTarget = null;
+ }}
+>
+ {#if waveLinkTarget}
+
+ Select a Wave product to link {waveLinkTarget.itemName} for
+ invoicing:
+
+ {
+ showWaveLinkDrawer = false;
+ waveLinkTarget = null;
+ }}
+ />
+ {/if}
+
diff --git a/frontend/src/routes/admin/scopes/+page.svelte b/frontend/src/routes/admin/scopes/+page.svelte
index dd3dfc4..4a7ac38 100644
--- a/frontend/src/routes/admin/scopes/+page.svelte
+++ b/frontend/src/routes/admin/scopes/+page.svelte
@@ -87,6 +87,22 @@
let importReplace = $state(false);
let importError = $state(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 @@
No tasks yet
{:else}
- {#each section.tasks as task (task.id)}
+ {#each section.tasks as task, taskIndex (task.id)}
{#if editingTaskId === task.id}
@@ -1438,6 +1516,48 @@
+ 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"
+ >
+
+
+ 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"
+ >
+
+
(editingTaskId = task.id)}
@@ -1615,12 +1735,44 @@
+
+ Upload JSON File
+
+
+
+
+
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
|