diff --git a/apps/web/src/features/finance/invoices/InvoiceList.tsx b/apps/web/src/features/finance/invoices/InvoiceList.tsx new file mode 100644 index 00000000..ca242b4f --- /dev/null +++ b/apps/web/src/features/finance/invoices/InvoiceList.tsx @@ -0,0 +1,291 @@ +import { Badge } from "@/common/components/ui/badge"; +import { Button } from "@/common/components/ui/button"; +import { Card, CardContent } from "@/common/components/ui/card"; +import { Input } from "@/common/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/common/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/common/components/ui/table"; +import { useListInvoices } from "@/dataconnect-generated/react"; +import { dataConnect } from "@/features/auth/firebase"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import type { RootState } from "@/store/store"; +import { format, isWithinInterval, parseISO } from "date-fns"; +import { FileText, Plus, Search } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +// Map InvoiceStatus enum to display labels and colors +const statusConfig: Record = { + DRAFT: { label: "Draft", className: "bg-slate-100 text-slate-600 border-transparent" }, + PENDING: { label: "Sent", className: "bg-blue-50 text-blue-700 border-blue-200" }, + PENDING_REVIEW: { label: "Pending Review", className: "bg-amber-50 text-amber-700 border-amber-200" }, + APPROVED: { label: "Approved", className: "bg-emerald-50 text-emerald-700 border-emerald-200" }, + DISPUTED: { label: "Disputed", className: "bg-red-50 text-red-700 border-red-200" }, + OVERDUE: { label: "Overdue", className: "bg-rose-50 text-rose-700 border-rose-200" }, + PAID: { label: "Paid", className: "bg-emerald-50 text-emerald-700 border-emerald-200" }, +}; + +export default function InvoiceList() { + const navigate = useNavigate(); + const { user } = useSelector((state: RootState) => state.auth); + + // Filtering state + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + // Fetch invoices using Data Connect + const { data, isLoading } = useListInvoices(dataConnect); + const invoices = data?.invoices || []; + + // Filter invoices based on user role and filters + const filteredInvoices = useMemo(() => { + return invoices.filter((inv) => { + // Role-based access (simplified for Master List) + // If user is client, they should see their invoices. If admin, they see all. + const userRole = user?.userRole?.toUpperCase(); + const isClient = userRole === "CLIENT"; + const isVendor = userRole === "VENDOR"; + + if (isClient && inv.businessId !== user?.uid) return false; + // In a real scenario, we'd match vendorId for vendor users + // if (isVendor && inv.vendorId !== user?.uid) return false; + + // Status filter + if (statusFilter !== "all" && inv.status !== statusFilter) return false; + + // Search term (Invoice #, Client name, Order name) + if (searchTerm) { + const search = searchTerm.toLowerCase(); + const matchesInvoice = inv.invoiceNumber?.toLowerCase().includes(search); + const matchesClient = inv.business?.businessName?.toLowerCase().includes(search); + const matchesEvent = inv.order?.eventName?.toLowerCase().includes(search); + if (!matchesInvoice && !matchesClient && !matchesEvent) return false; + } + + // Date range filter + if (startDate || endDate) { + const issueDate = parseISO(inv.issueDate as string); + const start = startDate ? parseISO(startDate) : new Date(0); + const end = endDate ? parseISO(endDate) : new Date(8640000000000000); + if (!isWithinInterval(issueDate, { start, end })) return false; + } + + return true; + }); + }, [invoices, user, statusFilter, searchTerm, startDate, endDate]); + + // Financial Summary + const metrics = useMemo(() => { + const total = filteredInvoices.reduce((sum, inv) => sum + (inv.amount || 0), 0); + const outstanding = filteredInvoices + .filter((inv) => inv.status !== "PAID" && inv.status !== "DRAFT") + .reduce((sum, inv) => sum + (inv.amount || 0), 0); + + return { total, outstanding }; + }, [filteredInvoices]); + + return ( + navigate("/invoices/new")} leadingIcon={}> + New Invoice + + } + > +
+ {/* Summary Card */} +
+ + +
+
+

Total Outstanding

+

+ ${metrics.outstanding.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+ +
+
+
+
+ + + +
+
+

Filtered Total

+

+ ${metrics.total.toLocaleString(undefined, { minimumFractionDigits: 2 })} +

+
+
+ +
+
+
+
+
+ + {/* Filters Bar */} + + +
+
+ + setSearchTerm(e.target.value)} + leadingIcon={} + /> +
+ +
+ + +
+ +
+ + setStartDate(e.target.value)} + /> +
+ +
+ + setEndDate(e.target.value)} + /> +
+
+
+
+ + {/* Invoices Table */} + +
+ + + + Invoice # + Client + Date + Amount + Status + Due Date + Actions + + + + {isLoading ? ( + + +
+
+
+
+
+ ) : filteredInvoices.length === 0 ? ( + + + +

No invoices found

+

Try adjusting your filters

+
+
+ ) : ( + filteredInvoices.map((invoice) => { + const status = statusConfig[invoice.status] || { label: invoice.status, className: "" }; + const issueDate = invoice.issueDate ? format(parseISO(invoice.issueDate as string), "MMM d, yyyy") : "—"; + const dueDate = invoice.dueDate ? format(parseISO(invoice.dueDate as string), "MMM d, yyyy") : "—"; + + return ( + navigate(`/invoices/${invoice.id}`)} + > + + {invoice.invoiceNumber} + + +

+ {invoice.business?.businessName || "—"} +

+

+ {invoice.order?.eventName || "Untitled Event"} +

+
+ + {issueDate} + + + ${invoice.amount?.toLocaleString(undefined, { minimumFractionDigits: 2 })} + + + + {status.label} + + + + {dueDate} + + + + +
+ ); + }) + )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 5032bac5..b073604c 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -24,6 +24,7 @@ import EditOrder from './features/operations/orders/EditOrder'; import Schedule from './features/operations/schedule/Schedule'; import StaffAvailability from './features/operations/availability/StaffAvailability'; import TaskBoard from './features/operations/tasks/TaskBoard'; +import InvoiceList from './features/finance/invoices/InvoiceList'; /** @@ -112,6 +113,9 @@ const AppRoutes: React.FC = () => { } /> } /> + + {/* Finance Routes */} + } /> } />