"use client"; import * as React from "react"; import { useState, useEffect } from "react"; import { api } from "~/trpc/react"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; import { Button } from "~/components/ui/button"; import { Label } from "~/components/ui/label"; import { DatePicker } from "~/components/ui/date-picker"; import { Badge } from "~/components/ui/badge"; import { Separator } from "~/components/ui/separator"; import { SearchableSelect } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { toast } from "sonner"; import { Calendar, FileText, User, Plus, Trash2, DollarSign, Clock, Edit3, Save, X, AlertCircle, Building, } from "lucide-react"; import { useRouter } from "next/navigation"; import { format } from "date-fns"; import { FormSkeleton } from "~/components/ui/skeleton"; import { EditableInvoiceItems } from "~/components/editable-invoice-items"; const STATUS_OPTIONS = [ { value: "draft", label: "Draft", }, { value: "sent", label: "Sent", }, { value: "paid", label: "Paid", }, { value: "overdue", label: "Overdue", }, ] as const; interface InvoiceFormProps { invoiceId?: string; } export function InvoiceForm({ invoiceId }: InvoiceFormProps) { const router = useRouter(); 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" as "draft" | "sent" | "paid" | "overdue", notes: "", taxRate: 0, items: [ { id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: 0, amount: 0, }, ], }); const [loading, setLoading] = useState(false); const [defaultRate, setDefaultRate] = useState(0); // Fetch clients and businesses for dropdowns const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery(); const { data: businesses, isLoading: loadingBusinesses } = api.businesses.getAll.useQuery(); // Fetch existing invoice data if editing const { data: existingInvoice, isLoading: loadingInvoice } = api.invoices.getById.useQuery({ id: invoiceId! }, { enabled: !!invoiceId }); // Populate form with existing data when editing React.useEffect(() => { if (existingInvoice && invoiceId) { setFormData({ 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" | "overdue", notes: existingInvoice.notes ?? "", taxRate: existingInvoice.taxRate, 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, })) || [ { id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: 0, amount: 0, }, ], }); // Set default rate from first item if (existingInvoice.items?.[0]) { setDefaultRate(existingInvoice.items[0].rate); } } }, [existingInvoice, invoiceId]); // 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]); // Add new item const addItem = () => { setFormData((prev) => ({ ...prev, items: [ ...prev.items, { id: crypto.randomUUID(), date: new Date(), description: "", hours: 0, rate: defaultRate, amount: 0, }, ], })); }; // Remove item const removeItem = (idx: number) => { if (formData.items.length > 1) { setFormData((prev) => ({ ...prev, items: prev.items.filter((_, i) => i !== idx), })); } }; // Apply default rate to all items const applyDefaultRate = () => { setFormData((prev) => ({ ...prev, items: prev.items.map((item) => ({ ...item, rate: defaultRate, amount: item.hours * defaultRate, })), })); }; // tRPC mutations const createInvoice = api.invoices.create.useMutation({ onSuccess: () => { toast.success("Invoice created successfully"); router.push("/dashboard/invoices"); }, onError: (error) => { toast.error(error.message || "Failed to create invoice"); }, }); const updateInvoice = api.invoices.update.useMutation({ onSuccess: () => { toast.success("Invoice updated successfully"); router.push(`/dashboard/invoices/${invoiceId}`); }, onError: (error) => { toast.error(error.message || "Failed to update invoice"); }, }); // Handle form submit const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Validate form if (!formData.businessId) { toast.error("Please select a business"); return; } if (!formData.clientId) { toast.error("Please select a client"); return; } if (formData.items.some((item) => !item.description.trim())) { toast.error("Please fill in all item descriptions"); return; } if (formData.items.some((item) => item.hours <= 0)) { toast.error("Please enter valid hours for all items"); return; } if (formData.items.some((item) => item.rate <= 0)) { toast.error("Please enter valid rates for all items"); return; } setLoading(true); try { // In the handleSubmit, ensure items are sent in the current array order with no sorting const submitData = { ...formData, items: formData.items.map((item) => ({ date: new Date(item.date), description: item.description, hours: item.hours, rate: item.rate, amount: item.amount, // position will be set by backend based on array order })), }; if (invoiceId) { await updateInvoice.mutateAsync({ id: invoiceId, ...submitData, }); } else { await createInvoice.mutateAsync(submitData); } } finally { setLoading(false); } }; // Show loading state while fetching existing invoice data if (invoiceId && loadingInvoice) { return (
{/* Invoice Details Card Skeleton */}
{Array.from({ length: 6 }).map((_, i) => (
))}
{/* Invoice Items Card Skeleton */}
{/* Items Table Header Skeleton */}
{Array.from({ length: 8 }).map((_, i) => (
))}
{/* Items Skeleton */}
{Array.from({ length: 3 }).map((_, i) => (
{Array.from({ length: 8 }).map((_, j) => (
))}
))}
{/* Form Controls Bar Skeleton */}
); } const selectedClient = clients?.find((c) => c.id === formData.clientId); const selectedBusiness = businesses?.find( (b) => b.id === formData.businessId, ); // Show loading state while fetching clients if (loadingClients) { return (
{/* Invoice Details Card Skeleton */}
{Array.from({ length: 6 }).map((_, i) => (
))}
{/* Invoice Items Card Skeleton */}
{/* Items Table Header Skeleton */}
{Array.from({ length: 8 }).map((_, i) => (
))}
{/* Items Skeleton */}
{Array.from({ length: 3 }).map((_, i) => (
{Array.from({ length: 8 }).map((_, j) => (
))}
))}
{/* Form Controls Bar Skeleton */}
); } return (
{/* Invoice Details Card */} Invoice Details
setFormData((f) => ({ ...f, businessId: value })) } options={ businesses?.map((business) => ({ value: business.id, label: business.name, })) ?? [] } placeholder="Select a business" searchPlaceholder="Search businesses..." disabled={loadingBusinesses} />
setFormData((f) => ({ ...f, clientId: value })) } options={ clients?.map((client) => ({ value: client.id, label: client.name, })) ?? [] } placeholder="Select a client" searchPlaceholder="Search clients..." disabled={loadingClients} />
setFormData((f) => ({ ...f, issueDate: date ?? new Date() })) } placeholder="Select issue date" required />
setFormData((f) => ({ ...f, dueDate: date ?? new Date() })) } placeholder="Select due date" required />
setDefaultRate(parseFloat(e.target.value) || 0) } placeholder="0.00" className="" />
setFormData((f) => ({ ...f, taxRate: parseFloat(e.target.value) || 0, })) } placeholder="0.00" className="" />
{selectedBusiness && (
Business Information

{selectedBusiness.name}

{selectedBusiness.email &&

{selectedBusiness.email}

} {selectedBusiness.phone &&

{selectedBusiness.phone}

} {selectedBusiness.addressLine1 && (

{selectedBusiness.addressLine1}

)} {(selectedBusiness.city ?? selectedBusiness.state ?? selectedBusiness.postalCode) && (

{[ selectedBusiness.city, selectedBusiness.state, selectedBusiness.postalCode, ] .filter(Boolean) .join(", ")}

)}
)} {selectedClient && (
Client Information

{selectedClient.name}

{selectedClient.email &&

{selectedClient.email}

} {selectedClient.phone &&

{selectedClient.phone}

}
)}