431 lines
18 KiB
JavaScript
431 lines
18 KiB
JavaScript
|
|
import React, { useState } from "react";
|
|
import { base44 } from "@/api/base44Client";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Link } from "react-router-dom";
|
|
import { createPageUrl } from "@/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { FileText, Plus, DollarSign, Search, Eye, Download } from "lucide-react";
|
|
import { format, parseISO, isPast } from "date-fns";
|
|
import PageHeader from "../components/common/PageHeader";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
const statusColors = {
|
|
'Open': 'bg-orange-500 text-white',
|
|
'Confirmed': 'bg-purple-500 text-white',
|
|
'Overdue': 'bg-red-500 text-white',
|
|
'Resolved': 'bg-blue-500 text-white',
|
|
'Paid': 'bg-green-500 text-white',
|
|
'Reconciled': 'bg-yellow-600 text-white',
|
|
'Disputed': 'bg-gray-500 text-white',
|
|
'Verified': 'bg-teal-500 text-white',
|
|
'Pending': 'bg-amber-500 text-white',
|
|
};
|
|
|
|
export default function Invoices() {
|
|
const [activeTab, setActiveTab] = useState("all");
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
|
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
|
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
const [paymentMethod, setPaymentMethod] = useState("");
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: user } = useQuery({
|
|
queryKey: ['current-user-invoices'],
|
|
queryFn: () => base44.auth.me(),
|
|
});
|
|
|
|
const { data: invoices = [], isLoading } = useQuery({
|
|
queryKey: ['invoices'],
|
|
queryFn: () => base44.entities.Invoice.list('-issue_date'),
|
|
initialData: [],
|
|
});
|
|
|
|
const userRole = user?.user_role || user?.role;
|
|
|
|
// Filter invoices based on user role
|
|
const visibleInvoices = React.useMemo(() => {
|
|
if (userRole === "client") {
|
|
return invoices.filter(inv =>
|
|
inv.business_name === user?.company_name ||
|
|
inv.manager_name === user?.full_name ||
|
|
inv.created_by === user?.email
|
|
);
|
|
}
|
|
if (userRole === "vendor") {
|
|
return invoices.filter(inv => inv.vendor_name === user?.company_name);
|
|
}
|
|
// Admin, procurement, operator can see all
|
|
return invoices;
|
|
}, [invoices, userRole, user]);
|
|
|
|
const updateInvoiceMutation = useMutation({
|
|
mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
|
setShowPaymentDialog(false);
|
|
setSelectedInvoice(null);
|
|
},
|
|
});
|
|
|
|
const getFilteredInvoices = () => {
|
|
let filtered = visibleInvoices;
|
|
|
|
// Status filter
|
|
if (activeTab !== "all") {
|
|
const statusMap = {
|
|
open: "Open",
|
|
disputed: "Disputed",
|
|
resolved: "Resolved",
|
|
verified: "Verified",
|
|
overdue: "Overdue",
|
|
reconciled: "Reconciled",
|
|
paid: "Paid"
|
|
};
|
|
filtered = filtered.filter(inv => inv.status === statusMap[activeTab]);
|
|
}
|
|
|
|
// Search filter
|
|
if (searchTerm) {
|
|
filtered = filtered.filter(inv =>
|
|
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
inv.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
inv.manager_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
inv.event_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
};
|
|
|
|
const filteredInvoices = getFilteredInvoices();
|
|
|
|
// Calculate metrics
|
|
const getStatusCount = (status) => {
|
|
if (status === "all") return visibleInvoices.length;
|
|
return visibleInvoices.filter(inv => inv.status === status).length;
|
|
};
|
|
|
|
const getTotalAmount = (status) => {
|
|
const filtered = status === "all"
|
|
? visibleInvoices
|
|
: visibleInvoices.filter(inv => inv.status === status);
|
|
return filtered.reduce((sum, inv) => sum + (inv.amount || 0), 0);
|
|
};
|
|
|
|
const allTotal = getTotalAmount("all");
|
|
const openTotal = getTotalAmount("Open");
|
|
const overdueTotal = getTotalAmount("Overdue");
|
|
const paidTotal = getTotalAmount("Paid");
|
|
|
|
const openPercentage = allTotal > 0 ? ((openTotal / allTotal) * 100).toFixed(1) : 0;
|
|
const overduePercentage = allTotal > 0 ? ((overdueTotal / allTotal) * 100).toFixed(1) : 0;
|
|
const paidPercentage = allTotal > 0 ? ((paidTotal / allTotal) * 100).toFixed(1) : 0;
|
|
|
|
const handleRecordPayment = () => {
|
|
if (selectedInvoice && paymentMethod) {
|
|
updateInvoiceMutation.mutate({
|
|
id: selectedInvoice.id,
|
|
data: {
|
|
...selectedInvoice,
|
|
status: "Paid",
|
|
paid_date: new Date().toISOString().split('T')[0],
|
|
payment_method: paymentMethod
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
|
<div className="max-w-[1600px] mx-auto">
|
|
<PageHeader
|
|
title="Invoices"
|
|
subtitle={`${filteredInvoices.length} ${filteredInvoices.length === 1 ? 'invoice' : 'invoices'} • $${allTotal.toLocaleString()} total`}
|
|
actions={
|
|
<>
|
|
<Button
|
|
onClick={() => setShowPaymentDialog(true)}
|
|
variant="outline"
|
|
className="bg-yellow-400 hover:bg-yellow-500 text-slate-900 border-0 font-semibold"
|
|
>
|
|
Record Payment
|
|
</Button>
|
|
<Button
|
|
onClick={() => setShowCreateDialog(true)}
|
|
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white shadow-lg"
|
|
>
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Create Invoice
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* Status Tabs */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
|
|
<TabsList className="bg-white border border-slate-200 h-auto p-1">
|
|
<TabsTrigger value="all" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
|
|
All Invoices <Badge variant="secondary" className="ml-2">{getStatusCount("all")}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="open">
|
|
Open <Badge variant="secondary" className="ml-2">{getStatusCount("Open")}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="disputed">
|
|
Disputed <Badge variant="secondary" className="ml-2">{getStatusCount("Disputed")}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="resolved">
|
|
Resolved <Badge variant="secondary" className="ml-2">{getStatusCount("Resolved")}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="verified">
|
|
Verified <Badge variant="secondary" className="ml-2">{getStatusCount("Verified")}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="overdue">
|
|
Overdue <Badge variant="secondary" className="ml-2">{getStatusCount("Overdue")}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="reconciled">
|
|
Reconciled <Badge variant="secondary" className="ml-2">{getStatusCount("Reconciled")}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="paid">
|
|
Paid <Badge variant="secondary" className="ml-2">{getStatusCount("Paid")}</Badge>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
|
<Card className="bg-white border-slate-200">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<p className="text-sm text-slate-500 mb-1">All</p>
|
|
<p className="text-3xl font-bold text-[#1C323E]">${allTotal.toLocaleString()}</p>
|
|
</div>
|
|
<Badge className="bg-[#1C323E] text-white">{getStatusCount("all")} invoices</Badge>
|
|
</div>
|
|
<div className="w-full bg-slate-200 rounded-full h-2">
|
|
<div className="bg-[#0A39DF] h-2 rounded-full" style={{ width: '100%' }}></div>
|
|
</div>
|
|
<p className="text-right text-sm font-semibold text-[#1C323E] mt-2">100%</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-white border-slate-200">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<p className="text-sm text-slate-500 mb-1">Open</p>
|
|
<p className="text-3xl font-bold text-[#1C323E]">${openTotal.toLocaleString()}</p>
|
|
</div>
|
|
<Badge className="bg-orange-500 text-white">{getStatusCount("Open")} invoices</Badge>
|
|
</div>
|
|
<div className="w-full bg-slate-200 rounded-full h-2">
|
|
<div className="bg-orange-500 h-2 rounded-full" style={{ width: `${openPercentage}%` }}></div>
|
|
</div>
|
|
<p className="text-right text-sm font-semibold text-orange-600 mt-2">{openPercentage}%</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-white border-slate-200">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<p className="text-sm text-slate-500 mb-1">Overdue</p>
|
|
<p className="text-3xl font-bold text-[#1C323E]">${overdueTotal.toLocaleString()}</p>
|
|
</div>
|
|
<Badge className="bg-red-500 text-white">{getStatusCount("Overdue")} invoices</Badge>
|
|
</div>
|
|
<div className="w-full bg-slate-200 rounded-full h-2">
|
|
<div className="bg-red-500 h-2 rounded-full" style={{ width: `${overduePercentage}%` }}></div>
|
|
</div>
|
|
<p className="text-right text-sm font-semibold text-red-600 mt-2">{overduePercentage}%</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-white border-slate-200">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<p className="text-sm text-slate-500 mb-1">Paid</p>
|
|
<p className="text-3xl font-bold text-[#1C323E]">${paidTotal.toLocaleString()}</p>
|
|
</div>
|
|
<Badge className="bg-green-500 text-white">{getStatusCount("Paid")} invoices</Badge>
|
|
</div>
|
|
<div className="w-full bg-slate-200 rounded-full h-2">
|
|
<div className="bg-green-500 h-2 rounded-full" style={{ width: `${paidPercentage}%` }}></div>
|
|
</div>
|
|
<p className="text-right text-sm font-semibold text-green-600 mt-2">{paidPercentage}%</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border border-slate-200">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
|
<Input
|
|
placeholder="Search invoices..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 border-slate-300"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Invoices Table */}
|
|
<Card className="border-slate-200">
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
|
<TableHead className="font-semibold text-slate-700">S #</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Manager Name</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Hub</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Invoice ID</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Cost Center</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Event</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Value $</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Count</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Payment Status</TableHead>
|
|
<TableHead className="font-semibold text-slate-700">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredInvoices.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={10} className="text-center py-12 text-slate-500">
|
|
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
|
<p className="font-medium">No invoices found</p>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredInvoices.map((invoice, idx) => (
|
|
<TableRow key={invoice.id} className="hover:bg-slate-50">
|
|
<TableCell>{idx + 1}</TableCell>
|
|
<TableCell className="font-medium">{invoice.manager_name || invoice.business_name}</TableCell>
|
|
<TableCell>{invoice.hub || "Hub Name"}</TableCell>
|
|
<TableCell>
|
|
<div>
|
|
<p className="font-semibold text-sm">{invoice.invoice_number}</p>
|
|
<p className="text-xs text-slate-500">{format(parseISO(invoice.issue_date), 'M.d.yyyy')}</p>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{invoice.cost_center || "Cost Center"}</TableCell>
|
|
<TableCell>{invoice.event_name || "Events Name"}</TableCell>
|
|
<TableCell className="font-semibold">${invoice.amount?.toLocaleString()}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
|
{invoice.item_count || 2}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge className={`${statusColors[invoice.status]} font-medium px-3 py-1`}>
|
|
{invoice.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="icon" className="hover:text-[#0A39DF]">
|
|
<Eye className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="hover:text-[#0A39DF]">
|
|
<Download className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Record Payment Dialog */}
|
|
<Dialog open={showPaymentDialog} onOpenChange={setShowPaymentDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Record Payment</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div>
|
|
<Label>Select Invoice</Label>
|
|
<Select onValueChange={(value) => setSelectedInvoice(filteredInvoices.find(i => i.id === value))}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Choose an invoice" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{filteredInvoices.filter(i => i.status !== "Paid").map((invoice) => (
|
|
<SelectItem key={invoice.id} value={invoice.id}>
|
|
{invoice.invoice_number} - ${invoice.amount} ({invoice.status})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Payment Method</Label>
|
|
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select payment method" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="Credit Card">Credit Card</SelectItem>
|
|
<SelectItem value="ACH">ACH Transfer</SelectItem>
|
|
<SelectItem value="Wire Transfer">Wire Transfer</SelectItem>
|
|
<SelectItem value="Check">Check</SelectItem>
|
|
<SelectItem value="Cash">Cash</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowPaymentDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleRecordPayment}
|
|
disabled={!selectedInvoice || !paymentMethod}
|
|
className="bg-[#0A39DF]"
|
|
>
|
|
Record Payment
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Create Invoice Dialog */}
|
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create Invoice</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<p className="text-sm text-slate-600">Invoice creation form coming soon...</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|