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:
@@ -82,13 +82,20 @@ bun run db:push # fast iteration during development
|
|||||||
# bun run db:migrate # same migrations the Docker image runs in production
|
# bun run db:migrate # same migrations the Docker image runs in production
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Demo account.** For App Store review and local testing, `bun run db:migrate` applies `0014_seed_demo_account.sql`, which creates a pre-populated user (`db:push` does not). Sign in at `/auth/login`:
|
||||||
|
|
||||||
|
- Email: `demo@example.com`
|
||||||
|
- Password: `demo123`
|
||||||
|
|
||||||
|
The account includes a sample business, clients, and invoices (draft, sent, and paid).
|
||||||
|
|
||||||
### 4. Run
|
### 4. Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, then sign in.
|
Open [http://localhost:3000](http://localhost:3000), register at `/auth/register`, or sign in with the demo account above.
|
||||||
|
|
||||||
## Docker deployment (app + database)
|
## Docker deployment (app + database)
|
||||||
|
|
||||||
|
|||||||
@@ -1,236 +1,5 @@
|
|||||||
import {
|
import { redirect } from "next/navigation";
|
||||||
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";
|
|
||||||
|
|
||||||
// File Upload Instructions Component
|
export default function ImportPage() {
|
||||||
function FormatInstructions() {
|
redirect("/dashboard/settings?tab=data");
|
||||||
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,"Web development work",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 "draft" 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { api, HydrateClient } from "~/trpc/server";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
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 { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
|
|
||||||
@@ -22,12 +22,6 @@ export default async function InvoicesPage() {
|
|||||||
title="Invoices"
|
title="Invoices"
|
||||||
description="Manage your invoices and track payments"
|
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">
|
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
<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 "acme-january"). 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,"API development",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,
|
Link as LinkIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { authClient } from "~/lib/auth-client";
|
||||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -89,6 +90,21 @@ import { useAppearance } from "~/components/providers/appearance-provider";
|
|||||||
import { brand, colorModes } from "~/lib/branding";
|
import { brand, colorModes } from "~/lib/branding";
|
||||||
import type { PdfTemplate } from "~/lib/appearance";
|
import type { PdfTemplate } from "~/lib/appearance";
|
||||||
import { ApiAccessSettings } from "./api-access-settings";
|
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(
|
const PdfPreviewFrame = dynamic(
|
||||||
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
|
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
|
||||||
@@ -106,7 +122,28 @@ function isFullHexColor(value: string) {
|
|||||||
return /^#[0-9A-Fa-f]{6}$/.test(value);
|
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 { data: session } = useAuthSession();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||||
@@ -406,7 +443,7 @@ export function SettingsContent() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTabs defaultValue="general">
|
<PageTabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
<PageTabsList>
|
<PageTabsList>
|
||||||
<PageTabsTrigger value="general">General</PageTabsTrigger>
|
<PageTabsTrigger value="general">General</PageTabsTrigger>
|
||||||
<PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
|
<PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
|
||||||
@@ -1139,6 +1176,27 @@ export function SettingsContent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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) */}
|
{/* Delete Account (Danger Zone) */}
|
||||||
<Card className="bg-card border-destructive/50 border">
|
<Card className="bg-card border-destructive/50 border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -5,7 +5,19 @@ import { DashboardPage } from "~/components/layout/dashboard-page";
|
|||||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||||
import { SettingsContent } from "./_components/settings-content";
|
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 (
|
return (
|
||||||
<DashboardPage>
|
<DashboardPage>
|
||||||
<DashboardPageHeader
|
<DashboardPageHeader
|
||||||
@@ -15,7 +27,7 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
<Suspense fallback={<DataTableSkeleton columns={1} rows={4} />}>
|
||||||
<SettingsContent />
|
<SettingsContent initialTab={initialTab} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</DashboardPage>
|
</DashboardPage>
|
||||||
|
|||||||
@@ -1,886 +1,2 @@
|
|||||||
"use client";
|
/** @deprecated Use InvoiceImportPage from ~/components/invoice-import-page */
|
||||||
|
export { InvoiceImportPage as CSVImportPage } from "~/components/invoice-import-page";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function FileUpload({
|
|||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={cn(
|
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",
|
"hover:border-primary/40 hover:bg-primary/10",
|
||||||
isDragActive && "border-primary/40 bg-primary/10",
|
isDragActive && "border-primary/40 bg-primary/10",
|
||||||
isDragReject && "border-destructive/40 bg-destructive/10",
|
isDragReject && "border-destructive/40 bg-destructive/10",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
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" &&
|
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",
|
"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,
|
className,
|
||||||
@@ -212,7 +212,7 @@ function SelectContentWithSearch({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
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" &&
|
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",
|
"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,
|
className,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||||
@@ -57,6 +58,39 @@ const updateStatusSchema = z.object({
|
|||||||
status: z.enum(["draft", "sent", "paid"]),
|
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(
|
async function verifyBusinessAccess(
|
||||||
ctx: InvoiceRouterContext,
|
ctx: InvoiceRouterContext,
|
||||||
businessId?: string | null,
|
businessId?: string | null,
|
||||||
@@ -133,6 +167,44 @@ const calculateInvoiceTotal = (
|
|||||||
return subtotal + taxAmount;
|
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({
|
export const invoicesRouter = createTRPCRouter({
|
||||||
getAll: protectedProcedure
|
getAll: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -661,6 +733,173 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
return { success: true, deleted: ownedIds.length };
|
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
|
previewPdf: protectedProcedure
|
||||||
.input(createInvoiceSchema)
|
.input(createInvoiceSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -154,7 +154,8 @@
|
|||||||
[data-slot="card"],
|
[data-slot="card"],
|
||||||
[data-slot="dialog-content"],
|
[data-slot="dialog-content"],
|
||||||
[data-slot="alert-dialog-content"],
|
[data-slot="alert-dialog-content"],
|
||||||
[data-slot="popover-content"] {
|
[data-slot="popover-content"],
|
||||||
|
[data-slot="select-content"] {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user