From 70d5dd10614e3b355104389bc9ab01c6965dc472 Mon Sep 17 00:00:00 2001 From: dhinesh-m24 Date: Mon, 9 Feb 2026 17:06:48 +0530 Subject: [PATCH] feat: Implement Invoice editor to create or modify invoices --- .../finance/invoices/InvoiceEditor.tsx | 1318 +++++++++++++++++ .../features/finance/invoices/InvoiceList.tsx | 4 +- apps/web/src/routes.tsx | 6 +- 3 files changed, 1324 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/features/finance/invoices/InvoiceEditor.tsx diff --git a/apps/web/src/features/finance/invoices/InvoiceEditor.tsx b/apps/web/src/features/finance/invoices/InvoiceEditor.tsx new file mode 100644 index 00000000..e4d48597 --- /dev/null +++ b/apps/web/src/features/finance/invoices/InvoiceEditor.tsx @@ -0,0 +1,1318 @@ +import { Badge } from "@/common/components/ui/badge"; +import { Button } from "@/common/components/ui/button"; +import { Card } from "@/common/components/ui/card"; +import { Input } from "@/common/components/ui/input"; +import { Label } from "@/common/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/common/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/common/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/common/components/ui/table"; +import { Textarea } from "@/common/components/ui/textarea"; +import { useToast } from "@/common/components/ui/use-toast"; +import { InvoiceStatus, InovicePaymentTerms } from "@/dataconnect-generated"; +import { + useCreateInvoice, + useCreateInvoiceTemplate, + useDeleteInvoiceTemplate, + useGetInvoiceById, + useListBusinesses, + useListInvoices, + useListInvoiceTemplates, + useListOrders, + useListStaff, + useListVendorRates, + useUpdateInvoice +} from "@/dataconnect-generated/react"; +import { dataConnect } from "@/features/auth/firebase"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { addDays, format, parseISO } from "date-fns"; +import { ArrowLeft, Building2, Calendar, Clock, FileText, Mail, Phone, Plus, Trash2, User } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; + +// Helper to convert 24h to 12h format +const convertTo12Hour = (time24?: string) => { + if (!time24) return "09:00 AM"; + const [hours, minutes] = time24.split(':'); + const hour = parseInt(hours); + const ampm = hour >= 12 ? 'PM' : 'AM'; + const hour12 = hour % 12 || 12; + return `${String(hour12).padStart(2, '0')}:${minutes} ${ampm}`; +}; + +export default function InvoiceEditor() { + const navigate = useNavigate(); + const { id: pathId } = useParams<{ id: string }>(); + const { toast } = useToast(); + const [searchParams] = useSearchParams(); + + const invoiceId = searchParams.get('id'); + const disputedIndices = searchParams.get('disputed')?.split(',').map(Number).filter(n => !isNaN(n)) || []; + + const effectiveInvoiceId = invoiceId || (pathId !== 'new' ? pathId : null); + const isEdit = !!effectiveInvoiceId; + + // Data Connect Queries + const { data: invoicesData } = useListInvoices(dataConnect); + const invoices = invoicesData?.invoices || []; + + const { data: eventsData } = useListOrders(dataConnect); + const events = eventsData?.orders || []; + + const { data: businessesData } = useListBusinesses(dataConnect); + const businesses = businessesData?.businesses || []; + + const { data: vendorRatesData } = useListVendorRates(dataConnect); + const vendorRates = vendorRatesData?.vendorRates || []; + + const { data: staffData } = useListStaff(dataConnect); + const staffDirectory = staffData?.staffs || []; + + const { data: templatesData, refetch: refetchTemplates } = useListInvoiceTemplates(dataConnect); + const templates = templatesData?.invoiceTemplates || []; + + const { data: currentInvoiceData } = useGetInvoiceById(dataConnect, { id: effectiveInvoiceId || "" }, { enabled: isEdit && !!effectiveInvoiceId }); + const existingInvoice = currentInvoiceData?.invoice; + + const [selectedClientId, setSelectedClientId] = useState(""); + + useEffect(() => { + if (existingInvoice) { + setSelectedClientId(existingInvoice.businessId); + } + }, [existingInvoice]); + + // Generate sequential invoice number based on client prefix + const generateInvoiceNumber = (clientName: string, existingInvoices: any[]) => { + const extractCompanyName = (name: string) => { + if (!name) return ''; + return name.split(/\s*[-–]\s*/)[0].trim(); + }; + + const generatePrefix = (name: string) => { + if (!name) return 'INV'; + const companyName = extractCompanyName(name); + const words = companyName.trim().split(/\s+/).filter(w => w.length > 0); + if (words.length === 0) return 'INV'; + if (words.length === 1) { + return words[0].substring(0, 4).toUpperCase(); + } else { + return words.slice(0, 4).map(w => w[0]).join('').toUpperCase(); + } + }; + + const prefix = generatePrefix(clientName); + + const existingNumbers = (existingInvoices || []) + .filter(inv => inv.invoiceNumber?.startsWith(prefix + '-')) + .map(inv => { + const match = inv.invoiceNumber.match(new RegExp(`^${prefix}-(\\d+)$`)); + return match ? parseInt(match[1]) : 0; + }) + .filter(n => !isNaN(n)); + + const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1001; + return `${prefix}-${nextNumber}`; + }; + + const [formData, setFormData] = useState({ + invoice_number: "", + event_id: "", + event_name: "", + invoice_date: format(new Date(), 'yyyy-MM-dd'), + due_date: format(addDays(new Date(), 45), 'yyyy-MM-dd'), + payment_terms: "NET_45", + hub: "", + manager: "", + vendor_id: "", + department: "", + po_reference: "", + from_company: { + name: "KROW Workforce", + address: "848 E Gish Rd Ste 1, San Jose, CA 95112", + phone: "(408) 936-0180", + email: "billing@krowworkforce.com" + }, + to_company: { + name: "", + phone: "", + email: "", + address: "", + manager_name: "", + hub_name: "", + vendor_id: "" + }, + staff_entries: [], + charges: [], + other_charges: 0, + notes: "", + }); + + useEffect(() => { + if (existingInvoice) { + setFormData({ + invoice_number: existingInvoice.invoiceNumber, + event_id: existingInvoice.orderId || "", + event_name: existingInvoice.order?.eventName || "", + invoice_date: existingInvoice.issueDate ? format(parseISO(existingInvoice.issueDate as string), 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd'), + due_date: existingInvoice.dueDate ? format(parseISO(existingInvoice.dueDate as string), 'yyyy-MM-dd') : format(addDays(new Date(), 45), 'yyyy-MM-dd'), + payment_terms: existingInvoice.paymentTerms || "NET_45", + hub: existingInvoice.hub || "", + manager: existingInvoice.managerName || "", + vendor_id: existingInvoice.vendorNumber || "", + department: existingInvoice.order?.deparment || "", + po_reference: existingInvoice.order?.poReference || "", + from_company: { + name: existingInvoice.vendor?.companyName || "KROW Workforce", + address: existingInvoice.vendor?.address || "848 E Gish Rd Ste 1, San Jose, CA 95112", + phone: existingInvoice.vendor?.phone || "(408) 936-0180", + email: existingInvoice.vendor?.email || "billing@krowworkforce.com" + }, + to_company: { + name: existingInvoice.business?.businessName || "", + phone: existingInvoice.business?.phone || "", + email: existingInvoice.business?.email || "", + address: existingInvoice.business?.address || "", + manager_name: existingInvoice.business?.contactName || "", + hub_name: existingInvoice.hub || "", + vendor_id: existingInvoice.vendorNumber || "" + }, + staff_entries: existingInvoice.roles || [], + charges: existingInvoice.charges || [], + other_charges: existingInvoice.otherCharges || 0, + notes: existingInvoice.notes || "", + }); + } else if (invoices.length > 0 && !formData.invoice_number) { + setFormData((prev: any) => ({ ...prev, invoice_number: generateInvoiceNumber('', invoices) })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existingInvoice, invoices]); + + const [timePickerOpen, setTimePickerOpen] = useState(null); + const [selectedTime, setSelectedTime] = useState({ hours: "09", minutes: "00", period: "AM" }); + + const getRateForPosition = (position: string) => { + const selectedBusiness = businesses.find(b => b.id === selectedClientId); + if (!selectedBusiness || !position) return 0; + + const businessName = selectedBusiness.businessName || ""; + const extractCompanyName = (name: string) => { + if (!name) return ''; + return name.split(/\s*[-–]\s*/)[0].trim(); + }; + const mainCompanyName = extractCompanyName(businessName); + + // Logic similar to MVP + const clientSpecificRate = vendorRates.find(rate => + rate.roleName?.toLowerCase() === position.toLowerCase() && + rate.client_name === businessName // This field might be different in Data Connect, checking listVendorRates output + ); + + if (clientSpecificRate) return clientSpecificRate.clientRate || 0; + + const defaultRate = vendorRates.find(rate => + rate.roleName?.toLowerCase() === position.toLowerCase() + ); + + return defaultRate?.clientRate || 0; + }; + + const handleClientSelect = (clientId: string) => { + setSelectedClientId(clientId); + const selectedBusiness = businesses.find(b => b.id === clientId); + + if (selectedBusiness) { + const hubName = selectedBusiness.hubBuilding || ""; + const newInvoiceNumber = generateInvoiceNumber(selectedBusiness.businessName, invoices); + + setFormData((prev: any) => ({ + ...prev, + invoice_number: isEdit ? prev.invoice_number : newInvoiceNumber, + to_company: { + name: selectedBusiness.businessName || "", + phone: selectedBusiness.phone || "", + email: selectedBusiness.email || "", + address: selectedBusiness.address || "", + manager_name: selectedBusiness.contactName || "", + hub_name: hubName, + vendor_id: "" // erp_vendor_id was not in business query, assuming empty for now + }, + hub: hubName, + manager: selectedBusiness.contactName || "", + department: "", + po_reference: "" + })); + } + }; + + const handleImportFromEvent = (event: any) => { + const staffEntries = (event.assignedStaff || []).map((staff: any) => { + const shift = (event.shifts as any[])?.[0]; + const role = shift?.roles?.find((r: any) => r.role === staff.role) || shift?.roles?.[0]; + + return { + name: staff.fullName || staff.staff_name || "", + date: event.date ? format(parseISO(event.date as string), 'MM/dd/yyyy') : format(new Date(), 'MM/dd/yyyy'), + position: staff.role || role?.role || "", + check_in: role?.start_time ? convertTo12Hour(role.start_time) : "09:00 AM", + check_out: role?.end_time ? convertTo12Hour(role.end_time) : "05:00 PM", + lunch: role?.break_minutes || 30, + worked_hours: role?.hours || 8, + regular_hours: Math.min(role?.hours || 8, 8), + ot_hours: Math.max(0, (role?.hours || 8) - 8), + dt_hours: 0, + rate: role?.cost_per_hour || getRateForPosition(staff.role || '') || 0, + regular_value: 0, + ot_value: 0, + dt_value: 0, + total: 0 + }; + }); + + staffEntries.forEach((entry: any) => { + entry.regular_value = entry.regular_hours * entry.rate; + entry.ot_value = entry.ot_hours * entry.rate * 1.5; + entry.dt_value = entry.dt_hours * entry.rate * 2; + entry.total = entry.regular_value + entry.ot_value + entry.dt_value; + }); + + const business = businesses.find(b => b.id === event.businessId); + + setFormData((prev: any) => ({ + ...prev, + event_id: event.id, + event_name: event.eventName || "", + hub: event.teamHub?.hubName || "", + manager: event.business?.contactName || "", + po_reference: event.poReference || "", + to_company: business ? { + name: business.businessName || "", + phone: business.phone || "", + email: business.email || "", + address: business.address || "", + manager_name: business.contactName || "", + hub_name: event.teamHub?.hubName || "", + vendor_id: "" + } : { + ...prev.to_company, + name: event.business?.businessName || prev.to_company.name, + hub_name: event.teamHub?.hubName || "" + }, + staff_entries: staffEntries.length > 0 ? staffEntries : prev.staff_entries + })); + + if (business) { + setSelectedClientId(business.id); + } + + toast({ + title: "✅ Event Imported", + description: `Imported ${staffEntries.length} staff entries from ${event.eventName}`, + }); + }; + + const handleDuplicateInvoice = (invoice: any) => { + const newStaffEntries = (invoice.roles || []).map((entry: any) => ({ + ...entry, + date: format(new Date(), 'MM/dd/yyyy') + })); + + const newInvoiceNumber = generateInvoiceNumber(invoice.business?.businessName || '', invoices); + + setFormData({ + invoice_number: newInvoiceNumber, + event_id: "", + event_name: invoice.order?.eventName || "", + invoice_date: format(new Date(), 'yyyy-MM-dd'), + due_date: format(addDays(new Date(), 45), 'yyyy-MM-dd'), + payment_terms: invoice.paymentTerms || "NET_45", + hub: invoice.hub || "", + manager: invoice.managerName || "", + vendor_id: invoice.vendorNumber || "", + department: invoice.order?.deparment || "", + po_reference: invoice.order?.poReference || "", + from_company: { + name: invoice.vendor?.companyName || formData.from_company.name, + address: invoice.vendor?.address || formData.from_company.address, + phone: invoice.vendor?.phone || formData.from_company.phone, + email: invoice.vendor?.email || formData.from_company.email, + }, + to_company: { + name: invoice.business?.businessName || "", + phone: invoice.business?.phone || "", + email: invoice.business?.email || "", + address: invoice.business?.address || "", + manager_name: invoice.business?.contactName || "", + hub_name: invoice.hub || "", + vendor_id: invoice.vendorNumber || "" + }, + staff_entries: newStaffEntries, + charges: invoice.charges || [], + other_charges: invoice.otherCharges || 0, + notes: invoice.notes || "", + }); + + if (invoice.businessId) { + setSelectedClientId(invoice.businessId); + } + + toast({ + title: "✅ Invoice Duplicated", + description: `Copied from ${invoice.invoiceNumber} - update dates and details as needed`, + }); + }; + + const { mutate: createTemplate } = useCreateInvoiceTemplate(dataConnect); + const { mutate: deleteTemplate } = useDeleteInvoiceTemplate(dataConnect); + + const handleUseTemplate = (template: any) => { + const newStaffEntries = (template.roles || []).map((entry: any) => ({ + ...entry, + name: "", + date: format(new Date(), 'MM/dd/yyyy'), + worked_hours: 0, + regular_hours: 0, + ot_hours: 0, + dt_hours: 0, + regular_value: 0, + ot_value: 0, + dt_value: 0, + total: 0 + })); + + const newInvoiceNumber = generateInvoiceNumber(template.business?.businessName || '', invoices); + + setFormData((prev: any) => ({ + ...prev, + invoice_number: newInvoiceNumber, + invoice_date: format(new Date(), 'yyyy-MM-dd'), + due_date: format(addDays(new Date(), 45), 'yyyy-MM-dd'), + payment_terms: template.paymentTerms || "NET_45", + hub: template.hub || "", + department: "", + po_reference: template.order?.poReference || "", + from_company: { + name: template.vendor?.companyName || prev.from_company.name, + address: template.vendor?.address || prev.from_company.address, + phone: template.vendor?.phone || prev.from_company.phone, + email: template.vendor?.email || prev.from_company.email, + }, + to_company: { + name: template.business?.businessName || "", + phone: template.business?.phone || "", + email: template.business?.email || "", + address: template.business?.address || "", + manager_name: template.business?.contactName || "", + hub_name: template.hub || "", + vendor_id: template.vendorNumber || "" + }, + staff_entries: newStaffEntries, + charges: template.charges || [], + notes: template.notes || "", + })); + + if (template.businessId) { + setSelectedClientId(template.businessId); + } + + toast({ + title: "✅ Template Applied", + description: `Applied "${template.name}" - fill in staff names and times`, + }); + }; + + const handleSaveTemplate = async (templateName: string) => { + const selectedBusiness = businesses.find(b => b.id === selectedClientId); + + await createTemplate({ + name: templateName, + ownerId: "00000000-0000-0000-0000-000000000000", // placeholder, usually from auth + businessId: selectedClientId || undefined, + vendorId: "00000000-0000-0000-0000-000000000000", // placeholder + paymentTerms: formData.payment_terms as InovicePaymentTerms, + invoiceNumber: formData.invoice_number, + issueDate: new Date(formData.invoice_date).toISOString(), + dueDate: new Date(formData.due_date).toISOString(), + hub: formData.hub, + managerName: formData.manager, + roles: formData.staff_entries, + charges: formData.charges, + otherCharges: parseFloat(formData.other_charges) || 0, + subtotal: totals.subtotal, + amount: totals.grandTotal, + notes: formData.notes, + staffCount: formData.staff_entries.length, + chargesCount: formData.charges.length, + }); + + refetchTemplates(); + + toast({ + title: "✅ Template Saved", + description: `"${templateName}" can now be reused for future invoices`, + }); + }; + + const handleDeleteTemplate = async (templateId: string) => { + await deleteTemplate({ id: templateId }); + refetchTemplates(); + toast({ + title: "Template Deleted", + description: "Template has been removed", + }); + }; + + const parseTimeToMinutes = (timeStr: string) => { + if (!timeStr || timeStr === "hh:mm") return null; + const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); + if (!match) return null; + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const period = match[3].toUpperCase(); + if (period === "PM" && hours !== 12) hours += 12; + if (period === "AM" && hours === 12) hours = 0; + return hours * 60 + minutes; + }; + + const calculateWorkedHours = (checkIn: string, checkOut: string, lunch: string | number) => { + const startMinutes = parseTimeToMinutes(checkIn); + const endMinutes = parseTimeToMinutes(checkOut); + if (startMinutes === null || endMinutes === null) return 0; + let totalMinutes = endMinutes - startMinutes; + if (totalMinutes < 0) totalMinutes += 24 * 60; + + if (lunch !== "NB" && (typeof lunch === 'number' ? lunch : parseInt(lunch)) >= 20) { + totalMinutes -= (typeof lunch === 'number' ? lunch : parseInt(lunch)); + } + return Math.max(0, totalMinutes / 60); + }; + + const calculateBillableHours = (checkIn: string, checkOut: string, lunch: string | number) => { + const startMinutes = parseTimeToMinutes(checkIn); + const endMinutes = parseTimeToMinutes(checkOut); + if (startMinutes === null || endMinutes === null) return { total: 0, nbPenalty: 0 }; + let totalMinutes = endMinutes - startMinutes; + if (totalMinutes < 0) totalMinutes += 24 * 60; + + let nbPenalty = 0; + if (lunch === "NB") { + nbPenalty = 1; + } else if ((typeof lunch === 'number' ? lunch : parseInt(lunch)) >= 20) { + totalMinutes -= (typeof lunch === 'number' ? lunch : parseInt(lunch)); + } + return { total: Math.max(0, totalMinutes / 60), nbPenalty }; + }; + + const handlePositionChange = (index: number, position: string) => { + const rate = getRateForPosition(position); + const newEntries = [...formData.staff_entries]; + newEntries[index] = { + ...newEntries[index], + position: position, + rate: rate || newEntries[index].rate + }; + + const entry = newEntries[index]; + entry.regular_value = (entry.regular_hours || 0) * (entry.rate || 0); + entry.ot_value = (entry.ot_hours || 0) * (entry.rate || 0) * 1.5; + entry.dt_value = (entry.dt_hours || 0) * (entry.rate || 0) * 2; + entry.total = entry.regular_value + entry.ot_value + entry.dt_value; + + setFormData({ ...formData, staff_entries: newEntries }); + }; + + const handleStaffChange = (index: number, field: string, value: any) => { + const newEntries = [...formData.staff_entries]; + newEntries[index] = { ...newEntries[index], [field]: value }; + + const entry = newEntries[index]; + + if (['check_in', 'check_out', 'lunch'].includes(field)) { + const workedHours = calculateWorkedHours(entry.check_in, entry.check_out, entry.lunch); + const { total: billableHours, nbPenalty } = calculateBillableHours(entry.check_in, entry.check_out, entry.lunch); + entry.worked_hours = parseFloat(workedHours.toFixed(2)); + + if (billableHours <= 8) { + entry.regular_hours = parseFloat(billableHours.toFixed(2)); + entry.ot_hours = 0; + entry.dt_hours = 0; + } else if (billableHours <= 12) { + entry.regular_hours = 8; + entry.ot_hours = parseFloat((billableHours - 8).toFixed(2)); + entry.dt_hours = 0; + } else { + entry.regular_hours = 8; + entry.ot_hours = 4; + entry.dt_hours = parseFloat((billableHours - 12).toFixed(2)); + } + + if (nbPenalty > 0) { + entry.regular_hours = parseFloat((entry.regular_hours + nbPenalty).toFixed(2)); + } + } + + if (['check_in', 'check_out', 'lunch', 'worked_hours', 'regular_hours', 'ot_hours', 'dt_hours', 'rate'].includes(field)) { + entry.regular_value = (entry.regular_hours || 0) * (entry.rate || 0); + entry.ot_value = (entry.ot_hours || 0) * (entry.rate || 0) * 1.5; + entry.dt_value = (entry.dt_hours || 0) * (entry.rate || 0) * 2; + entry.total = entry.regular_value + entry.ot_value + entry.dt_value; + } + + setFormData({ ...formData, staff_entries: newEntries }); + }; + + const handleChargeChange = (index: number, field: string, value: any) => { + const newCharges = [...formData.charges]; + newCharges[index] = { ...newCharges[index], [field]: value }; + + if (['qty', 'rate'].includes(field)) { + newCharges[index].price = (newCharges[index].qty || 0) * (newCharges[index].rate || 0); + } + + setFormData({ ...formData, charges: newCharges }); + }; + + const handleRemoveStaff = (index: number) => { + setFormData({ + ...formData, + staff_entries: formData.staff_entries.filter((_: any, i: number) => i !== index) + }); + }; + + const handleRemoveCharge = (index: number) => { + setFormData({ + ...formData, + charges: formData.charges.filter((_: any, i: number) => i !== index) + }); + }; + + const handleTimeSelect = (entryIndex: number, field: string) => { + const timeString = `${selectedTime.hours}:${selectedTime.minutes} ${selectedTime.period}`; + handleStaffChange(entryIndex, field, timeString); + setTimePickerOpen(null); + }; + + const handleAddStaffEntry = () => { + setFormData({ + ...formData, + staff_entries: [ + ...formData.staff_entries, + { + name: "", + date: format(new Date(), 'MM/dd/yyyy'), + position: "", + check_in: "hh:mm", + lunch: 0, + check_out: "", + worked_hours: 0, + regular_hours: 0, + ot_hours: 0, + dt_hours: 0, + rate: 0, + regular_value: 0, + ot_value: 0, + dt_value: 0, + total: 0 + } + ] + }); + }; + + const handleAddCharge = () => { + setFormData({ + ...formData, + charges: [ + ...formData.charges, + { + name: "Gas Compensation", + qty: 1, + rate: 0, + price: 0 + } + ] + }); + }; + + const calculateTotals = () => { + const staffTotal = formData.staff_entries.reduce((sum: number, entry: any) => sum + (entry.total || 0), 0); + const chargesTotal = formData.charges.reduce((sum: number, charge: any) => sum + (charge.price || 0), 0); + const subtotal = staffTotal + chargesTotal; + const otherCharges = parseFloat(formData.other_charges) || 0; + const grandTotal = subtotal + otherCharges; + + return { subtotal, otherCharges, grandTotal }; + }; + + const totals = calculateTotals(); + + const { mutateAsync: createInvoice, isPending: creating } = useCreateInvoice(dataConnect); + const { mutateAsync: updateInvoice, isPending: updating } = useUpdateInvoice(dataConnect); + + const handleSave = async (statusOverride?: string) => { + const data = formData; + const staffTotal = data.staff_entries.reduce((sum: number, entry: any) => sum + (entry.total || 0), 0); + const chargesTotal = data.charges.reduce((sum: number, charge: any) => sum + ((charge.qty * charge.rate) || 0), 0); + const subtotal = staffTotal + chargesTotal; + const total = subtotal + (parseFloat(data.other_charges) || 0); + + const invoiceStatus = (statusOverride || (existingInvoice?.status === InvoiceStatus.DISPUTED ? InvoiceStatus.PENDING_REVIEW : (existingInvoice?.status || InvoiceStatus.DRAFT))) as InvoiceStatus; + + const payload = { + status: invoiceStatus, + vendorId: "00000000-0000-0000-0000-000000000000", // placeholder + businessId: selectedClientId || "00000000-0000-0000-0000-000000000000", // placeholder + orderId: data.event_id || "00000000-0000-0000-0000-000000000000", // placeholder + paymentTerms: data.payment_terms as InovicePaymentTerms, + invoiceNumber: data.invoice_number, + issueDate: new Date(data.invoice_date).toISOString(), + dueDate: new Date(data.due_date).toISOString(), + hub: data.hub, + managerName: data.manager, + vendorNumber: data.vendor_id, + roles: data.staff_entries, + charges: data.charges, + subtotal: subtotal, + otherCharges: parseFloat(data.other_charges) || 0, + amount: total, + notes: data.notes, + staffCount: data.staff_entries.length, + chargesCount: data.charges.length, + }; + + try { + if (effectiveInvoiceId) { + await updateInvoice({ id: effectiveInvoiceId, ...payload }); + } else { + await createInvoice(payload); + } + + toast({ + title: isEdit ? "✅ Invoice Updated" : "✅ Invoice Created", + description: isEdit ? "Invoice has been updated successfully" : "Invoice has been created successfully", + }); + navigate("/invoices"); + } catch (error) { + console.error("Error saving invoice:", error); + toast({ + title: "❌ Error", + description: "Failed to save invoice. Please try again.", + variant: "destructive" + }); + } + }; + + const isSaving = creating || updating; + + // Mock email sending + const handleSendToClient = async () => { + const invoiceData = { ...formData, status: InvoiceStatus.PENDING_REVIEW }; + await handleSave(InvoiceStatus.PENDING_REVIEW); + + // Simulate email + await new Promise(resolve => setTimeout(resolve, 1000)); + + toast({ + title: "📧 Email Sent", + description: `Invoice emailed to ${invoiceData.to_company?.email || 'client'}`, + }); + }; + + return ( + + {existingInvoice?.status || "Draft"} + + } + backAction={ + + } + > +
+ {/* Dispute Alert Banner */} + {existingInvoice?.status === InvoiceStatus.DISPUTED && disputedIndices.length > 0 && ( +
+
+
+ ! +
+
+

Disputed Items Highlighted

+

+ {disputedIndices.length} line item(s) have been disputed by the client. + Reason: {existingInvoice.dispute_reason || "Not specified"} +

+ {existingInvoice.dispute_details && ( +

+ "{existingInvoice.dispute_details}" +

+ )} +

+ Please review and correct the highlighted rows, then resubmit for approval. +

+
+
+
+ )} + +
+ {/* Quick Actions - Simplified */} + {!isEdit && ( +
+
+
+ +
+
+

Import from Event

+

Quickly fill invoice from a completed event's shifts

+
+
+ +
+ )} + + {/* Client Selection - Top Section */} + {!isEdit && ( +
+
+
+ +
+
+

Select Client

+

Choose a client to auto-fill company details and rates

+
+
+ +
+ )} + + {/* Invoice Details Header */} +
+
+
+
+ +
+
+

