feat: Implement detail view for specific invoice
This commit is contained in:
281
apps/web/src/features/finance/invoices/InvoiceDetail.tsx
Normal file
281
apps/web/src/features/finance/invoices/InvoiceDetail.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { Badge } from "@/common/components/ui/badge";
|
||||||
|
import { Button } from "@/common/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/common/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/common/components/ui/table";
|
||||||
|
import { InvoiceStatus } from "@/dataconnect-generated";
|
||||||
|
import { useGetInvoiceById, useUpdateInvoice, useListRecentPaymentsByInvoiceId } from "@/dataconnect-generated/react";
|
||||||
|
import { dataConnect } from "@/features/auth/firebase";
|
||||||
|
import DashboardLayout from "@/features/layouts/DashboardLayout";
|
||||||
|
import type { RootState } from "@/store/store";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { ArrowLeft, Download, Mail, CheckCircle, FileText, User, Calendar, MapPin, DollarSign } from "lucide-react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
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 InvoiceDetail() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: invoiceId } = useParams<{ id: string }>();
|
||||||
|
const { user } = useSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
// Fetch Invoice Data
|
||||||
|
const { data: invoiceData, isLoading: loadingInvoice } = useGetInvoiceById(dataConnect, { id: invoiceId! });
|
||||||
|
const invoice = invoiceData?.invoice;
|
||||||
|
|
||||||
|
// Fetch Payment History
|
||||||
|
const { data: paymentsData, isLoading: loadingPayments } = useListRecentPaymentsByInvoiceId(dataConnect, { invoiceId: invoiceId! });
|
||||||
|
const payments = paymentsData?.recentPayments || [];
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const { mutate: updateInvoice } = useUpdateInvoice(dataConnect);
|
||||||
|
|
||||||
|
const handleMarkAsPaid = () => {
|
||||||
|
if (!invoiceId) return;
|
||||||
|
updateInvoice({ id: invoiceId, status: InvoiceStatus.PAID });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendInvoice = () => {
|
||||||
|
// Logic for sending invoice (e.g., email service)
|
||||||
|
console.log("Sending invoice...");
|
||||||
|
updateInvoice({ id: invoiceId!, status: InvoiceStatus.PENDING });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadPDF = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingInvoice) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout title="Invoice Detail">
|
||||||
|
<div className="flex items-center justify-center min-h-[50vh]">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout title="Invoice Not Found">
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
|
||||||
|
<p className="text-xl font-semibold">Invoice not found</p>
|
||||||
|
<Button onClick={() => navigate("/invoices")} leadingIcon={<ArrowLeft />}>
|
||||||
|
Back to Invoices
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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") : "—";
|
||||||
|
|
||||||
|
// Parse JSON data for roles/charges (assuming they are JSON from the schema)
|
||||||
|
const lineItems = (invoice.roles as any[]) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout
|
||||||
|
title={`Invoice ${invoice.invoiceNumber}`}
|
||||||
|
backAction={
|
||||||
|
<Button variant="ghost" onClick={() => navigate("/invoices")} leadingIcon={<ArrowLeft />}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2 print:hidden">
|
||||||
|
<Button variant="outline" leadingIcon={<Download />} onClick={handleDownloadPDF}>
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
{invoice.status === "DRAFT" && (
|
||||||
|
<Button leadingIcon={<Mail />} onClick={handleSendInvoice}>
|
||||||
|
Send Invoice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invoice.status !== "PAID" && (
|
||||||
|
<Button variant="default" leadingIcon={<CheckCircle />} onClick={handleMarkAsPaid} className="bg-emerald-600 hover:bg-emerald-700">
|
||||||
|
Mark as Paid
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Header & Client Info */}
|
||||||
|
<Card className="border-border/50 shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-2">Client Information</h3>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-lg">{invoice.business?.businessName}</p>
|
||||||
|
<p className="text-sm text-secondary-text">{invoice.business?.email}</p>
|
||||||
|
<p className="text-sm text-secondary-text">{invoice.business?.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-secondary-text">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>{invoice.hub || (invoice.order as any)?.teamHub?.hubName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 p-4 rounded-xl space-y-3 min-w-[240px]">
|
||||||
|
<div className="flex justify-between items-center border-b border-border/50 pb-2">
|
||||||
|
<span className="text-xs font-medium text-secondary-text">Status</span>
|
||||||
|
<Badge className={`${status.className} font-bold`}>{status.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-secondary-text flex items-center gap-1.5"><Calendar className="w-3.5 h-3.5" /> Issued</span>
|
||||||
|
<span className="font-medium">{issueDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-secondary-text flex items-center gap-1.5"><Calendar className="w-3.5 h-3.5" /> Due Date</span>
|
||||||
|
<span className="font-medium">{dueDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Line Items Table */}
|
||||||
|
<Card className="border-border/50 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-border/50 bg-muted/20">
|
||||||
|
<h3 className="font-bold text-sm">Line Items (Shifts & Staff)</h3>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/10">
|
||||||
|
<TableHead className="text-[10px] font-bold uppercase py-3">Staff / Role</TableHead>
|
||||||
|
<TableHead className="text-[10px] font-bold uppercase text-center">Hours</TableHead>
|
||||||
|
<TableHead className="text-[10px] font-bold uppercase text-right">Rate</TableHead>
|
||||||
|
<TableHead className="text-[10px] font-bold uppercase text-right">Total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{lineItems.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
No line items recorded for this invoice.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
lineItems.map((item: any, idx: number) => (
|
||||||
|
<TableRow key={idx} className="border-b border-border/40">
|
||||||
|
<TableCell className="py-4">
|
||||||
|
<p className="font-bold text-xs">{item.staffName || "Staff Member"}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{item.roleName || "Support"}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-xs">{item.hours || 0}h</TableCell>
|
||||||
|
<TableCell className="text-right text-xs">${(item.rate || 0).toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right font-bold text-xs">${(item.total || 0).toFixed(2)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Payment History */}
|
||||||
|
<Card className="border-border/50 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-border/50 bg-muted/20">
|
||||||
|
<h3 className="font-bold text-sm">Payment History</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-0">
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground text-sm">
|
||||||
|
No payment records found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{payments.map((payment) => (
|
||||||
|
<TableRow key={payment.id}>
|
||||||
|
<TableCell className="py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-xs">Payment Received</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{payment.createdAt ? format(parseISO(payment.createdAt as string), "MMM d, yyyy") : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant="outline" className="text-emerald-700 bg-emerald-50 border-emerald-100 text-[10px]">
|
||||||
|
{payment.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Financial Summary Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border-border/50 shadow-sm bg-primary/[0.02]">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h3 className="font-bold text-sm mb-4">Financial Summary</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm text-secondary-text">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span className="font-medium">${(invoice.subtotal || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-secondary-text">
|
||||||
|
<span>Fees</span>
|
||||||
|
<span className="font-medium">${(invoice.otherCharges || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-border/50 flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Grand Total</p>
|
||||||
|
<p className="text-2xl font-black text-primary-text mt-1">
|
||||||
|
${invoice.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/50 shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h3 className="font-bold text-sm mb-3">Internal Notes</h3>
|
||||||
|
<p className="text-sm text-secondary-text italic">
|
||||||
|
{invoice.notes || "No internal notes provided for this invoice."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="p-4 bg-muted/20 rounded-xl border border-border/50 text-center">
|
||||||
|
<FileText className="w-8 h-8 mx-auto mb-2 text-muted-foreground/30" />
|
||||||
|
<p className="text-xs text-secondary-text leading-relaxed">
|
||||||
|
For any questions regarding this invoice, please contact support@krowworkforce.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import Schedule from './features/operations/schedule/Schedule';
|
|||||||
import StaffAvailability from './features/operations/availability/StaffAvailability';
|
import StaffAvailability from './features/operations/availability/StaffAvailability';
|
||||||
import TaskBoard from './features/operations/tasks/TaskBoard';
|
import TaskBoard from './features/operations/tasks/TaskBoard';
|
||||||
import InvoiceList from './features/finance/invoices/InvoiceList';
|
import InvoiceList from './features/finance/invoices/InvoiceList';
|
||||||
|
import InvoiceDetail from './features/finance/invoices/InvoiceDetail';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +117,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
|
|
||||||
{/* Finance Routes */}
|
{/* Finance Routes */}
|
||||||
<Route path="/invoices" element={<InvoiceList />} />
|
<Route path="/invoices" element={<InvoiceList />} />
|
||||||
|
<Route path="/invoices/:id" element={<InvoiceDetail />} />
|
||||||
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user