diff --git a/README.md b/README.md
index 034cb77..cd1b052 100644
--- a/README.md
+++ b/README.md
@@ -82,13 +82,20 @@ bun run db:push # fast iteration during development
# 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
```bash
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)
diff --git a/src/app/dashboard/invoices/import/page.tsx b/src/app/dashboard/invoices/import/page.tsx
index dabb524..985f98b 100644
--- a/src/app/dashboard/invoices/import/page.tsx
+++ b/src/app/dashboard/invoices/import/page.tsx
@@ -1,236 +1,5 @@
-import {
- AlertCircle,
- ArrowLeft,
- CheckCircle,
- Download,
- FileSpreadsheet,
- FileText,
- Info,
- Upload,
-} from "lucide-react";
-import Link from "next/link";
-import { CSVImportPage } from "~/components/csv-import-page";
-import { DashboardPageHeader } from "~/components/layout/page-header";
-import { DashboardPage, dashboardGridClass } from "~/components/layout/dashboard-page";
-import { cn } from "~/lib/utils";
-import { Badge } from "~/components/ui/badge";
-import { Button } from "~/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
-import { HydrateClient } from "~/trpc/server";
+import { redirect } from "next/navigation";
-// File Upload Instructions Component
-function FormatInstructions() {
- return (
-
- {/* Required Format */}
-
-
-
-
- Required CSV Format
-
-
-
-
-
- DATE,DESCRIPTION,HOURS,RATE,AMOUNT
-
-
-
-
-
Required Columns:
-
- {[
- { 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) => (
-
- {col.field}
-
- {col.desc}
-
-
- ))}
-
-
-
-
-
File Naming:
-
- Name your CSV files in{" "}
-
- YYYY-MM-DD.csv
- {" "}
- format for automatic date detection.
-
-
-
-
-
- {/* Sample Data & Download */}
-
-
-
-
- Sample Template
-
-
-
-
- Download our sample CSV template to see the exact format required
- for importing time entries.
-
-
-
-
-
-
-
Pro Tip
-
- The template includes sample data and formatting examples to
- help you get started quickly.
-
-
-
-
-
-
-
Sample Row:
-
-
- 1/15/24,"Web development work",8,75.00,600.00
-
-
-
-
-
-
-
-
- );
-}
-
-// Important Notes Section
-function ImportantNotes() {
- return (
-
-
-
-
- Important Notes
-
-
-
-
-
-
Before Importing:
-
- • Use M/DD/YY format for dates (e.g., 1/15/24)
- • Ensure rates are in decimal format (e.g., 75.50)
- • File names should follow YYYY-MM-DD.csv format
- • Select a client before importing
-
-
-
-
What Happens:
-
- • Each CSV file creates one invoice
- • Invoice dates are derived from filename
- • Invoices are created in "draft" status
- • You can review and edit before sending
-
-
-
-
-
- );
-}
-
-// File Format Help Section
-function FileFormatHelp() {
- return (
-
-
-
-
- Supported File Formats
-
-
-
-
-
-
-
-
-
CSV Files
-
- Comma-separated values from Excel, Google Sheets, or any CSV
- editor
-
-
-
-
-
-
-
Max Size
-
- Up to 10MB per file with no limit on number of rows
-
-
-
-
-
-
-
Validation
-
- Real-time validation with clear error messages and feedback
-
-
-
-
-
- );
-}
-
-export default async function ImportPage() {
- return (
-
-
-
-
-
- Back to Invoices
-
-
-
-
-
- {/* Main CSV Import Component */}
-
-
- {/* File Format Help */}
-
-
- {/* Format Instructions */}
-
-
- {/* Important Notes */}
-
-
-
- );
+export default function ImportPage() {
+ redirect("/dashboard/settings?tab=data");
}
diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx
index 74903ac..1ddf83c 100644
--- a/src/app/dashboard/invoices/page.tsx
+++ b/src/app/dashboard/invoices/page.tsx
@@ -4,7 +4,7 @@ import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
-import { Plus, Upload } from "lucide-react";
+import { Plus } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
@@ -22,12 +22,6 @@ export default async function InvoicesPage() {
title="Invoices"
description="Manage your invoices and track payments"
>
-
-
-
- Import CSV
-
-
diff --git a/src/app/dashboard/settings/_components/invoice-import/import-format-info-dialog.tsx b/src/app/dashboard/settings/_components/invoice-import/import-format-info-dialog.tsx
new file mode 100644
index 0000000..52e0a52
--- /dev/null
+++ b/src/app/dashboard/settings/_components/invoice-import/import-format-info-dialog.tsx
@@ -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 (
+ <>
+ setOpen(true)}>
+
+ Format guide
+
+
+
+
+
+
+
+ Import format guide
+
+
+ CSV and JSON reference for bulk invoice imports. All imported
+ invoices are created as drafts for review.
+
+
+
+
+
+
+
+ CSV
+
+
+
+ JSON
+
+
+
+
+
+ One CSV file creates one invoice. The invoice title is the
+ filename without the extension (e.g.{" "}
+
+ acme-january.csv
+ {" "}
+ → title "acme-january"). Column headers are flexible
+ and auto-detected from the .csv extension.
+
+
+
+
+ date,description,quantity,rate
+
+
+
+
+
+ Columns (header row required)
+
+
+ {CSV_COLUMNS.map((col) => (
+
+
+ {col.field}
+
+
+ {col.desc}
+ {col.required === true && " — required"}
+ {typeof col.required === "string" &&
+ ` — ${col.required} required`}
+
+
+ ))}
+
+
+
+
+
Example rows
+
+
+ 2024-01-15,"API development",8,125.00
+
+
+ 1/16/24,Design review,2,125.00
+
+
+
+
+
+
Rules
+
+
+ • Column names are case-insensitive. Legacy columns{" "}
+
+ HOURS
+ {" "}
+ and{" "}
+
+ DATE
+ {" "}
+ are still supported.
+
+
+ • Select a default client in Settings → Data before uploading
+ CSV files.
+
+
+ • Each line item needs a description (or item), quantity, and
+ rate.
+
+ • Max 10 MB per file, up to 50 files at once.
+
+ • Preview staged invoices and fix per-row errors before you
+ commit the import.
+
+
+
+
+
+
+
+
+
+ Import one or many invoices from a single JSON file. Clients are
+ matched by email, then name, or created automatically when
+ details are provided.
+
+
+
+
+
+
Rules
+
+
+ • Root may be{" "}
+
+ {"{ invoices: [...] }"}
+
+ , an array, or a single invoice object.
+
+
+ • Line items use{" "}
+
+ quantity
+ {" "}
+ or{" "}
+
+ hours
+
+ .
+
+
+ • Issue and due dates default from item dates (+30 days for
+ due).
+
+
+ • New clients are created when JSON includes unknown client
+ details.
+
+ • Max 10 MB per file, up to 50 files at once.
+
+ • Partial success: valid invoices import; errors are reported
+ per row.
+
+
+
+
+
+
+
+
+
+ setOpen(false)}>
+ Close
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/dashboard/settings/_components/invoice-import/import-page-header-actions.tsx b/src/app/dashboard/settings/_components/invoice-import/import-page-header-actions.tsx
new file mode 100644
index 0000000..1ef1e6f
--- /dev/null
+++ b/src/app/dashboard/settings/_components/invoice-import/import-page-header-actions.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ImportFormatInfoDialog } from "./import-format-info-dialog";
+
+export function ImportPageHeaderActions() {
+ return ;
+}
diff --git a/src/app/dashboard/settings/_components/invoice-import/import-sample-download.tsx b/src/app/dashboard/settings/_components/invoice-import/import-sample-download.tsx
new file mode 100644
index 0000000..eebd860
--- /dev/null
+++ b/src/app/dashboard/settings/_components/invoice-import/import-sample-download.tsx
@@ -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 (
+
+
+ Download CSV template
+
+ );
+}
+
+export function ImportJsonTemplateButton({
+ className,
+}: {
+ className?: string;
+}) {
+ return (
+
+
+ Download JSON template
+
+ );
+}
+
+export function ImportTemplateButtons({ className }: { className?: string }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/dashboard/settings/_components/settings-content.tsx b/src/app/dashboard/settings/_components/settings-content.tsx
index 6708239..cfaaa75 100644
--- a/src/app/dashboard/settings/_components/settings-content.tsx
+++ b/src/app/dashboard/settings/_components/settings-content.tsx
@@ -21,6 +21,7 @@ import {
Link as LinkIcon,
} from "lucide-react";
import dynamic from "next/dynamic";
+import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "~/lib/auth-client";
import { useAuthSession } from "~/hooks/use-auth-session";
import * as React from "react";
@@ -89,6 +90,21 @@ import { useAppearance } from "~/components/providers/appearance-provider";
import { brand, colorModes } from "~/lib/branding";
import type { PdfTemplate } from "~/lib/appearance";
import { ApiAccessSettings } from "./api-access-settings";
+import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions";
+
+const InvoiceImportPage = dynamic(
+ () =>
+ import("~/components/invoice-import-page").then(
+ (module) => module.InvoiceImportPage,
+ ),
+ {
+ loading: () => (
+
+ Loading import tools...
+
+ ),
+ },
+);
const PdfPreviewFrame = dynamic(
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
@@ -106,7 +122,28 @@ function isFullHexColor(value: string) {
return /^#[0-9A-Fa-f]{6}$/.test(value);
}
-export function SettingsContent() {
+const SETTINGS_TABS = ["general", "preferences", "data", "api"] as const;
+type SettingsTab = (typeof SETTINGS_TABS)[number];
+
+function isSettingsTab(value: string | null | undefined): value is SettingsTab {
+ return SETTINGS_TABS.includes(value as SettingsTab);
+}
+
+export function SettingsContent({
+ initialTab = "general",
+}: {
+ initialTab?: SettingsTab;
+}) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const tabParam = searchParams.get("tab");
+ const activeTab = isSettingsTab(tabParam) ? tabParam : initialTab;
+
+ const handleTabChange = (value: string) => {
+ if (!isSettingsTab(value)) return;
+ router.replace(`/dashboard/settings?tab=${value}`, { scroll: false });
+ };
+
const { data: session } = useAuthSession();
const [name, setName] = useState("");
const [deleteConfirmText, setDeleteConfirmText] = useState("");
@@ -406,7 +443,7 @@ export function SettingsContent() {
];
return (
-
+
General
Preferences
@@ -1139,6 +1176,27 @@ export function SettingsContent() {
+ {/* Import Invoices */}
+
+
+
+
+
+
+ Import Invoices
+
+
+ Upload CSV or JSON files to create draft invoices in bulk
+
+
+
+
+
+
+
+
+
+
{/* Delete Account (Danger Zone) */}
diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx
index 87ff672..0443bb7 100644
--- a/src/app/dashboard/settings/page.tsx
+++ b/src/app/dashboard/settings/page.tsx
@@ -5,7 +5,19 @@ import { DashboardPage } from "~/components/layout/dashboard-page";
import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content";
-export default async function SettingsPage() {
+export default async function SettingsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ tab?: string }>;
+}) {
+ const params = await searchParams;
+ const validTabs = ["general", "preferences", "data", "api"] as const;
+ const initialTab = validTabs.includes(
+ params.tab as (typeof validTabs)[number],
+ )
+ ? (params.tab as (typeof validTabs)[number])
+ : "general";
+
return (
}>
-
+
diff --git a/src/components/csv-import-page.tsx b/src/components/csv-import-page.tsx
index 7df7449..39865aa 100644
--- a/src/components/csv-import-page.tsx
+++ b/src/components/csv-import-page.tsx
@@ -1,886 +1,2 @@
-"use client";
-
-import {
- AlertCircle,
- Clock,
- DollarSign,
- Eye,
- FileText,
- Trash2,
- Upload,
- Users,
-} from "lucide-react";
-import { useState } from "react";
-import { toast } from "sonner";
-import { Badge } from "~/components/ui/badge";
-import { Button } from "~/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
-import { DatePicker } from "~/components/ui/date-picker";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "~/components/ui/dialog";
-import { FileUpload } from "~/components/forms/file-upload";
-import { Input } from "~/components/ui/input";
-import { Label } from "~/components/ui/label";
-import { Progress } from "~/components/ui/progress";
-import { api } from "~/trpc/react";
-
-interface CSVRow {
- DATE: string;
- DESCRIPTION: string;
- HOURS: number;
- RATE: number;
- AMOUNT: number;
-}
-
-interface ParsedItem {
- date: Date;
- description: string;
- hours: number;
- rate: number;
- amount: number;
-}
-
-interface FileData {
- file: File;
- parsedItems: ParsedItem[];
- previewData: CSVRow[];
- invoiceNumber: string;
- clientId: string;
- issueDate: Date | null;
- dueDate: Date | null;
- status: "pending" | "ready" | "error";
- errors: string[];
- hasDateError: boolean;
-}
-
-export function CSVImportPage() {
- const [files, setFiles] = useState([]);
- const [globalClientId, setGlobalClientId] = useState("");
- const [previewModalOpen, setPreviewModalOpen] = useState(false);
- const [selectedFileIndex, setSelectedFileIndex] = useState(
- 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) => {
- 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 (
-
- {/* Global Client Selection */}
-
-
-
-
- Default Client
-
-
-
-
-
- Select Default Client (Optional)
-
-
{
- 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}
- >
- No default client (select individually)
- {clients?.map((client) => (
-
- {client.name}
-
- ))}
-
-
- This client will be automatically selected for all uploaded files.
- You can still change individual files below.
-
-
-
-
-
- {/* File Upload Area */}
-
-
-
-
- Upload CSV Files
-
-
-
-
-
- {/* Summary Card */}
- {totalFiles > 0 && (
-
-
-
-
- Import Summary
-
-
-
-
-
-
- {totalFiles}
-
-
Files
-
-
-
- {totalItems}
-
-
- Total Items
-
-
-
-
- {totalAmount.toLocaleString("en-US", {
- style: "currency",
- currency: "USD",
- })}
-
-
- Total Amount
-
-
-
-
- {readyFiles}/{totalFiles}
-
-
Ready
-
-
-
-
- )}
-
-
-
- {/* File List */}
- {files.length > 0 && (
-
-
-
- Uploaded Files
-
-
-
-
- {files.map((fileData, index) => (
-
-
-
-
-
-
- {fileData.file.name}
-
-
- {fileData.parsedItems.length} items •{" "}
- {fileData.parsedItems
- .reduce((sum, item) => sum + item.hours, 0)
- .toFixed(1)}{" "}
- hours
-
-
-
-
- openPreview(index)}
- >
-
- Preview
-
- removeFile(index)}
- className="text-destructive hover:text-destructive/80"
- >
-
- Remove
-
-
-
-
-
-
-
- Invoice Number
-
-
-
-
-
-
- Client
-
-
- 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}
- >
- Select Client
- {clients?.map((client) => (
-
- {client.name}
-
- ))}
-
-
-
-
-
- Issue Date
-
-
- updateFileData(index, { issueDate: date ?? null })
- }
- placeholder="Select issue date"
- className="h-9"
- />
-
-
-
-
- Due Date
-
-
- updateFileData(index, { dueDate: date ?? null })
- }
- placeholder="Select due date"
- className="h-9"
- />
-
-
-
- {/* Error Display */}
- {fileData.errors.length > 0 && (
-
-
-
- {fileData.errors.map((error, errorIndex) => (
-
- •
- {error}
-
- ))}
-
-
- )}
-
-
-
- Total:{" "}
- {fileData.parsedItems
- .reduce((sum, item) => sum + item.amount, 0)
- .toLocaleString("en-US", {
- style: "currency",
- currency: "USD",
- })}
-
-
- {fileData.errors.length > 0 && (
-
- {fileData.errors.length} Error
- {fileData.errors.length !== 1 ? "s" : ""}
-
- )}
- 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"}
-
-
-
-
- ))}
-
-
-
- )}
-
- {/* Batch Actions */}
- {files.length > 0 && (
-
-
-
-
- Create Invoices
-
-
-
-
- {isProcessing && (
-
-
- Creating invoices... ({progressCount}/{totalFiles})
-
-
-
- )}
-
-
- {readyFiles} of {totalFiles} files ready for import
-
-
- {isProcessing
- ? "Processing..."
- : `Import ${readyFiles} Invoice${readyFiles !== 1 ? "s" : ""}`}
-
-
-
-
-
- )}
-
- {/* Preview Modal */}
-
-
-
-
-
- {selectedFileIndex !== null &&
- files[selectedFileIndex]?.file.name}
-
-
- Preview of parsed CSV data
-
-
-
- {selectedFileIndex !== null && files[selectedFileIndex] && (
-
-
-
-
-
- {files[selectedFileIndex].parsedItems.length} items
-
-
-
-
-
- {files[selectedFileIndex].parsedItems
- .reduce((sum, item) => sum + item.hours, 0)
- .toFixed(1)}{" "}
- total hours
-
-
-
-
-
- {files[selectedFileIndex].parsedItems
- .reduce((sum, item) => sum + item.amount, 0)
- .toLocaleString("en-US", {
- style: "currency",
- currency: "USD",
- })}
-
-
-
-
-
-
-
-
-
-
-
- Date
-
-
- Description
-
-
- Hours
-
-
- Rate
-
-
- Amount
-
-
-
-
- {files[selectedFileIndex].parsedItems.map(
- (item, index) => (
-
-
- {item.date.toLocaleDateString()}
-
-
- {item.description}
-
-
- {item.hours}
-
-
- {item.rate.toLocaleString("en-US", {
- style: "currency",
- currency: "USD",
- })}
-
-
- {item.amount.toLocaleString("en-US", {
- style: "currency",
- currency: "USD",
- })}
-
-
- ),
- )}
-
-
-
-
-
-
- )}
-
-
- setPreviewModalOpen(false)}
- >
- Close
-
-
-
-
-
- );
-}
+/** @deprecated Use InvoiceImportPage from ~/components/invoice-import-page */
+export { InvoiceImportPage as CSVImportPage } from "~/components/invoice-import-page";
diff --git a/src/components/forms/file-upload.tsx b/src/components/forms/file-upload.tsx
index 688fc7d..ce23e39 100644
--- a/src/components/forms/file-upload.tsx
+++ b/src/components/forms/file-upload.tsx
@@ -152,7 +152,7 @@ export function FileUpload({
([]);
+ const [globalClientId, setGlobalClientId] = useState("");
+ const [globalBusinessId, setGlobalBusinessId] = useState("");
+ const [previewId, setPreviewId] = useState
(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+
+ const { data: clients, isLoading: loadingClients } =
+ api.clients.getAll.useQuery();
+ const { data: businesses, isLoading: loadingBusinesses } =
+ api.businesses.getAll.useQuery();
+
+ const utils = api.useUtils();
+
+ const bulkImport = api.invoices.bulkImport.useMutation({
+ onSuccess: (result) => {
+ void utils.invoices.getAll.invalidate();
+ if (result.clientsCreated > 0) {
+ void utils.clients.getAll.invalidate();
+ }
+ const parts = [
+ `${result.invoicesCreated} invoice${result.invoicesCreated !== 1 ? "s" : ""} created`,
+ ];
+ if (result.clientsCreated > 0) {
+ parts.push(
+ `${result.clientsCreated} client${result.clientsCreated !== 1 ? "s" : ""} created`,
+ );
+ }
+ toast.success(parts.join(", "));
+ if (result.errors.length > 0) {
+ toast.warning(
+ `${result.errors.length} invoice${result.errors.length !== 1 ? "s" : ""} skipped:\n${result.errors.slice(0, 3).join("\n")}${result.errors.length > 3 ? "\n..." : ""}`,
+ );
+ }
+ setInvoices([]);
+ },
+ onError: (error) => {
+ toast.error(error.message || "Import failed");
+ },
+ });
+
+ const applyGlobalClient = (clientId: string) => {
+ setInvoices((prev) =>
+ prev.map((inv) => ({
+ ...inv,
+ clientId: inv.clientId || clientId,
+ })),
+ );
+ };
+
+ const handleFileSelect = async (selectedFiles: File[]) => {
+ for (const file of selectedFiles) {
+ const format = detectImportFormat(file.name);
+ const text = await file.text();
+
+ if (format === "json") {
+ const parsed = parseInvoiceJSON(text);
+ const staged: StagedInvoice[] = parsed.map((inv) => ({
+ ...inv,
+ id: newId(),
+ clientId: globalClientId,
+ format: "json" as const,
+ sourceFile: file.name,
+ }));
+ setInvoices((prev) => [...prev, ...staged]);
+
+ const errorCount = staged.filter((s) => s.errors.length > 0).length;
+ if (errorCount > 0) {
+ toast.error(
+ `${file.name}: ${errorCount} invoice${errorCount !== 1 ? "s" : ""} with validation issues`,
+ );
+ } else {
+ toast.success(
+ `Parsed ${staged.length} invoice${staged.length !== 1 ? "s" : ""} from ${file.name}`,
+ );
+ }
+ } else {
+ const parsed = parseInvoiceCSV(text, file.name);
+ const staged: StagedInvoice = {
+ ...parsed,
+ id: newId(),
+ clientId: globalClientId,
+ format: "csv",
+ };
+ setInvoices((prev) => [...prev, staged]);
+
+ if (parsed.errors.length > 0) {
+ toast.error(
+ `${file.name}: ${parsed.errors.length} issue${parsed.errors.length !== 1 ? "s" : ""}`,
+ );
+ } else {
+ toast.success(
+ `Parsed ${parsed.items.length} items from ${file.name}`,
+ );
+ }
+ }
+ }
+ };
+
+ const removeInvoice = (id: string) => {
+ setInvoices((prev) => prev.filter((inv) => inv.id !== id));
+ };
+
+ const updateInvoice = (id: string, updates: Partial) => {
+ setInvoices((prev) =>
+ prev.map((inv) => {
+ if (inv.id !== id) return inv;
+ const updated = { ...inv, ...updates };
+ if (updates.issueDate !== undefined && !updates.dueDate) {
+ const due = new Date(updated.issueDate ?? new Date());
+ due.setDate(due.getDate() + 30);
+ updated.dueDate = due;
+ }
+ return updated;
+ }),
+ );
+ };
+
+ const isReady = (inv: StagedInvoice) =>
+ inv.errors.length === 0 &&
+ inv.items.length > 0 &&
+ !!(inv.clientId || globalClientId || inv.client?.name) &&
+ !!inv.issueDate &&
+ !!inv.dueDate;
+
+ const readyCount = invoices.filter(isReady).length;
+
+ const validateBeforeImport = (): string[] => {
+ const errors: string[] = [];
+ if (!globalBusinessId && (!businesses || businesses.length === 0)) {
+ errors.push("Create a business in Settings before importing");
+ }
+ invoices.forEach((inv) => {
+ if (inv.errors.length > 0) {
+ errors.push(`${inv.name}: ${inv.errors.join("; ")}`);
+ }
+ if (inv.items.length === 0) {
+ errors.push(`${inv.name}: no line items`);
+ }
+ if (!inv.clientId && !globalClientId && !inv.client?.name) {
+ errors.push(`${inv.name}: client required`);
+ }
+ if (!inv.issueDate) errors.push(`${inv.name}: issue date required`);
+ if (!inv.dueDate) errors.push(`${inv.name}: due date required`);
+ });
+ return errors;
+ };
+
+ const processImport = async () => {
+ const errors = validateBeforeImport();
+ if (errors.length > 0) {
+ toast.error(`Fix these issues first:\n${errors.slice(0, 5).join("\n")}`);
+ return;
+ }
+
+ const readyInvoices = invoices.filter(isReady);
+ if (readyInvoices.length === 0) return;
+
+ setIsProcessing(true);
+ try {
+ await bulkImport.mutateAsync({
+ defaultClientId: globalClientId || undefined,
+ defaultBusinessId: globalBusinessId || undefined,
+ invoices: readyInvoices.map((inv) => ({
+ name: inv.name,
+ issueDate: inv.issueDate,
+ dueDate: inv.dueDate,
+ clientId: inv.clientId || globalClientId || undefined,
+ client: inv.client,
+ items: inv.items.map((item) => ({
+ date: item.date,
+ description: item.description,
+ quantity: item.quantity,
+ rate: item.rate,
+ })),
+ sourceFile: inv.sourceFile,
+ })),
+ });
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const previewInvoice = previewId
+ ? invoices.find((i) => i.id === previewId)
+ : null;
+
+ const totalItems = invoices.reduce((sum, inv) => sum + inv.items.length, 0);
+ const totalAmount = invoices.reduce(
+ (sum, inv) =>
+ sum + inv.items.reduce((s, item) => s + item.quantity * item.rate, 0),
+ 0,
+ );
+
+ return (
+
+ {/* Upload — primary action */}
+
+
+
+
+ Upload files
+
+
+
+
+
+ {invoices.length > 0 && (
+
+
+
+
+
+
+ )}
+
+
+
+ {/* Defaults */}
+
+
+
+
+
+ Default business
+
+
+
+
+
+ Business for imported invoices
+
+
+ setGlobalBusinessId(value === NONE ? "" : value)
+ }
+ disabled={loadingBusinesses}
+ >
+
+
+
+
+ Use default business
+ {businesses?.map((b) => (
+
+ {b.name}
+ {b.isDefault ? " (default)" : ""}
+
+ ))}
+
+
+
+ Required — your default business is used if none is selected.
+
+
+
+
+
+
+
+
+
+ Default client
+
+
+
+
+
+ Client for CSV imports (optional)
+
+
{
+ const id = value === NONE ? "" : value;
+ setGlobalClientId(id);
+ if (id) applyGlobalClient(id);
+ }}
+ disabled={loadingClients}
+ >
+
+
+
+
+
+ No default (JSON client or per-invoice)
+
+ {clients?.map((client) => (
+
+ {client.name}
+
+ ))}
+
+
+
+ CSV files need a client. JSON can include client details per
+ invoice.
+
+
+
+
+
+
+ {/* Staged invoices */}
+ {invoices.length > 0 && (
+
+
+ Preview
+
+
+ {invoices.map((inv) => (
+
+
+
+ {inv.format === "json" ? (
+
+ ) : (
+
+ )}
+
+
+ {inv.name}
+
+
+ {inv.items.length} items
+ {inv.sourceFile ? ` • ${inv.sourceFile}` : ""}
+ {inv.client?.name ? ` • ${inv.client.name}` : ""}
+
+
+
+
+ setPreviewId(inv.id)}
+ >
+
+ Preview
+
+ removeInvoice(inv.id)}
+ className="text-destructive hover:text-destructive/80"
+ >
+
+ Remove
+
+
+
+
+
+
+
+ Invoice title
+
+
+ updateInvoice(inv.id, { name: e.target.value })
+ }
+ />
+
+
+
+
+ Client
+
+
+ updateInvoice(inv.id, {
+ clientId: value === NONE ? "" : value,
+ })
+ }
+ disabled={loadingClients}
+ >
+
+
+
+
+
+ {inv.client?.name
+ ? `Use JSON: ${inv.client.name}`
+ : "Select client"}
+
+ {clients?.map((client) => (
+
+ {client.name}
+
+ ))}
+
+
+
+
+
+
+ Issue date
+
+
+ updateInvoice(inv.id, { issueDate: date })
+ }
+ placeholder="Issue date"
+ className="h-9"
+ />
+
+
+
+
+ Due date
+
+
+ updateInvoice(inv.id, { dueDate: date })
+ }
+ placeholder="Due date"
+ className="h-9"
+ />
+
+
+
+ {inv.errors.length > 0 && (
+
+
+
+ {inv.errors.map((err, i) => (
+ • {err}
+ ))}
+
+
+ )}
+
+
+
+ Total:{" "}
+ {inv.items
+ .reduce((s, item) => s + item.quantity * item.rate, 0)
+ .toLocaleString("en-US", {
+ style: "currency",
+ currency: "USD",
+ })}
+
+
+ {isReady(inv) ? "Ready" : "Pending"}
+
+
+
+ ))}
+
+
+ )}
+
+ {invoices.length > 0 && (
+
+
+
+
+ Import invoices
+
+
+
+
+ {isProcessing && (
+
+
+ Importing {readyCount} invoice
+ {readyCount !== 1 ? "s" : ""}...
+
+
+
+ )}
+
+
+ {readyCount} of {invoices.length} ready • all imported as
+ drafts
+
+
+ {isProcessing
+ ? "Importing..."
+ : `Import ${readyCount} Invoice${readyCount !== 1 ? "s" : ""}`}
+
+
+
+
+
+ )}
+
+
setPreviewId(null)}>
+
+
+
+
+ {previewInvoice?.name}
+
+ Line item preview
+
+
+ {previewInvoice && (
+
+
+
+
+
+ Date
+
+
+ Description
+
+
+ Qty
+
+
+ Rate
+
+
+ Amount
+
+
+
+
+ {previewInvoice.items.map((item, idx) => (
+
+
+ {item.date?.toLocaleDateString() ?? "—"}
+
+
+ {item.description}
+
+ {item.quantity}
+
+ {item.rate.toLocaleString("en-US", {
+ style: "currency",
+ currency: "USD",
+ })}
+
+
+ {(item.quantity * item.rate).toLocaleString("en-US", {
+ style: "currency",
+ currency: "USD",
+ })}
+
+
+ ))}
+
+
+
+ )}
+
+
+ setPreviewId(null)}>
+ Close
+
+
+
+
+
+ );
+}
+
+function SummaryStat({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | number;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 2db317c..0ab9bce 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -68,7 +68,7 @@ function SelectContent({
= {
+ 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;
+ 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";
+}
diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts
index e8dd3c7..0857e32 100644
--- a/src/server/api/routers/invoices.ts
+++ b/src/server/api/routers/invoices.ts
@@ -10,6 +10,7 @@ import {
} from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
+import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
import { Resend } from "resend";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
@@ -57,6 +58,39 @@ const updateStatusSchema = z.object({
status: z.enum(["draft", "sent", "paid"]),
});
+const bulkImportItemSchema = z.object({
+ date: z.coerce.date().optional(),
+ description: z.string().min(1, "Description is required"),
+ quantity: z.number().min(0, "Quantity must be positive"),
+ rate: z.number().min(0, "Rate must be positive"),
+});
+
+const bulkImportClientSchema = z.object({
+ name: z.string().optional(),
+ email: z.string().email("Invalid client email").optional().or(z.literal("")),
+});
+
+const bulkImportInvoiceSchema = z.object({
+ name: z.string().min(1, "Invoice name is required"),
+ issueDate: z.coerce.date().optional(),
+ dueDate: z.coerce.date().optional(),
+ clientId: z.string().optional(),
+ client: bulkImportClientSchema.optional(),
+ items: z
+ .array(bulkImportItemSchema)
+ .min(1, "At least one line item is required"),
+ notes: z.string().optional(),
+ sourceFile: z.string().optional(),
+});
+
+const bulkImportSchema = z.object({
+ defaultClientId: z.string().optional(),
+ defaultBusinessId: z.string().optional().or(z.literal("")),
+ invoices: z.array(bulkImportInvoiceSchema).min(1, "No invoices to import"),
+});
+
+type BulkImportInvoiceInput = z.infer;
+
async function verifyBusinessAccess(
ctx: InvoiceRouterContext,
businessId?: string | null,
@@ -133,6 +167,44 @@ const calculateInvoiceTotal = (
return subtotal + taxAmount;
};
+type ClientRecord = typeof clients.$inferSelect;
+
+function findExistingClient(
+ userClients: ClientRecord[],
+ clientRef?: { name?: string; email?: string },
+): ClientRecord | undefined {
+ if (!clientRef) return undefined;
+
+ if (clientRef.email?.trim()) {
+ const email = clientRef.email.trim().toLowerCase();
+ const byEmail = userClients.find(
+ (c) => c.email?.toLowerCase() === email,
+ );
+ if (byEmail) return byEmail;
+ }
+
+ if (clientRef.name?.trim()) {
+ const name = clientRef.name.trim().toLowerCase();
+ const byName = userClients.find((c) => c.name.toLowerCase() === name);
+ if (byName) return byName;
+ }
+
+ return undefined;
+}
+
+function deriveIssueDateFromItems(
+ items: BulkImportInvoiceInput["items"],
+ fallback?: Date,
+): Date {
+ const itemDates = items
+ .map((i) => i.date)
+ .filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()));
+ if (itemDates.length > 0) {
+ return new Date(Math.max(...itemDates.map((d) => d.getTime())));
+ }
+ return fallback ?? new Date();
+}
+
export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
@@ -661,6 +733,173 @@ export const invoicesRouter = createTRPCRouter({
return { success: true, deleted: ownedIds.length };
}),
+ bulkImport: protectedProcedure
+ .input(bulkImportSchema)
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id;
+
+ const business = await resolveBusinessForInvoice(
+ ctx,
+ input.defaultBusinessId,
+ );
+ if (!business) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "No business found. Create a business in Settings before importing invoices.",
+ });
+ }
+
+ if (input.defaultClientId) {
+ await verifyClientAccess(ctx, input.defaultClientId);
+ }
+ if (input.defaultBusinessId && input.defaultBusinessId.trim() !== "") {
+ await verifyBusinessAccess(ctx, input.defaultBusinessId);
+ }
+
+ let invoicesCreated = 0;
+ let clientsCreated = 0;
+ const rowErrors: string[] = [];
+
+ try {
+ await ctx.db.transaction(async (tx) => {
+ const userClients = await tx
+ .select()
+ .from(clients)
+ .where(eq(clients.createdById, userId));
+
+ for (let i = 0; i < input.invoices.length; i++) {
+ const inv = input.invoices[i]!;
+ const label = inv.sourceFile ?? inv.name ?? `Invoice ${i + 1}`;
+
+ try {
+ let clientId = inv.clientId ?? input.defaultClientId;
+
+ if (!clientId) {
+ const existing = findExistingClient(userClients, inv.client);
+ if (existing) {
+ clientId = existing.id;
+ } else if (inv.client?.name?.trim()) {
+ const [newClient] = await tx
+ .insert(clients)
+ .values({
+ name: inv.client.name.trim(),
+ email:
+ inv.client.email && inv.client.email.trim() !== ""
+ ? inv.client.email.trim()
+ : null,
+ createdById: userId,
+ })
+ .returning();
+
+ if (!newClient) {
+ rowErrors.push(`${label}: failed to create client`);
+ continue;
+ }
+
+ userClients.push(newClient);
+ clientId = newClient.id;
+ clientsCreated++;
+ }
+ }
+
+ if (!clientId) {
+ rowErrors.push(
+ `${label}: no client specified (select a default client or include client details in JSON)`,
+ );
+ continue;
+ }
+
+ const clientRecord = userClients.find((c) => c.id === clientId);
+ if (!clientRecord) {
+ rowErrors.push(`${label}: client not found`);
+ continue;
+ }
+
+ const issueDate =
+ inv.issueDate ?? deriveIssueDateFromItems(inv.items);
+ const dueDate = inv.dueDate ?? defaultDueDate(issueDate);
+
+ const dbItems = inv.items.map((item) => ({
+ date: item.date ?? issueDate,
+ description: item.description,
+ hours: item.quantity,
+ rate: item.rate,
+ }));
+
+ const totalAmount = calculateInvoiceTotal(dbItems, 0);
+ const invoiceNumber =
+ inv.name.trim().slice(0, 100) || generateInvoiceNumber();
+
+ const notes =
+ inv.notes ??
+ (inv.sourceFile
+ ? `Imported from ${inv.sourceFile}`
+ : "Imported invoice");
+
+ const [invoice] = await tx
+ .insert(invoices)
+ .values({
+ invoiceNumber,
+ businessId: business.id,
+ clientId,
+ issueDate,
+ dueDate,
+ status: "draft",
+ totalAmount,
+ taxRate: 0,
+ currency: "USD",
+ notes,
+ createdById: userId,
+ })
+ .returning();
+
+ if (!invoice) {
+ rowErrors.push(`${label}: failed to create invoice`);
+ continue;
+ }
+
+ await tx.insert(invoiceItems).values(
+ dbItems.map((item, idx) => ({
+ ...item,
+ invoiceId: invoice.id,
+ amount: item.hours * item.rate,
+ position: idx,
+ })),
+ );
+
+ invoicesCreated++;
+ } catch (err) {
+ const msg =
+ err instanceof Error ? err.message : "Unknown error";
+ rowErrors.push(`${label}: ${msg}`);
+ }
+ }
+ });
+ } catch (error) {
+ if (error instanceof TRPCError) throw error;
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to import invoices",
+ cause: error,
+ });
+ }
+
+ if (invoicesCreated === 0 && rowErrors.length > 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: rowErrors.join("\n"),
+ });
+ }
+
+ return {
+ success: true,
+ invoicesCreated,
+ clientsCreated,
+ errors: rowErrors,
+ };
+ }),
+
previewPdf: protectedProcedure
.input(createInvoiceSchema)
.query(async ({ ctx, input }) => {
diff --git a/src/styles/globals.css b/src/styles/globals.css
index f252dfe..e60d6e6 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -154,7 +154,8 @@
[data-slot="card"],
[data-slot="dialog-content"],
[data-slot="alert-dialog-content"],
- [data-slot="popover-content"] {
+ [data-slot="popover-content"],
+ [data-slot="select-content"] {
border-radius: var(--radius-lg);
}