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); }