Project details: cost & breakdown section shows partial payment details
This commit is contained in:
@@ -10378,3 +10378,112 @@ export const MOCK_STORM_ATTRIBUTION_LEADS = [
|
|||||||
{ id: 'SAL-034', firstName: 'Brittany', lastName: 'Baker', address: '12020 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-09-21T09:30:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
{ id: 'SAL-034', firstName: 'Brittany', lastName: 'Baker', address: '12020 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'Contacted', contractValue: null, createdAt: '2025-09-21T09:30:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||||
{ id: 'SAL-035', firstName: 'Dylan', lastName: 'Nelson', address: '12080 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2025-09-22T10:45:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
{ id: 'SAL-035', firstName: 'Dylan', lastName: 'Nelson', address: '12080 Coit Rd', city: 'Plano', state: 'TX', urgency: 'standard', leadSource: 'Canvassers', status: 'New', contractValue: null, createdAt: '2025-09-22T10:45:00Z', stormSource: { id: 'SE-007', areaName: 'Frisco / N Plano', date: '2025-09-18', maxHailSize: 1.1, severity: 'moderate' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const generateMockPaymentRequests = (rowId, actual, paid) => {
|
||||||
|
if (actual <= 0) return [];
|
||||||
|
|
||||||
|
// Extract a numeric seed from the rowId (e.g. 'exp_base_0' -> 0, 'exp_team_1' -> 1)
|
||||||
|
const seedNum = parseInt(rowId.replace(/[^0-9]/g, '')) || 0;
|
||||||
|
const seed = seedNum % 2;
|
||||||
|
|
||||||
|
if (paid) {
|
||||||
|
if (actual <= 1000) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_1`,
|
||||||
|
requestDate: '2026-05-02',
|
||||||
|
requestedAmount: actual,
|
||||||
|
amountPaid: actual,
|
||||||
|
paymentDate: '2026-05-05',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const part1 = Math.round(actual * 0.6);
|
||||||
|
const part2 = actual - part1;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_1`,
|
||||||
|
requestDate: '2026-04-20',
|
||||||
|
requestedAmount: part1,
|
||||||
|
amountPaid: part1,
|
||||||
|
paymentDate: '2026-04-22',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_2`,
|
||||||
|
requestDate: '2026-05-05',
|
||||||
|
requestedAmount: part2,
|
||||||
|
amountPaid: part2,
|
||||||
|
paymentDate: '2026-05-07',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (actual > 2000) {
|
||||||
|
const part1 = Math.round(actual * 0.5);
|
||||||
|
const part2 = actual - part1;
|
||||||
|
|
||||||
|
if (seed === 1) {
|
||||||
|
// Return one paid request and one partially paid request
|
||||||
|
const part2Paid = Math.round(part2 * 0.4);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_1`,
|
||||||
|
requestDate: '2026-05-10',
|
||||||
|
requestedAmount: part1,
|
||||||
|
amountPaid: part1,
|
||||||
|
paymentDate: '2026-05-12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_2`,
|
||||||
|
requestDate: '2026-05-20',
|
||||||
|
requestedAmount: part2,
|
||||||
|
amountPaid: part2Paid,
|
||||||
|
paymentDate: '2026-05-22',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Return one paid request and one unpaid request
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_1`,
|
||||||
|
requestDate: '2026-05-10',
|
||||||
|
requestedAmount: part1,
|
||||||
|
amountPaid: part1,
|
||||||
|
paymentDate: '2026-05-12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_2`,
|
||||||
|
requestDate: '2026-05-20',
|
||||||
|
requestedAmount: part2,
|
||||||
|
amountPaid: 0,
|
||||||
|
paymentDate: null,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (seed === 1) {
|
||||||
|
// Return a single partially paid request
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_1`,
|
||||||
|
requestDate: '2026-05-15',
|
||||||
|
requestedAmount: actual,
|
||||||
|
amountPaid: Math.round(actual * 0.5),
|
||||||
|
paymentDate: '2026-05-18',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Return a single unpaid request
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `req_${rowId}_1`,
|
||||||
|
requestDate: '2026-05-15',
|
||||||
|
requestedAmount: actual,
|
||||||
|
amountPaid: 0,
|
||||||
|
paymentDate: null,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore, generateMockPaymentRequests } from '../../data/mockStore';
|
||||||
import { LIFECYCLE_STAGES, REWORK_STAGE, nextStage, prevStage, stageToPct } from '../../data/lifecycle';
|
import { LIFECYCLE_STAGES, REWORK_STAGE, nextStage, prevStage, stageToPct } from '../../data/lifecycle';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
import { AnimatedCounter } from '../../components/AnimatedCounter';
|
import { AnimatedCounter } from '../../components/AnimatedCounter';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
@@ -150,27 +150,37 @@ const inferExpenseType = (category = '') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function seedExpenseRowsMock(project) {
|
function seedExpenseRowsMock(project) {
|
||||||
const baseRows = (project?.budgetBreakdown || []).map((b, i) => ({
|
const baseRows = (project?.budgetBreakdown || []).map((b, i) => {
|
||||||
id: `exp_base_${i}`,
|
const id = `exp_base_${i}`;
|
||||||
type: inferExpenseType(b.category),
|
const type = inferExpenseType(b.category);
|
||||||
name: b.name || '—',
|
const name = b.name || '—';
|
||||||
category: b.category || '',
|
const category = b.category || '';
|
||||||
allocated: Number(b.allocated) || 0,
|
const allocated = Number(b.allocated) || 0;
|
||||||
actual: Number(b.actual) || 0,
|
const actual = Number(b.actual) || 0;
|
||||||
paid: Number(b.actual) > 0 && Number(b.actual) >= Number(b.allocated) * 0.9,
|
const paid = Number(b.actual) > 0 && Number(b.actual) >= Number(b.allocated) * 0.9;
|
||||||
}));
|
return {
|
||||||
|
id, type, name, category, allocated, actual, paid,
|
||||||
|
paymentRequests: generateMockPaymentRequests(id, actual, paid)
|
||||||
|
};
|
||||||
|
});
|
||||||
const seed = (project?.id || '').charCodeAt((project?.id || 'x').length - 1) % 3;
|
const seed = (project?.id || '').charCodeAt((project?.id || 'x').length - 1) % 3;
|
||||||
const teamRows = [
|
const teamRows = [
|
||||||
{ id: 'exp_team_1', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', allocated: 4200, actual: 4200, paid: true },
|
{ id: 'exp_team_1', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', allocated: 4200, actual: 4200, paid: true },
|
||||||
{ id: 'exp_team_2', type: 'team', name: 'Cody Tatum', category: 'Canvasser', allocated: 1500, actual: 1500, paid: false },
|
{ id: 'exp_team_2', type: 'team', name: 'Cody Tatum', category: 'Canvasser', allocated: 1500, actual: 1500, paid: false },
|
||||||
{ id: 'exp_team_3', type: 'team', name: 'Sarah Calloway', category: 'Estimator', allocated: 1200, actual: 0, paid: false },
|
{ id: 'exp_team_3', type: 'team', name: 'Sarah Calloway', category: 'Estimator', allocated: 1200, actual: 0, paid: false },
|
||||||
].slice(0, 2 + seed);
|
].slice(0, 2 + seed).map(row => ({
|
||||||
|
...row,
|
||||||
|
paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid)
|
||||||
|
}));
|
||||||
const subRows = [
|
const subRows = [
|
||||||
{ id: 'exp_sub_1', type: 'subcontractor', name: 'PlumbPro Services', category: 'Plumbing', allocated: 1800, actual: 1800, paid: true },
|
{ id: 'exp_sub_1', type: 'subcontractor', name: 'PlumbPro Services', category: 'Plumbing', allocated: 1800, actual: 1800, paid: true },
|
||||||
{ id: 'exp_sub_2', type: 'subcontractor', name: 'Bright Electric LLC', category: 'Electrical', allocated: 2400, actual: 2150, paid: false },
|
{ id: 'exp_sub_2', type: 'subcontractor', name: 'Bright Electric LLC', category: 'Electrical', allocated: 2400, actual: 2150, paid: false },
|
||||||
{ id: 'exp_sub_3', type: 'subcontractor', name: 'Premium Painters', category: 'Painting', allocated: 950, actual: 950, paid: true },
|
{ id: 'exp_sub_3', type: 'subcontractor', name: 'Premium Painters', category: 'Painting', allocated: 950, actual: 950, paid: true },
|
||||||
{ id: 'exp_sub_4', type: 'subcontractor', name: 'Solid Civil Works', category: 'Civil work', allocated: 3200, actual: 0, paid: false },
|
{ id: 'exp_sub_4', type: 'subcontractor', name: 'Solid Civil Works', category: 'Civil work', allocated: 3200, actual: 0, paid: false },
|
||||||
].slice(0, 3 + seed);
|
].slice(0, 3 + seed).map(row => ({
|
||||||
|
...row,
|
||||||
|
paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid)
|
||||||
|
}));
|
||||||
return [...baseRows, ...teamRows, ...subRows];
|
return [...baseRows, ...teamRows, ...subRows];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +232,70 @@ const OwnerProjectDetail = () => {
|
|||||||
const [expenseRows, setExpenseRows] = useState([]);
|
const [expenseRows, setExpenseRows] = useState([]);
|
||||||
const [expensesSeeded, setExpensesSeeded] = useState(false);
|
const [expensesSeeded, setExpensesSeeded] = useState(false);
|
||||||
|
|
||||||
|
// ── Row Expansion state for Cost Breakdown ─────────────────────────────────
|
||||||
|
const [expandedRowIds, setExpandedRowIds] = useState(new Set());
|
||||||
|
const toggleRowExpanded = (rowId) => {
|
||||||
|
setExpandedRowIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(rowId)) {
|
||||||
|
next.delete(rowId);
|
||||||
|
} else {
|
||||||
|
next.add(rowId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions for payment requests & status calculations
|
||||||
|
const getRequestRemaining = (req) => (Number(req.requestedAmount) || 0) - (Number(req.amountPaid) || 0);
|
||||||
|
const getRequestStatus = (req) => {
|
||||||
|
const remaining = getRequestRemaining(req);
|
||||||
|
const paid = Number(req.amountPaid) || 0;
|
||||||
|
if (remaining <= 0) return 'Paid';
|
||||||
|
if (paid > 0) return 'Partially Paid';
|
||||||
|
return 'Unpaid';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRowTotalPaid = (row) => {
|
||||||
|
const reqs = row.paymentRequests || [];
|
||||||
|
return reqs.reduce((sum, r) => sum + (Number(r.amountPaid) || 0), 0);
|
||||||
|
};
|
||||||
|
const getRowRemaining = (row) => {
|
||||||
|
const actual = Number(row.actual) || 0;
|
||||||
|
const totalPaid = getRowTotalPaid(row);
|
||||||
|
return Math.max(0, actual - totalPaid);
|
||||||
|
};
|
||||||
|
const getRowStatus = (row) => {
|
||||||
|
const actual = Number(row.actual) || 0;
|
||||||
|
const totalPaid = getRowTotalPaid(row);
|
||||||
|
const remaining = getRowRemaining(row);
|
||||||
|
|
||||||
|
if (actual === 0) {
|
||||||
|
return 'Unpaid';
|
||||||
|
}
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return 'Paid';
|
||||||
|
}
|
||||||
|
if (totalPaid > 0) {
|
||||||
|
return 'Partially Paid';
|
||||||
|
}
|
||||||
|
return 'Unpaid';
|
||||||
|
};
|
||||||
|
|
||||||
|
const processedExpenseRows = useMemo(() => {
|
||||||
|
return expenseRows.map(row => {
|
||||||
|
const remaining = getRowRemaining(row);
|
||||||
|
const status = getRowStatus(row);
|
||||||
|
const paid = Number(row.actual) > 0 && remaining === 0;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
remaining,
|
||||||
|
status,
|
||||||
|
paid
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [expenseRows]);
|
||||||
|
|
||||||
const [openTooltip, setOpenTooltip] = useState(null);
|
const [openTooltip, setOpenTooltip] = useState(null);
|
||||||
|
|
||||||
// ── Commission config state ─────────────────────────────────────────────
|
// ── Commission config state ─────────────────────────────────────────────
|
||||||
@@ -339,6 +413,7 @@ const OwnerProjectDetail = () => {
|
|||||||
allocated: amt,
|
allocated: amt,
|
||||||
actual: amt,
|
actual: amt,
|
||||||
paid: false,
|
paid: false,
|
||||||
|
paymentRequests: [],
|
||||||
};
|
};
|
||||||
setExpenseRows(prev => [newRow, ...prev]);
|
setExpenseRows(prev => [newRow, ...prev]);
|
||||||
};
|
};
|
||||||
@@ -379,11 +454,11 @@ const OwnerProjectDetail = () => {
|
|||||||
const activeCommissionConfig = resolvedCommission;
|
const activeCommissionConfig = resolvedCommission;
|
||||||
|
|
||||||
const expenseTotals = useMemo(() => {
|
const expenseTotals = useMemo(() => {
|
||||||
const total = expenseRows.reduce((s, r) => s + (Number(r.actual) || Number(r.allocated) || 0), 0);
|
const total = processedExpenseRows.reduce((s, r) => s + (Number(r.actual) || Number(r.allocated) || 0), 0);
|
||||||
const paid = expenseRows.reduce((s, r) => s + (r.paid ? (Number(r.actual) || Number(r.allocated) || 0) : 0), 0);
|
const paid = processedExpenseRows.reduce((s, r) => s + (r.paid ? (Number(r.actual) || Number(r.allocated) || 0) : 0), 0);
|
||||||
const outstanding = Math.max(0, total - paid);
|
const outstanding = Math.max(0, total - paid);
|
||||||
return { total, paid, outstanding };
|
return { total, paid, outstanding };
|
||||||
}, [expenseRows]);
|
}, [processedExpenseRows]);
|
||||||
|
|
||||||
const profitMetrics = useMemo(() => {
|
const profitMetrics = useMemo(() => {
|
||||||
if (!project) return { totalCosts: 0, totalExpenses: 0, paidExpenses: 0, outstandingExpenses: 0, totalBudget: 0, commission: 0, commissionFormula: '', grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 };
|
if (!project) return { totalCosts: 0, totalExpenses: 0, paidExpenses: 0, outstandingExpenses: 0, totalBudget: 0, commission: 0, commissionFormula: '', grossProfit: 0, netProfit: 0, grossMargin: 0, netMargin: 0 };
|
||||||
@@ -1072,15 +1147,22 @@ const OwnerProjectDetail = () => {
|
|||||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Category / Role</th>
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Category / Role</th>
|
||||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Allocated</th>
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Allocated</th>
|
||||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Actual</th>
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Actual</th>
|
||||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-center">Paid</th>
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right">Remaining</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-center">Status</th>
|
||||||
|
<th className="w-10"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
{expenseRows.map((row) => {
|
{processedExpenseRows.map((row) => {
|
||||||
const cfg = EXPENSE_TYPE_CONFIG[row.type] || EXPENSE_TYPE_CONFIG.other;
|
const cfg = EXPENSE_TYPE_CONFIG[row.type] || EXPENSE_TYPE_CONFIG.other;
|
||||||
const TypeIcon = cfg.icon;
|
const TypeIcon = cfg.icon;
|
||||||
|
const isExpanded = expandedRowIds.has(row.id);
|
||||||
return (
|
return (
|
||||||
<tr key={row.id} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
<React.Fragment key={row.id}>
|
||||||
|
<tr
|
||||||
|
onClick={() => toggleRowExpanded(row.id)}
|
||||||
|
className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${cfg.color} ${cfg.bg} ${cfg.border}`}>
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${cfg.color} ${cfg.bg} ${cfg.border}`}>
|
||||||
<TypeIcon size={11} />
|
<TypeIcon size={11} />
|
||||||
@@ -1091,28 +1173,94 @@ const OwnerProjectDetail = () => {
|
|||||||
<td className="px-5 py-4 text-sm text-zinc-500 dark:text-zinc-400">{row.category || '—'}</td>
|
<td className="px-5 py-4 text-sm text-zinc-500 dark:text-zinc-400">{row.category || '—'}</td>
|
||||||
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-600 dark:text-zinc-300">{formatCurrency(row.allocated)}</td>
|
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-600 dark:text-zinc-300">{formatCurrency(row.allocated)}</td>
|
||||||
<td className="px-5 py-4 text-right font-mono text-sm text-[#00f0ff]">{formatCurrency(row.actual)}</td>
|
<td className="px-5 py-4 text-right font-mono text-sm text-[#00f0ff]">{formatCurrency(row.actual)}</td>
|
||||||
|
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-600 dark:text-zinc-300">{formatCurrency(row.remaining)}</td>
|
||||||
<td className="px-5 py-4 text-center">
|
<td className="px-5 py-4 text-center">
|
||||||
<label className="inline-flex items-center justify-center cursor-pointer">
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${
|
||||||
<input
|
row.status === 'Paid'
|
||||||
type="checkbox"
|
? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20'
|
||||||
checked={!!row.paid}
|
: row.status === 'Partially Paid'
|
||||||
onChange={() => togglePaid(row.id)}
|
? 'text-amber-600 dark:text-[#fda913] bg-amber-50 dark:bg-[#fda913]/10 border-amber-200 dark:border-[#fda913]/20'
|
||||||
className="sr-only peer"
|
: 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border-red-200 dark:border-[#ff003c]/20'
|
||||||
aria-label={`Mark ${row.name || 'expense'} paid`}
|
}`}>
|
||||||
/>
|
{row.status}
|
||||||
<span className={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${row.paid
|
</span>
|
||||||
? 'bg-emerald-500 border-emerald-500 dark:bg-[#39ff14] dark:border-[#39ff14]'
|
</td>
|
||||||
: 'bg-white dark:bg-[#18181b] border-zinc-300 dark:border-white/20 hover:border-emerald-400'}`}>
|
<td className="px-5 py-4 text-center">
|
||||||
{row.paid && <CheckCircle size={12} className="text-white dark:text-black" strokeWidth={3} />}
|
<ChevronDownIcon
|
||||||
|
size={14}
|
||||||
|
className={`shrink-0 text-zinc-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="bg-zinc-50/50 dark:bg-white/[0.02]">
|
||||||
|
<td colSpan={8} className="p-0 border-t border-zinc-200 dark:border-white/5">
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-5 bg-zinc-50/40 dark:bg-zinc-950/20">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-3">Payment Request History</h4>
|
||||||
|
{row.paymentRequests && row.paymentRequests.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5 shadow-sm bg-white dark:bg-[#121214]">
|
||||||
|
<table className="w-full text-left border-collapse text-xs">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-zinc-900/50 border-b border-zinc-200 dark:border-white/5">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400">Request Date</th>
|
||||||
|
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400 text-right">Requested Amount</th>
|
||||||
|
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400 text-right">Amount Paid</th>
|
||||||
|
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400 text-right">Remaining</th>
|
||||||
|
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400">Payment Date</th>
|
||||||
|
<th className="px-4 py-2.5 font-bold text-zinc-500 dark:text-zinc-400 text-center">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{row.paymentRequests.map((req) => {
|
||||||
|
const reqRemaining = getRequestRemaining(req);
|
||||||
|
const reqStatus = getRequestStatus(req);
|
||||||
|
return (
|
||||||
|
<tr key={req.id} className="hover:bg-zinc-50/50 dark:hover:bg-white/[0.01] transition-colors">
|
||||||
|
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-300 font-mono">{req.requestDate || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-zinc-600 dark:text-zinc-300">{formatCurrency(req.requestedAmount)}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-[#39ff14]">{formatCurrency(req.amountPaid)}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-zinc-500">{formatCurrency(reqRemaining)}</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-300 font-mono">{req.paymentDate || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-widest border ${
|
||||||
|
reqStatus === 'Paid'
|
||||||
|
? 'text-green-600 dark:text-[#39ff14] bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20'
|
||||||
|
: reqStatus === 'Partially Paid'
|
||||||
|
? 'text-amber-600 dark:text-[#fda913] bg-amber-50 dark:bg-[#fda913]/10 border-amber-200 dark:border-[#fda913]/20'
|
||||||
|
: 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border-red-200 dark:border-[#ff003c]/20'
|
||||||
|
}`}>
|
||||||
|
{reqStatus}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{expenseRows.length === 0 && (
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 text-xs italic pl-1">No payment requests recorded for this expense.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{processedExpenseRows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-5 py-10 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
<td colSpan={8} className="px-5 py-10 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
No expenses recorded yet.
|
No expenses recorded yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1124,13 +1272,16 @@ const OwnerProjectDetail = () => {
|
|||||||
<span className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Totals</span>
|
<span className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Totals</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-zinc-600 dark:text-zinc-300">
|
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-zinc-600 dark:text-zinc-300">
|
||||||
{formatCurrency(expenseRows.reduce((s, r) => s + (Number(r.allocated) || 0), 0))}
|
{formatCurrency(processedExpenseRows.reduce((s, r) => s + (Number(r.allocated) || 0), 0))}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-[#00f0ff]">
|
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-[#00f0ff]">
|
||||||
{formatCurrency(profitMetrics.totalExpenses)}
|
{formatCurrency(profitMetrics.totalExpenses)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-center text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-widest">
|
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-zinc-600 dark:text-zinc-300">
|
||||||
{expenseRows.filter(r => r.paid).length}/{expenseRows.length}
|
{formatCurrency(processedExpenseRows.reduce((s, r) => s + r.remaining, 0))}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-center text-[10px] font-mono font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-widest" colSpan={2}>
|
||||||
|
{processedExpenseRows.filter(r => r.paid).length}/{processedExpenseRows.length}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|||||||
Reference in New Issue
Block a user