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
116 changes: 72 additions & 44 deletions src/components/ExportModal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
'use client';

import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import type { Invoice } from '@stellar-split/sdk';
import type { ExportFilterOptions } from '@/lib/invoiceExcelExport';
import { downloadExcel, generateExportFilename } from '@/lib/invoiceExcelExport';
import { apiFetch } from '@/lib/apiClient';
import {
exportPayments,
addExportHistoryEntry,
getExportHistory,
type ExportFormat,
type ExportHistoryEntry,
} from '@/lib/paymentExport';

/** Return a date string in "YYYY-MM-DD" format for an offset of `daysAgo` from today. */
function dateString(daysAgo: number = 0): string {
Expand All @@ -28,6 +33,15 @@ export default function ExportModal({
}: ExportModalProps) {
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [exportFormat, setExportFormat] = useState<ExportFormat>('csv');
const [history, setHistory] = useState<ExportHistoryEntry[]>([]);

// Load recent export history when the modal opens
useEffect(() => {
if (isOpen) {
setHistory(getExportHistory());
}
}, [isOpen]);

// Date-range state — "From" defaults to 30 days ago, "To" defaults to today
const [startDate, setStartDate] = useState<string>(() => dateString(30));
Expand Down Expand Up @@ -93,47 +107,16 @@ export default function ExportModal({
filters.assets = selectedAssets;
}

// 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 result = await exportPayments(invoices, filters, exportFormat);

const response = await apiFetch(`/api/invoices/export?${params.toString()}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
invoices,
filters,
}),
// Record the export in history
addExportHistoryEntry({
format: exportFormat,
invoiceCount: result.invoiceCount,
filters,
filename: result.filename,
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Export failed');
}

// Handle async export (202 Accepted)
if (response.status === 202) {
const jobData = await response.json();
setError(`Export queued as job ${jobData.jobId}. This will be processed in the background.`);
return;
}

// Handle synchronous export (200 OK)
const blob = await response.blob();
const filename = generateExportFilename('invoices');

// Download the file
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setHistory(getExportHistory());

onExport?.();
onClose();
Expand All @@ -143,7 +126,7 @@ export default function ExportModal({
setIsExporting(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoices, startDate, endDate, selectedStatuses, selectedAssets, onExport, onClose]);
}, [invoices, startDate, endDate, selectedStatuses, selectedAssets, exportFormat, onExport, onClose]);

if (!isOpen) return null;

Expand Down Expand Up @@ -247,6 +230,27 @@ export default function ExportModal({
)}
</div>

{/* Format Selector */}
<div>
<label
htmlFor="export-format"
className="block text-sm font-medium text-gray-700 mb-2"
>
Format
</label>
<select
id="export-format"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as ExportFormat)}
disabled={isExporting}
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"
>
<option value="csv">CSV</option>
<option value="json">JSON</option>
<option value="pdf">PDF</option>
</select>
</div>

{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Expand Down Expand Up @@ -288,6 +292,30 @@ export default function ExportModal({
))}
</div>
</div>

{/* Export History */}
{history.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Recent Exports
</label>
<ul className="space-y-1 text-sm text-gray-600">
{history.slice(0, 5).map((entry) => (
<li
key={entry.id}
className="flex justify-between gap-2 border-b border-gray-100 pb-1"
>
<span className="truncate">
{entry.filename} ({entry.format.toUpperCase()})
</span>
<span className="text-xs text-gray-400 whitespace-nowrap">
{new Date(entry.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
</div>
)}
</div>

{/* Footer */}
Expand All @@ -304,7 +332,7 @@ export default function ExportModal({
disabled={isExporting || invoices.length === 0}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? 'Exporting...' : 'Export to Excel'}
{isExporting ? 'Exporting...' : `Export to ${exportFormat.toUpperCase()}`}
</button>
</div>
</div>
Expand Down
46 changes: 25 additions & 21 deletions src/components/PaymentExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,23 @@
import { useState } from 'react';
import type { Payment } from '@stellar-split/sdk';
import {
paymentsToCSV,
downloadCSV,
generatePaymentExportFilename,
filterPaymentsByDateRange,
PAYMENT_EXPORT_COLUMNS,
PAYMENT_EXPORT_COLUMN_LABELS,
type PaymentExportColumn,
} from '@/lib/csvExport';
import {
buildPaymentSummaryHtml,
downloadPaymentSummaryPDF,
} from '@/lib/paymentExportPdf';
exportPayments,
recordExportHistory,
type ExportFormat,
} from '@/lib/paymentExport';

interface Props {
invoiceId: string;
payments: Payment[];
}

type ExportFormat = 'csv' | 'pdf';

const EXPORT_FORMATS: ExportFormat[] = ['csv', 'pdf'];
const EXPORT_FORMATS: ExportFormat[] = ['csv', 'json', 'pdf'];

export default function PaymentExport({ invoiceId, payments }: Props) {
const [showFilters, setShowFilters] = useState(false);
Expand Down Expand Up @@ -56,17 +52,23 @@ export default function PaymentExport({ invoiceId, payments }: Props) {
endDate || null
);

if (exportFormat === 'pdf') {
const html = buildPaymentSummaryHtml(invoiceId, filteredPayments);
downloadPaymentSummaryPDF(html);
} else {
// Convert to CSV
const csv = paymentsToCSV(filteredPayments, selectedColumns);

// Download
const filename = generatePaymentExportFilename(invoiceId);
downloadCSV(csv, filename);
}
exportPayments({
invoiceId,
payments: filteredPayments,
format: exportFormat,
columns: selectedColumns,
startDate: startDate || null,
endDate: endDate || null,
});

recordExportHistory({
invoiceId,
format: exportFormat,
paymentCount: filteredPayments.length,
startDate: startDate || null,
endDate: endDate || null,
columns: exportFormat === 'csv' ? selectedColumns : undefined,
});
} catch (error) {
console.error('Export failed:', error);
alert('Failed to export payments. Please try again.');
Expand Down Expand Up @@ -225,7 +227,9 @@ export default function PaymentExport({ invoiceId, payments }: Props) {
<p className="text-xs text-gray-500 mt-2">
{exportFormat === 'pdf'
? 'PDF will include the invoice number, payer addresses, amounts, timestamps, and transaction hashes.'
: 'CSV will include payer address, amount (USDC), timestamp, and transaction hash for all payments.'}
: exportFormat === 'json'
? 'JSON will include payer address, amount (USDC), timestamp, and transaction hash for all payments.'
: 'CSV will include payer address, amount (USDC), timestamp, and transaction hash for all payments.'}
</p>
)}
</div>
Expand Down
33 changes: 19 additions & 14 deletions src/components/ReminderChecker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
import { useState, useEffect, useCallback } from "react";
import { getReminders, type Reminder } from "@/lib/reminders";
import { getSnoozeState, snoozeInvoice, type SnoozeOption } from "@/lib/expirySnooze";
import {
isReminderSuppressed,
dispatchReminder,
recordReminderEvent,
} from "@/lib/reminderEngine";

const CHECK_INTERVAL_MS = 60_000;

Expand All @@ -12,18 +17,10 @@ const SNOOZE_OPTIONS: { option: SnoozeOption; label: string }[] = [
{ option: "3d", label: "3 days" },
];

async function fireNotification(reminder: Reminder) {
if (!("Notification" in window)) return;
const show = () =>
new Notification(`StellarSplit Reminder — Invoice #${reminder.invoiceId}`, {
body: reminder.message,
icon: "/favicon.ico",
});
if (Notification.permission === "granted") {
show();
} else if (Notification.permission === "default") {
const permission = await Notification.requestPermission();
if (permission === "granted") show();
async function deliverReminder(reminder: Reminder) {
const delivered = await dispatchReminder(reminder);
if (delivered) {
recordReminderEvent(reminder.invoiceId, "sent");
}
}

Expand All @@ -38,11 +35,14 @@ export default function ReminderChecker() {
const checkReminders = useCallback(() => {
const now = new Date();
const due = getReminders().filter(
(r) => new Date(r.reminderDate) <= now && !getSnoozeState(r.invoiceId)
(r) =>
new Date(r.reminderDate) <= now &&
!getSnoozeState(r.invoiceId) &&
!isReminderSuppressed(r.invoiceId)
);
setDueReminders((prev) => {
const newlyDue = due.filter((r) => !prev.some((p) => p.invoiceId === r.invoiceId));
newlyDue.forEach(fireNotification);
newlyDue.forEach(deliverReminder);
return due;
});
}, []);
Expand All @@ -59,13 +59,18 @@ export default function ReminderChecker() {
setDueReminders((prev) => prev.filter((r) => r.invoiceId !== invoiceId));
};

const handleOpen = (invoiceId: string) => {
recordReminderEvent(invoiceId, "opened");
};

if (dueReminders.length === 0) return null;

return (
<div className="fixed bottom-4 right-4 z-40 flex flex-col gap-2 max-w-sm">
{dueReminders.map((reminder) => (
<div
key={reminder.invoiceId}
onClick={() => handleOpen(reminder.invoiceId)}
className="bg-gray-900 border border-gray-700 rounded-lg p-3 shadow-lg flex items-start justify-between gap-3"
>
<div className="text-sm text-gray-200">
Expand Down
Loading
Loading