feat: Initialize monorepo structure and comprehensive documentation
This commit establishes the new monorepo architecture for the KROW Workforce platform. Key changes include: - Reorganized project into `frontend-web`, `mobile-apps`, `firebase`, `scripts`, and `secrets` directories. - Updated `Makefile` to support the new monorepo layout and automate Base44 export integration. - Fixed `scripts/prepare-export.js` for ES module compatibility and global component import resolution. - Created and updated `CONTRIBUTING.md` for developer onboarding. - Restructured, renamed, and translated all `docs/` files for clarity and consistency. - Implemented an interactive internal launchpad with diagram viewing capabilities. - Configured base Firebase project files (`firebase.json`, security rules). - Updated `README.md` to reflect the new project structure and documentation overview.
This commit is contained in:
430
frontend-web/src/pages/Invoices.jsx
Normal file
430
frontend-web/src/pages/Invoices.jsx
Normal file
@@ -0,0 +1,430 @@
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user