"use client"; import { AlertCircle, Building2, DollarSign, Eye, FileJson, FileSpreadsheet, FileText, Trash2, Upload, Users, } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { FileUpload } from "~/components/forms/file-upload"; import { dashboardGapClass, dashboardGridClass, dashboardStatGridClass, } from "~/components/layout/dashboard-page"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { DatePicker } from "~/components/ui/date-picker"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Progress } from "~/components/ui/progress"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { detectImportFormat, parseInvoiceCSV, parseInvoiceJSON, type ImportFormat, type ImportInvoice, } from "~/lib/invoice-import"; import { cn } from "~/lib/utils"; import { api } from "~/trpc/react"; interface StagedInvoice extends ImportInvoice { id: string; clientId: string; format: ImportFormat; } const NONE = "__none__"; function newId() { return crypto.randomUUID(); } export function InvoiceImportPage() { const [invoices, setInvoices] = useState([]); const [globalClientId, setGlobalClientId] = useState(""); const [globalBusinessId, setGlobalBusinessId] = useState(""); const [previewId, setPreviewId] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery(); const { data: businesses, isLoading: loadingBusinesses } = api.businesses.getAll.useQuery(); const utils = api.useUtils(); const bulkImport = api.invoices.bulkImport.useMutation({ onSuccess: (result) => { void utils.invoices.getAll.invalidate(); if (result.clientsCreated > 0) { void utils.clients.getAll.invalidate(); } const parts = [ `${result.invoicesCreated} invoice${result.invoicesCreated !== 1 ? "s" : ""} created`, ]; if (result.clientsCreated > 0) { parts.push( `${result.clientsCreated} client${result.clientsCreated !== 1 ? "s" : ""} created`, ); } toast.success(parts.join(", ")); if (result.errors.length > 0) { toast.warning( `${result.errors.length} invoice${result.errors.length !== 1 ? "s" : ""} skipped:\n${result.errors.slice(0, 3).join("\n")}${result.errors.length > 3 ? "\n..." : ""}`, ); } setInvoices([]); }, onError: (error) => { toast.error(error.message || "Import failed"); }, }); const applyGlobalClient = (clientId: string) => { setInvoices((prev) => prev.map((inv) => ({ ...inv, clientId: inv.clientId || clientId, })), ); }; const handleFileSelect = async (selectedFiles: File[]) => { for (const file of selectedFiles) { const format = detectImportFormat(file.name); const text = await file.text(); if (format === "json") { const parsed = parseInvoiceJSON(text); const staged: StagedInvoice[] = parsed.map((inv) => ({ ...inv, id: newId(), clientId: globalClientId, format: "json" as const, sourceFile: file.name, })); setInvoices((prev) => [...prev, ...staged]); const errorCount = staged.filter((s) => s.errors.length > 0).length; if (errorCount > 0) { toast.error( `${file.name}: ${errorCount} invoice${errorCount !== 1 ? "s" : ""} with validation issues`, ); } else { toast.success( `Parsed ${staged.length} invoice${staged.length !== 1 ? "s" : ""} from ${file.name}`, ); } } else { const parsed = parseInvoiceCSV(text, file.name); const staged: StagedInvoice = { ...parsed, id: newId(), clientId: globalClientId, format: "csv", }; setInvoices((prev) => [...prev, staged]); if (parsed.errors.length > 0) { toast.error( `${file.name}: ${parsed.errors.length} issue${parsed.errors.length !== 1 ? "s" : ""}`, ); } else { toast.success( `Parsed ${parsed.items.length} items from ${file.name}`, ); } } } }; const removeInvoice = (id: string) => { setInvoices((prev) => prev.filter((inv) => inv.id !== id)); }; const updateInvoice = (id: string, updates: Partial) => { setInvoices((prev) => prev.map((inv) => { if (inv.id !== id) return inv; const updated = { ...inv, ...updates }; if (updates.issueDate !== undefined && !updates.dueDate) { const due = new Date(updated.issueDate ?? new Date()); due.setDate(due.getDate() + 30); updated.dueDate = due; } return updated; }), ); }; const isReady = (inv: StagedInvoice) => inv.errors.length === 0 && inv.items.length > 0 && !!(inv.clientId || globalClientId || inv.client?.name) && !!inv.issueDate && !!inv.dueDate; const readyCount = invoices.filter(isReady).length; const validateBeforeImport = (): string[] => { const errors: string[] = []; if (!globalBusinessId && (!businesses || businesses.length === 0)) { errors.push("Create a business in Settings before importing"); } invoices.forEach((inv) => { if (inv.errors.length > 0) { errors.push(`${inv.name}: ${inv.errors.join("; ")}`); } if (inv.items.length === 0) { errors.push(`${inv.name}: no line items`); } if (!inv.clientId && !globalClientId && !inv.client?.name) { errors.push(`${inv.name}: client required`); } if (!inv.issueDate) errors.push(`${inv.name}: issue date required`); if (!inv.dueDate) errors.push(`${inv.name}: due date required`); }); return errors; }; const processImport = async () => { const errors = validateBeforeImport(); if (errors.length > 0) { toast.error(`Fix these issues first:\n${errors.slice(0, 5).join("\n")}`); return; } const readyInvoices = invoices.filter(isReady); if (readyInvoices.length === 0) return; setIsProcessing(true); try { await bulkImport.mutateAsync({ defaultClientId: globalClientId || undefined, defaultBusinessId: globalBusinessId || undefined, invoices: readyInvoices.map((inv) => ({ name: inv.name, issueDate: inv.issueDate, dueDate: inv.dueDate, clientId: inv.clientId || globalClientId || undefined, client: inv.client, items: inv.items.map((item) => ({ date: item.date, description: item.description, quantity: item.quantity, rate: item.rate, })), sourceFile: inv.sourceFile, })), }); } finally { setIsProcessing(false); } }; const previewInvoice = previewId ? invoices.find((i) => i.id === previewId) : null; const totalItems = invoices.reduce((sum, inv) => sum + inv.items.length, 0); const totalAmount = invoices.reduce( (sum, inv) => sum + inv.items.reduce((s, item) => s + item.quantity * item.rate, 0), 0, ); return (
{/* Upload — primary action */} Upload files {invoices.length > 0 && (
)}
{/* Defaults */}
Default business

Required — your default business is used if none is selected.

Default client

CSV files need a client. JSON can include client details per invoice.

{/* Staged invoices */} {invoices.length > 0 && ( Preview {invoices.map((inv) => (
{inv.format === "json" ? ( ) : ( )}

{inv.name}

{inv.items.length} items {inv.sourceFile ? ` • ${inv.sourceFile}` : ""} {inv.client?.name ? ` • ${inv.client.name}` : ""}

updateInvoice(inv.id, { name: e.target.value }) } />
updateInvoice(inv.id, { issueDate: date }) } placeholder="Issue date" className="h-9" />
updateInvoice(inv.id, { dueDate: date }) } placeholder="Due date" className="h-9" />
{inv.errors.length > 0 && (
Issues
    {inv.errors.map((err, i) => (
  • • {err}
  • ))}
)}
Total:{" "} {inv.items .reduce((s, item) => s + item.quantity * item.rate, 0) .toLocaleString("en-US", { style: "currency", currency: "USD", })} {isReady(inv) ? "Ready" : "Pending"}
))}
)} {invoices.length > 0 && ( Import invoices
{isProcessing && (
Importing {readyCount} invoice {readyCount !== 1 ? "s" : ""}...
)}
{readyCount} of {invoices.length} ready • all imported as drafts
)} setPreviewId(null)}> {previewInvoice?.name} Line item preview {previewInvoice && (
{previewInvoice.items.map((item, idx) => ( ))}
Date Description Qty Rate Amount
{item.date?.toLocaleDateString() ?? "—"} {item.description} {item.quantity} {item.rate.toLocaleString("en-US", { style: "currency", currency: "USD", })} {(item.quantity * item.rate).toLocaleString("en-US", { style: "currency", currency: "USD", })}
)}
); } function SummaryStat({ label, value, }: { label: string; value: string | number; }) { return (
{value}
{label}
); }