Skip to content

Commit 46ec8d7

Browse files
committed
feat: add multi-select action toolbar for bulk invoice operations (#506)
1 parent 84ac79f commit 46ec8d7

4 files changed

Lines changed: 341 additions & 12 deletions

File tree

src/app/api/invoices/bulk/route.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,23 @@ import { NextRequest, NextResponse } from "next/server";
33
/**
44
* PATCH /api/invoices/bulk
55
*
6-
* Bulk archive/unarchive operation endpoint.
7-
* Accepts up to 200 invoice IDs per request with archive status.
6+
* Bulk operation endpoint for archive, delete, and tag operations.
7+
* Accepts up to 200 invoice IDs per request.
8+
*
9+
* Request body:
10+
* - invoiceIds: string[] (required)
11+
* - action: 'archive' | 'delete' | 'tag' (required)
12+
* - archived?: boolean (for archive action)
13+
* - tags?: string[] (for tag action)
814
*/
915
export async function PATCH(request: NextRequest) {
1016
try {
1117
const body = await request.json();
12-
const { invoiceIds, archived } = body as {
18+
const { invoiceIds, action, archived, tags } = body as {
1319
invoiceIds: string[];
14-
archived: boolean;
20+
action: string;
21+
archived?: boolean;
22+
tags?: string[];
1523
};
1624

1725
// Validate inputs
@@ -29,23 +37,53 @@ export async function PATCH(request: NextRequest) {
2937
);
3038
}
3139

32-
if (typeof archived !== "boolean") {
40+
if (!["archive", "delete", "tag"].includes(action)) {
3341
return NextResponse.json(
34-
{ error: "archived must be a boolean" },
42+
{ error: "action must be 'archive', 'delete', or 'tag'" },
3543
{ status: 400 },
3644
);
3745
}
3846

39-
// TODO: Implement actual database storage of archived status
40-
// For now, this endpoint validates the request and returns success.
41-
// In production, store archived status in database alongside invoice data.
47+
if (action === "archive" && typeof archived !== "boolean") {
48+
return NextResponse.json(
49+
{ error: "archived must be a boolean for archive action" },
50+
{ status: 400 },
51+
);
52+
}
53+
54+
if (action === "tag" && !Array.isArray(tags)) {
55+
return NextResponse.json(
56+
{ error: "tags must be an array for tag action" },
57+
{ status: 400 },
58+
);
59+
}
60+
61+
// TODO: Implement actual database operations
62+
// For now, validate the request and return success.
63+
// In production:
64+
// - archive: Update archived status in database
65+
// - delete: Mark invoices as deleted or remove them
66+
// - tag: Apply tags to invoices
67+
68+
let message = "";
69+
switch (action) {
70+
case "archive":
71+
message = `${invoiceIds.length} invoices ${archived ? "archived" : "unarchived"}`;
72+
break;
73+
case "delete":
74+
message = `${invoiceIds.length} invoices deleted`;
75+
break;
76+
case "tag":
77+
message = `${invoiceIds.length} invoices tagged with: ${tags?.join(", ")}`;
78+
break;
79+
}
4280

4381
return NextResponse.json(
4482
{
4583
success: true,
4684
count: invoiceIds.length,
47-
archived,
48-
message: `${invoiceIds.length} invoices ${archived ? "archived" : "unarchived"}`,
85+
action,
86+
message,
4987
},
5088
{ status: 200 },
5189
);

src/components/DashboardClient.tsx

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { formatAmount } from "@stellar-split/sdk";
2020
import type { Invoice } from "@stellar-split/sdk";
2121
import { useInfiniteInvoices } from "@/hooks/useInfiniteInvoices";
2222
import InvoiceListSentinel from "@/components/InvoiceListSentinel";
23+
import { useInvoiceSelection } from "@/hooks/useInvoiceSelection";
24+
import BulkActionToolbar from "@/components/invoice/BulkActionToolbar";
2325
import {
2426
DASHBOARD_PRESETS,
2527
SORT_OPTIONS,
@@ -81,6 +83,18 @@ export default function DashboardClient() {
8183
const { unreadCount } = useActivityFeed();
8284
const [splitMetaMap, setSplitMetaMap] = useState<Record<string, { installments?: { dueDate: number; status: string }[] }>>({});
8385

86+
// Multi-select state management
87+
const {
88+
selectedIds,
89+
isSelecting,
90+
toggleSelecting,
91+
toggleInvoice,
92+
selectAll,
93+
deselectAll,
94+
isSelected,
95+
selectedCount,
96+
} = useInvoiceSelection();
97+
8498
// ── URL mutation helpers ────────────────────────────────────────────────────
8599

86100
const pushParams = useCallback(
@@ -110,6 +124,47 @@ export default function DashboardClient() {
110124
const isFiltered =
111125
statuses.length > 0 || dateFrom || dateTo || sort !== "newest" || !!tag;
112126

127+
const handleBulkArchive = async () => {
128+
try {
129+
const response = await fetch("/api/invoices/bulk", {
130+
method: "PATCH",
131+
headers: { "Content-Type": "application/json" },
132+
body: JSON.stringify({
133+
invoiceIds: Array.from(selectedIds),
134+
action: "archive",
135+
archived: true,
136+
}),
137+
});
138+
139+
if (response.ok) {
140+
deselectAll();
141+
// Optionally refresh the invoice list
142+
}
143+
} catch (error) {
144+
console.error("Bulk archive failed:", error);
145+
}
146+
};
147+
148+
const handleBulkDelete = async () => {
149+
try {
150+
const response = await fetch("/api/invoices/bulk", {
151+
method: "PATCH",
152+
headers: { "Content-Type": "application/json" },
153+
body: JSON.stringify({
154+
invoiceIds: Array.from(selectedIds),
155+
action: "delete",
156+
}),
157+
});
158+
159+
if (response.ok) {
160+
deselectAll();
161+
// Optionally refresh the invoice list
162+
}
163+
} catch (error) {
164+
console.error("Bulk delete failed:", error);
165+
}
166+
};
167+
113168
// ── Data fetching ───────────────────────────────────────────────────────────
114169
const [activePreset, setActivePreset] = useState<DashboardPresetId>("all");
115170
const [shareQRInvoiceId, setShareQRInvoiceId] = useState<string | null>(null);
@@ -566,6 +621,24 @@ export default function DashboardClient() {
566621
</button>
567622
</>
568623
)}
624+
{!isSelecting && !compareMode && !reminderSelect && (
625+
<button
626+
onClick={toggleSelecting}
627+
className="min-h-11 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-semibold transition-colors"
628+
aria-label="Enable multi-select mode"
629+
>
630+
Select
631+
</button>
632+
)}
633+
{isSelecting && (
634+
<button
635+
onClick={toggleSelecting}
636+
className="min-h-11 px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors text-gray-300"
637+
aria-label="Exit multi-select mode"
638+
>
639+
Cancel Selection
640+
</button>
641+
)}
569642
<Link
570643
href="/invoice/new"
571644
className="min-h-11 inline-flex items-center px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold transition-colors"
@@ -753,14 +826,44 @@ export default function DashboardClient() {
753826
const isReminderSelected = reminderSelected.has(inv.id);
754827
const isCompareSelectable = compareMode;
755828
const isCompareSelected = compareSelected.has(inv.id);
829+
const isMultiSelectable = isSelecting;
830+
const isMultiSelected = isSelected(inv.id);
756831

757832
const card = (
758833
<InvoiceCard invoice={inv} displayNumber={getOrAssignDisplayNumber(inv.id)} tags={tagsByInvoice[inv.id] ?? []} />
759834
);
760835

761836
return (
762837
<div key={inv.id}>
763-
{isSelectable ? (
838+
{isMultiSelectable ? (
839+
<button
840+
type="button"
841+
onClick={() => toggleInvoice(inv.id)}
842+
aria-pressed={isMultiSelected}
843+
aria-label={`${isMultiSelected ? "Deselect" : "Select"} Invoice #${inv.id}`}
844+
className={`w-full text-left rounded-xl ring-2 transition-all ${
845+
isMultiSelected
846+
? "ring-indigo-500"
847+
: "ring-transparent hover:ring-gray-600"
848+
}`}
849+
>
850+
<div className="relative">
851+
{isMultiSelected && (
852+
<span
853+
aria-hidden="true"
854+
className="absolute top-3 right-3 w-5 h-5 rounded-full bg-indigo-500 flex items-center justify-center text-white text-xs font-bold z-10"
855+
>
856+
857+
</span>
858+
)}
859+
<InvoiceCard
860+
invoice={inv}
861+
displayNumber={getOrAssignDisplayNumber(inv.id)}
862+
tags={tagsByInvoice[inv.id] ?? []}
863+
/>
864+
</div>
865+
</button>
866+
) : isSelectable ? (
764867
<button
765868
type="button"
766869
onClick={() => toggleSelect(inv.id)}
@@ -928,6 +1031,21 @@ export default function DashboardClient() {
9281031
onClose={() => setShareQRInvoiceId(null)}
9291032
/>
9301033

1034+
{isSelecting && selectedCount > 0 && (
1035+
<BulkActionToolbar
1036+
selectedCount={selectedCount}
1037+
selectedIds={selectedIds}
1038+
totalVisible={visibleInvoices.length}
1039+
onSelectAll={() => selectAll(visibleInvoices.map(inv => inv.id))}
1040+
onDeselectAll={deselectAll}
1041+
onArchive={handleBulkArchive}
1042+
onDelete={handleBulkDelete}
1043+
onTag={() => {
1044+
// TODO: Implement tagging dialog
1045+
}}
1046+
/>
1047+
)}
1048+
9311049
<ActivityFeed open={feedOpen} />
9321050
</>
9331051
);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { Trash2, Archive, Tag, X } from "lucide-react";
5+
6+
interface Props {
7+
selectedCount: number;
8+
selectedIds: Set<string>;
9+
totalVisible: number;
10+
onSelectAll: () => void;
11+
onDeselectAll: () => void;
12+
onArchive: () => Promise<void>;
13+
onDelete: () => Promise<void>;
14+
onTag: () => void;
15+
}
16+
17+
export default function BulkActionToolbar({
18+
selectedCount,
19+
selectedIds,
20+
totalVisible,
21+
onSelectAll,
22+
onDeselectAll,
23+
onArchive,
24+
onDelete,
25+
onTag,
26+
}: Props) {
27+
const [processing, setProcessing] = useState(false);
28+
29+
const handleArchive = async () => {
30+
setProcessing(true);
31+
try {
32+
await onArchive();
33+
} finally {
34+
setProcessing(false);
35+
}
36+
};
37+
38+
const handleDelete = async () => {
39+
if (!window.confirm(`Delete ${selectedCount} invoice(s)? This cannot be undone.`)) {
40+
return;
41+
}
42+
setProcessing(true);
43+
try {
44+
await onDelete();
45+
} finally {
46+
setProcessing(false);
47+
}
48+
};
49+
50+
return (
51+
<div className="fixed bottom-0 left-0 right-0 z-40 bg-gray-900 border-t border-gray-700 shadow-lg">
52+
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between gap-4">
53+
<div className="flex items-center gap-4 flex-1">
54+
<div className="text-sm font-medium text-gray-300">
55+
{selectedCount} of {totalVisible} selected
56+
</div>
57+
58+
{selectedCount > 0 && totalVisible > selectedCount && (
59+
<button
60+
onClick={onSelectAll}
61+
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors underline"
62+
>
63+
Select all {totalVisible}
64+
</button>
65+
)}
66+
</div>
67+
68+
<div className="flex items-center gap-2">
69+
<button
70+
onClick={onTag}
71+
disabled={processing}
72+
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-sm font-medium transition-colors disabled:opacity-50 text-gray-300"
73+
aria-label="Tag selected invoices"
74+
>
75+
<Tag size={16} />
76+
<span className="hidden sm:inline">Tag</span>
77+
</button>
78+
79+
<button
80+
onClick={handleArchive}
81+
disabled={processing}
82+
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-sm font-medium transition-colors disabled:opacity-50 text-gray-300"
83+
aria-label="Archive selected invoices"
84+
>
85+
<Archive size={16} />
86+
<span className="hidden sm:inline">Archive</span>
87+
</button>
88+
89+
<button
90+
onClick={handleDelete}
91+
disabled={processing}
92+
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-red-900/30 hover:bg-red-900/50 text-sm font-medium transition-colors disabled:opacity-50 text-red-400"
93+
aria-label="Delete selected invoices"
94+
>
95+
<Trash2 size={16} />
96+
<span className="hidden sm:inline">Delete</span>
97+
</button>
98+
99+
<button
100+
onClick={onDeselectAll}
101+
disabled={processing}
102+
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors disabled:opacity-50"
103+
aria-label="Close toolbar"
104+
>
105+
<X size={16} />
106+
</button>
107+
</div>
108+
</div>
109+
</div>
110+
);
111+
}

0 commit comments

Comments
 (0)