Invoice Details

+
+ + Event: {formData.event_name || "Internal Support"} +
+
+
+ +
+
Invoice Number
+
{formData.invoice_number}
+
+ +
+
+ + setFormData({ ...formData, invoice_date: e.target.value })} + className="mt-1" + /> +
+
+ + setFormData({ ...formData, due_date: e.target.value })} + className="mt-1" + /> +
+
+ +
+
+ + setFormData({ ...formData, hub: e.target.value })} + placeholder="Hub" + className="mt-1" + /> +
+
+ + setFormData({ ...formData, manager: e.target.value })} + placeholder="Manager Name" + className="mt-1" + /> +
+
+ +
+ + setFormData({ ...formData, vendor_id: e.target.value })} + placeholder="Vendor #" + className="mt-1" + /> +
+
+ +
+
+ +
+ {[ + { label: "30 days", value: "NET_30", days: 30 }, + { label: "45 days", value: "NET_45", days: 45 }, + { label: "60 days", value: "NET_60", days: 60 } + ].map(term => ( + setFormData({ + ...formData, + payment_terms: term.value, + due_date: format(addDays(new Date(formData.invoice_date), term.days), 'yyyy-MM-dd') + })} + > + {term.label} + + ))} +
+
+ +
+
+ Department: + setFormData({ ...formData, department: e.target.value })} + placeholder="Dept Code" + className="h-9 w-full md:w-48" + /> +
+
+ PO#: + setFormData({ ...formData, po_reference: e.target.value })} + placeholder="PO Number" + className="h-9 w-full md:w-48" + /> +
+
+
+
+ + {/* From and To - Compact */} +
+ {/* From Company */} +
+
+
F
+ From (Vendor) +
+ {/* Inputs for from_company */} +
+ {['name', 'address', 'phone', 'email'].map(field => ( +
+ setFormData({ ...formData, from_company: { ...formData.from_company, [field]: e.target.value } })} + className="w-full text-sm font-medium text-primary-text bg-transparent border-0 border-b border-transparent hover:border-border focus:border-primary focus:ring-0 px-0 py-1 transition-all placeholder:text-muted-foreground/50" + placeholder={field.charAt(0).toUpperCase() + field.slice(1)} + /> + {field === 'email' && } + {field === 'phone' && } +
+ ))} +
+
+ + {/* To Company */} +
+
+
T
+ To (Client) +
+
+ {['name', 'hub_name', 'department', 'po_reference', 'address', 'phone', 'email'].map(field => ( +
+ { + if (field === 'department' || field === 'po_reference') { + setFormData({ ...formData, [field]: e.target.value }); + } else { + setFormData({ ...formData, to_company: { ...formData.to_company, [field]: e.target.value } }); + } + }} + placeholder={field.replace('_', ' ').charAt(0).toUpperCase() + field.slice(1)} + className="w-full text-sm text-secondary-text bg-transparent border-0 border-b border-transparent hover:border-border focus:border-primary focus:ring-0 px-0 py-1 transition-all placeholder:text-muted-foreground/50" + /> +
+ ))} +
+
+
+ + {/* Staff Table */} +
+
+
+
+ +
+
+

