feat(owner): ground Financial Overview cards + drill-down modal in real records (collected/AR/payouts/vendor-spend)

This commit is contained in:
Satyam Rastogi
2026-05-29 18:49:11 +05:30
parent c27d64d75e
commit 6a5b857de3
2 changed files with 68 additions and 95 deletions
+43 -48
View File
@@ -2,9 +2,11 @@ import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { X, ArrowUpRight, ArrowDownRight, Filter, Download, Search, CheckCircle, Clock, AlertCircle } from 'lucide-react';
import { useMockStore } from '../../data/mockStore';
import { DEMO_TODAY } from '../../data/mockStore';
import { financialOverview } from '../../data/selectors';
const FinancialDetailsModal = ({ isOpen, onClose, type, ownerId }) => {
const { projects: allProjects, vendors } = useMockStore();
const { projects: allProjects } = useMockStore();
const projects = ownerId ? allProjects.filter(p => p.ownerId === ownerId) : allProjects;
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({ key: 'amount', direction: 'desc' });
@@ -25,71 +27,63 @@ const FinancialDetailsModal = ({ isOpen, onClose, type, ownerId }) => {
// --- Data Derivation Logic ---
const fin = useMemo(() => financialOverview(projects, DEMO_TODAY), [projects]);
const data = useMemo(() => {
let items = [];
let summary = { total: 0, label: '' };
switch (type) {
case 'revenue':
summary.label = 'Total Revenue';
// Get all active/completed projects
items = projects
.filter(p => p.status === 'active' || p.status === 'completed')
.map(p => ({
id: p.id,
name: p.address, // Project Name
summary.label = 'Total Collected';
items = fin.collected.items.map((it, idx) => ({
id: `${it.projectId}-R${idx}`,
name: it.project,
entity: 'Client',
date: p.startDate,
status: p.status,
amount: p.budget,
type: 'Project Budget'
date: it.date,
status: 'Paid',
amount: it.amount,
type: it.label
}));
break;
case 'ar':
summary.label = 'Outstanding AR';
// Mock invoices from projects
items = projects.flatMap(p =>
(p.invoices || []).filter(i => i.status === 'pending').map(i => ({
id: i.id,
name: p.address,
items = fin.outstandingAR.items.map((it, idx) => ({
id: `${it.projectId}-AR${idx}`,
name: it.project,
entity: 'Client',
date: i.dueDate,
status: 'Overdue', // Mock status
amount: i.amount,
type: 'Invoice'
}))
);
date: it.dueDate,
status: 'Pending',
amount: it.amount,
type: it.label
}));
break;
case 'payouts':
summary.label = 'Pending Payouts';
// Mock vendor bills from project milestones
items = projects.flatMap(p =>
(p.milestones || []).filter(m => m.status === 'completed').map(m => ({
id: `BILL-${m.id}`,
name: vendors.find(v => v.id === m.assignedTo)?.vendorName || 'Unknown Vendor',
items = fin.pendingPayouts.items.map((it, idx) => ({
id: `${it.projectId}-P${idx}`,
name: it.label,
entity: 'Vendor',
date: m.dueDate,
status: 'Pending Approval',
amount: p.budget * 0.05, // Mock 5% per milestone
type: 'Milestone Payment'
}))
);
date: it.dueDate,
status: 'Pending',
amount: it.amount,
type: 'Vendor Invoice'
}));
break;
case 'spend':
summary.label = 'YTD Vendor Spend';
// Mock spend from vendors
items = vendors.map(v => ({
id: v.id,
name: v.vendorName,
entity: v.type,
date: '2024-YTD',
items = fin.vendorSpend.items.map((it, idx) => ({
id: `${it.projectId}-S${idx}`,
name: it.label,
entity: 'Vendor',
date: it.date,
status: 'Paid',
amount: v.spend?.totalSpend || 0,
type: 'Total Spend'
})).filter(i => i.amount > 0);
amount: it.amount,
type: 'Vendor Invoice'
}));
break;
default:
@@ -98,13 +92,14 @@ const FinancialDetailsModal = ({ isOpen, onClose, type, ownerId }) => {
summary.total = items.reduce((sum, item) => sum + item.amount, 0);
return { items, summary };
}, [type, projects, vendors]);
}, [type, fin]);
// --- Filtering & Sorting ---
const filteredItems = data.items.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.id.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredItems = data.items.filter(item => {
const lc = searchTerm.toLowerCase();
return (item.name ?? '').toLowerCase().includes(lc) ||
(item.id ?? '').toLowerCase().includes(lc);
});
const sortedItems = [...filteredItems].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
+21 -43
View File
@@ -1,48 +1,24 @@
import React, { useMemo } from 'react';
import { useMockStore } from '../../data/mockStore';
import { DEMO_TODAY } from '../../data/mockStore';
import { financialOverview } from '../../data/selectors';
import { DollarSign, TrendingUp, TrendingDown, CreditCard } from 'lucide-react';
import { SpotlightCard } from '../SpotlightCard';
const FinancialKPICards = ({ onCardClick, ownerId }) => {
const { projects, vendors } = useMockStore();
const { projects } = useMockStore();
// Calculate KPIs derived from mock data — filtered by ownerId when provided
const metrics = useMemo(() => {
const ownerProjects = ownerId ? projects.filter(p => p.ownerId === ownerId) : projects;
let totalRevenue = 0;
let pendingPayouts = 0;
ownerProjects.forEach(project => {
if (project.status === 'active' || project.status === 'completed') {
totalRevenue += project.budget;
}
if (project.invoices) {
project.invoices.forEach(inv => {
if (inv.status === 'pending') {
pendingPayouts += inv.amount;
}
});
}
});
const totalVendorSpend = vendors.reduce((acc, v) => acc + (v.spend?.totalSpend || 0), 0);
return {
totalRevenue,
outstandingAR: totalRevenue * 0.3,
pendingPayouts,
totalVendorSpend
};
}, [projects, vendors, ownerId]);
const fin = useMemo(
() => financialOverview(ownerId ? projects.filter(p => p.ownerId === ownerId) : projects, DEMO_TODAY),
[projects, ownerId]
);
const cards = [
{
id: 'revenue',
title: 'Revenue This Month',
value: `$${(metrics.totalRevenue / 1000).toFixed(1)}k`,
change: '+12%',
title: 'Collected to Date',
value: `$${(fin.collected.total / 1000).toFixed(1)}k`,
change: `${fin.collected.items.length} payments`,
trend: 'up',
icon: DollarSign,
color: 'text-emerald-400',
@@ -52,10 +28,10 @@ const FinancialKPICards = ({ onCardClick, ownerId }) => {
{
id: 'ar',
title: 'Outstanding AR',
value: `$${(metrics.outstandingAR / 1000).toFixed(1)}k`,
change: '5 Pending',
value: `$${(fin.outstandingAR.total / 1000).toFixed(1)}k`,
change: `${fin.outstandingAR.count} pending`,
trend: 'neutral',
icon: TrendingUp, // Invoicing icon
icon: TrendingUp,
color: 'text-blue-400',
bg: 'bg-blue-400/10',
border: 'border-blue-400/20'
@@ -63,9 +39,11 @@ const FinancialKPICards = ({ onCardClick, ownerId }) => {
{
id: 'payouts',
title: 'Pending Payouts',
value: `$${(metrics.pendingPayouts / 1000).toFixed(1)}k`,
change: 'Due in 7 days',
trend: 'down', // Money going out
value: `$${(fin.pendingPayouts.total / 1000).toFixed(1)}k`,
change: fin.pendingPayouts.dueSoonCount > 0
? `${fin.pendingPayouts.dueSoonCount} due in 7d`
: `${fin.pendingPayouts.items.length} pending`,
trend: 'down',
icon: CreditCard,
color: 'text-amber-400',
bg: 'bg-amber-400/10',
@@ -74,9 +52,9 @@ const FinancialKPICards = ({ onCardClick, ownerId }) => {
{
id: 'spend',
title: 'YTD Vendor Spend',
value: `$${(metrics.totalVendorSpend / 1000).toFixed(1)}k`,
change: '+8% vs LY',
trend: 'up', // Spending more
value: `$${(fin.vendorSpend.total / 1000).toFixed(1)}k`,
change: `${fin.vendorSpend.items.length} invoices`,
trend: 'up',
icon: TrendingDown,
color: 'text-purple-400',
bg: 'bg-purple-400/10',