"use client"; import * as React from "react"; import { useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Label } from "~/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { DatePicker } from "~/components/ui/date-picker"; import { Input } from "~/components/ui/input"; import { NumberInput } from "~/components/ui/number-input"; import { PageHeader } from "~/components/layout/page-header"; import { InvoiceLineItems } from "./invoice-line-items"; import { InvoiceCalendarView } from "./invoice-calendar-view"; import { EmailPreview } from "./email-preview"; import { api } from "~/trpc/react"; import { toast } from "sonner"; import { Save, Calendar as CalendarIcon, Tag, User, List, FileText, ChevronDown, Mail, } from "lucide-react"; import { SUPPORTED_CURRENCIES } from "~/lib/currency"; import { Textarea } from "~/components/ui/textarea"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog"; import { STATUS_OPTIONS } from "./invoice/types"; import type { InvoiceFormData, InvoiceItem } from "./invoice/types"; import type { ParsedLineItem } from "~/lib/parse-line-item"; import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel"; import { CountUp } from "~/components/ui/count-up"; interface InvoiceFormProps { invoiceId?: string; } function InvoiceFormSkeleton() { return (
{" "} {/* Tabs Skeleton */}
); } function getDefaultHourlyRate(value: unknown) { if (typeof value !== "object" || value === null) return null; const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate; return typeof rate === "number" ? rate : null; } function plainTextToHtml(value: string) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/\n/g, "
"); } function createDefaultInvoiceFormData(blank = false): InvoiceFormData { return { invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, invoicePrefix: "#", businessId: "", clientId: "", issueDate: new Date(), dueDate: new Date(), status: "draft", notes: "", emailMessage: "", taxRate: 0, currency: "USD", defaultHourlyRate: null, items: blank ? [] : [ { id: crypto.randomUUID(), date: new Date(), description: "", hours: 1, rate: 0, amount: 0, }, ], }; } export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { const router = useRouter(); const searchParams = useSearchParams(); const isBlank = searchParams.get("blank") === "1"; const utils = api.useUtils(); // State const [formData, setFormData] = useState(() => createDefaultInvoiceFormData(isBlank), ); const [loading, setLoading] = useState(false); const [initialized, setInitialized] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [activeTab, setActiveTab] = useState("details"); const [previewTab, setPreviewTab] = useState("pdf"); const [previewPinned, setPreviewPinned] = useState(false); useEffect(() => { const media = window.matchMedia("(min-width: 1024px)"); const update = () => setPreviewPinned(media.matches); update(); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, []); // Queries (Same as before) const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery(); const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({ type: "notes", }); const { data: businesses, isLoading: loadingBusinesses } = api.businesses.getAll.useQuery(); const { data: existingInvoice, isLoading: loadingInvoice } = api.invoices.getById.useQuery( { id: invoiceId! }, { enabled: !!invoiceId && invoiceId !== "new" }, ); const deleteInvoice = api.invoices.delete.useMutation({ onSuccess: () => { toast.success("Invoice deleted"); router.push("/dashboard/invoices"); }, onError: (e) => toast.error(e.message ?? "Failed to delete"), }); // Init Effects (Same as before) useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset initialization state when the routed invoice changes. setInitialized(false); }, [invoiceId]); useEffect(() => { if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) { // ... (Mapping logic same as before) const mappedItems: InvoiceItem[] = existingInvoice.items?.map((item) => ({ id: crypto.randomUUID(), date: new Date(item.date), description: item.description, hours: item.hours, rate: item.rate, amount: item.amount, })) || []; // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form. setFormData({ invoiceNumber: existingInvoice.invoiceNumber, invoicePrefix: existingInvoice.invoicePrefix ?? "#", businessId: existingInvoice.businessId ?? "", clientId: existingInvoice.clientId, issueDate: new Date(existingInvoice.issueDate), dueDate: new Date(existingInvoice.dueDate), status: existingInvoice.status as "draft" | "sent" | "paid", notes: existingInvoice.notes ?? "", emailMessage: existingInvoice.emailMessage ?? "", taxRate: existingInvoice.taxRate, currency: existingInvoice.currency ?? "USD", defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null, items: mappedItems.length > 0 ? mappedItems : [ { id: crypto.randomUUID(), date: new Date(), description: "", hours: 1, rate: 0, amount: 0, }, ], }); setInitialized(true); } else if ( (!invoiceId || invoiceId === "new") && businesses && !initialized ) { const defaultBusiness = businesses.find((b) => b.isDefault) ?? businesses[0]; if (defaultBusiness) setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id })); setInitialized(true); } }, [invoiceId, existingInvoice, businesses, initialized]); const totals = React.useMemo(() => { const subtotal = formData.items.reduce( (sum, item) => sum + item.hours * item.rate, 0, ); const taxAmount = (subtotal * formData.taxRate) / 100; const total = subtotal + taxAmount; return { subtotal, taxAmount, total }; }, [formData.items, formData.taxRate]); const emailPreviewMessage = React.useMemo( () => plainTextToHtml(formData.emailMessage.trim()), [formData.emailMessage], ); const pdfPreviewInput = React.useMemo( () => ({ invoiceNumber: formData.invoiceNumber, invoicePrefix: formData.invoicePrefix, businessId: formData.businessId || "", clientId: formData.clientId, issueDate: formData.issueDate, dueDate: formData.dueDate, status: formData.status, notes: formData.notes, emailMessage: formData.emailMessage, taxRate: formData.taxRate, currency: formData.currency, items: formData.items.map((item) => ({ date: item.date, description: item.description || "Service", hours: item.hours, rate: item.rate, })), }), [formData], ); const selectedClient = React.useMemo( () => clients?.find((client) => client.id === formData.clientId), [clients, formData.clientId], ); const selectedBusiness = React.useMemo( () => businesses?.find((business) => business.id === formData.businessId) ?? businesses?.find((business) => business.isDefault), [businesses, formData.businessId], ); // Handlers (addItem, updateItem etc. - same as before) const addItem = (date?: unknown) => { const validDate = date instanceof Date ? date : new Date(); setFormData((prev) => ({ ...prev, items: [ ...prev.items, { id: crypto.randomUUID(), date: validDate, description: "", hours: 1, rate: prev.defaultHourlyRate ?? 0, amount: prev.defaultHourlyRate ?? 0, }, ], })); }; const addItemWithValues = (parsed: ParsedLineItem) => { setFormData((prev) => ({ ...prev, items: [ ...prev.items, { id: crypto.randomUUID(), date: new Date(), description: parsed.description, hours: parsed.hours ?? 1, rate: parsed.rate ?? prev.defaultHourlyRate ?? 0, amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0), }, ], })); }; const removeItem = (idx: number) => { if (formData.items.length > 1) setFormData((prev) => ({ ...prev, items: prev.items.filter((_, i) => i !== idx), })); }; const updateItem = ( idx: number, field: string, value: string | number | Date, ) => { setFormData((prev) => ({ ...prev, items: prev.items.map((item, i) => { if (i === idx) { const updated = { ...item, [field]: value }; if (field === "hours" || field === "rate") { updated.amount = updated.hours * updated.rate; } return updated; } return item; }), })); }; const createInvoice = api.invoices.create.useMutation({ onSuccess: (inv) => { toast.success("Created"); void utils.invoices.getAll.invalidate(); router.push(`/dashboard/invoices/${inv.id}`); }, onError: (e) => toast.error(e.message), }); const updateInvoice = api.invoices.update.useMutation({ onSuccess: () => { toast.success("Updated"); if (invoiceId && invoiceId !== "new") { void utils.invoices.getById.invalidate({ id: invoiceId }); } void utils.invoices.getAll.invalidate(); router.push( invoiceId === "new" ? "/dashboard/invoices" : `/dashboard/invoices/${invoiceId}`, ); }, onError: (e) => toast.error(e.message), }); const handleSubmit = async () => { setLoading(true); if (!formData.clientId) { toast.error("Select Client"); setLoading(false); return; } const itemsToSave = formData.items.filter((item) => item.description?.trim()); let invalidItemIndex = -1; for (let i = 0; i < formData.items.length; i++) { const item = formData.items[i]; const desc = item?.description?.trim() ?? ""; if (!desc && ((item?.hours ?? 0) > 0 || (item?.rate ?? 0) > 0)) { invalidItemIndex = i; break; } } if (invalidItemIndex !== -1) { toast.error(`Item #${invalidItemIndex + 1} is missing a description`); setLoading(false); setActiveTab("items"); // Switch to items tab // Timeout to allow tab switch rendering setTimeout(() => { const element = document.getElementById( `invoice-item-${invalidItemIndex}`, ); if (element) { element.scrollIntoView({ behavior: "smooth", block: "center" }); // Optional: Highlight effect element.classList.add("ring-2", "ring-destructive", "ring-offset-2"); setTimeout( () => element.classList.remove( "ring-2", "ring-destructive", "ring-offset-2", ), 2000, ); } }, 100); return; } try { const payload = { invoiceNumber: formData.invoiceNumber, invoicePrefix: formData.invoicePrefix, businessId: formData.businessId || "", clientId: formData.clientId, issueDate: formData.issueDate, dueDate: formData.dueDate, status: formData.status, notes: formData.notes, emailMessage: formData.emailMessage, taxRate: formData.taxRate, currency: formData.currency, items: itemsToSave.map((i) => ({ date: i.date, description: i.description, hours: i.hours, rate: i.rate, amount: i.hours * i.rate, })), }; if (invoiceId && invoiceId !== "new" && invoiceId !== undefined) await updateInvoice.mutateAsync({ id: invoiceId, ...payload }); else await createInvoice.mutateAsync(payload); } catch (e) { console.error(e); } finally { setLoading(false); } }; const updateField = ( field: K, value: InvoiceFormData[K], ) => setFormData((p) => ({ ...p, [field]: value })); const handleDelete = () => setDeleteDialogOpen(true); const confirmDelete = () => { if (invoiceId) deleteInvoice.mutate({ id: invoiceId }); }; if ( !initialized || loadingClients || loadingBusinesses || (invoiceId && invoiceId !== "new" && loadingInvoice) ) return ; return ( <>
{invoiceId !== "new" && ( )}
{/* TAB SELECTOR: w-full, p-1, visible background */} Details Items Timesheet Preview {/* DETAILS TAB */} Client Details
Invoice Settings
updateField("issueDate", d ?? new Date()) } className="w-full" />
updateField("dueDate", d ?? new Date()) } className="w-full" />
updateField("invoicePrefix", e.target.value) } placeholder="#" className="w-full" />
updateField("invoiceNumber", e.target.value) } placeholder="INV-20260428-000001" className="w-full font-mono" />
updateField("taxRate", v)} suffix="%" className="w-full" />
updateField("defaultHourlyRate", v)} prefix="$" disabled={!formData.clientId} className="w-full" />
Email Message