Staff Entries

+

{formData.staff_entries.length} entries

+
+
+ +
+ + +
+ + + + # + Name + Position + ClockIn + Lunch + Checkout + Wrkd + Reg + OT + DT + Rate + Reg$ + OT$ + DT$ + Total + + + + + {formData.staff_entries.map((entry: any, idx: number) => ( + + {idx + 1} + + + + + + + { }} + /> +
+ {staffDirectory.map(staff => ( + + ))} +
+
+
+
+ + + + + setTimePickerOpen(open ? `checkin-${idx}` : null)}> + + + + +
+
+ + +
+ {['AM', 'PM'].map(p => ( + + ))} +
+
+ +
+
+
+
+ + + + + setTimePickerOpen(open ? `checkout-${idx}` : null)}> + + + + +
+
+ + +
+ {['AM', 'PM'].map(p => ( + + ))} +
+
+ +
+
+
+
+ + handleStaffChange(idx, 'worked_hours', parseFloat(e.target.value))} className="h-8 w-14 text-xs px-1 text-center font-medium bg-muted/20 border-0" /> + + + handleStaffChange(idx, 'regular_hours', parseFloat(e.target.value))} className="h-8 w-12 text-xs px-1 text-center" /> + + + handleStaffChange(idx, 'ot_hours', parseFloat(e.target.value))} className="h-8 w-12 text-xs px-1 text-center" /> + + + handleStaffChange(idx, 'dt_hours', parseFloat(e.target.value))} className="h-8 w-12 text-xs px-1 text-center" /> + + + handleStaffChange(idx, 'rate', parseFloat(e.target.value))} className="h-8 w-14 text-xs px-1 text-right" /> + + ${entry.regular_value?.toFixed(0) || "0"} + ${entry.ot_value?.toFixed(0) || "0"} + ${entry.dt_value?.toFixed(0) || "0"} + ${entry.total?.toFixed(2) || "0"} + + + +
+ ))} +
+
+
+
+
+ + {/* Charges */} +
+
+
+
+ 💰 +
+
+

Additional Charges

+

{formData.charges.length} charges

+
+
+ +
+ + +
+ + + + # + Name + QTY + Rate + Price + Actions + + + + {formData.charges.map((charge: any, idx: number) => ( + + {idx + 1} + + handleChargeChange(idx, 'name', e.target.value)} className="h-8 max-w-md" /> + + + handleChargeChange(idx, 'qty', parseFloat(e.target.value))} className="h-8 w-20" /> + + + handleChargeChange(idx, 'rate', parseFloat(e.target.value))} className="h-8 w-24" /> + + ${charge.price?.toFixed(2) || "0.00"} + + + + + ))} + +
+
+
+
+ + {/* Totals */} +
+
+
+
+ Sub total: + ${totals.subtotal.toFixed(2)} +
+
+ Other charges: + setFormData({ ...formData, other_charges: e.target.value })} className="h-8 w-32 text-right bg-white" /> +
+
+ Grand total: + ${totals.grandTotal.toFixed(2)} +
+
+
+
+ + {/* Notes */} +
+ +