feat: Implement invoice list view with filtering

This commit is contained in:
dhinesh-m24
2026-02-09 16:30:15 +05:30
parent 3a993c1ca2
commit e25b37b39b
2 changed files with 295 additions and 0 deletions

View 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>
);
}

View File

@@ -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 />} />