Project details: cost & breakdown section change table styling & added cost type dropdown in Team Cost model

This commit is contained in:
Mayur Shinde
2026-06-05 13:41:20 +05:30
parent 647bdddaf5
commit 4f57469e30
3 changed files with 424 additions and 59 deletions
+264
View File
@@ -0,0 +1,264 @@
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { X, Users, DollarSign } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import Select from '../ui/Select';
const PRESET_COST_TYPES = [
{ value: 'Commission', label: 'Commission' },
{ value: 'Salary', label: 'Salary' },
{ value: 'Bonus', label: 'Bonus' },
{ value: 'Hourly Wage', label: 'Hourly Wage' },
{ value: 'Per Diem', label: 'Per Diem' },
{ value: 'Referral Fee', label: 'Referral Fee' },
{ value: 'Overtime', label: 'Overtime' },
];
const inputBase =
'w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-3 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-purple-500 dark:focus:border-purple-400 focus:ring-1 focus:ring-purple-500/30 transition-all';
const AddTeamCostModal = ({ isOpen, onClose, onSubmit }) => {
const [form, setForm] = useState({ name: '', role: '', costType: '', customCostType: '', amount: '' });
const [showCustomInput, setShowCustomInput] = useState(false);
const firstInputRef = useRef(null);
// Reset form on open
useEffect(() => {
if (isOpen) {
setForm({ name: '', role: '', costType: '', customCostType: '', amount: '' });
setShowCustomInput(false);
setTimeout(() => firstInputRef.current?.focus(), 80);
}
}, [isOpen]);
// Esc to close
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
if (isOpen) window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
const resolvedCostType = form.costType === '__custom__' ? form.customCostType.trim() : form.costType;
const handleSubmit = (e) => {
e.preventDefault();
if (!form.name.trim() || !form.role.trim() || !resolvedCostType || !form.amount) return;
onSubmit({
name: form.name.trim(),
category: form.role.trim(),
costType: resolvedCostType,
amount: form.amount,
});
onClose();
};
return createPortal(
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center justify-center p-0 sm:p-4" role="dialog" aria-modal="true" aria-labelledby="atcm-title">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Sheet / Dialog */}
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 40, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
className="relative w-full sm:max-w-lg bg-white dark:bg-[#111113] rounded-t-3xl sm:rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden flex flex-col"
style={{ maxHeight: '92dvh' }}
onClick={e => e.stopPropagation()}
>
{/* Drag handle (mobile) */}
<div className="flex justify-center pt-3 pb-1 sm:hidden">
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 sm:px-6 py-4 border-b border-zinc-100 dark:border-white/8 bg-zinc-50 dark:bg-white/[0.03] shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-purple-500/10 shrink-0">
<Users size={18} className="text-purple-500 dark:text-purple-400" />
</div>
<div>
<h2 id="atcm-title" className="text-sm sm:text-base font-bold text-zinc-900 dark:text-white tracking-wide uppercase">
Add Team Cost
</h2>
<p className="text-[11px] text-zinc-500 dark:text-zinc-500 mt-0.5">
Record a team member expense
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors shrink-0"
aria-label="Close"
>
<X size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0" noValidate>
<div className="flex-1 overflow-y-auto px-5 sm:px-6 py-5 space-y-4">
{/* Team Member Name */}
<div className="space-y-1.5">
<label htmlFor="atcm-name" className="block text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Team Member Name <span className="text-purple-500">*</span>
</label>
<input
ref={firstInputRef}
id="atcm-name"
type="text"
className={inputBase}
placeholder="e.g. Jesus Gonzales"
required
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
{/* Role / Category */}
<div className="space-y-1.5">
<label htmlFor="atcm-role" className="block text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Role / Category <span className="text-purple-500">*</span>
</label>
<input
id="atcm-role"
type="text"
className={inputBase}
placeholder="e.g. Sales Rep, Canvasser, Estimator"
required
value={form.role}
onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
/>
</div>
{/* Cost Type */}
<div className="space-y-1.5">
<label htmlFor="atcm-costtype" className="block text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Cost Type <span className="text-purple-500">*</span>
</label>
<Select
id="atcm-costtype"
value={form.costType}
onChange={(val) => {
if (val === '__custom__') {
setShowCustomInput(true);
setForm(f => ({ ...f, costType: '__custom__', customCostType: '' }));
} else {
setShowCustomInput(false);
setForm(f => ({ ...f, costType: val, customCostType: '' }));
}
}}
options={[
...PRESET_COST_TYPES,
{ value: '__custom__', label: 'Other / Custom…' },
]}
placeholder="Select cost type…"
className="py-3"
/>
<AnimatePresence>
{showCustomInput && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
<input
id="atcm-customtype"
type="text"
className={`${inputBase} mt-2`}
placeholder="Enter custom cost type…"
required={showCustomInput}
value={form.customCostType}
onChange={e => setForm(f => ({ ...f, customCostType: e.target.value }))}
autoFocus
/>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Amount */}
<div className="space-y-1.5">
<label htmlFor="atcm-amount" className="block text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Amount <span className="text-purple-500">*</span>
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-zinc-500 text-sm pointer-events-none">$</span>
<input
id="atcm-amount"
type="number"
min="0"
step="0.01"
className={`${inputBase} pl-8`}
placeholder="0.00"
required
value={form.amount}
onChange={e => setForm(f => ({ ...f, amount: e.target.value }))}
/>
</div>
</div>
{/* Live preview */}
{(form.name || resolvedCostType || form.amount) && (
<div className="rounded-xl bg-purple-50 dark:bg-purple-500/[0.07] border border-purple-200 dark:border-purple-500/20 px-4 py-3 flex items-center gap-3">
<div className="p-1.5 rounded-lg bg-purple-500/15 shrink-0">
<DollarSign size={13} className="text-purple-600 dark:text-purple-400" />
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-purple-900 dark:text-purple-300 truncate">
{form.name || '—'} · {resolvedCostType || '—'}
</p>
<p className="text-[11px] text-purple-700/70 dark:text-purple-400/70 mt-0.5">
{form.amount ? `$${parseFloat(form.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '—'}
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-5 sm:px-6 py-4 border-t border-zinc-100 dark:border-white/8 bg-zinc-50 dark:bg-white/[0.02] flex flex-col-reverse sm:flex-row sm:justify-end gap-2.5 sm:gap-3 shrink-0">
<button
type="button"
onClick={onClose}
className="w-full sm:w-auto px-5 py-2.5 text-sm font-semibold rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!form.name.trim() || !form.role.trim() || !resolvedCostType || !form.amount}
className="w-full sm:w-auto px-6 py-2.5 text-sm font-bold rounded-xl border bg-purple-500/20 text-purple-600 dark:text-purple-300 border-purple-500/30 hover:bg-purple-500/30 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-sm"
>
Save Team Cost
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
);
};
export default AddTeamCostModal;
+111 -28
View File
@@ -10379,22 +10379,21 @@ export const MOCK_STORM_ATTRIBUTION_LEADS = [
{ 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) => { export const generateMockPaymentRequests = (rowId, actual, paid, allocated = 0) => {
if (actual <= 0) return [];
// Extract a numeric seed from the rowId (e.g. 'exp_base_0' -> 0, 'exp_team_1' -> 1) // 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 seedNum = parseInt(rowId.replace(/[^0-9]/g, '')) || 0;
const seed = seedNum % 2; const seed = seedNum % 3;
if (paid) { // Case: actual > 0 and fully paid
if (actual > 0 && paid) {
if (actual <= 1000) { if (actual <= 1000) {
return [ return [
{ {
id: `req_${rowId}_1`, id: `req_${rowId}_1`,
requestDate: '2026-05-02', requestDate: '2026-04-28',
requestedAmount: actual, requestedAmount: actual,
amountPaid: actual, amountPaid: actual,
paymentDate: '2026-05-05', paymentDate: '2026-05-02',
} }
]; ];
} else { } else {
@@ -10403,81 +10402,114 @@ export const generateMockPaymentRequests = (rowId, actual, paid) => {
return [ return [
{ {
id: `req_${rowId}_1`, id: `req_${rowId}_1`,
requestDate: '2026-04-20', requestDate: '2026-04-15',
requestedAmount: part1, requestedAmount: part1,
amountPaid: part1, amountPaid: part1,
paymentDate: '2026-04-22', paymentDate: '2026-04-18',
}, },
{ {
id: `req_${rowId}_2`, id: `req_${rowId}_2`,
requestDate: '2026-05-05', requestDate: '2026-05-01',
requestedAmount: part2, requestedAmount: part2,
amountPaid: part2, amountPaid: part2,
paymentDate: '2026-05-07', paymentDate: '2026-05-04',
} }
]; ];
} }
} else { }
// Case: actual > 0, not fully paid
if (actual > 0 && !paid) {
if (actual > 2000) { if (actual > 2000) {
const part1 = Math.round(actual * 0.5); const part1 = Math.round(actual * 0.5);
const part2 = actual - part1; const part2 = actual - part1;
if (seed === 1) { if (seed === 0) {
// Return one paid request and one partially paid request // One paid + one partially paid
const part2Paid = Math.round(part2 * 0.4); const part2Paid = Math.round(part2 * 0.4);
return [ return [
{ {
id: `req_${rowId}_1`, id: `req_${rowId}_1`,
requestDate: '2026-05-10', requestDate: '2026-05-05',
requestedAmount: part1, requestedAmount: part1,
amountPaid: part1, amountPaid: part1,
paymentDate: '2026-05-12', paymentDate: '2026-05-08',
}, },
{ {
id: `req_${rowId}_2`, id: `req_${rowId}_2`,
requestDate: '2026-05-20', requestDate: '2026-05-18',
requestedAmount: part2, requestedAmount: part2,
amountPaid: part2Paid, amountPaid: part2Paid,
paymentDate: '2026-05-22', paymentDate: '2026-05-22',
} }
]; ];
} else { } else if (seed === 1) {
// Return one paid request and one unpaid request // One paid + one fully unpaid
return [ return [
{ {
id: `req_${rowId}_1`, id: `req_${rowId}_1`,
requestDate: '2026-05-10', requestDate: '2026-05-08',
requestedAmount: part1, requestedAmount: part1,
amountPaid: part1, amountPaid: part1,
paymentDate: '2026-05-12', paymentDate: '2026-05-10',
}, },
{ {
id: `req_${rowId}_2`, id: `req_${rowId}_2`,
requestDate: '2026-05-20', requestDate: '2026-05-22',
requestedAmount: part2, requestedAmount: part2,
amountPaid: 0, amountPaid: 0,
paymentDate: null, paymentDate: null,
} }
]; ];
} else {
// Three splits: paid, partial, unpaid
const p1 = Math.round(actual * 0.4);
const p2 = Math.round(actual * 0.35);
const p3 = actual - p1 - p2;
const p2Paid = Math.round(p2 * 0.6);
return [
{
id: `req_${rowId}_1`,
requestDate: '2026-04-20',
requestedAmount: p1,
amountPaid: p1,
paymentDate: '2026-04-23',
},
{
id: `req_${rowId}_2`,
requestDate: '2026-05-05',
requestedAmount: p2,
amountPaid: p2Paid,
paymentDate: '2026-05-09',
},
{
id: `req_${rowId}_3`,
requestDate: '2026-05-20',
requestedAmount: p3,
amountPaid: 0,
paymentDate: null,
}
];
} }
} else { } else {
if (seed === 1) { if (seed === 1) {
// Return a single partially paid request // Single partially paid request
const partPaid = Math.round(actual * 0.5);
return [ return [
{ {
id: `req_${rowId}_1`, id: `req_${rowId}_1`,
requestDate: '2026-05-15', requestDate: '2026-05-12',
requestedAmount: actual, requestedAmount: actual,
amountPaid: Math.round(actual * 0.5), amountPaid: partPaid,
paymentDate: '2026-05-18', paymentDate: '2026-05-15',
} }
]; ];
} else { } else {
// Return a single unpaid request // Single unpaid request
return [ return [
{ {
id: `req_${rowId}_1`, id: `req_${rowId}_1`,
requestDate: '2026-05-15', requestDate: '2026-05-14',
requestedAmount: actual, requestedAmount: actual,
amountPaid: 0, amountPaid: 0,
paymentDate: null, paymentDate: null,
@@ -10486,4 +10518,55 @@ export const generateMockPaymentRequests = (rowId, actual, paid) => {
} }
} }
} }
// Case: actual === 0 but allocated > 0 pending / not-started
// Show a pending payment request for the allocated amount (requested but not yet started)
if (actual <= 0 && allocated > 0) {
if (seed === 0) {
// Fully pending (submitted, no payment yet)
return [
{
id: `req_${rowId}_1`,
requestDate: '2026-05-25',
requestedAmount: allocated,
amountPaid: 0,
paymentDate: null,
}
];
} else if (seed === 1) {
// Two-part pending initial deposit requested, balance pending
const deposit = Math.round(allocated * 0.3);
const balance = allocated - deposit;
return [
{
id: `req_${rowId}_1`,
requestDate: '2026-05-20',
requestedAmount: deposit,
amountPaid: 0,
paymentDate: null,
},
{
id: `req_${rowId}_2`,
requestDate: '2026-05-28',
requestedAmount: balance,
amountPaid: 0,
paymentDate: null,
}
];
} else {
// Single pending with partial deposit paid upfront
const deposit = Math.round(allocated * 0.25);
return [
{
id: `req_${rowId}_1`,
requestDate: '2026-05-22',
requestedAmount: allocated,
amountPaid: deposit,
paymentDate: '2026-05-24',
}
];
}
}
return [];
}; };
+47 -29
View File
@@ -20,6 +20,7 @@ import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
import EstimateDetailModal from '../../components/estimates/EstimateDetailModal'; import EstimateDetailModal from '../../components/estimates/EstimateDetailModal';
import CreateItemModal from '../../components/owner/CreateItemModal'; import CreateItemModal from '../../components/owner/CreateItemModal';
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal'; import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
import AddTeamCostModal from '../../components/owner/AddTeamCostModal';
import TeamManagementPanel from '../../components/owner/TeamManagementPanel'; import TeamManagementPanel from '../../components/owner/TeamManagementPanel';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver'; import { resolveCommission, findAssignedUserRoleKey } from '../../utils/commissionResolver';
@@ -133,7 +134,7 @@ const PAYMENTS_PER_PAGE = 5;
const EXPENSE_TYPE_CONFIG = { const EXPENSE_TYPE_CONFIG = {
material: { label: 'Material', icon: Wrench, color: 'text-sky-600 dark:text-[#00f0ff]', bg: 'bg-sky-50 dark:bg-blue-500/10', border: 'border-sky-200 dark:border-blue-500/20' }, material: { label: 'Material', icon: Wrench, color: 'text-sky-600 dark:text-[#00f0ff]', bg: 'bg-sky-50 dark:bg-blue-500/10', border: 'border-sky-200 dark:border-blue-500/20' },
labor: { label: 'Labor', icon: HardHat, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20' }, labor: { label: 'Labor', icon: HardHat, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20' },
team: { label: 'Team Commission', icon: UserCheck, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20' }, team: { label: 'Team Cost', icon: UserCheck, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20' },
subcontractor: { label: 'Subcontractor', icon: HardHat, color: 'text-amber-600 dark:text-[#fda913]', bg: 'bg-amber-50 dark:bg-[#fda913]/10', border: 'border-amber-200 dark:border-[#fda913]/20' }, subcontractor: { label: 'Subcontractor', icon: HardHat, color: 'text-amber-600 dark:text-[#fda913]', bg: 'bg-amber-50 dark:bg-[#fda913]/10', border: 'border-amber-200 dark:border-[#fda913]/20' },
other: { label: 'Other', icon: DollarSign, color: 'text-zinc-600 dark:text-zinc-400', bg: 'bg-zinc-100 dark:bg-white/5', border: 'border-zinc-200 dark:border-white/10' }, other: { label: 'Other', icon: DollarSign, color: 'text-zinc-600 dark:text-zinc-400', bg: 'bg-zinc-100 dark:bg-white/5', border: 'border-zinc-200 dark:border-white/10' },
}; };
@@ -160,7 +161,7 @@ function seedExpenseRowsMock(project) {
const 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 { return {
id, type, name, category, allocated, actual, paid, id, type, name, category, allocated, actual, paid,
paymentRequests: generateMockPaymentRequests(id, actual, paid) paymentRequests: generateMockPaymentRequests(id, actual, paid, allocated)
}; };
}); });
const seed = (project?.id || '').charCodeAt((project?.id || 'x').length - 1) % 3; const seed = (project?.id || '').charCodeAt((project?.id || 'x').length - 1) % 3;
@@ -170,7 +171,7 @@ function seedExpenseRowsMock(project) {
{ 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).map(row => ({ ].slice(0, 2 + seed).map(row => ({
...row, ...row,
paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid) paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid, row.allocated)
})); }));
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 },
@@ -179,7 +180,7 @@ function seedExpenseRowsMock(project) {
{ 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).map(row => ({ ].slice(0, 3 + seed).map(row => ({
...row, ...row,
paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid) paymentRequests: generateMockPaymentRequests(row.id, row.actual, row.paid, row.allocated)
})); }));
return [...baseRows, ...teamRows, ...subRows]; return [...baseRows, ...teamRows, ...subRows];
} }
@@ -232,20 +233,15 @@ 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 ───────────────────────────────── // ── Row Expansion state for Cost Breakdown (accordion — one open at a time) ──
const [expandedRowIds, setExpandedRowIds] = useState(new Set()); const [expandedRowId, setExpandedRowId] = useState(null);
const toggleRowExpanded = (rowId) => { const toggleRowExpanded = (rowId) => {
setExpandedRowIds(prev => { setExpandedRowId(prev => (prev === rowId ? null : rowId));
const next = new Set(prev);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
return next;
});
}; };
// ── Add Team Cost modal ───────────────────────────────────────────────────
const [addTeamCostOpen, setAddTeamCostOpen] = useState(false);
// Helper functions for payment requests & status calculations // Helper functions for payment requests & status calculations
const getRequestRemaining = (req) => (Number(req.requestedAmount) || 0) - (Number(req.amountPaid) || 0); const getRequestRemaining = (req) => (Number(req.requestedAmount) || 0) - (Number(req.amountPaid) || 0);
const getRequestStatus = (req) => { const getRequestStatus = (req) => {
@@ -256,6 +252,10 @@ const OwnerProjectDetail = () => {
return 'Unpaid'; return 'Unpaid';
}; };
const getRowTotalRequested = (row) => {
const reqs = row.paymentRequests || [];
return reqs.reduce((sum, r) => sum + (Number(r.requestedAmount) || 0), 0);
};
const getRowTotalPaid = (row) => { const getRowTotalPaid = (row) => {
const reqs = row.paymentRequests || []; const reqs = row.paymentRequests || [];
return reqs.reduce((sum, r) => sum + (Number(r.amountPaid) || 0), 0); return reqs.reduce((sum, r) => sum + (Number(r.amountPaid) || 0), 0);
@@ -263,14 +263,20 @@ const OwnerProjectDetail = () => {
const getRowRemaining = (row) => { const getRowRemaining = (row) => {
const actual = Number(row.actual) || 0; const actual = Number(row.actual) || 0;
const totalPaid = getRowTotalPaid(row); const totalPaid = getRowTotalPaid(row);
return Math.max(0, actual - totalPaid); if (actual > 0) {
return Math.max(0, actual - totalPaid);
}
// For zero-actual rows, use total requested - total paid from payment requests
const totalRequested = getRowTotalRequested(row);
return Math.max(0, totalRequested - totalPaid);
}; };
const getRowStatus = (row) => { const getRowStatus = (row) => {
const actual = Number(row.actual) || 0; const actual = Number(row.actual) || 0;
const totalPaid = getRowTotalPaid(row); const totalPaid = getRowTotalPaid(row);
const remaining = getRowRemaining(row); const remaining = getRowRemaining(row);
const totalRequested = getRowTotalRequested(row);
if (actual === 0) { if (actual === 0 && totalRequested === 0) {
return 'Unpaid'; return 'Unpaid';
} }
if (remaining <= 0) { if (remaining <= 0) {
@@ -279,6 +285,10 @@ const OwnerProjectDetail = () => {
if (totalPaid > 0) { if (totalPaid > 0) {
return 'Partially Paid'; return 'Partially Paid';
} }
// Has payment requests but nothing paid
if (totalRequested > 0) {
return 'Unpaid';
}
return 'Unpaid'; return 'Unpaid';
}; };
@@ -1092,16 +1102,7 @@ const OwnerProjectDetail = () => {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <button
onClick={() => setCreateModalConfig({ onClick={() => setAddTeamCostOpen(true)}
title: 'Add Team Commission Cost', icon: UserCheck, iconColor: 'text-purple-500 dark:text-purple-400', iconBg: 'bg-purple-500/10',
submitLabel: 'Save Team Cost', submitColor: 'bg-purple-500/20 text-purple-500 dark:text-purple-400 border-purple-500/30',
fields: [
{ name: 'name', label: 'Team Member Name', type: 'text', required: true, placeholder: 'e.g. Jesus Gonzales' },
{ name: 'category', label: 'Role / Category', type: 'text', required: true, placeholder: 'e.g. Sales Rep' },
{ name: 'amount', label: 'Commission Amount ($)', type: 'number', required: true },
],
onSubmit: (d) => handleAddExpense(d, 'team'),
})}
className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30 px-3 py-1.5 rounded-lg text-xs font-bold transition flex items-center gap-2" className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30 px-3 py-1.5 rounded-lg text-xs font-bold transition flex items-center gap-2"
> >
<UserCheck size={14} /> Add Team Cost <UserCheck size={14} /> Add Team Cost
@@ -1156,12 +1157,16 @@ const OwnerProjectDetail = () => {
{processedExpenseRows.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); const isExpanded = expandedRowId === row.id;
return ( return (
<React.Fragment key={row.id}> <React.Fragment key={row.id}>
<tr <tr
onClick={() => toggleRowExpanded(row.id)} onClick={() => toggleRowExpanded(row.id)}
className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors cursor-pointer" className={`transition-colors cursor-pointer border-l-2 ${
isExpanded
? 'bg-amber-50/60 dark:bg-amber-500/[0.06] border-l-amber-500 dark:border-l-[#fda913]'
: 'hover:bg-zinc-50 dark:hover:bg-white/5 border-l-transparent'
}`}
> >
<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}`}>
@@ -2700,6 +2705,19 @@ const OwnerProjectDetail = () => {
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span> <span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
{/* Add Team Cost Modal */}
<AddTeamCostModal
isOpen={addTeamCostOpen}
onClose={() => setAddTeamCostOpen(false)}
onSubmit={(data) => {
handleAddExpense(
{ name: data.name, category: data.category, amount: data.amount },
'team'
);
setAddTeamCostOpen(false);
}}
/>
{/* Commission Settings Modal */} {/* Commission Settings Modal */}
<CommissionSettingsModal <CommissionSettingsModal
isOpen={commissionSettingsOpen} isOpen={commissionSettingsOpen}