Files
Krow-workspace/src/pages/Invoices.jsx

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