Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/app/api/invoices/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ interface ExportRequest {
* POST /api/invoices/export
* Generate and return Excel export of invoices with multiple worksheets
*
* Query params (optional, merged with body filters):
* from – ISO date string (YYYY-MM-DD), maps to filters.startDate
* to – ISO date string (YYYY-MM-DD), maps to filters.endDate (end of day)
*
* For exports < 500 invoices: returns file directly with 200 OK
* For exports >= 500 invoices: returns job ID with 202 Accepted for async processing
*/
Expand All @@ -42,8 +46,42 @@ export async function POST(request: NextRequest) {
);
}

// Filter invoices based on provided filters
const filteredInvoices = filterInvoices(invoices, filters);
// Merge query-param date range into filters (query params take precedence)
const { searchParams } = request.nextUrl;
const fromParam = searchParams.get('from');
const toParam = searchParams.get('to');

const mergedFilters: ExportFilterOptions = { ...filters };

if (fromParam) {
const fromTs = new Date(fromParam).getTime();
if (!isNaN(fromTs)) {
mergedFilters.startDate = fromTs / 1000;
}
}

if (toParam) {
// Include the full end day
const toDate = new Date(toParam);
toDate.setHours(23, 59, 59, 999);
const toTs = toDate.getTime();
if (!isNaN(toTs)) {
mergedFilters.endDate = toTs / 1000;
}
}

// Validate date range when both are provided
if (mergedFilters.startDate !== undefined && mergedFilters.endDate !== undefined) {
if (mergedFilters.startDate > mergedFilters.endDate) {
return NextResponse.json(
{ error: '"from" date must not be after "to" date.' },
{ status: 400 }
);
}
}

// Filter invoices based on merged filters
const filteredInvoices = filterInvoices(invoices, mergedFilters);

// Check if we need async processing
const ASYNC_THRESHOLD = 500;
Expand Down
14 changes: 10 additions & 4 deletions src/components/CsvRecipientImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ function parseCsv(text: string): CsvRow[] {
const amtIdx = header.indexOf("amount");

return lines.slice(1).map((line) => {
// Trim every column value to remove spreadsheet export artifacts
const cols = line.split(",").map((c) => c.trim());
const address = addrIdx >= 0 ? (cols[addrIdx] ?? "") : (cols[0] ?? "");
const percentage = pctIdx >= 0 ? (cols[pctIdx] ?? "") : undefined;
const amount = amtIdx >= 0 ? (cols[amtIdx] ?? "") : pctIdx < 0 ? (cols[1] ?? "") : undefined;
const address = (addrIdx >= 0 ? (cols[addrIdx] ?? "") : (cols[0] ?? "")).trim();
const percentage = pctIdx >= 0 ? (cols[pctIdx] ?? "").trim() : undefined;
const amount = amtIdx >= 0
? (cols[amtIdx] ?? "").trim()
: pctIdx < 0
? (cols[1] ?? "").trim()
: undefined;
return { address, percentage, amount };
}).filter((r) => r.address !== "");
}
Expand Down Expand Up @@ -132,7 +137,8 @@ export default function CsvRecipientImport({ onImport, existingCount = 0 }: Prop
field: "address" | "percentage" | "amount",
value: string
) => {
const updated = rows.map((r, i) => (i === idx ? { ...r, [field]: value } : r));
// Trim whitespace so inline edits are treated consistently with CSV parse
const updated = rows.map((r, i) => (i === idx ? { ...r, [field]: value.trim() } : r));
setRows(validateRows(updated, existingCount));
};

Expand Down
50 changes: 47 additions & 3 deletions src/components/DuplicateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import FocusTrap from "./FocusTrap";

interface Props {
invoiceId: string;
onConfirm: (deadlineIso: string) => void;
/** Called with the chosen name and deadline ISO string when the user confirms. */
onConfirm: (name: string, deadlineIso: string) => void;
onClose: () => void;
}

Expand All @@ -26,16 +27,27 @@ function defaultDeadline(): string {
}

export default function DuplicateModal({ invoiceId, onConfirm, onClose }: Props) {
const [name, setName] = useState(`Copy of Invoice #${invoiceId}`);
const [nameError, setNameError] = useState<string | null>(null);
const [deadline, setDeadline] = useState(defaultDeadline);
const [validationError, setValidationError] = useState<string | null>(null);

const handleConfirm = () => {
// Validate name
const trimmedName = name.trim();
if (!trimmedName) {
setNameError("Name cannot be empty.");
return;
}

// Validate deadline
const err = validateDeadline(deadline);
if (err) {
setValidationError(err);
return;
}
onConfirm(deadline);

onConfirm(trimmedName, deadline);
};

return (
Expand Down Expand Up @@ -65,9 +77,41 @@ export default function DuplicateModal({ invoiceId, onConfirm, onClose }: Props)
</div>

<p className="text-sm text-gray-400 mb-4">
Choose a new deadline for the duplicated invoice. All other fields will be pre-filled from the original.
Choose a name and new deadline for the duplicated invoice. All other fields will be pre-filled from the original.
</p>

{/* Duplicate name field */}
<div className="mb-4">
<label
htmlFor="dup-name"
className="block text-sm font-medium text-gray-300 mb-1"
>
Duplicate Name
</label>
<input
id="dup-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setNameError(null);
}}
className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
aria-describedby={nameError ? "dup-name-error" : undefined}
aria-invalid={!!nameError}
placeholder={`Copy of Invoice #${invoiceId}`}
/>
{nameError && (
<p
id="dup-name-error"
role="alert"
className="text-red-400 text-xs mt-1"
>
{nameError}
</p>
)}
</div>

