"use client"; import * as React from "react"; import { useState } 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 { Separator } from "~/components/ui/separator"; import { Textarea } from "~/components/ui/textarea"; import { NumberInput } from "~/components/ui/number-input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { toast } from "sonner"; import { FileText, DollarSign, Clock, Save, Check } from "lucide-react"; import { useRouter } from "next/navigation"; import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { InvoiceLineItems } from "~/components/forms/invoice-line-items"; import { PageHeader } from "~/components/layout/page-header"; 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; } // Custom skeleton for invoice form function InvoiceFormSkeleton() { return (
{/* Form Content */}
{/* Left Column - Content with Tabs */}
{/* Tabs - Match actual TabsList structure */}
{/* Invoice Details Card */}
{/* First row - stacked on mobile */}
{/* Second row */}
{/* Third row */}
{/* Status field */}
{/* Notes field */}
{/* Invoice Items Card */}
{/* Line item skeleton */}
{/* Description */}
{/* Date, Hours, Rate - stacked on mobile */}
{/* Amount display */}
{/* Add item button */}
{/* Right Column - Summary */}
{/* Totals */}
{/* Stats */}
{/* Floating Action Bar Skeleton - Mobile only */}
); } function InvoiceForm({ invoiceId }: InvoiceFormProps) { const router = useRouter(); const utils = api.useUtils(); 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, defaultHourlyRate: 100, items: [ { id: crypto.randomUUID(), date: new Date(), description: "", hours: 1, rate: 100, amount: 100, }, ], }); const [loading, setLoading] = useState(false); // 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, defaultHourlyRate: 100, 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: 1, rate: 100, amount: 100, }, ], }); } }, [existingInvoice, invoiceId]); // Auto-fill default business for new invoices React.useEffect(() => { if (!invoiceId && businesses && !formData.businessId) { const defaultBusiness = businesses.find((b) => b.isDefault); if (defaultBusiness) { setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id })); } } }, [businesses, formData.businessId, invoiceId]); // Update default hourly rate when client changes React.useEffect(() => { if (formData.clientId && clients) { const selectedClient = clients.find((c) => c.id === formData.clientId); if (selectedClient?.defaultHourlyRate) { setFormData((prev) => ({ ...prev, defaultHourlyRate: selectedClient.defaultHourlyRate, })); } } }, [formData.clientId, clients]); // 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: 1, rate: formData.defaultHourlyRate, amount: formData.defaultHourlyRate, }, ], })); }; // Remove item const removeItem = (idx: number) => { if (formData.items.length > 1) { setFormData((prev) => ({ ...prev, items: prev.items.filter((_, i) => i !== idx), })); } }; // Update item const updateItem = ( idx: number, field: string, value: string | number | Date, ) => { setFormData((prev) => ({ ...prev, items: prev.items.map((item, i) => i === idx ? { ...item, [field]: value } : item, ), })); }; // Move item up const moveItemUp = (idx: number) => { if (idx === 0) return; // Already at top setFormData((prev) => { const newItems = [...prev.items]; if (idx > 0 && idx < newItems.length) { const temp = newItems[idx - 1]!; newItems[idx - 1] = newItems[idx]!; newItems[idx] = temp; } return { ...prev, items: newItems }; }); }; // Move item down const moveItemDown = (idx: number) => { if (idx === formData.items.length - 1) return; // Already at bottom setFormData((prev) => { const newItems = [...prev.items]; if (idx >= 0 && idx < newItems.length - 1) { const temp = newItems[idx]!; newItems[idx] = newItems[idx + 1]!; newItems[idx + 1] = temp; } return { ...prev, items: newItems }; }); }; // Reorder items const reorderItems = (newItems: typeof formData.items) => { setFormData((prev) => ({ ...prev, items: newItems, })); }; // tRPC mutations const createInvoice = api.invoices.create.useMutation({ onSuccess: () => { toast.success("Invoice created successfully"); // Invalidate related queries to refresh cache void utils.invoices.getAll.invalidate(); 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"); // Invalidate related queries to refresh cache void utils.invoices.getAll.invalidate(); if (invoiceId) { void utils.invoices.getById.invalidate({ id: invoiceId }); } router.push("/dashboard/invoices"); }, onError: (error) => { console.error("Update invoice error:", error); toast.error(error.message || "Failed to update invoice"); }, }); const updateStatus = api.invoices.updateStatus.useMutation({ onSuccess: () => { toast.success("Status updated successfully"); // Invalidate related queries to refresh cache void utils.invoices.getAll.invalidate(); if (invoiceId) { void utils.invoices.getById.invalidate({ id: invoiceId }); } router.push("/dashboard/invoices"); }, onError: (error) => { console.error("Update status error:", error); toast.error(error.message || "Failed to update status"); }, }); // Check if only status has changed compared to existing invoice const hasOnlyStatusChanged = React.useMemo(() => { if (!existingInvoice || !invoiceId) return false; return ( formData.invoiceNumber === existingInvoice.invoiceNumber && formData.businessId === (existingInvoice.businessId ?? "") && formData.clientId === existingInvoice.clientId && formData.issueDate.getTime() === new Date(existingInvoice.issueDate).getTime() && formData.dueDate.getTime() === new Date(existingInvoice.dueDate).getTime() && formData.status !== existingInvoice.status && formData.notes === (existingInvoice.notes ?? "") && formData.taxRate === existingInvoice.taxRate && JSON.stringify( formData.items.map((item) => ({ date: item.date.getTime(), description: item.description, hours: item.hours, rate: item.rate, })), ) === JSON.stringify( (existingInvoice.items ?? []).map((item) => ({ date: new Date(item.date).getTime(), description: item.description, hours: item.hours, rate: item.rate, })), ) ); }, [formData, existingInvoice, invoiceId]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); try { if (invoiceId && hasOnlyStatusChanged) { // Use dedicated status update mutation for status-only changes console.log("Using status-only update:", { id: invoiceId, status: formData.status, }); await updateStatus.mutateAsync({ id: invoiceId, status: formData.status, }); } else { // Use full update mutation for all other changes const invoiceData = { invoiceNumber: formData.invoiceNumber, businessId: formData.businessId || 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, })), }; console.log("Submitting invoice data:", invoiceData); if (invoiceId) { await updateInvoice.mutateAsync({ id: invoiceId, ...invoiceData }); } else { await createInvoice.mutateAsync(invoiceData); } } } catch (error) { console.error("Error saving invoice:", error); toast.error("Failed to save invoice. Check console for details."); } finally { setLoading(false); } }; // Show loading state if (loadingClients || loadingBusinesses || (invoiceId && loadingInvoice)) { return ; } return ( <>
{/* Form Content */}
{/* Left Column - Content with Tabs */}
Invoice Details Invoice Items {/* Invoice Details */} Invoice Details
{invoiceId && hasOnlyStatusChanged && (
Only status will be updated
)}
setFormData((prev) => ({ ...prev, issueDate: date ?? new Date(), })) } className="w-full" />
setFormData((prev) => ({ ...prev, dueDate: date ?? new Date(), })) } className="w-full" />
setFormData((prev) => ({ ...prev, taxRate: value, })) } min={0} max={100} step={1} suffix="%" width="full" />
setFormData((prev) => ({ ...prev, defaultHourlyRate: value, })) } min={0} step={1} prefix="$" width="full" />