Project Progression: added popup when project move from contract signed to work in progress
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text-[#00f0ff]", iconBg = "bg-blue-500/10", fields, onSubmit, submitLabel = "Submit", submitColor = "bg-blue-500/20 text-[#00f0ff] border-blue-500/30" }) => {
|
const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text-[#00f0ff]", iconBg = "bg-blue-500/10", description, fields = [], onSubmit, submitLabel = "Submit", submitColor = "bg-blue-500/20 text-[#00f0ff] border-blue-500/30" }) => {
|
||||||
const [formData, setFormData] = useState({});
|
const [formData, setFormData] = useState({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,6 +44,9 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
|
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto px-5 sm:px-6 py-5 space-y-5">
|
<div className="flex-1 overflow-y-auto px-5 sm:px-6 py-5 space-y-5">
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{description}</p>
|
||||||
|
)}
|
||||||
{fields.map((field, i) => (
|
{fields.map((field, i) => (
|
||||||
<div key={i} className="space-y-1.5">
|
<div key={i} className="space-y-1.5">
|
||||||
<label htmlFor={field.name} className="text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400">{field.label}</label>
|
<label htmlFor={field.name} className="text-xs font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400">{field.label}</label>
|
||||||
|
|||||||
@@ -2158,6 +2158,43 @@ const MOCK_PROJECTS = [
|
|||||||
{ name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, committed: 2000, actual: 2000 },
|
{ name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, committed: 2000, actual: 2000 },
|
||||||
{ name: 'Junk King DFW', category: 'Cleanup & Haul-Away', allocated: 4000, committed: 1500, actual: 0 }
|
{ name: 'Junk King DFW', category: 'Cleanup & Haul-Away', allocated: 4000, committed: 1500, actual: 0 }
|
||||||
],
|
],
|
||||||
|
// Cost breakdown / expenses for the table (requested amounts never exceed actual)
|
||||||
|
expenses: [
|
||||||
|
{ id: 'exp_001_1', type: 'material', name: 'ABC Supply Co.', category: 'Materials - Shingles', allocated: 15000, actual: 7000, paid: false, paymentRequests: [
|
||||||
|
{ id: 'req_001_1_1', requestDate: '2026-01-26', requestedAmount: 5000, amountPaid: 5000, paymentDate: '2026-01-28' },
|
||||||
|
{ id: 'req_001_1_2', requestDate: '2026-02-10', requestedAmount: 2000, amountPaid: 1000, paymentDate: '2026-02-12' }
|
||||||
|
] },
|
||||||
|
{ id: 'exp_001_2', type: 'material', name: 'ABC Supply Co.', category: 'Materials - Underlayment', allocated: 3500, actual: 2500, paid: true, paymentRequests: [
|
||||||
|
{ id: 'req_001_2_1', requestDate: '2026-01-27', requestedAmount: 2500, amountPaid: 2500, paymentDate: '2026-01-30' }
|
||||||
|
] },
|
||||||
|
{ id: 'exp_001_3', type: 'labor', name: 'Texas Roofing Crew A', category: 'Labor - Tear Off', allocated: 8000, actual: 5000, paid: true, paymentRequests: [
|
||||||
|
{ id: 'req_001_3_1', requestDate: '2026-01-29', requestedAmount: 3000, amountPaid: 3000, paymentDate: '2026-02-01' },
|
||||||
|
{ id: 'req_001_3_2', requestDate: '2026-02-03', requestedAmount: 2000, amountPaid: 2000, paymentDate: '2026-02-05' }
|
||||||
|
] },
|
||||||
|
{ id: 'exp_001_4', type: 'labor', name: 'Texas Roofing Crew A', category: 'Labor - Install', allocated: 12000, actual: 0, paid: false, paymentRequests: [] },
|
||||||
|
{ id: 'exp_001_5', type: 'other', name: 'City of Plano', category: 'Permits & Inspection', allocated: 2500, actual: 2000, paid: true, paymentRequests: [
|
||||||
|
{ id: 'req_001_5_1', requestDate: '2026-01-18', requestedAmount: 2000, amountPaid: 2000, paymentDate: '2026-01-20' }
|
||||||
|
] },
|
||||||
|
{ id: 'exp_001_6', type: 'other', name: 'Junk King DFW', category: 'Cleanup & Haul-Away', allocated: 4000, actual: 0, paid: false, paymentRequests: [] },
|
||||||
|
{ id: 'exp_001_7', type: 'team', name: 'Jesus Gonzales', category: 'Sales Rep', costType: 'Commission', allocated: 4200, actual: 4200, paid: true, paymentRequests: [
|
||||||
|
{ id: 'req_001_7_1', requestDate: '2026-02-06', requestedAmount: 4200, amountPaid: 4200, paymentDate: '2026-02-08' }
|
||||||
|
] },
|
||||||
|
{ id: 'exp_001_8', type: 'team', name: 'Cody Tatum', category: 'Canvasser', costType: 'Hourly Wage', allocated: 1500, actual: 1500, paid: false, paymentRequests: [
|
||||||
|
{ id: 'req_001_8_1', requestDate: '2026-02-09', requestedAmount: 1500, amountPaid: 750, paymentDate: '2026-02-11' }
|
||||||
|
] },
|
||||||
|
{ id: 'exp_001_9', type: 'subcontractor', name: 'Carlos Mendoza', category: 'Electrical', allocated: 1800, actual: 1800, paid: true, paymentRequests: [
|
||||||
|
{ id: 'req_001_9_1', requestDate: '2026-02-04', requestedAmount: 1800, amountPaid: 1800, paymentDate: '2026-02-06' }
|
||||||
|
] },
|
||||||
|
{ id: 'exp_001_10', type: 'subcontractor', name: 'Maya Patel', category: 'Roofing', allocated: 2400, actual: 2400, paid: false, paymentRequests: [
|
||||||
|
{ id: 'req_001_10_1', requestDate: '2026-02-12', requestedAmount: 1200, amountPaid: 1200, paymentDate: '2026-02-14' },
|
||||||
|
{ id: 'req_001_10_2', requestDate: '2026-02-20', requestedAmount: 1200, amountPaid: 0, paymentDate: null }
|
||||||
|
] }
|
||||||
|
],
|
||||||
|
paymentsReceived: [
|
||||||
|
{ id: 'rcv_001_1', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' },
|
||||||
|
{ id: 'rcv_001_2', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' },
|
||||||
|
{ id: 'rcv_001_3', from: 'State Farm Insurance', method: 'ACH', amount: 6200, date: '2026-02-18', refNumber: 'ACH-2026-4499', memo: 'Supplement approval — additional scope' }
|
||||||
|
],
|
||||||
changeOrders: [],
|
changeOrders: [],
|
||||||
rfis: [],
|
rfis: [],
|
||||||
riskLog: [
|
riskLog: [
|
||||||
@@ -2214,6 +2251,65 @@ const MOCK_PROJECTS = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// TEST PROJECT: Contract Signed with NO team members — used to verify the
|
||||||
|
// "Add Team Members First" guard when advancing to "Work In Progress".
|
||||||
|
{
|
||||||
|
id: 'PRJ-2026-TEST',
|
||||||
|
ownerId: 'own_001',
|
||||||
|
propertyId: 'P-2602',
|
||||||
|
address: 'TEST — 100 Demo Street, Plano, TX 75023',
|
||||||
|
contractorId: 'con_001',
|
||||||
|
subcontractorIds: [],
|
||||||
|
projectType: 'Roof Replacement',
|
||||||
|
status: 'active',
|
||||||
|
phase: 'Pre-Construction',
|
||||||
|
budget: 32000,
|
||||||
|
approvedBudget: 32000,
|
||||||
|
committedCost: 12000,
|
||||||
|
actualCost: 0,
|
||||||
|
spent: 0,
|
||||||
|
variancePercent: 0,
|
||||||
|
margin: 0.30,
|
||||||
|
commissionType: 'percent_gross',
|
||||||
|
commissionRate: 10,
|
||||||
|
commission: 3200,
|
||||||
|
startDate: '2026-06-01',
|
||||||
|
endDate: '2026-07-15',
|
||||||
|
completionPercentage: 40,
|
||||||
|
healthScore: 78,
|
||||||
|
openRFIs: 0,
|
||||||
|
changeOrderCount: 0,
|
||||||
|
pendingInvoiceCount: 0,
|
||||||
|
budgetBreakdown: [],
|
||||||
|
// Cost breakdown / expenses for the table (Contract Signed — allocated only, nothing spent yet)
|
||||||
|
expenses: [],
|
||||||
|
// No funds received yet at Contract Signed
|
||||||
|
paymentsReceived: [],
|
||||||
|
changeOrders: [],
|
||||||
|
rfis: [],
|
||||||
|
riskLog: [],
|
||||||
|
issueLog: [],
|
||||||
|
paymentSchedule: [
|
||||||
|
{ id: 'ps_test_1', milestone: 'Mobilization (20%)', amount: 6400, dueDate: '2026-06-05', status: 'pending' }
|
||||||
|
],
|
||||||
|
activityTimeline: [
|
||||||
|
{ date: '2026-06-01', action: 'Contract Signed', user: 'Justin Johnson', details: 'Contract executed; awaiting team assignment.' }
|
||||||
|
],
|
||||||
|
milestones: [],
|
||||||
|
invoices: [],
|
||||||
|
documents: [
|
||||||
|
{ name: 'Scope of Work - Roof Replacement', url: '#', type: 'contract' }
|
||||||
|
],
|
||||||
|
teamMembers: [],
|
||||||
|
lifecycleStage: 'Contract Signed',
|
||||||
|
stageHistory: [
|
||||||
|
{ stage: 'Lead', date: '2026-05-15', by: 'Justin Johnson', note: 'Storm damage lead identified — TEST project.' },
|
||||||
|
{ stage: 'Damage Verified', date: '2026-05-20', by: 'Justin Johnson', note: 'Adjuster confirmed hail damage; claim approved.' },
|
||||||
|
{ stage: 'Scope Approved', date: '2026-05-25', by: 'Justin Johnson', note: 'Full roof replacement scope signed off.' },
|
||||||
|
{ stage: 'Contract Signed', date: '2026-06-01', by: 'Justin Johnson', note: 'Contract executed; mobilization deposit pending.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// Project 2: Completed, zero variance (perfectly on budget)
|
// Project 2: Completed, zero variance (perfectly on budget)
|
||||||
{
|
{
|
||||||
id: 'PRJ-2026-002',
|
id: 'PRJ-2026-002',
|
||||||
|
|||||||
@@ -115,7 +115,12 @@ function seedDocsMock(projectId) {
|
|||||||
return all.slice(0, 4 + seed);
|
return all.slice(0, 4 + seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedPaymentsMock(projectId) {
|
function seedPaymentsMock(project) {
|
||||||
|
// Prefer per-project payments-received data when defined on the project (even if empty)
|
||||||
|
if (Array.isArray(project?.paymentsReceived)) return project.paymentsReceived;
|
||||||
|
|
||||||
|
// Fallback (legacy) for any project not yet migrated — generic pool sliced by id
|
||||||
|
const projectId = project?.id || '';
|
||||||
const all = [
|
const all = [
|
||||||
{ id: 'pay_001', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' },
|
{ id: 'pay_001', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' },
|
||||||
{ id: 'pay_002', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' },
|
{ id: 'pay_002', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' },
|
||||||
@@ -124,6 +129,7 @@ function seedPaymentsMock(projectId) {
|
|||||||
{ id: 'pay_005', from: 'State Farm Insurance', method: 'Check', amount: 4100, date: '2026-03-12', refNumber: 'CHK-7761', memo: 'Final supplement payout' },
|
{ id: 'pay_005', from: 'State Farm Insurance', method: 'Check', amount: 4100, date: '2026-03-12', refNumber: 'CHK-7761', memo: 'Final supplement payout' },
|
||||||
{ id: 'pay_006', from: 'Justin Johnson', method: 'Cash', amount: 500, date: '2026-03-20', refNumber: '', memo: 'Gutter guard add-on' },
|
{ id: 'pay_006', from: 'Justin Johnson', method: 'Cash', amount: 500, date: '2026-03-20', refNumber: '', memo: 'Gutter guard add-on' },
|
||||||
];
|
];
|
||||||
|
if (!projectId) return all.slice(0, 3);
|
||||||
const seed = projectId.charCodeAt(projectId.length - 1) % 3;
|
const seed = projectId.charCodeAt(projectId.length - 1) % 3;
|
||||||
return all.slice(0, 3 + seed);
|
return all.slice(0, 3 + seed);
|
||||||
}
|
}
|
||||||
@@ -162,6 +168,25 @@ const inferExpenseType = (category = '') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function seedExpenseRowsMock(project) {
|
function seedExpenseRowsMock(project) {
|
||||||
|
// Prefer per-project expenses data when defined on the project (even if empty)
|
||||||
|
if (Array.isArray(project?.expenses)) {
|
||||||
|
return project.expenses.map((e, i) => {
|
||||||
|
const allocated = Number(e.allocated) || 0;
|
||||||
|
const actual = Number(e.actual) || 0;
|
||||||
|
return {
|
||||||
|
id: e.id || `exp_${i}`,
|
||||||
|
type: e.type || inferExpenseType(e.category),
|
||||||
|
name: e.name || '—',
|
||||||
|
category: e.category || '',
|
||||||
|
costType: e.costType || '',
|
||||||
|
allocated,
|
||||||
|
actual,
|
||||||
|
paid: e.paid ?? (actual > 0 && actual >= allocated * 0.9),
|
||||||
|
paymentRequests: e.paymentRequests || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const baseRows = (project?.budgetBreakdown || []).map((b, i) => {
|
const baseRows = (project?.budgetBreakdown || []).map((b, i) => {
|
||||||
const id = `exp_base_${i}`;
|
const id = `exp_base_${i}`;
|
||||||
const type = inferExpenseType(b.category);
|
const type = inferExpenseType(b.category);
|
||||||
@@ -236,7 +261,8 @@ const OwnerProjectDetail = () => {
|
|||||||
const [createModalConfig, setCreateModalConfig] = useState(null);
|
const [createModalConfig, setCreateModalConfig] = useState(null);
|
||||||
|
|
||||||
// ── Payments state ─────────────────────────────────────────────────────────
|
// ── Payments state ─────────────────────────────────────────────────────────
|
||||||
const [payments, setPayments] = useState(() => seedPaymentsMock(projectId));
|
// Seeded from the resolved project (see effect below) so each project shows its own payments
|
||||||
|
const [payments, setPayments] = useState([]);
|
||||||
const [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' });
|
const [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' });
|
||||||
const [paymentPage, setPaymentPage] = useState(0);
|
const [paymentPage, setPaymentPage] = useState(0);
|
||||||
|
|
||||||
@@ -416,6 +442,7 @@ const OwnerProjectDetail = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (project && !expensesSeeded) {
|
if (project && !expensesSeeded) {
|
||||||
setExpenseRows(seedExpenseRowsMock(project));
|
setExpenseRows(seedExpenseRowsMock(project));
|
||||||
|
setPayments(seedPaymentsMock(project));
|
||||||
setExpensesSeeded(true);
|
setExpensesSeeded(true);
|
||||||
}
|
}
|
||||||
}, [project, expensesSeeded]);
|
}, [project, expensesSeeded]);
|
||||||
@@ -767,7 +794,24 @@ const OwnerProjectDetail = () => {
|
|||||||
<div className="flex flex-wrap items-center gap-2 mb-5">
|
<div className="flex flex-wrap items-center gap-2 mb-5">
|
||||||
{!isAtFinalStage && (
|
{!isAtFinalStage && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setCreateModalConfig({
|
onClick={() => {
|
||||||
|
// Gate: a project can't move to "Work In Progress" without any team members assigned
|
||||||
|
const hasTeamMembers = (project.teamMembers || []).length > 0;
|
||||||
|
if (nextStageName === 'Work In Progress' && !hasTeamMembers) {
|
||||||
|
setCreateModalConfig({
|
||||||
|
title: 'Add Team Members First',
|
||||||
|
icon: Users,
|
||||||
|
iconColor: 'text-amber-500 dark:text-amber-400',
|
||||||
|
iconBg: 'bg-amber-500/10',
|
||||||
|
description: 'This project has no team members assigned yet. Add at least one team member in the Team tab before moving the project to "Work In Progress".',
|
||||||
|
submitLabel: 'Go to Team Tab',
|
||||||
|
submitColor: 'bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||||
|
fields: [],
|
||||||
|
onSubmit: () => setActiveTab('team'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreateModalConfig({
|
||||||
title: `Advance to "${nextStageName}"`,
|
title: `Advance to "${nextStageName}"`,
|
||||||
icon: ChevronRight,
|
icon: ChevronRight,
|
||||||
iconColor: 'text-sky-500 dark:text-[#00f0ff]',
|
iconColor: 'text-sky-500 dark:text-[#00f0ff]',
|
||||||
@@ -778,7 +822,8 @@ const OwnerProjectDetail = () => {
|
|||||||
{ name: 'note', label: 'Note (optional)', type: 'textarea', required: false, placeholder: 'Add a note about this stage transition…' },
|
{ name: 'note', label: 'Note (optional)', type: 'textarea', required: false, placeholder: 'Add a note about this stage transition…' },
|
||||||
],
|
],
|
||||||
onSubmit: (d) => advanceLifecycleStage(project.id, nextStageName, d.note || '', user?.name || user?.id || 'Owner'),
|
onSubmit: (d) => advanceLifecycleStage(project.id, nextStageName, d.note || '', user?.name || user?.id || 'Owner'),
|
||||||
})}
|
});
|
||||||
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-sky-500/15 hover:bg-sky-500/25 text-sky-600 dark:text-[#00f0ff] border border-sky-500/30 dark:border-[#00f0ff]/30 text-xs font-bold transition shadow-sm"
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-sky-500/15 hover:bg-sky-500/25 text-sky-600 dark:text-[#00f0ff] border border-sky-500/30 dark:border-[#00f0ff]/30 text-xs font-bold transition shadow-sm"
|
||||||
>
|
>
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
|
|||||||
Reference in New Issue
Block a user