"use client"; import React, { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { api } from "~/trpc/react"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; import { NumberInput } from "~/components/ui/number-input"; import { Label } from "~/components/ui/label"; import { Textarea } from "~/components/ui/textarea"; import { PageHeader } from "~/components/layout/page-header"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "~/components/ui/alert-dialog"; import { Badge } from "~/components/ui/badge"; import { Separator } from "~/components/ui/separator"; import { DatePicker } from "~/components/ui/date-picker"; import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { toast } from "sonner"; import { ArrowLeft, Save, Plus, Trash2, FileText, Building, User, Loader2, Send, DollarSign, Hash, Edit3, } from "lucide-react"; interface InvoiceItem { tempId: string; date: Date; description: string; hours: number; rate: number; amount: number; } interface InvoiceFormData { invoiceNumber: string; businessId: string | undefined; clientId: string; issueDate: Date; dueDate: Date; notes: string; taxRate: number; items: InvoiceItem[]; } function InvoiceItemCard({ item, index, onUpdate, onDelete, _isLast, }: { item: InvoiceItem; index: number; onUpdate: ( index: number, field: keyof InvoiceItem, value: string | number | Date, ) => void; onDelete: (index: number) => void; _isLast: boolean; }) { const handleFieldChange = ( field: keyof InvoiceItem, value: string | number | Date, ) => { onUpdate(index, field, value); }; return ( {/* Header with item number and delete */} Item {index + 1} Delete Item Are you sure you want to delete this line item? This action cannot be undone. Cancel onDelete(index)} className="bg-red-600 hover:bg-red-700" > Delete {/* Description */} handleFieldChange("description", e.target.value)} placeholder="Description of work..." className="min-h-[48px] resize-none text-sm" rows={1} /> {/* Date, Hours, Rate, Amount in compact grid */} Date handleFieldChange("date", date ?? new Date()) } className="[&>button]:h-8 [&>button]:text-xs" /> Hours handleFieldChange("hours", value)} min={0} step={0.25} placeholder="0" className="text-xs" /> Rate handleFieldChange("rate", value)} min={0} step={0.25} placeholder="0.00" prefix="$" className="text-xs" /> Amount ${(item.hours * item.rate).toFixed(2)} ); } export default function NewInvoicePage() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); // Initialize form data with defaults const today = new Date(); const thirtyDaysFromNow = new Date(today); thirtyDaysFromNow.setDate(today.getDate() + 30); // Auto-generate invoice number const generateInvoiceNumber = () => { const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const timestamp = Date.now().toString().slice(-4); return `INV-${year}${month}-${timestamp}`; }; const [formData, setFormData] = useState({ invoiceNumber: generateInvoiceNumber(), businessId: undefined, clientId: "", issueDate: today, dueDate: thirtyDaysFromNow, notes: "", taxRate: 0, items: [ { tempId: `item-${Date.now()}`, date: today, description: "", hours: 0, rate: 0, amount: 0, }, ], }); // Floating action bar ref const footerRef = useRef(null); // Queries const { data: clients, isLoading: clientsLoading } = api.clients.getAll.useQuery(); const { data: businesses, isLoading: businessesLoading } = api.businesses.getAll.useQuery(); // Set default business when data loads useEffect(() => { if (businesses && !formData.businessId) { const defaultBusiness = businesses.find((b) => b.isDefault); if (defaultBusiness) { setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id })); } } }, [businesses, formData.businessId]); // Mutations const createInvoice = api.invoices.create.useMutation({ onSuccess: (invoice) => { toast.success("Invoice created successfully"); router.push(`/dashboard/invoices/${invoice.id}`); }, onError: (error) => { toast.error(error.message || "Failed to create invoice"); }, }); const handleItemUpdate = ( index: number, field: keyof InvoiceItem, value: string | number | Date, ) => { const updatedItems = [...formData.items]; const currentItem = updatedItems[index]; if (currentItem) { updatedItems[index] = { ...currentItem, [field]: value }; // Recalculate amount for hours or rate changes if (field === "hours" || field === "rate") { const updatedItem = updatedItems[index]; if (!updatedItem) return; updatedItem.amount = updatedItem.hours * updatedItem.rate; } } setFormData({ ...formData, items: updatedItems }); }; const handleItemDelete = (index: number) => { if (formData.items.length === 1) { toast.error("At least one line item is required"); return; } const updatedItems = formData.items.filter((_, i) => i !== index); setFormData({ ...formData, items: updatedItems }); }; const handleAddItem = () => { const newItem: InvoiceItem = { tempId: `item-${Date.now()}`, date: new Date(), description: "", hours: 0, rate: 0, amount: 0, }; setFormData({ ...formData, items: [...formData.items, newItem], }); }; const handleSaveDraft = async () => { await handleSave("draft"); }; const handleCreateInvoice = async () => { await handleSave("sent"); }; const handleSave = async (status: "draft" | "sent") => { // Validation if (!formData.clientId) { toast.error("Please select a client"); return; } if (formData.items.length === 0) { toast.error("At least one line item is required"); return; } // Check if all items have required fields const invalidItems = formData.items.some( (item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0, ); if (invalidItems) { toast.error("All line items must have description, hours, and rate"); return; } setIsLoading(true); try { await createInvoice.mutateAsync({ ...formData, businessId: formData.businessId ?? undefined, status, }); } finally { setIsLoading(false); } }; const calculateSubtotal = () => { return formData.items.reduce((sum, item) => sum + item.amount, 0); }; const calculateTax = () => { return (calculateSubtotal() * formData.taxRate) / 100; }; const calculateTotal = () => { return calculateSubtotal() + calculateTax(); }; const isFormValid = () => { return ( formData.clientId && formData.items.length > 0 && formData.items.every( (item) => item.description.trim() && item.hours > 0 && item.rate > 0, ) ); }; if (clientsLoading || businessesLoading) { return ( ); } return ( Back to Invoices Back {/* Invoice Header */} Invoice Details Invoice Number {formData.invoiceNumber} setFormData({ ...formData, issueDate: date ?? new Date(), }) } label="Issue Date" required /> setFormData({ ...formData, dueDate: date ?? new Date(), }) } label="Due Date" required /> {/* Business & Client */} Business & Client From Business setFormData({ ...formData, businessId: value || undefined, }) } > {businesses?.map((business) => ( {business.name} {business.isDefault && ( Default )} ))} {(!businesses || businesses.length === 0) && ( No businesses found.{" "} Create one first )} Client * setFormData({ ...formData, clientId: value }) } > {clients?.map((client) => ( {client.name} {client.email} ))} {(!clients || clients.length === 0) && ( No clients found.{" "} Create one first )} {/* Line Items */} Line Items ({formData.items.length}) Add Item {formData.items.map((item, index) => ( ))} {/* Tax & Totals */} Tax & Totals Tax Rate (%) setFormData({ ...formData, taxRate: value, }) } min={0} max={100} step={0.01} placeholder="0.00" suffix="%" /> Notes setFormData({ ...formData, notes: e.target.value }) } placeholder="Payment terms, additional notes..." rows={4} className="resize-none" /> Subtotal: ${calculateSubtotal().toFixed(2)} Tax ({formData.taxRate}%): ${calculateTax().toFixed(2)} Total: ${calculateTotal().toFixed(2)} {/* Action Buttons */} Cancel {isLoading ? ( ) : ( )} Save Draft {isLoading ? ( ) : ( )} Create Invoice Cancel {isLoading ? ( ) : ( )} Save Draft {isLoading ? ( ) : ( )} Create Invoice ); }
No businesses found.{" "} Create one first
No clients found.{" "} Create one first