diff --git a/src/app/api/invoices/export/route.ts b/src/app/api/invoices/export/route.ts index 837e9de..7c34770 100644 --- a/src/app/api/invoices/export/route.ts +++ b/src/app/api/invoices/export/route.ts @@ -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 */ @@ -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; diff --git a/src/components/CsvRecipientImport.tsx b/src/components/CsvRecipientImport.tsx index 63ff8b9..f2d7eb9 100644 --- a/src/components/CsvRecipientImport.tsx +++ b/src/components/CsvRecipientImport.tsx @@ -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 !== ""); } @@ -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)); }; diff --git a/src/components/DuplicateModal.tsx b/src/components/DuplicateModal.tsx index fdd225e..755ed8b 100644 --- a/src/components/DuplicateModal.tsx +++ b/src/components/DuplicateModal.tsx @@ -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; } @@ -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(null); const [deadline, setDeadline] = useState(defaultDeadline); const [validationError, setValidationError] = useState(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 ( @@ -65,9 +77,41 @@ export default function DuplicateModal({ invoiceId, onConfirm, onClose }: Props)

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

+ {/* Duplicate name field */} +
+ + { + 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 && ( + + )} +
+
- 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" - /> - 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" - /> +
+ + { + 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" + /> +
+
+ + { + 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" + /> +
+ {dateRangeError && ( + + )}
{/* Status Filter */} diff --git a/src/components/InvoiceShareQRModal.tsx b/src/components/InvoiceShareQRModal.tsx index e9950ce..57cfbda 100644 --- a/src/components/InvoiceShareQRModal.tsx +++ b/src/components/InvoiceShareQRModal.tsx @@ -14,10 +14,14 @@ const QRCodeCanvas = dynamic( interface Props { open: boolean; invoiceId: string; + /** Invoice title; falls back to "Invoice #" 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(null); @@ -74,13 +78,24 @@ export default function InvoiceShareQRModal({ open, invoiceId, onClose }: Props) onClick={(e) => e.stopPropagation()} > {/* Header */} -
-

- Share via QR Code -

+
+
+

+ {invoiceTitle ?? `Invoice #${invoiceId}`} +

+ {totalAmount && ( +

+ {totalAmount} +

+ )} +

Share via QR Code

+