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