feat(projects): interactive lifecycle progression control with stage history
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import { LIFECYCLE_STAGES, REWORK_STAGE, nextStage, prevStage, stageToPct } from '../../data/lifecycle';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import { AnimatedCounter } from '../../components/AnimatedCounter';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -190,6 +191,7 @@ const OwnerProjectDetail = () => {
|
||||
updateOrgCommissionDefaults, setUserCommissionOverride,
|
||||
removeUserCommissionOverride, updateProject,
|
||||
addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
|
||||
advanceLifecycleStage,
|
||||
} = useMockStore();
|
||||
const navigate = useNavigate();
|
||||
const { can } = usePermissions();
|
||||
@@ -554,41 +556,164 @@ const OwnerProjectDetail = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Project Progression */}
|
||||
<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} />
|
||||
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Project Progression</h3>
|
||||
</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">
|
||||
{/* Stepper Nodes */}
|
||||
{["Lead", "Inspection Scheduled", "Damage Verified", "Scope Approved", "Project Completed"].map((step, i) => {
|
||||
const isActive = (i * 25) <= (project.completionPercentage || 0);
|
||||
{/* Current stage label */}
|
||||
{(() => {
|
||||
const currentStage = project.lifecycleStage || LIFECYCLE_STAGES[0];
|
||||
const isRework = currentStage === REWORK_STAGE;
|
||||
const currentIdx = LIFECYCLE_STAGES.indexOf(currentStage);
|
||||
// Condensed milestone labels for stepper display
|
||||
const STEPPER_STAGES = ['Lead', 'Damage Verified', 'Contract Signed', 'Work In Progress', 'Project Completed', 'Final Payment Received — Closed'];
|
||||
const STEPPER_LABELS = ['Lead', 'Damage Verified', 'Contract Signed', 'Work In Progress', 'Completed', 'Closed'];
|
||||
// Consistency rule: WIP uses stored %, all others use stageToPct
|
||||
const displayPct = currentStage === 'Work In Progress'
|
||||
? (project.completionPercentage || 0)
|
||||
: stageToPct(currentStage);
|
||||
const isOwnerAdmin = ['OWNER', 'ADMIN'].includes(user?.role);
|
||||
const nextStageName = nextStage(currentStage);
|
||||
const isAtFinalStage = currentStage === 'Final Payment Received — Closed';
|
||||
const isFinalInspectionStage = currentStage === 'Final Inspection Scheduled' || currentStage === 'Final Inspection In Progress';
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col items-center gap-3 w-20 relative">
|
||||
<>
|
||||
<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
|
||||
${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'}
|
||||
${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-[10px] text-center font-bold tracking-wide uppercase leading-tight ${isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-500'}`}>
|
||||
{step}
|
||||
<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 className="mt-8 flex items-center gap-4">
|
||||
<ProgressBar progress={project.completionPercentage || 0} 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">{project.completionPercentage || 0}% CMP</span>
|
||||
|
||||
{/* Rework indicator */}
|
||||
{isRework && (
|
||||
<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">
|
||||
<AlertTriangle size={13} />
|
||||
Rework in progress — project re-enters Final Inspection after rework is resolved
|
||||
</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 “{nextStageName}”
|
||||
</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>
|
||||
|
||||
{/* Relationship Intelligence */}
|
||||
|
||||
Reference in New Issue
Block a user