feat(projects): interactive lifecycle progression control with stage history

This commit is contained in:
Satyam Rastogi
2026-05-29 17:30:16 +05:30
parent ffb2307066
commit 5dff6cfaea
+155 -30
View File
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { useMockStore } from '../../data/mockStore'; import { useMockStore } from '../../data/mockStore';
import { LIFECYCLE_STAGES, REWORK_STAGE, nextStage, prevStage, stageToPct } from '../../data/lifecycle';
import { SpotlightCard } from '../../components/SpotlightCard'; import { SpotlightCard } from '../../components/SpotlightCard';
import { AnimatedCounter } from '../../components/AnimatedCounter'; import { AnimatedCounter } from '../../components/AnimatedCounter';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@@ -190,6 +191,7 @@ const OwnerProjectDetail = () => {
updateOrgCommissionDefaults, setUserCommissionOverride, updateOrgCommissionDefaults, setUserCommissionOverride,
removeUserCommissionOverride, updateProject, removeUserCommissionOverride, updateProject,
addJobTeamMember, removeJobTeamMember, updateJobTeamMember, addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
advanceLifecycleStage,
} = useMockStore(); } = useMockStore();
const navigate = useNavigate(); const navigate = useNavigate();
const { can } = usePermissions(); const { can } = usePermissions();
@@ -554,41 +556,164 @@ const OwnerProjectDetail = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Project Progression */} {/* Project Progression */}
<NeoCard spotlightColor="rgba(0, 240, 255, 0.1)" className="lg:col-span-2"> <NeoCard spotlightColor="rgba(0, 240, 255, 0.1)" className="lg:col-span-2">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-4">
<Milestone className={NEON_BLUE} size={20} /> <Milestone className={NEON_BLUE} size={20} />
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Project Progression</h3> <h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Project Progression</h3>
</div> </div>
<div className="relative pt-8 pb-4">
{/* Stepper Line */}
<div className="absolute top-[38px] left-[10%] right-[10%] h-1 bg-zinc-200 dark:bg-zinc-800 rounded-full z-0" />
<motion.div
initial={{ width: 0 }} animate={{ width: `${project.completionPercentage || 0}%` }} transition={{ duration: 1.5 }}
className="absolute top-[38px] left-[10%] h-1 bg-gradient-to-r from-blue-600 to-[#00f0ff] rounded-full shadow-[0_0_10px_#00f0ff] z-0"
/>
<div className="relative z-10 flex justify-between"> {/* Current stage label */}
{/* Stepper Nodes */} {(() => {
{["Lead", "Inspection Scheduled", "Damage Verified", "Scope Approved", "Project Completed"].map((step, i) => { const currentStage = project.lifecycleStage || LIFECYCLE_STAGES[0];
const isActive = (i * 25) <= (project.completionPercentage || 0); const isRework = currentStage === REWORK_STAGE;
return ( const currentIdx = LIFECYCLE_STAGES.indexOf(currentStage);
<div key={i} className="flex flex-col items-center gap-3 w-20 relative"> // Condensed milestone labels for stepper display
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 shadow-lg transition-colors const STEPPER_STAGES = ['Lead', 'Damage Verified', 'Contract Signed', 'Work In Progress', 'Project Completed', 'Final Payment Received — Closed'];
${isActive ? 'bg-zinc-50 dark:bg-zinc-900 border-sky-500 dark:border-[#00f0ff] text-sky-500 dark:text-[#00f0ff] dark:shadow-[0_0_15px_rgba(0,240,255,0.4)]' : 'bg-white dark:bg-black border-zinc-300 dark:border-zinc-700 text-zinc-400 dark:text-zinc-600'} const STEPPER_LABELS = ['Lead', 'Damage Verified', 'Contract Signed', 'Work In Progress', 'Completed', 'Closed'];
`}> // Consistency rule: WIP uses stored %, all others use stageToPct
{isActive ? <CheckCircle size={14} /> : <span className="w-2 h-2 rounded-full bg-zinc-300 dark:bg-zinc-700" />} const displayPct = currentStage === 'Work In Progress'
</div> ? (project.completionPercentage || 0)
<span className={`text-[10px] text-center font-bold tracking-wide uppercase leading-tight ${isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-500'}`}> : stageToPct(currentStage);
{step} const isOwnerAdmin = ['OWNER', 'ADMIN'].includes(user?.role);
</span> const nextStageName = nextStage(currentStage);
const isAtFinalStage = currentStage === 'Final Payment Received — Closed';
const isFinalInspectionStage = currentStage === 'Final Inspection Scheduled' || currentStage === 'Final Inspection In Progress';
return (
<>
<div className="flex items-center gap-3 mb-5">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide ${isRework ? 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border border-orange-500/30' : 'bg-sky-500/15 text-sky-600 dark:text-[#00f0ff] border border-sky-500/30 dark:border-[#00f0ff]/30'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${isRework ? 'bg-orange-500' : 'bg-sky-500 dark:bg-[#00f0ff]'}`} />
{currentStage}
</span>
<span className="text-xs font-mono text-zinc-500">{displayPct}% complete</span>
</div>
{/* Stepper */}
<div className="relative pt-8 pb-4 mb-2">
<div className="absolute top-[38px] left-[4%] right-[4%] h-1 bg-zinc-200 dark:bg-zinc-800 rounded-full z-0" />
<motion.div
initial={{ width: 0 }}
animate={{ width: `${displayPct}%` }}
transition={{ duration: 1.5 }}
className="absolute top-[38px] left-[4%] h-1 bg-gradient-to-r from-blue-600 to-[#00f0ff] rounded-full shadow-[0_0_10px_#00f0ff] z-0"
/>
<div className="relative z-10 flex justify-between">
{STEPPER_STAGES.map((step, i) => {
const stepIdx = LIFECYCLE_STAGES.indexOf(step);
const isActive = isRework
? stepIdx <= LIFECYCLE_STAGES.indexOf('Work In Progress')
: stepIdx <= currentIdx;
const isCurrent = step === currentStage;
return (
<div key={step} className="flex flex-col items-center gap-2 w-16 relative">
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 shadow-lg transition-colors
${isCurrent ? 'bg-sky-50 dark:bg-zinc-900 border-sky-500 dark:border-[#00f0ff] text-sky-500 dark:text-[#00f0ff] dark:shadow-[0_0_15px_rgba(0,240,255,0.5)] scale-110' : isActive ? 'bg-zinc-50 dark:bg-zinc-900 border-sky-400 dark:border-[#00f0ff]/60 text-sky-400 dark:text-[#00f0ff]/60' : 'bg-white dark:bg-black border-zinc-300 dark:border-zinc-700 text-zinc-400 dark:text-zinc-600'}
`}>
{isActive ? <CheckCircle size={14} /> : <span className="w-2 h-2 rounded-full bg-zinc-300 dark:bg-zinc-700" />}
</div>
<span className={`text-[9px] text-center font-bold tracking-wide uppercase leading-tight ${isCurrent ? 'text-sky-600 dark:text-[#00f0ff]' : isActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-600'}`}>
{STEPPER_LABELS[i]}
</span>
</div>
);
})}
</div> </div>
); </div>
})}
</div> {/* Rework indicator */}
</div> {isRework && (
<div className="mt-8 flex items-center gap-4"> <div className="mb-4 flex items-center gap-2 px-3 py-2 rounded-lg bg-orange-500/10 border border-orange-500/30 text-orange-600 dark:text-orange-400 text-xs font-bold">
<ProgressBar progress={project.completionPercentage || 0} colorClass="bg-gradient-to-r from-blue-600 to-[#00f0ff]" shadowClass="shadow-[0_0_10px_#00f0ff]" height="h-2" /> <AlertTriangle size={13} />
<span className="text-xs font-mono font-bold text-[#00f0ff] uppercase">{project.completionPercentage || 0}% CMP</span> Rework in progress project re-enters Final Inspection after rework is resolved
</div> </div>
)}
{/* Progress bar */}
<div className="flex items-center gap-4 mb-5">
<ProgressBar progress={displayPct} colorClass="bg-gradient-to-r from-blue-600 to-[#00f0ff]" shadowClass="shadow-[0_0_10px_#00f0ff]" height="h-2" />
<span className="text-xs font-mono font-bold text-[#00f0ff] uppercase shrink-0">{displayPct}% CMP</span>
</div>
{/* Owner/Admin Advance controls */}
{isOwnerAdmin && (
<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'),
})}
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} />
Advance to &ldquo;{nextStageName}&rdquo;
</button>
)}
{isFinalInspectionStage && (
<button
onClick={() => setCreateModalConfig({
title: 'Send to Rework',
icon: AlertOctagon,
iconColor: 'text-orange-500',
iconBg: 'bg-orange-500/10',
submitLabel: 'Confirm — Send to Rework',
submitColor: 'bg-orange-500/20 text-orange-500 border-orange-500/30',
fields: [
{ name: 'note', label: 'Rework reason (optional)', type: 'textarea', required: false, placeholder: 'Describe what needs to be reworked…' },
],
onSubmit: (d) => advanceLifecycleStage(project.id, REWORK_STAGE, d.note || '', user?.name || user?.id || 'Owner'),
})}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-orange-500/10 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400 border border-orange-500/30 text-xs font-bold transition shadow-sm"
>
<AlertOctagon size={14} />
Send to Rework
</button>
)}
{isAtFinalStage && (
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-xs font-bold">
<CheckCircle size={13} /> Project fully closed
</span>
)}
</div>
)}
{/* Stage History Timeline */}
{(project.stageHistory || []).length > 0 && (
<div className="border-t border-zinc-200 dark:border-white/5 pt-4">
<p className="text-[10px] font-mono font-bold uppercase tracking-widest text-zinc-500 mb-3">Stage History</p>
<div className="relative">
<div className="absolute left-[7px] top-0 bottom-0 w-px bg-zinc-200 dark:bg-white/10" />
<div className="space-y-3">
{[...(project.stageHistory)].reverse().map((entry, i) => (
<div key={i} className="relative flex items-start gap-3 pl-6">
<div className="absolute left-[3px] top-1.5 w-2 h-2 rounded-full bg-white dark:bg-[#09090b] border-2 border-sky-500 dark:border-[#00f0ff]" />
<div className="flex-1 flex flex-wrap items-start justify-between gap-x-3 gap-y-0.5">
<span className="text-xs font-bold text-zinc-900 dark:text-white leading-tight">{entry.stage}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-zinc-500">{entry.date}</span>
{entry.by && <span className="text-[10px] uppercase tracking-wider text-sky-600 dark:text-[#00f0ff]">{entry.by}</span>}
</div>
{entry.note && (
<span className="w-full text-[11px] text-zinc-500 dark:text-zinc-400 leading-snug mt-0.5">{entry.note}</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</>
);
})()}
</NeoCard> </NeoCard>
{/* Relationship Intelligence */} {/* Relationship Intelligence */}