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 { 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({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,6 +44,9 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text
|
||||
{/* Content */}
|
||||
<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">
|
||||
{description && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{description}</p>
|
||||
)}
|
||||
{fields.map((field, i) => (
|
||||
<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>
|
||||
|
||||
@@ -2158,6 +2158,43 @@ const MOCK_PROJECTS = [
|
||||
{ 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 }
|
||||
],
|
||||
// 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: [],
|
||||
rfis: [],
|
||||
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)
|
||||
{
|
||||
id: 'PRJ-2026-002',
|
||||
|
||||
@@ -115,7 +115,12 @@ function seedDocsMock(projectId) {
|
||||
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 = [
|
||||
{ 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' },
|
||||
@@ -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_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;
|
||||
return all.slice(0, 3 + seed);
|
||||
}
|
||||
@@ -162,6 +168,25 @@ const inferExpenseType = (category = '') => {
|
||||
};
|
||||
|
||||
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 id = `exp_base_${i}`;
|
||||
const type = inferExpenseType(b.category);
|
||||
@@ -236,7 +261,8 @@ const OwnerProjectDetail = () => {
|
||||
const [createModalConfig, setCreateModalConfig] = useState(null);
|
||||
|
||||
// ── 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 [paymentPage, setPaymentPage] = useState(0);
|
||||
|
||||
@@ -416,6 +442,7 @@ const OwnerProjectDetail = () => {
|
||||
useEffect(() => {
|
||||
if (project && !expensesSeeded) {
|
||||
setExpenseRows(seedExpenseRowsMock(project));
|
||||
setPayments(seedPaymentsMock(project));
|
||||
setExpensesSeeded(true);
|
||||
}
|
||||
}, [project, expensesSeeded]);
|
||||
@@ -767,18 +794,36 @@ const OwnerProjectDetail = () => {
|
||||
<div className="flex flex-wrap items-center gap-2 mb-5">
|
||||
{!isAtFinalStage && (
|
||||
<button
|
||||
onClick={() => setCreateModalConfig({
|
||||
title: `Advance to "${nextStageName}"`,
|
||||
icon: ChevronRight,
|
||||
iconColor: 'text-sky-500 dark:text-[#00f0ff]',
|
||||
iconBg: 'bg-sky-500/10',
|
||||
submitLabel: `Advance Stage`,
|
||||
submitColor: 'bg-sky-500/20 text-sky-600 dark:text-[#00f0ff] border-sky-500/30',
|
||||
fields: [
|
||||
{ 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'),
|
||||
})}
|
||||
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}"`,
|
||||
icon: ChevronRight,
|
||||
iconColor: 'text-sky-500 dark:text-[#00f0ff]',
|
||||
iconBg: 'bg-sky-500/10',
|
||||
submitLabel: `Advance Stage`,
|
||||
submitColor: 'bg-sky-500/20 text-sky-600 dark:text-[#00f0ff] border-sky-500/30',
|
||||
fields: [
|
||||
{ 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'),
|
||||
});
|
||||
}}
|
||||
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} />
|
||||
|
||||
Reference in New Issue
Block a user