From 6b73c32c25ebc98633079fd1e158cee72c34c57c Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 26 Jun 2026 12:39:46 -0400 Subject: [PATCH] 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 --- README.md | 9 +- src/app/dashboard/invoices/import/page.tsx | 237 +---- src/app/dashboard/invoices/page.tsx | 8 +- .../import-format-info-dialog.tsx | 245 +++++ .../import-page-header-actions.tsx | 7 + .../invoice-import/import-sample-download.tsx | 52 + .../settings/_components/settings-content.tsx | 62 +- src/app/dashboard/settings/page.tsx | 16 +- src/components/csv-import-page.tsx | 888 +----------------- src/components/forms/file-upload.tsx | 2 +- src/components/invoice-import-page.tsx | 680 ++++++++++++++ src/components/ui/select.tsx | 4 +- src/lib/invoice-import-templates.ts | 68 ++ src/lib/invoice-import.ts | 369 ++++++++ src/server/api/routers/invoices.ts | 239 +++++ src/styles/globals.css | 3 +- 16 files changed, 1753 insertions(+), 1136 deletions(-) create mode 100644 src/app/dashboard/settings/_components/invoice-import/import-format-info-dialog.tsx create mode 100644 src/app/dashboard/settings/_components/invoice-import/import-page-header-actions.tsx create mode 100644 src/app/dashboard/settings/_components/invoice-import/import-sample-download.tsx create mode 100644 src/components/invoice-import-page.tsx create mode 100644 src/lib/invoice-import-templates.ts create mode 100644 src/lib/invoice-import.ts 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 -

-
-
- -
-

Sample Filename:

-
-

2024-01-15.csv

-
-
-
-
-
- ); -} - -// 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 ( - - - - - - - - - {/* 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 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. +

+ +
+

Example

+
+
+                    {JSON_TEMPLATE}
+                  
+
+
+ +
+

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. +
  • +
+
+ + +
+
+ + + + +
+
+ + ); +} 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 ( + + ); +} + +export function ImportJsonTemplateButton({ + className, +}: { + className?: string; +}) { + return ( + + ); +} + +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 - - - -
- - -

- 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 -

-
-
-
- - -
-
- -
-
- - -
- -
- - -
- -
- - - updateFileData(index, { issueDate: date ?? null }) - } - placeholder="Select issue date" - className="h-9" - /> -
- -
- - - updateFileData(index, { dueDate: date ?? null }) - } - placeholder="Select due date" - className="h-9" - /> -
-
- - {/* Error Display */} - {fileData.errors.length > 0 && ( -
-
- - - Issues Found - -
-
    - {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 -
- -
-
-
-
- )} - - {/* 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", - })} - -
-
- -
-
-
- - - - - - - - - - - - {files[selectedFileIndex].parsedItems.map( - (item, index) => ( - - - - - - - - ), - )} - -
- Date - - Description - - Hours - - Rate - - Amount -
- {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", - })} -
-
-
-
-
- )} - - - - -
-
-
- ); -} +/** @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 + + + +
+ + +

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

+
+
+
+ + + + + + Default client + + + +
+ + +

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

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

+ {inv.name} +

+

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

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