"use client"; import * as React from "react"; import { useState, useEffect } from "react"; import { useRouter } 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 { api } from "~/trpc/react"; import { toast } from "sonner"; import { Save, Calendar as CalendarIcon, Tag, User, List, FileText, ChevronDown, } 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 { CountUp } from "~/components/ui/count-up"; interface InvoiceFormProps { invoiceId?: string; } function InvoiceFormSkeleton() { return (
{" "} {/* Tabs Skeleton */}
); } export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { const router = useRouter(); const utils = api.useUtils(); // State const [formData, setFormData] = useState({ 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: "", taxRate: 0, currency: "USD", defaultHourlyRate: null, items: [ { id: crypto.randomUUID(), date: new Date(), description: "", hours: 1, rate: 0, amount: 0, }, ], }); const [loading, setLoading] = useState(false); const [initialized, setInitialized] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [activeTab, setActiveTab] = useState("details"); // 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(() => { 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, })) .sort( (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), ) || []; 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 ?? "", 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]); // 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 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; } // Validate Items - Check for empty description let invalidItemIndex = -1; for (let i = 0; i < formData.items.length; i++) { if ( !formData.items[i]?.description || formData.items[i]?.description.trim() === "" ) { 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, taxRate: formData.taxRate, currency: formData.currency, items: formData.items .sort( (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), ) .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 {/* DETAILS TAB */} Client Details
Invoice Config
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("taxRate", v)} suffix="%" className="w-full" />
updateField("defaultHourlyRate", v)} prefix="$" disabled={!formData.clientId} className="w-full" />
{/* Notes card — spans both columns */} Notes {noteTemplates && noteTemplates.length > 0 && ( {noteTemplates.map((t) => ( updateField("notes", t.content)} > {t.name} ))} )}