Add bulk invoice import with templates and refresh import UX

Move invoice import configuration into settings, redesign the import flow with shared components and sample templates, document the demo account in README, and polish upload and button styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 12:39:46 -04:00
co-authored by Cursor
parent 5978c8d903
commit 6b73c32c25
16 changed files with 1753 additions and 1136 deletions
+3 -234
View File
@@ -1,236 +1,5 @@
import {
AlertCircle,
ArrowLeft,
CheckCircle,
Download,
FileSpreadsheet,
FileText,
Info,
Upload,
} from "lucide-react";
import Link from "next/link";
import { CSVImportPage } from "~/components/csv-import-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGridClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { HydrateClient } from "~/trpc/server";
import { redirect } from "next/navigation";
// File Upload Instructions Component
function FormatInstructions() {
return (
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
{/* Required Format */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Required CSV Format
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted/50 p-4">
<p className="text-muted-foreground font-mono text-sm">
DATE,DESCRIPTION,HOURS,RATE,AMOUNT
</p>
</div>
<div className="space-y-3">
<h4 className="font-semibold">Required Columns:</h4>
<div className="grid gap-2">
{[
{ field: "DATE", desc: "Date of work (M/DD/YY format)" },
{ field: "DESCRIPTION", desc: "Description of work performed" },
{ field: "HOURS", desc: "Number of hours worked" },
{ field: "RATE", desc: "Hourly rate (decimal)" },
{
field: "AMOUNT",
desc: "Total amount (calculated from hours × rate)",
},
].map((col) => (
<div key={col.field} className="flex items-start gap-3">
<Badge className="border text-xs">{col.field}</Badge>
<span className="text-muted-foreground text-sm">
{col.desc}
</span>
</div>
))}
</div>
</div>
<div className="pt-2">
<h4 className="mb-2 font-semibold">File Naming:</h4>
<p className="text-muted-foreground text-sm">
Name your CSV files in{" "}
<code className="bg-muted rounded px-1 text-xs">
YYYY-MM-DD.csv
</code>{" "}
format for automatic date detection.
</p>
</div>
</CardContent>
</Card>
{/* Sample Data & Download */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Download className="text-primary h-5 w-5" />
Sample Template
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Download our sample CSV template to see the exact format required
for importing time entries.
</p>
<div className="bg-primary/10 p-4">
<div className="flex items-start gap-3">
<Info className="text-primary mt-0.5 h-5 w-5" />
<div>
<p className="text-success text-sm font-medium">Pro Tip</p>
<p className="text-success text-sm">
The template includes sample data and formatting examples to
help you get started quickly.
</p>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Row:</h4>
<div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs break-all">
1/15/24,&quot;Web development work&quot;,8,75.00,600.00
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Filename:</h4>
<div className="bg-muted/50 p-3">
<p className="text-muted font-mono text-xs">2024-01-15.csv</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
// Important Notes Section
function ImportantNotes() {
return (
<Card className="bg-card border-border border border-l-4 border-l-amber-500">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<AlertCircle className="text-primary h-5 w-5" />
Important Notes
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="mb-2 font-semibold">Before Importing:</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Use M/DD/YY format for dates (e.g., 1/15/24)</li>
<li> Ensure rates are in decimal format (e.g., 75.50)</li>
<li> File names should follow YYYY-MM-DD.csv format</li>
<li> Select a client before importing</li>
</ul>
</div>
<div>
<h4 className="mb-2 font-semibold">What Happens:</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Each CSV file creates one invoice</li>
<li> Invoice dates are derived from filename</li>
<li> Invoices are created in &quot;draft&quot; status</li>
<li> You can review and edit before sending</li>
</ul>
</div>
</div>
</CardContent>
</Card>
);
}
// File Format Help Section
function FileFormatHelp() {
return (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileSpreadsheet className="text-primary h-5 w-5" />
Supported File Formats
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-2 text-center">
<div className="bg-accent mx-auto w-fit p-3">
<FileSpreadsheet className="text-foreground-foreground h-6 w-6" />
</div>
<h4 className="font-semibold">CSV Files</h4>
<p className="text-muted-foreground text-sm">
Comma-separated values from Excel, Google Sheets, or any CSV
editor
</p>
</div>
<div className="space-y-2 text-center">
<div className="bg-primary/10 mx-auto w-fit p-3">
<Upload className="text-primary h-6 w-6" />
</div>
<h4 className="font-semibold">Max Size</h4>
<p className="text-muted-foreground text-sm">
Up to 10MB per file with no limit on number of rows
</p>
</div>
<div className="space-y-2 text-center">
<div className="bg-secondary mx-auto w-fit p-3">
<CheckCircle className="text-muted-foreground-foreground h-6 w-6" />
</div>
<h4 className="font-semibold">Validation</h4>
<p className="text-muted-foreground text-sm">
Real-time validation with clear error messages and feedback
</p>
</div>
</div>
</CardContent>
</Card>
);
}
export default async function ImportPage() {
return (
<DashboardPage>
<DashboardPageHeader
title="Import Time Entries"
description="Upload CSV files to create invoices from your time tracking data"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="lg">
<ArrowLeft className="mr-2 h-5 w-5" />
Back to Invoices
</Button>
</Link>
</DashboardPageHeader>
<HydrateClient>
{/* Main CSV Import Component */}
<CSVImportPage />
{/* File Format Help */}
<FileFormatHelp />
{/* Format Instructions */}
<FormatInstructions />
{/* Important Notes */}
<ImportantNotes />
</HydrateClient>
</DashboardPage>
);
export default function ImportPage() {
redirect("/dashboard/settings?tab=data");
}
+1 -7
View File
@@ -4,7 +4,7 @@ import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { Plus, Upload } from "lucide-react";
import { Plus } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
@@ -22,12 +22,6 @@ export default async function InvoicesPage() {
title="Invoices"
description="Manage your invoices and track payments"
>
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/import">
<Upload className="mr-2 h-5 w-5" />
<span>Import CSV</span>
</Link>
</Button>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" />
@@ -0,0 +1,245 @@
"use client";
import { CircleHelp, FileJson, FileSpreadsheet, FileText } from "lucide-react";
import { useState } from "react";
import {
ImportCsvTemplateButton,
ImportJsonTemplateButton,
} from "./import-sample-download";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { JSON_TEMPLATE } from "~/lib/invoice-import-templates";
const CSV_COLUMNS = [
{
field: "date",
required: false,
desc: "Work date (M/D/YY, YYYY-MM-DD, or ISO)",
},
{
field: "item",
required: false,
desc: "Short item name (combined with description if both present)",
},
{
field: "description",
required: "one of item/description",
desc: "Line item description",
},
{
field: "quantity",
required: true,
desc: "Hours or units (aliases: hours, qty)",
},
{
field: "rate",
required: true,
desc: "Unit rate (aliases: price, hourly rate)",
},
] as const;
export function ImportFormatInfoDialog() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<CircleHelp className="mr-2 h-4 w-4" />
Format guide
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] flex-col sm:max-w-4xl">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Import format guide
</DialogTitle>
<DialogDescription>
CSV and JSON reference for bulk invoice imports. All imported
invoices are created as drafts for review.
</DialogDescription>
</DialogHeader>
<PageTabs defaultValue="csv" className="min-h-0 flex-1">
<PageTabsList>
<PageTabsTrigger value="csv">
<FileSpreadsheet className="mr-1.5 h-4 w-4" />
CSV
</PageTabsTrigger>
<PageTabsTrigger value="json">
<FileJson className="mr-1.5 h-4 w-4" />
JSON
</PageTabsTrigger>
</PageTabsList>
<PageTabsContent
value="csv"
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
>
<p className="text-muted-foreground text-sm">
One CSV file creates one invoice. The invoice title is the
filename without the extension (e.g.{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
acme-january.csv
</code>{" "}
title &quot;acme-january&quot;). Column headers are flexible
and auto-detected from the .csv extension.
</p>
<div className="bg-muted border-border rounded-md border p-3">
<p className="text-foreground font-mono text-sm">
date,description,quantity,rate
</p>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">
Columns (header row required)
</h4>
<div className="space-y-2">
{CSV_COLUMNS.map((col) => (
<div key={col.field} className="flex items-start gap-3">
<Badge className="border font-mono text-xs">
{col.field}
</Badge>
<span className="text-muted-foreground text-sm">
{col.desc}
{col.required === true && " — required"}
{typeof col.required === "string" &&
`${col.required} required`}
</span>
</div>
))}
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Example rows</h4>
<div className="bg-muted border-border space-y-2 rounded-md border p-3">
<p className="text-foreground font-mono text-xs break-all">
2024-01-15,&quot;API development&quot;,8,125.00
</p>
<p className="text-foreground font-mono text-xs break-all">
1/16/24,Design review,2,125.00
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Rules</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li>
Column names are case-insensitive. Legacy columns{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
HOURS
</code>{" "}
and{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
DATE
</code>{" "}
are still supported.
</li>
<li>
Select a default client in Settings Data before uploading
CSV files.
</li>
<li>
Each line item needs a description (or item), quantity, and
rate.
</li>
<li> Max 10 MB per file, up to 50 files at once.</li>
<li>
Preview staged invoices and fix per-row errors before you
commit the import.
</li>
</ul>
</div>
<ImportCsvTemplateButton />
</PageTabsContent>
<PageTabsContent
value="json"
className="max-h-[min(60vh,32rem)] overflow-y-auto pr-1"
>
<p className="text-muted-foreground text-sm">
Import one or many invoices from a single JSON file. Clients are
matched by email, then name, or created automatically when
details are provided.
</p>
<div className="space-y-2">
<h4 className="text-sm font-medium">Example</h4>
<div className="bg-muted border-border max-h-64 overflow-auto rounded-md border p-3">
<pre className="text-foreground font-mono text-xs whitespace-pre-wrap">
{JSON_TEMPLATE}
</pre>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Rules</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li>
Root may be{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
{"{ invoices: [...] }"}
</code>
, an array, or a single invoice object.
</li>
<li>
Line items use{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
quantity
</code>{" "}
or{" "}
<code className="bg-muted text-foreground rounded border border-border px-1 font-mono text-xs">
hours
</code>
.
</li>
<li>
Issue and due dates default from item dates (+30 days for
due).
</li>
<li>
New clients are created when JSON includes unknown client
details.
</li>
<li> Max 10 MB per file, up to 50 files at once.</li>
<li>
Partial success: valid invoices import; errors are reported
per row.
</li>
</ul>
</div>
<ImportJsonTemplateButton />
</PageTabsContent>
</PageTabs>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,7 @@
"use client";
import { ImportFormatInfoDialog } from "./import-format-info-dialog";
export function ImportPageHeaderActions() {
return <ImportFormatInfoDialog />;
}
@@ -0,0 +1,52 @@
"use client";
import { FileJson, FileSpreadsheet } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
downloadCsvTemplate,
downloadJsonTemplate,
} from "~/lib/invoice-import-templates";
import { cn } from "~/lib/utils";
export function ImportCsvTemplateButton({
className,
}: {
className?: string;
}) {
return (
<Button
variant="outline"
className={cn("hover-lift shadow-sm", className)}
onClick={downloadCsvTemplate}
>
<FileSpreadsheet className="mr-2 h-5 w-5" />
Download CSV template
</Button>
);
}
export function ImportJsonTemplateButton({
className,
}: {
className?: string;
}) {
return (
<Button
variant="outline"
className={cn("hover-lift shadow-sm", className)}
onClick={downloadJsonTemplate}
>
<FileJson className="mr-2 h-5 w-5" />
Download JSON template
</Button>
);
}
export function ImportTemplateButtons({ className }: { className?: string }) {
return (
<div className={className}>
<ImportCsvTemplateButton />
<ImportJsonTemplateButton />
</div>
);
}
@@ -21,6 +21,7 @@ import {
Link as LinkIcon,
} from "lucide-react";
import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { useAuthSession } from "~/hooks/use-auth-session";
import * as React from "react";
@@ -89,6 +90,21 @@ import { useAppearance } from "~/components/providers/appearance-provider";
import { brand, colorModes } from "~/lib/branding";
import type { PdfTemplate } from "~/lib/appearance";
import { ApiAccessSettings } from "./api-access-settings";
import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
const InvoiceImportPage = dynamic(
() =>
import("~/components/invoice-import-page").then(
(module) => module.InvoiceImportPage,
),
{
loading: () => (
<div className="bg-muted/30 text-muted-foreground flex h-32 items-center justify-center rounded-lg border text-sm">
Loading import tools...
</div>
),
},
);
const PdfPreviewFrame = dynamic(
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
@@ -106,7 +122,28 @@ function isFullHexColor(value: string) {
return /^#[0-9A-Fa-f]{6}$/.test(value);
}
export function SettingsContent() {
const SETTINGS_TABS = ["general", "preferences", "data", "api"] as const;
type SettingsTab = (typeof SETTINGS_TABS)[number];
function isSettingsTab(value: string | null | undefined): value is SettingsTab {
return SETTINGS_TABS.includes(value as SettingsTab);
}
export function SettingsContent({
initialTab = "general",
}: {
initialTab?: SettingsTab;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const activeTab = isSettingsTab(tabParam) ? tabParam : initialTab;
const handleTabChange = (value: string) => {
if (!isSettingsTab(value)) return;
router.replace(`/dashboard/settings?tab=${value}`, { scroll: false });
};
const { data: session } = useAuthSession();
const [name, setName] = useState("");
const [deleteConfirmText, setDeleteConfirmText] = useState("");
@@ -406,7 +443,7 @@ export function SettingsContent() {
];
return (
<PageTabs defaultValue="general">
<PageTabs value={activeTab} onValueChange={handleTabChange}>
<PageTabsList>
<PageTabsTrigger value="general">General</PageTabsTrigger>
<PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
@@ -1139,6 +1176,27 @@ export function SettingsContent() {
</CardContent>
</Card>
{/* Import Invoices */}
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<CardTitle className="text-foreground flex items-center gap-2">
<FileUp className="text-primary h-5 w-5" />
Import Invoices
</CardTitle>
<CardDescription>
Upload CSV or JSON files to create draft invoices in bulk
</CardDescription>
</div>
<ImportPageHeaderActions />
</div>
</CardHeader>
<CardContent>
<InvoiceImportPage />
</CardContent>
</Card>
{/* Delete Account (Danger Zone) */}
<Card className="bg-card border-destructive/50 border">
<CardHeader>
+14 -2
View File
@@ -5,7 +5,19 @@ import { DashboardPage } from "~/components/layout/dashboard-page";
import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content";
export default async function SettingsPage() {
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const params = await searchParams;
const validTabs = ["general", "preferences", "data", "api"] as const;
const initialTab = validTabs.includes(
params.tab as (typeof validTabs)[number],
)
? (params.tab as (typeof validTabs)[number])
: "general";
return (
<DashboardPage>
<DashboardPageHeader
@@ -15,7 +27,7 @@ export default async function SettingsPage() {
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
<SettingsContent />
<SettingsContent initialTab={initialTab} />
</Suspense>
</HydrateClient>
</DashboardPage>
+2 -886
View File
@@ -1,886 +1,2 @@
"use client";
import {
AlertCircle,
Clock,
DollarSign,
Eye,
FileText,
Trash2,
Upload,
Users,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
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 { FileUpload } from "~/components/forms/file-upload";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
interface CSVRow {
DATE: string;
DESCRIPTION: string;
HOURS: number;
RATE: number;
AMOUNT: number;
}
interface ParsedItem {
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface FileData {
file: File;
parsedItems: ParsedItem[];
previewData: CSVRow[];
invoiceNumber: string;
clientId: string;
issueDate: Date | null;
dueDate: Date | null;
status: "pending" | "ready" | "error";
errors: string[];
hasDateError: boolean;
}
export function CSVImportPage() {
const [files, setFiles] = useState<FileData[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [selectedFileIndex, setSelectedFileIndex] = useState<number | null>(
null,
);
const [isProcessing, setIsProcessing] = useState(false);
const [progressCount, setProgressCount] = useState(0);
// Fetch clients for dropdown
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const createInvoice = api.invoices.create.useMutation({
onSuccess: () => {
toast.success("Invoice created successfully");
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
},
});
const parseCSVLine = (line: string): string[] => {
const result: string[] = [];
let current = "";
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote inside quoted field
current += '"';
i += 2; // Skip both quotes
} else {
// Toggle quote state
inQuotes = !inQuotes;
i++;
}
} else if (char === "," && !inQuotes) {
// End of field
result.push(current.trim());
current = "";
i++;
} else {
// Regular character
current += char;
i++;
}
}
// Add the last field
result.push(current.trim());
return result;
};
const parseCSV = (csvText: string): CSVRow[] => {
const lines = csvText.split("\n");
const headers = parseCSVLine(lines[0] ?? "");
// Validate headers
const requiredHeaders = ["DATE", "DESCRIPTION", "HOURS", "RATE", "AMOUNT"];
const missingHeaders = requiredHeaders.filter((h) => !headers?.includes(h));
if (missingHeaders.length > 0) {
throw new Error(`Missing required headers: ${missingHeaders.join(", ")}`);
}
return lines
.slice(1)
.filter((line) => line.trim())
.map((line) => {
const values = parseCSVLine(line);
return {
DATE: values[0] ?? "",
DESCRIPTION: values[1] ?? "",
HOURS: parseFloat(values[2] ?? "0") || 0,
RATE: parseFloat(values[3] ?? "0") || 0,
AMOUNT: parseFloat(values[4] ?? "0") || 0,
};
})
.filter((row) => row.DESCRIPTION && row.HOURS > 0 && row.RATE > 0);
};
const parseDate = (dateStr: string): Date => {
// Handle m/dd/yy format
const parts = dateStr.split("/");
if (parts.length === 3) {
const month = parseInt(parts[0] ?? "1") - 1; // 0-based month
const day = parseInt(parts[1] ?? "1");
const year = parseInt(parts[2] ?? "2000") + 2000; // Assume 20xx
return new Date(year, month, day);
}
// Fallback to standard date parsing
return new Date(dateStr);
};
const handleFileSelect = async (selectedFiles: File[]) => {
for (const file of selectedFiles) {
const errors: string[] = [];
let hasDateError = false;
let issueDate: Date | null = null;
let dueDate: Date | null = null;
// Check filename format
const filenameMatch = /^(\d{4}-\d{2}-\d{2})\.csv$/.exec(file.name);
if (!filenameMatch) {
errors.push("Filename must be in YYYY-MM-DD.csv format");
hasDateError = true;
} else {
const filenameDate = filenameMatch[1] ?? "";
issueDate = new Date(filenameDate);
if (isNaN(issueDate.getTime())) {
errors.push("Invalid date in filename");
hasDateError = true;
} else {
dueDate = new Date(issueDate);
dueDate.setDate(dueDate.getDate() + 30);
}
}
try {
const text = await file.text();
const csvData = parseCSV(text);
// Parse items for invoice creation
const items = csvData.map((row) => ({
date: parseDate(row.DATE),
description: row.DESCRIPTION,
hours: row.HOURS,
rate: row.RATE,
amount: row.HOURS * row.RATE, // Calculate amount ourselves
}));
const fileData: FileData = {
file,
parsedItems: items,
previewData: csvData,
invoiceNumber: issueDate
? `INV-${issueDate.toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`
: `INV-${Date.now()}`,
clientId: globalClientId, // Use global client if set
issueDate,
dueDate,
status: errors.length > 0 ? "error" : "pending",
errors,
hasDateError,
};
setFiles((prev) => [...prev, fileData]);
if (errors.length > 0) {
toast.error(
`${file.name} has ${errors.length} error${errors.length > 1 ? "s" : ""}`,
);
} else {
toast.success(`Parsed ${items.length} items from ${file.name}`);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
const fileData: FileData = {
file,
parsedItems: [],
previewData: [],
invoiceNumber: `INV-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
clientId: globalClientId,
issueDate: null,
dueDate: null,
status: "error",
errors: [`Error parsing CSV: ${errorMessage}`],
hasDateError: true,
};
setFiles((prev) => [...prev, fileData]);
toast.error(`Error parsing ${file.name}: ${errorMessage}`);
}
}
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
// Apply global client to all files that don't have a client selected
const applyGlobalClient = (clientId: string) => {
setFiles((prev) =>
prev.map((file) => ({
...file,
clientId: file.clientId || clientId, // Only apply if no client is already selected
})),
);
};
const updateFileData = (index: number, updates: Partial<FileData>) => {
setFiles((prev) =>
prev.map((file, i) => {
if (i !== index) return file;
const updatedFile = { ...file, ...updates };
// Recalculate errors if issue date or due date was updated
if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
const newErrors = [...updatedFile.errors];
// Remove filename format error if a valid issue date is now set
if (
updatedFile.issueDate &&
newErrors.includes("Filename must be in YYYY-MM-DD.csv format")
) {
const errorIndex = newErrors.indexOf(
"Filename must be in YYYY-MM-DD.csv format",
);
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
// Remove invalid date error if a valid issue date is now set
if (
updatedFile.issueDate &&
newErrors.includes("Invalid date in filename")
) {
const errorIndex = newErrors.indexOf("Invalid date in filename");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
updatedFile.errors = newErrors;
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
updatedFile.hasDateError = newErrors.some(
(error) =>
error.includes("Filename") || error.includes("Invalid date"),
);
}
return updatedFile;
}),
);
};
const openPreview = (index: number) => {
setSelectedFileIndex(index);
setPreviewModalOpen(true);
};
const validateFiles = () => {
const errors: string[] = [];
files.forEach((fileData) => {
// Check for existing errors
if (fileData.errors.length > 0) {
errors.push(`${fileData.file.name}: ${fileData.errors.join(", ")}`);
}
if (!fileData.clientId && !globalClientId) {
errors.push(`${fileData.file.name}: Client not selected`);
}
if (fileData.parsedItems.length === 0) {
errors.push(`${fileData.file.name}: No valid items found`);
}
if (!fileData.issueDate) {
errors.push(`${fileData.file.name}: Issue date required`);
}
if (!fileData.dueDate) {
errors.push(`${fileData.file.name}: Due date required`);
}
});
return errors;
};
const processBatch = async () => {
const errors = validateFiles();
if (errors.length > 0) {
toast.error(`Please fix the following issues:\n${errors.join("\n")}`);
return;
}
setIsProcessing(true);
setProgressCount(0);
let successCount = 0;
let errorCount = 0;
for (const fileData of files) {
try {
// Validate required fields before sending
const clientId = fileData.clientId || globalClientId;
if (!clientId) {
throw new Error(`No client selected for ${fileData.file.name}`);
}
if (!fileData.issueDate) {
throw new Error(`No issue date for ${fileData.file.name}`);
}
if (!fileData.dueDate) {
throw new Error(`No due date for ${fileData.file.name}`);
}
if (!fileData.invoiceNumber) {
throw new Error(`No invoice number for ${fileData.file.name}`);
}
if (!fileData.parsedItems || fileData.parsedItems.length === 0) {
throw new Error(`No items found for ${fileData.file.name}`);
}
const invoiceData = {
invoiceNumber: fileData.invoiceNumber,
clientId: clientId,
issueDate: fileData.issueDate,
dueDate: fileData.dueDate,
status: "draft" as const,
notes: `Imported from CSV: ${fileData.file.name}`,
items: fileData.parsedItems.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})),
};
console.log("Creating invoice with data:", invoiceData);
await createInvoice.mutateAsync(invoiceData);
console.log("Invoice created successfully");
successCount++;
} catch (error) {
errorCount++;
console.error(
`Failed to create invoice for ${fileData.file.name}:`,
error,
);
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
toast.error(
`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`,
);
}
setProgressCount((prev) => prev + 1);
}
setIsProcessing(false);
if (successCount > 0) {
toast.success(
`Successfully created ${successCount} invoice${successCount > 1 ? "s" : ""}`,
);
}
if (errorCount > 0) {
toast.error(
`Failed to create ${errorCount} invoice${errorCount > 1 ? "s" : ""}`,
);
}
if (successCount > 0) {
setFiles([]);
}
};
const totalFiles = files.length;
const readyFiles = files.filter(
(f) =>
f.errors.length === 0 &&
(f.clientId || globalClientId) &&
f.issueDate &&
f.dueDate,
).length;
const totalItems = files.reduce((sum, f) => sum + f.parsedItems.length, 0);
const totalAmount = files.reduce(
(sum, f) =>
sum + f.parsedItems.reduce((itemSum, item) => itemSum + item.amount, 0),
0,
);
return (
<div className="space-y-6">
{/* Global Client Selection */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
Default Client
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-client" className="text-sm font-medium">
Select Default Client (Optional)
</Label>
<select
id="global-client"
value={globalClientId}
onChange={(e) => {
const newClientId = e.target.value;
setGlobalClientId(newClientId);
if (newClientId) {
applyGlobalClient(newClientId);
}
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-12 w-full border px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
>
<option value="">No default client (select individually)</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
This client will be automatically selected for all uploaded files.
You can still change individual files below.
</p>
</div>
</CardContent>
</Card>
{/* File Upload Area */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Upload className="text-primary h-5 w-5" />
Upload CSV Files
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FileUpload
onFilesSelected={handleFileSelect}
accept={{ "text/csv": [".csv"] }}
maxFiles={50}
maxSize={5 * 1024 * 1024} // 5MB
placeholder="Drag & drop CSV files here, or click to select"
description="Files must be named YYYY-MM-DD.csv (e.g., 2024-01-15.csv). Up to 50 files can be uploaded at once."
/>
{/* Summary Card */}
{totalFiles > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
Import Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="bg-primary/10 grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalFiles}
</div>
<div className="text-muted-foreground text-sm">Files</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalItems}
</div>
<div className="text-muted-foreground text-sm">
Total Items
</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="text-muted-foreground text-sm">
Total Amount
</div>
</div>
<div className="text-center">
<div className="text-primary text-2xl font-bold">
{readyFiles}/{totalFiles}
</div>
<div className="text-muted-foreground text-sm">Ready</div>
</div>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
{/* File List */}
{files.length > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
Uploaded Files
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{files.map((fileData, index) => (
<div key={index} className="border-border bg-card border p-4">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<FileText className="text-primary h-5 w-5" />
<div>
<h3 className="text-foreground truncate font-medium">
{fileData.file.name}
</h3>
<p className="text-muted-foreground text-sm">
{fileData.parsedItems.length} items {" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.hours, 0)
.toFixed(1)}{" "}
hours
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openPreview(index)}
>
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
<Button
variant="outline"
size="sm"
onClick={() => removeFile(index)}
className="text-destructive hover:text-destructive/80"
>
<Trash2 className="mr-1 h-4 w-4" />
Remove
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Invoice Number
</Label>
<Input
value={fileData.invoiceNumber}
className="h-9 text-sm"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Client
</Label>
<select
value={fileData.clientId}
onChange={(e) =>
updateFileData(index, { clientId: e.target.value })
}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={loadingClients}
>
<option value="">Select Client</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Issue Date
</Label>
<DatePicker
date={fileData.issueDate ?? undefined}
onDateChange={(date) =>
updateFileData(index, { issueDate: date ?? null })
}
placeholder="Select issue date"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Due Date
</Label>
<DatePicker
date={fileData.dueDate ?? undefined}
onDateChange={(date) =>
updateFileData(index, { dueDate: date ?? null })
}
placeholder="Select due date"
className="h-9"
/>
</div>
</div>
{/* Error Display */}
{fileData.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 mt-4 border p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium">
Issues Found
</span>
</div>
<ul className="text-destructive space-y-1 text-sm">
{fileData.errors.map((error, errorIndex) => (
<li
key={errorIndex}
className="flex items-start gap-2"
>
<span className="text-destructive"></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-4 flex items-center justify-between">
<div className="text-muted-foreground text-sm">
Total:{" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.amount, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="flex items-center gap-2">
{fileData.errors.length > 0 && (
<Badge variant="destructive" className="text-xs">
{fileData.errors.length} Error
{fileData.errors.length !== 1 ? "s" : ""}
</Badge>
)}
<Badge
variant={
fileData.errors.length > 0
? "destructive"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "default"
: "secondary"
}
className="text-xs"
>
{fileData.errors.length > 0
? "Has Errors"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "Ready"
: "Pending"}
</Badge>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Batch Actions */}
{files.length > 0 && (
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<DollarSign className="text-primary h-5 w-5" />
Create Invoices
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="flex w-full flex-col gap-2">
<span className="text-muted-foreground text-sm">
Creating invoices... ({progressCount}/{totalFiles})
</span>
<Progress
value={Math.round((progressCount / totalFiles) * 100)}
className="h-2"
/>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{readyFiles} of {totalFiles} files ready for import
</div>
<Button
onClick={processBatch}
disabled={readyFiles === 0 || isProcessing}
variant="default"
>
{isProcessing
? "Processing..."
: `Import ${readyFiles} Invoice${readyFiles !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="bg-card border-border flex max-h-[90vh] max-w-4xl flex-col border">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="text-foreground flex items-center gap-2 text-xl font-bold">
<FileText className="text-primary h-5 w-5" />
{selectedFileIndex !== null &&
files[selectedFileIndex]?.file.name}
</DialogTitle>
<DialogDescription className="text-gray-600">
Preview of parsed CSV data
</DialogDescription>
</DialogHeader>
{selectedFileIndex !== null && files[selectedFileIndex] && (
<div className="flex min-h-0 flex-1 flex-col space-y-4">
<div className="grid flex-shrink-0 grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex items-center gap-2">
<FileText className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm">
{files[selectedFileIndex].parsedItems.length} items
</span>
</div>
<div className="flex items-center gap-2">
<Clock className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm">
{files[selectedFileIndex].parsedItems
.reduce((sum, item) => sum + item.hours, 0)
.toFixed(1)}{" "}
total hours
</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="text-primary h-4 w-4" />
<span className="text-muted-foreground text-sm font-medium">
{files[selectedFileIndex].parsedItems
.reduce((sum, item) => sum + item.amount, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<div className="p-0">
<div className="max-h-96 overflow-auto">
<table className="w-full border-collapse">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="text-muted-foreground p-2 text-left font-medium">
Date
</th>
<th className="text-muted-foreground p-2 text-left font-medium">
Description
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Hours
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Rate
</th>
<th className="text-muted-foreground p-2 text-right font-medium whitespace-nowrap">
Amount
</th>
</tr>
</thead>
<tbody>
{files[selectedFileIndex].parsedItems.map(
(item, index) => (
<tr key={index} className="border-border border-b">
<td className="text-foreground p-2 whitespace-nowrap">
{item.date.toLocaleDateString()}
</td>
<td className="text-foreground max-w-xs truncate p-2">
{item.description}
</td>
<td className="text-foreground p-2 text-right whitespace-nowrap">
{item.hours}
</td>
<td className="text-foreground p-2 text-right whitespace-nowrap">
{item.rate.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
<td className="text-foreground p-2 text-right font-medium whitespace-nowrap">
{item.amount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
<Button
variant="outline"
onClick={() => setPreviewModalOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
/** @deprecated Use InvoiceImportPage from ~/components/invoice-import-page */
export { InvoiceImportPage as CSVImportPage } from "~/components/invoice-import-page";
+1 -1
View File
@@ -152,7 +152,7 @@ export function FileUpload({
<div
{...getRootProps()}
className={cn(
"cursor-pointer border-2 border-dashed p-8 text-center transition-colors",
"cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors",
"hover:border-primary/40 hover:bg-primary/10",
isDragActive && "border-primary/40 bg-primary/10",
isDragReject && "border-destructive/40 bg-destructive/10",
+680
View File
@@ -0,0 +1,680 @@
"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<StagedInvoice[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
const [globalBusinessId, setGlobalBusinessId] = useState("");
const [previewId, setPreviewId] = useState<string | null>(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<StagedInvoice>) => {
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 (
<div className={cn("flex flex-col", dashboardGapClass)}>
{/* Upload — primary action */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="text-primary h-5 w-5" />
Upload files
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FileUpload
onFilesSelected={handleFileSelect}
accept={{
"text/csv": [".csv"],
"application/json": [".json"],
}}
maxFiles={50}
maxSize={10 * 1024 * 1024}
placeholder="Drag & drop CSV or JSON files here, or click to select"
description="CSV: one file = one invoice. JSON: multiple invoices per file."
/>
{invoices.length > 0 && (
<div className={cn("bg-primary/10 p-4", dashboardStatGridClass)}>
<SummaryStat label="Invoices" value={invoices.length} />
<SummaryStat label="Line items" value={totalItems} />
<SummaryStat
label="Total amount"
value={totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
/>
<SummaryStat
label="Ready"
value={`${readyCount}/${invoices.length}`}
/>
</div>
)}
</CardContent>
</Card>
{/* Defaults */}
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="text-primary h-5 w-5" />
Default business
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-business" className="text-sm font-medium">
Business for imported invoices
</Label>
<Select
value={globalBusinessId || NONE}
onValueChange={(value) =>
setGlobalBusinessId(value === NONE ? "" : value)
}
disabled={loadingBusinesses}
>
<SelectTrigger id="global-business" className="h-11">
<SelectValue placeholder="Use default business" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Use default business</SelectItem>
{businesses?.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
{b.isDefault ? " (default)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Required your default business is used if none is selected.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
Default client
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-client" className="text-sm font-medium">
Client for CSV imports (optional)
</Label>
<Select
value={globalClientId || NONE}
onValueChange={(value) => {
const id = value === NONE ? "" : value;
setGlobalClientId(id);
if (id) applyGlobalClient(id);
}}
disabled={loadingClients}
>
<SelectTrigger id="global-client" className="h-11">
<SelectValue placeholder="No default client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
No default (JSON client or per-invoice)
</SelectItem>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
CSV files need a client. JSON can include client details per
invoice.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Staged invoices */}
{invoices.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoices.map((inv) => (
<div
key={inv.id}
className="border-border bg-muted/20 space-y-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{inv.format === "json" ? (
<FileJson className="text-primary h-5 w-5 shrink-0" />
) : (
<FileSpreadsheet className="text-primary h-5 w-5 shrink-0" />
)}
<div className="min-w-0">
<h3 className="text-foreground truncate font-medium">
{inv.name}
</h3>
<p className="text-muted-foreground text-sm">
{inv.items.length} items
{inv.sourceFile ? `${inv.sourceFile}` : ""}
{inv.client?.name ? `${inv.client.name}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPreviewId(inv.id)}
>
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
<Button
variant="outline"
size="sm"
onClick={() => removeInvoice(inv.id)}
className="text-destructive hover:text-destructive/80"
>
<Trash2 className="mr-1 h-4 w-4" />
Remove
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Invoice title
</Label>
<Input
value={inv.name}
className="h-9 text-sm"
onChange={(e) =>
updateInvoice(inv.id, { name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Client
</Label>
<Select
value={inv.clientId || NONE}
onValueChange={(value) =>
updateInvoice(inv.id, {
clientId: value === NONE ? "" : value,
})
}
disabled={loadingClients}
>
<SelectTrigger className="h-9">
<SelectValue
placeholder={
inv.client?.name
? `Use JSON: ${inv.client.name}`
: "Select client"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
{inv.client?.name
? `Use JSON: ${inv.client.name}`
: "Select client"}
</SelectItem>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Issue date
</Label>
<DatePicker
date={inv.issueDate}
onDateChange={(date) =>
updateInvoice(inv.id, { issueDate: date })
}
placeholder="Issue date"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-medium">
Due date
</Label>
<DatePicker
date={inv.dueDate}
onDateChange={(date) =>
updateInvoice(inv.id, { dueDate: date })
}
placeholder="Due date"
className="h-9"
/>
</div>
</div>
{inv.errors.length > 0 && (
<div className="border-destructive/20 bg-destructive/10 rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="text-destructive h-4 w-4" />
<span className="text-destructive text-sm font-medium">
Issues
</span>
</div>
<ul className="text-destructive space-y-1 text-sm">
{inv.errors.map((err, i) => (
<li key={i}> {err}</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Total:{" "}
{inv.items
.reduce((s, item) => s + item.quantity * item.rate, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</span>
<Badge variant={isReady(inv) ? "default" : "secondary"}>
{isReady(inv) ? "Ready" : "Pending"}
</Badge>
</div>
</div>
))}
</CardContent>
</Card>
)}
{invoices.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="text-primary h-5 w-5" />
Import invoices
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="flex w-full flex-col gap-2">
<span className="text-muted-foreground text-sm">
Importing {readyCount} invoice
{readyCount !== 1 ? "s" : ""}...
</span>
<Progress value={50} className="h-2" />
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-muted-foreground text-sm">
{readyCount} of {invoices.length} ready all imported as
drafts
</span>
<Button
onClick={processImport}
disabled={readyCount === 0 || isProcessing}
className="sm:shrink-0"
>
{isProcessing
? "Importing..."
: `Import ${readyCount} Invoice${readyCount !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Dialog open={!!previewId} onOpenChange={() => setPreviewId(null)}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="text-primary h-5 w-5" />
{previewInvoice?.name}
</DialogTitle>
<DialogDescription>Line item preview</DialogDescription>
</DialogHeader>
{previewInvoice && (
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="text-muted-foreground p-2 text-left text-sm font-medium">
Date
</th>
<th className="text-muted-foreground p-2 text-left text-sm font-medium">
Description
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Qty
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Rate
</th>
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
Amount
</th>
</tr>
</thead>
<tbody>
{previewInvoice.items.map((item, idx) => (
<tr key={idx} className="border-border border-b">
<td className="p-2 text-sm whitespace-nowrap">
{item.date?.toLocaleDateString() ?? "—"}
</td>
<td className="max-w-xs truncate p-2 text-sm">
{item.description}
</td>
<td className="p-2 text-right text-sm">{item.quantity}</td>
<td className="p-2 text-right text-sm">
{item.rate.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
<td className="p-2 text-right text-sm font-medium">
{(item.quantity * item.rate).toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewId(null)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function SummaryStat({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<div className="text-center">
<div className="text-primary text-2xl font-bold">{value}</div>
<div className="text-muted-foreground text-sm">{label}</div>
</div>
);
}
+2 -2
View File
@@ -68,7 +68,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border-0 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -212,7 +212,7 @@ function SelectContentWithSearch({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-md border-0 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden border-0 shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
+68
View File
@@ -0,0 +1,68 @@
export const CSV_TEMPLATE_FILENAME = "acme-january-template.csv";
export const JSON_TEMPLATE_FILENAME = "invoice-import-template.json";
/** Matches parseInvoiceCSV column expectations (date, item/description, quantity, rate). */
export const CSV_TEMPLATE = `date,item,description,quantity,rate
2024-01-15,,API development,8,125.00
2024-01-16,Design,Design review and feedback,2,125.00
1/17/24,,Documentation,4,125.00`;
/** Matches parseInvoiceJSON shape (client, issueDate, dueDate, items). */
export const JSON_TEMPLATE = JSON.stringify(
{
invoices: [
{
name: "January Services",
issueDate: "2024-01-31",
dueDate: "2024-03-01",
client: {
name: "Acme Corp",
email: "billing@acme.com",
},
items: [
{
date: "2024-01-15",
description: "API development",
quantity: 8,
rate: 125,
},
{
date: "2024-01-16",
item: "Design",
description: "Design review",
quantity: 2,
rate: 125,
},
],
},
],
},
null,
2,
);
export function downloadImportTemplate(
content: string,
filename: string,
mimeType: string,
) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export function downloadCsvTemplate() {
downloadImportTemplate(CSV_TEMPLATE, CSV_TEMPLATE_FILENAME, "text/csv");
}
export function downloadJsonTemplate() {
downloadImportTemplate(
JSON_TEMPLATE,
JSON_TEMPLATE_FILENAME,
"application/json",
);
}
+369
View File
@@ -0,0 +1,369 @@
export type ImportFormat = "csv" | "json";
export interface ImportItem {
date?: Date;
description: string;
quantity: number;
rate: number;
}
export interface ImportClientRef {
name?: string;
email?: string;
}
export interface ImportInvoice {
name: string;
issueDate?: Date;
dueDate?: Date;
client?: ImportClientRef;
clientId?: string;
items: ImportItem[];
sourceFile?: string;
errors: string[];
}
const COLUMN_ALIASES: Record<string, string[]> = {
date: ["date", "item date", "work date", "service date"],
item: ["item", "title", "name", "task"],
description: ["description", "desc", "details", "work", "notes"],
quantity: ["quantity", "qty", "hours", "hour", "units", "amount hours"],
rate: ["rate", "hourly rate", "price", "unit price", "unit_rate"],
};
function normalizeHeader(header: string): string {
return header.trim().toLowerCase().replace(/[_-]+/g, " ");
}
function resolveColumnIndex(
headers: string[],
field: keyof typeof COLUMN_ALIASES,
): number {
const aliases = COLUMN_ALIASES[field] ?? [];
for (let i = 0; i < headers.length; i++) {
const normalized = normalizeHeader(headers[i] ?? "");
if (aliases.includes(normalized)) return i;
}
return -1;
}
export function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
current += '"';
i += 2;
} else {
inQuotes = !inQuotes;
i++;
}
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = "";
i++;
} else {
current += char;
i++;
}
}
result.push(current.trim());
return result;
}
export function parseFlexibleDate(dateStr: string): Date | undefined {
const trimmed = dateStr.trim();
if (!trimmed) return undefined;
// ISO date (YYYY-MM-DD)
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
if (isoMatch) {
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
}
// M/DD/YY or M/DD/YYYY
const slashParts = trimmed.split("/");
if (slashParts.length === 3) {
const month = parseInt(slashParts[0] ?? "1", 10) - 1;
const day = parseInt(slashParts[1] ?? "1", 10);
let year = parseInt(slashParts[2] ?? "2000", 10);
if (year < 100) year += 2000;
const d = new Date(year, month, day);
if (!isNaN(d.getTime())) return d;
}
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
return undefined;
}
function parseNumber(value: string): number {
const cleaned = value.replace(/[$,\s]/g, "");
const n = parseFloat(cleaned);
return isNaN(n) ? 0 : n;
}
function stripExtension(filename: string): string {
return filename.replace(/\.[^.]+$/, "");
}
function buildItemDescription(item: string, description: string): string {
const parts = [item.trim(), description.trim()].filter(Boolean);
return parts.join(" — ") || "Imported item";
}
function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
const itemDates = items
.map((i) => i.date)
.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()));
if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
}
return fallback ?? new Date();
}
function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate);
due.setDate(due.getDate() + 30);
return due;
}
export function parseInvoiceCSV(
csvText: string,
filename: string,
): ImportInvoice {
const errors: string[] = [];
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
if (lines.length === 0) {
return {
name: stripExtension(filename),
items: [],
sourceFile: filename,
errors: ["File is empty"],
};
}
const headers = parseCSVLine(lines[0] ?? "");
const dateIdx = resolveColumnIndex(headers, "date");
const itemIdx = resolveColumnIndex(headers, "item");
const descIdx = resolveColumnIndex(headers, "description");
const qtyIdx = resolveColumnIndex(headers, "quantity");
const rateIdx = resolveColumnIndex(headers, "rate");
if (descIdx === -1 && itemIdx === -1) {
errors.push(
'Missing description column (expected "description" or "item")',
);
}
if (qtyIdx === -1) {
errors.push('Missing quantity column (expected "quantity" or "hours")');
}
if (rateIdx === -1) {
errors.push('Missing rate column (expected "rate" or "price")');
}
const items: ImportItem[] = [];
for (let rowIdx = 1; rowIdx < lines.length; rowIdx++) {
const values = parseCSVLine(lines[rowIdx] ?? "");
if (values.every((v) => !v.trim())) continue;
const itemText = itemIdx >= 0 ? (values[itemIdx] ?? "") : "";
const descText = descIdx >= 0 ? (values[descIdx] ?? "") : "";
const description = buildItemDescription(itemText, descText);
const quantity = qtyIdx >= 0 ? parseNumber(values[qtyIdx] ?? "0") : 0;
const rate = rateIdx >= 0 ? parseNumber(values[rateIdx] ?? "0") : 0;
if (!description || description === "Imported item") {
if (!itemText && !descText) continue;
}
if (quantity <= 0) {
errors.push(`Row ${rowIdx + 1}: quantity must be greater than 0`);
continue;
}
if (rate <= 0) {
errors.push(`Row ${rowIdx + 1}: rate must be greater than 0`);
continue;
}
let date: Date | undefined;
if (dateIdx >= 0) {
const rawDate = values[dateIdx] ?? "";
if (rawDate.trim()) {
date = parseFlexibleDate(rawDate);
if (!date) {
errors.push(`Row ${rowIdx + 1}: invalid date "${rawDate}"`);
}
}
}
items.push({ date, description, quantity, rate });
}
const issueDate = deriveIssueDate(items);
return {
name: stripExtension(filename),
issueDate,
dueDate: defaultDueDate(issueDate),
items,
sourceFile: filename,
errors:
items.length === 0 && errors.length === 0
? ["No valid line items found"]
: errors,
};
}
interface JsonInvoiceItem {
date?: string;
description?: string;
item?: string;
quantity?: number;
hours?: number;
rate?: number;
}
interface JsonInvoice {
name?: string;
invoiceNumber?: string;
issueDate?: string;
dueDate?: string;
client?: { name?: string; email?: string };
clientName?: string;
items?: JsonInvoiceItem[];
}
function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
const errors: string[] = [];
const name = raw.name ?? raw.invoiceNumber ?? `Imported Invoice ${index + 1}`;
const clientName = raw.client?.name ?? raw.clientName;
const clientEmail = raw.client?.email;
const items: ImportItem[] = (raw.items ?? []).map((item, itemIdx) => {
const description = buildItemDescription(
item.item ?? "",
item.description ?? "",
);
const quantity = item.quantity ?? item.hours ?? 0;
const rate = item.rate ?? 0;
if (!description || description === "Imported item") {
errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`);
}
if (quantity <= 0) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: quantity must be greater than 0`,
);
}
if (rate <= 0) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: rate must be greater than 0`,
);
}
let date: Date | undefined;
if (item.date) {
date = parseFlexibleDate(item.date);
if (!date) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: invalid date "${item.date}"`,
);
}
}
return { date, description, quantity, rate };
});
let issueDate: Date | undefined;
if (raw.issueDate) {
issueDate = parseFlexibleDate(raw.issueDate);
if (!issueDate) {
errors.push(`Invoice "${name}": invalid issue date "${raw.issueDate}"`);
}
}
let dueDate: Date | undefined;
if (raw.dueDate) {
dueDate = parseFlexibleDate(raw.dueDate);
if (!dueDate) {
errors.push(`Invoice "${name}": invalid due date "${raw.dueDate}"`);
}
}
const resolvedIssue = issueDate ?? deriveIssueDate(items);
const resolvedDue = dueDate ?? defaultDueDate(resolvedIssue);
if (items.length === 0) {
errors.push(`Invoice "${name}": at least one item is required`);
}
return {
name,
issueDate: resolvedIssue,
dueDate: resolvedDue,
client:
clientName || clientEmail
? { name: clientName, email: clientEmail }
: undefined,
items,
errors,
};
}
export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return [
{
name: "JSON Import",
items: [],
errors: ["Invalid JSON format"],
},
];
}
let rawInvoices: JsonInvoice[] = [];
if (Array.isArray(parsed)) {
rawInvoices = parsed as JsonInvoice[];
} else if (parsed && typeof parsed === "object") {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj.invoices)) {
rawInvoices = obj.invoices as JsonInvoice[];
} else if (obj.items || obj.name || obj.invoiceNumber) {
rawInvoices = [obj];
}
}
if (rawInvoices.length === 0) {
return [
{
name: "JSON Import",
items: [],
errors: ['No invoices found (expected { "invoices": [...] } or an array)'],
},
];
}
return rawInvoices.map((inv, idx) => normalizeJsonInvoice(inv, idx));
}
export function detectImportFormat(filename: string): ImportFormat {
return filename.toLowerCase().endsWith(".json") ? "json" : "csv";
}
+239
View File
@@ -10,6 +10,7 @@ import {
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { Resend } from "resend";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
@@ -57,6 +58,39 @@ const updateStatusSchema = z.object({
status: z.enum(["draft", "sent", "paid"]),
});
const bulkImportItemSchema = z.object({
date: z.coerce.date().optional(),
description: z.string().min(1, "Description is required"),
quantity: z.number().min(0, "Quantity must be positive"),
rate: z.number().min(0, "Rate must be positive"),
});
const bulkImportClientSchema = z.object({
name: z.string().optional(),
email: z.string().email("Invalid client email").optional().or(z.literal("")),
});
const bulkImportInvoiceSchema = z.object({
name: z.string().min(1, "Invoice name is required"),
issueDate: z.coerce.date().optional(),
dueDate: z.coerce.date().optional(),
clientId: z.string().optional(),
client: bulkImportClientSchema.optional(),
items: z
.array(bulkImportItemSchema)
.min(1, "At least one line item is required"),
notes: z.string().optional(),
sourceFile: z.string().optional(),
});
const bulkImportSchema = z.object({
defaultClientId: z.string().optional(),
defaultBusinessId: z.string().optional().or(z.literal("")),
invoices: z.array(bulkImportInvoiceSchema).min(1, "No invoices to import"),
});
type BulkImportInvoiceInput = z.infer<typeof bulkImportInvoiceSchema>;
async function verifyBusinessAccess(
ctx: InvoiceRouterContext,
businessId?: string | null,
@@ -133,6 +167,44 @@ const calculateInvoiceTotal = (
return subtotal + taxAmount;
};
type ClientRecord = typeof clients.$inferSelect;
function findExistingClient(
userClients: ClientRecord[],
clientRef?: { name?: string; email?: string },
): ClientRecord | undefined {
if (!clientRef) return undefined;
if (clientRef.email?.trim()) {
const email = clientRef.email.trim().toLowerCase();
const byEmail = userClients.find(
(c) => c.email?.toLowerCase() === email,
);
if (byEmail) return byEmail;
}
if (clientRef.name?.trim()) {
const name = clientRef.name.trim().toLowerCase();
const byName = userClients.find((c) => c.name.toLowerCase() === name);
if (byName) return byName;
}
return undefined;
}
function deriveIssueDateFromItems(
items: BulkImportInvoiceInput["items"],
fallback?: Date,
): Date {
const itemDates = items
.map((i) => i.date)
.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()));
if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
}
return fallback ?? new Date();
}
export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
@@ -661,6 +733,173 @@ export const invoicesRouter = createTRPCRouter({
return { success: true, deleted: ownedIds.length };
}),
bulkImport: protectedProcedure
.input(bulkImportSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
const business = await resolveBusinessForInvoice(
ctx,
input.defaultBusinessId,
);
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"No business found. Create a business in Settings before importing invoices.",
});
}
if (input.defaultClientId) {
await verifyClientAccess(ctx, input.defaultClientId);
}
if (input.defaultBusinessId && input.defaultBusinessId.trim() !== "") {
await verifyBusinessAccess(ctx, input.defaultBusinessId);
}
let invoicesCreated = 0;
let clientsCreated = 0;
const rowErrors: string[] = [];
try {
await ctx.db.transaction(async (tx) => {
const userClients = await tx
.select()
.from(clients)
.where(eq(clients.createdById, userId));
for (let i = 0; i < input.invoices.length; i++) {
const inv = input.invoices[i]!;
const label = inv.sourceFile ?? inv.name ?? `Invoice ${i + 1}`;
try {
let clientId = inv.clientId ?? input.defaultClientId;
if (!clientId) {
const existing = findExistingClient(userClients, inv.client);
if (existing) {
clientId = existing.id;
} else if (inv.client?.name?.trim()) {
const [newClient] = await tx
.insert(clients)
.values({
name: inv.client.name.trim(),
email:
inv.client.email && inv.client.email.trim() !== ""
? inv.client.email.trim()
: null,
createdById: userId,
})
.returning();
if (!newClient) {
rowErrors.push(`${label}: failed to create client`);
continue;
}
userClients.push(newClient);
clientId = newClient.id;
clientsCreated++;
}
}
if (!clientId) {
rowErrors.push(
`${label}: no client specified (select a default client or include client details in JSON)`,
);
continue;
}
const clientRecord = userClients.find((c) => c.id === clientId);
if (!clientRecord) {
rowErrors.push(`${label}: client not found`);
continue;
}
const issueDate =
inv.issueDate ?? deriveIssueDateFromItems(inv.items);
const dueDate = inv.dueDate ?? defaultDueDate(issueDate);
const dbItems = inv.items.map((item) => ({
date: item.date ?? issueDate,
description: item.description,
hours: item.quantity,
rate: item.rate,
}));
const totalAmount = calculateInvoiceTotal(dbItems, 0);
const invoiceNumber =
inv.name.trim().slice(0, 100) || generateInvoiceNumber();
const notes =
inv.notes ??
(inv.sourceFile
? `Imported from ${inv.sourceFile}`
: "Imported invoice");
const [invoice] = await tx
.insert(invoices)
.values({
invoiceNumber,
businessId: business.id,
clientId,
issueDate,
dueDate,
status: "draft",
totalAmount,
taxRate: 0,
currency: "USD",
notes,
createdById: userId,
})
.returning();
if (!invoice) {
rowErrors.push(`${label}: failed to create invoice`);
continue;
}
await tx.insert(invoiceItems).values(
dbItems.map((item, idx) => ({
...item,
invoiceId: invoice.id,
amount: item.hours * item.rate,
position: idx,
})),
);
invoicesCreated++;
} catch (err) {
const msg =
err instanceof Error ? err.message : "Unknown error";
rowErrors.push(`${label}: ${msg}`);
}
}
});
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to import invoices",
cause: error,
});
}
if (invoicesCreated === 0 && rowErrors.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: rowErrors.join("\n"),
});
}
return {
success: true,
invoicesCreated,
clientsCreated,
errors: rowErrors,
};
}),
previewPdf: protectedProcedure
.input(createInvoiceSchema)
.query(async ({ ctx, input }) => {
+2 -1
View File
@@ -154,7 +154,8 @@
[data-slot="card"],
[data-slot="dialog-content"],
[data-slot="alert-dialog-content"],
[data-slot="popover-content"] {
[data-slot="popover-content"],
[data-slot="select-content"] {
border-radius: var(--radius-lg);
}