<div className="mb-4">
<label
htmlFor="dup-deadline"
Expand Down
106 changes: 87 additions & 19 deletions src/components/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import type { ExportFilterOptions } from '@/lib/invoiceExcelExport';
import { downloadExcel, generateExportFilename } from '@/lib/invoiceExcelExport';
import { apiFetch } from '@/lib/apiClient';

/** Return a date string in "YYYY-MM-DD" format for an offset of `daysAgo` from today. */
function dateString(daysAgo: number = 0): string {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
return d.toISOString().slice(0, 10);
}

interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
Expand All @@ -22,9 +29,12 @@ export default function ExportModal({
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);

// Filter state
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
// Date-range state — "From" defaults to 30 days ago, "To" defaults to today
const [startDate, setStartDate] = useState<string>(() => dateString(30));
const [endDate, setEndDate] = useState<string>(() => dateString(0));
const [dateRangeError, setDateRangeError] = useState<string | null>(null);

// Other filter state
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const [selectedAssets, setSelectedAssets] = useState<string[]>([]);

Expand All @@ -48,7 +58,19 @@ export default function ExportModal({
);
};

/** Validate date range and return true when valid. */
const validateDateRange = (): boolean => {
if (startDate && endDate && startDate > endDate) {
setDateRangeError('"From" date must not be after "To" date.');
return false;
}
setDateRangeError(null);
return true;
};

const handleExport = useCallback(async () => {
if (!validateDateRange()) return;

setIsExporting(true);
setError(null);

Expand All @@ -59,7 +81,10 @@ export default function ExportModal({
filters.startDate = new Date(startDate).getTime() / 1000;
}
if (endDate) {
filters.endDate = new Date(endDate).getTime() / 1000;
// Include the full end day by advancing to end-of-day
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
filters.endDate = end.getTime() / 1000;
}
if (selectedStatuses.length > 0) {
filters.statuses = selectedStatuses;
Expand All @@ -68,7 +93,12 @@ export default function ExportModal({
filters.assets = selectedAssets;
}

const response = await apiFetch('/api/invoices/export', {
// Build query params so the API route can also filter server-side
const params = new URLSearchParams();
if (startDate) params.set('from', startDate);
if (endDate) params.set('to', endDate);

const response = await apiFetch(`/api/invoices/export?${params.toString()}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -112,6 +142,7 @@ export default function ExportModal({
} finally {
setIsExporting(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoices, startDate, endDate, selectedStatuses, selectedAssets, onExport, onClose]);

if (!isOpen) return null;
Expand Down Expand Up @@ -162,21 +193,58 @@ export default function ExportModal({
Date Range
</label>
<div className="grid grid-cols-2 gap-4">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={isExporting}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-100"
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={isExporting}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-100"
/>
<div>
<label
htmlFor="export-from"
className="block text-xs text-gray-500 mb-1"
>
From
</label>
<input
id="export-from"
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setDateRangeError(null);
}}
disabled={isExporting}
aria-describedby={dateRangeError ? 'export-date-error' : undefined}
aria-invalid={!!dateRangeError}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-100"
/>
</div>
<div>
<label
htmlFor="export-to"
className="block text-xs text-gray-500 mb-1"
>
To
</label>
<input
id="export-to"
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setDateRangeError(null);
}}
disabled={isExporting}
aria-describedby={dateRangeError ? 'export-date-error' : undefined}
aria-invalid={!!dateRangeError}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-100"
/>
</div>
</div>
{dateRangeError && (
<p
id="export-date-error"
role="alert"
className="mt-1 text-xs text-red-600"
>
{dateRangeError}
</p>
)}
</div>

{/* Status Filter */}
Expand Down
27 changes: 21 additions & 6 deletions src/components/InvoiceShareQRModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ const QRCodeCanvas = dynamic(
interface Props {
open: boolean;
invoiceId: string;
/** Invoice title; falls back to "Invoice #<id>" when absent */
invoiceTitle?: string;
/** Formatted total amount string, e.g. "250.00 USDC" */
totalAmount?: string;
onClose: () => void;
}

export default function InvoiceShareQRModal({ open, invoiceId, onClose }: Props) {
export default function InvoiceShareQRModal({ open, invoiceId, invoiceTitle, totalAmount, onClose }: Props) {
const toast = useToast();
const canvasRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -74,13 +78,24 @@ export default function InvoiceShareQRModal({ open, invoiceId, onClose }: Props)
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
<h2 className="text-sm font-semibold text-gray-200">
Share via QR Code
</h2>
<div className="flex items-start justify-between px-4 py-3 border-b border-gray-800">
<div className="flex flex-col gap-0.5 min-w-0 pr-2">
<h2
className="text-sm font-semibold text-gray-200 truncate"
title={invoiceTitle ?? `Invoice #${invoiceId}`}
>
{invoiceTitle ?? `Invoice #${invoiceId}`}
</h2>
{totalAmount && (
<p className="text-xs text-gray-400 truncate" aria-label={`Total: ${totalAmount}`}>
{totalAmount}
</p>
)}
<p className="text-xs text-gray-500">Share via QR Code</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-800 text-gray-300 transition-colors"
className="p-2 rounded-lg hover:bg-gray-800 text-gray-300 transition-colors shrink-0"
aria-label="Close share QR modal"
>
Expand Down