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>