"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 { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Textarea } from "~/components/ui/textarea"; import { Separator } from "~/components/ui/separator"; 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 { NumberInput } from "~/components/ui/number-input"; import { PageHeader } from "~/components/layout/page-header"; import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { InvoiceLineItems } from "./invoice-line-items"; import { api } from "~/trpc/react"; import { toast } from "sonner"; import { FileText, DollarSign, Check, Save, Clock, Trash2 } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog"; const STATUS_OPTIONS = [ { value: "draft", label: "Draft" }, { value: "sent", label: "Sent" }, { value: "paid", label: "Paid" }, ]; interface InvoiceFormProps { invoiceId?: string; } interface InvoiceItem { id: string; date: Date; description: string; hours: number; rate: number; amount: number; } interface FormData { invoiceNumber: string; businessId: string; clientId: string; issueDate: Date; dueDate: Date; status: "draft" | "sent" | "paid"; notes: string; taxRate: number; defaultHourlyRate: number; items: InvoiceItem[]; } function InvoiceFormSkeleton() { return (
); } export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { const router = useRouter(); const utils = api.useUtils(); // Single state object for all form data const [formData, setFormData] = useState({ invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`, businessId: "", clientId: "", issueDate: new Date(), dueDate: new Date(), status: "draft", notes: "", taxRate: 0, defaultHourlyRate: 25, items: [ { id: crypto.randomUUID(), date: new Date(), description: "", hours: 1, rate: 25, amount: 25, }, ], }); const [loading, setLoading] = useState(false); const [initialized, setInitialized] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); // Data queries const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery(); const { data: businesses, isLoading: loadingBusinesses } = api.businesses.getAll.useQuery(); const { data: existingInvoice, isLoading: loadingInvoice } = api.invoices.getById.useQuery( { id: invoiceId! }, { enabled: !!invoiceId && invoiceId !== "new" }, ); // Delete mutation const deleteInvoice = api.invoices.delete.useMutation({ onSuccess: () => { toast.success("Invoice deleted successfully"); router.push("/dashboard/invoices"); }, onError: (error) => { toast.error(error.message ?? "Failed to delete invoice"); }, }); // Single initialization effect - only runs once when data is ready useEffect(() => { if (initialized) return; const dataReady = !loadingClients && !loadingBusinesses && (!invoiceId || invoiceId === "new" || !loadingInvoice); if (!dataReady) return; if (invoiceId && invoiceId !== "new" && existingInvoice) { // Initialize with existing invoice data const formDataToSet = { invoiceNumber: existingInvoice.invoiceNumber, 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, defaultHourlyRate: 25, items: existingInvoice.items?.map((item) => ({ id: crypto.randomUUID(), date: new Date(item.date), description: item.description, hours: item.hours, rate: item.rate, amount: item.amount, })) || [], }; setFormData(formDataToSet); } else if ((!invoiceId || invoiceId === "new") && businesses) { // New invoice - set default business const defaultBusiness = businesses.find((b) => b.isDefault); if (defaultBusiness) { setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id })); } else if (businesses.length > 0) { // If no default business, use the first one setFormData((prev) => ({ ...prev, businessId: businesses[0]!.id })); } } setInitialized(true); }, [ loadingClients, loadingBusinesses, loadingInvoice, existingInvoice, businesses, invoiceId, initialized, ]); // Update default hourly rate when client changes (only during initialization) useEffect(() => { if (!initialized || !formData.clientId || !clients) return; const selectedClient = clients.find((c) => c.id === formData.clientId); if (selectedClient?.defaultHourlyRate) { setFormData((prev) => ({ ...prev, defaultHourlyRate: selectedClient.defaultHourlyRate, })); } }, [formData.clientId, clients, initialized]); // Calculate totals 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]); // Item management functions const addItem = () => { setFormData((prev) => ({ ...prev, items: [ ...prev.items, { id: crypto.randomUUID(), date: new Date(), description: "", hours: 1, rate: prev.defaultHourlyRate, amount: prev.defaultHourlyRate, }, ], })); }; 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 updatedItem = { ...item, [field]: value }; if (field === "hours" || field === "rate") { updatedItem.amount = updatedItem.hours * updatedItem.rate; } return updatedItem; } return item; }), })); }; const moveItemUp = (idx: number) => { if (idx === 0) return; setFormData((prev) => { const newItems = [...prev.items]; if (idx > 0 && idx < newItems.length) { const currentItem = newItems[idx]; const previousItem = newItems[idx - 1]; if (currentItem && previousItem) { newItems[idx - 1] = currentItem; newItems[idx] = previousItem; } } return { ...prev, items: newItems }; }); }; const moveItemDown = (idx: number) => { if (idx === formData.items.length - 1) return; setFormData((prev) => { const newItems = [...prev.items]; if (idx >= 0 && idx < newItems.length - 1) { const currentItem = newItems[idx]; const nextItem = newItems[idx + 1]; if (currentItem && nextItem) { newItems[idx] = nextItem; newItems[idx + 1] = currentItem; } } return { ...prev, items: newItems }; }); }; const reorderItems = (newItems: InvoiceItem[]) => { setFormData((prev) => ({ ...prev, items: newItems })); }; // Mutations const createInvoice = api.invoices.create.useMutation({ onSuccess: (invoice) => { toast.success("Invoice created successfully"); void utils.invoices.getAll.invalidate(); router.push(`/dashboard/invoices/${invoice.id}`); }, onError: (error) => { toast.error(error.message || "Failed to create invoice"); }, }); const updateInvoice = api.invoices.update.useMutation({ onSuccess: async () => { toast.success("Invoice updated successfully"); await utils.invoices.getAll.invalidate(); // The update mutation returns { success: true }, so we use the current invoiceId if (invoiceId && invoiceId !== "new") { router.push(`/dashboard/invoices/${invoiceId}`); } else { router.push("/dashboard/invoices"); } }, onError: (error) => { toast.error(error.message || "Failed to update invoice"); }, }); // Form submission const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); try { // Validate required fields if (!formData.clientId || formData.clientId.trim() === "") { toast.error("Please select a client"); setLoading(false); return; } if (!formData.invoiceNumber.trim()) { toast.error("Invoice number is required"); setLoading(false); return; } // Business is optional in the schema, so we don't require it // if (!formData.businessId || formData.businessId.trim() === "") { // toast.error("Please select a business"); // setLoading(false); // return; // } if (formData.items.length === 0) { toast.error("At least one invoice item is required"); setLoading(false); return; } // Validate each item for (let i = 0; i < formData.items.length; i++) { const item = formData.items[i]; if (!item) continue; if (!item.description.trim()) { toast.error(`Item ${i + 1}: Description is required`); setLoading(false); return; } if (item.hours <= 0) { toast.error(`Item ${i + 1}: Hours must be greater than 0`); setLoading(false); return; } if (item.rate <= 0) { toast.error(`Item ${i + 1}: Rate must be greater than 0`); setLoading(false); return; } } // Prepare invoice data const invoiceData = { invoiceNumber: formData.invoiceNumber, businessId: formData.businessId || "", // Ensure it's not undefined clientId: formData.clientId, issueDate: formData.issueDate, dueDate: formData.dueDate, status: formData.status, notes: formData.notes, taxRate: formData.taxRate, items: formData.items.map((item) => ({ date: item.date, description: item.description, hours: item.hours, rate: item.rate, amount: item.hours * item.rate, })), }; if (invoiceId && invoiceId !== "new") { await updateInvoice.mutateAsync({ id: invoiceId, ...invoiceData }); } else { await createInvoice.mutateAsync(invoiceData); } } catch (error) { console.error("Invoice save error:", error); toast.error("Failed to save invoice. Please try again."); } finally { setLoading(false); } }; const handleDelete = () => { setDeleteDialogOpen(true); }; const confirmDelete = () => { if (invoiceId && invoiceId !== "new") { deleteInvoice.mutate({ id: invoiceId }); } }; // Field update functions const updateField = ( field: K, value: FormData[K], ) => { setFormData((prev) => ({ ...prev, [field]: value })); }; // Show loading state if ( !initialized || loadingClients || loadingBusinesses || (invoiceId && invoiceId !== "new" && loadingInvoice) ) { return ; } return ( <>
{invoiceId && invoiceId !== "new" && ( )}
Invoice Details Invoice Items Invoice Details
updateField("issueDate", date ?? new Date()) } className="w-full" />
updateField("dueDate", date ?? new Date()) } className="w-full" />
updateField("taxRate", value)} min={0} max={100} step={1} suffix="%" width="full" />
updateField("defaultHourlyRate", value) } min={0} step={1} prefix="$" width="full" />