feat: Implement invoice list view with filtering
This commit is contained in:
291
apps/web/src/features/finance/invoices/InvoiceList.tsx
Normal file
291
apps/web/src/features/finance/invoices/InvoiceList.tsx
Normal file
@@ -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<string, { label: string; className: string }> = {
|
||||
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<string>("all");
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
|
||||
// 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 (
|
||||
<DashboardLayout
|
||||
title="Invoices"
|
||||
subtitle={`${filteredInvoices.length} invoices found`}
|
||||
actions={
|
||||
<Button onClick={() => navigate("/invoices/new")} leadingIcon={<Plus />}>
|
||||
New Invoice
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Summary Card */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="border-border/50 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-secondary-text font-medium">Total Outstanding</p>
|
||||
<h2 className="text-3xl font-bold text-rose-600 mt-1">
|
||||
${metrics.outstanding.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-rose-50 rounded-full flex items-center justify-center">
|
||||
<FileText className="w-6 h-6 text-rose-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-secondary-text font-medium">Filtered Total</p>
|
||||
<h2 className="text-3xl font-bold text-primary-text mt-1">
|
||||
${metrics.total.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Search className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<Card className="border-border/50 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-secondary-text uppercase tracking-wider">Search</label>
|
||||
<Input
|
||||
placeholder="Invoice #, Client, Event..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
leadingIcon={<Search />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-secondary-text uppercase tracking-wider">Status</label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{Object.entries(statusConfig).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-secondary-text uppercase tracking-wider">From Date</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-secondary-text uppercase tracking-wider">To Date</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoices Table */}
|
||||
<Card className="border-border/50 shadow-sm overflow-hidden bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30 hover:bg-muted/30 border-b border-border/50">
|
||||
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider py-4">Invoice #</TableHead>
|
||||
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider">Client</TableHead>
|
||||
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider">Date</TableHead>
|
||||
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider text-right">Amount</TableHead>
|
||||
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider text-center">Status</TableHead>
|
||||
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider text-center">Due Date</TableHead>
|
||||
<TableHead className="font-bold text-[10px] text-muted-text uppercase tracking-wider text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-16">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredInvoices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-16">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p className="font-medium text-secondary-text">No invoices found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Try adjusting your filters</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
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 (
|
||||
<TableRow
|
||||
key={invoice.id}
|
||||
className="hover:bg-muted/20 cursor-pointer transition-all border-b border-border/50 group"
|
||||
onClick={() => navigate(`/invoices/${invoice.id}`)}
|
||||
>
|
||||
<TableCell className="py-4 font-bold text-primary-text text-xs">
|
||||
{invoice.invoiceNumber}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-medium text-primary-text text-sm">
|
||||
{invoice.business?.businessName || "—"}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{invoice.order?.eventName || "Untitled Event"}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-secondary-text">
|
||||
{issueDate}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-primary-text">
|
||||
${invoice.amount?.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`${status.className} font-semibold text-[10px]`}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm text-secondary-text">
|
||||
{dueDate}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/invoices/${invoice.id}/edit`);
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
<Route path='/availability' element={<StaffAvailability />} />
|
||||
|
||||
<Route path="/tasks" element={<TaskBoard />} />
|
||||
|
||||
{/* Finance Routes */}
|
||||
<Route path="/invoices" element={<InvoiceList />} />
|
||||
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
|
||||
Reference in New Issue
Block a user