Project details: cost & breakdown section change table styling & added cost type dropdown in Team Cost model
This commit is contained in:
@@ -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
@@ -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 [];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
if (actual > 0) {
|
||||||
return Math.max(0, actual - totalPaid);
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user