diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index f21dac5..2ad6860 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -13,6 +13,12 @@ const menuAside: MenuAsideItem[] = [ label: 'Legacy Launchpad', permissions: 'CREATE_PROJECTS', }, + { + href: '/business-command-center', + icon: icon.mdiViewDashboardOutline, + label: 'Command center', + permissions: 'READ_PROJECTS', + }, { href: '/users/users-list', diff --git a/frontend/src/pages/business-command-center.tsx b/frontend/src/pages/business-command-center.tsx new file mode 100644 index 0000000..0a55bf1 --- /dev/null +++ b/frontend/src/pages/business-command-center.tsx @@ -0,0 +1,5195 @@ +import { + mdiAccountTieOutline, + mdiAlertCircle, + mdiArrowRight, + mdiBullhornOutline, + mdiCashMultiple, + mdiChartTimelineVariant, + mdiCheckCircle, + mdiFileDocumentOutline, + mdiHomeCityOutline, + mdiMapMarkerOutline, + mdiOpenInNew, + mdiRobotOutline, + mdiScaleBalance, + mdiViewDashboardOutline, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { humanize } from '../helpers/humanize'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type ProjectRecord = Record; + +type MetricCardProps = { + helper: string; + icon: string; + label: string; + value: string | number; +}; + +type SectionCardProps = { + action?: React.ReactNode; + children: React.ReactNode; + eyebrow: string; + icon: string; + title: string; +}; + +type StageRunnerAction = { + helper: string; + href: string; + id: string; + label: string; + priority: string; +}; + +type StageRunnerStage = { + blockedChecks: StageRunnerAction[]; + completedChecks: number; + missingChecks: StageRunnerAction[]; + passedChecks: string[]; + readiness: number; + stage: string; + status: string; + totalChecks: number; +}; + +type TimelineMilestone = { + date: Date; + helper: string; + href: string; + id: string; + label: string; + source: string; + status?: string; +}; + +type BudgetDriver = { + amount: number; + helper: string; + href: string; + id: string; + label: string; +}; + +type GuidedQueueLane = 'do_now' | 'do_next' | 'waiting_blocker'; + +type GuidedQueueItem = { + area: string; + helper: string; + href: string; + id: string; + label: string; + lane: GuidedQueueLane; + priority: string; + reason: string; + score: number; + status?: string; +}; + +type BusinessAreaReadinessItem = { + area: string; + href: string; + isInPlay: boolean; + nextFocus: string; + openLabel: string; + score: number; + signals: string[]; + status: string; + summary: string; + timingLabel: string; + urgentCount: number; + waitingCount: number; +}; + +type FounderDecisionItem = { + area: string; + helper: string; + href: string; + id: string; + impact: string; + label: string; + openLabel: string; + priority: string; + score: number; + status?: string; +}; + +type GoLiveGateItem = { + area: string; + evidence: string; + href: string; + id: string; + label: string; + nextFocus: string; + openLabel: string; + score: number; + status: string; +}; + +type OpeningPathItem = { + area: string; + helper: string; + href: string; + id: string; + label: string; + openLabel: string; + priority: string; + status?: string; +}; + +type OpeningPathWindow = { + description: string; + empty: string; + id: string; + items: OpeningPathItem[]; + title: string; +}; + +const projectStageOrder = [ + 'ideation', + 'planning', + 'funding', + 'site_selection', + 'legal_compliance', + 'design_build', + 'staffing_training', + 'marketing_sales', + 'launch', + 'operating', + 'paused', + 'completed', +]; + +const liveStageOrder = projectStageOrder.filter((stage) => !['paused', 'completed'].includes(stage)); + +const phaseTypeOrder = [ + 'idea_intake', + 'feasibility', + 'funding', + 'land_property', + 'design_engineering', + 'construction', + 'legal_compliance', + 'procurement', + 'staffing', + 'training', + 'marketing', + 'operations', + 'launch', +]; + +const priorityWeight: Record = { + critical: 4, + high: 3, + medium: 2, + low: 1, +}; + +const businessAreaOrder = ['strategy', 'planning', 'funding', 'property', 'legal', 'operations', 'hiring', 'marketing', 'launch']; + +const businessAreaActivationStage: Record = { + strategy: 'ideation', + planning: 'ideation', + funding: 'planning', + property: 'funding', + legal: 'site_selection', + operations: 'design_build', + hiring: 'design_build', + marketing: 'staffing_training', + launch: 'marketing_sales', +}; + +const readinessStatusWeight: Record = { + off_track: 3, + at_risk: 2, + planned: 1, + on_track: 0, +}; + +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, +}); +const dateFormatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); + +function toItems(value: any): ProjectRecord[] { + return Array.isArray(value) ? value : []; +} + +function toNumber(value: any) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 0; +} + +function clampScore(value: number) { + return Math.max(0, Math.min(Math.round(value), 100)); +} + +function formatCurrency(value: any) { + const numeric = Number(value); + + if (!Number.isFinite(numeric) || numeric <= 0) { + return '—'; + } + + return currencyFormatter.format(numeric); +} + +function formatCurrencyWithZero(value: any) { + const numeric = Number(value); + + if (!Number.isFinite(numeric)) { + return '—'; + } + + return currencyFormatter.format(numeric); +} + +function parseValidDate(value: any) { + if (!value) { + return null; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function startOfDay(value: Date) { + const normalized = new Date(value); + normalized.setHours(0, 0, 0, 0); + return normalized; +} + +function diffInCalendarDays(from: Date, to: Date) { + const millisecondsPerDay = 1000 * 60 * 60 * 24; + return Math.ceil((startOfDay(to).getTime() - startOfDay(from).getTime()) / millisecondsPerDay); +} + +function formatDate(value: any) { + if (!value) { + return 'TBD'; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return 'TBD'; + } + + return dateFormatter.format(parsed); +} + +function formatLocationSummary(location?: ProjectRecord | null) { + if (!location) { + return 'Flexible / to be chosen'; + } + + const cityState = [location.city, location.state].filter(Boolean).join(', '); + if (cityState) { + return cityState; + } + + if (location.county) { + return location.county; + } + + if (location.label) { + return location.label; + } + + if (location.country) { + return location.country; + } + + return 'Flexible / to be chosen'; +} + +function getStageArea(stage?: string | null) { + const map: Record = { + ideation: 'strategy', + planning: 'planning', + funding: 'funding', + site_selection: 'property', + legal_compliance: 'legal', + design_build: 'operations', + staffing_training: 'hiring', + marketing_sales: 'marketing', + launch: 'launch', + operating: 'operations', + paused: 'planning', + completed: 'operations', + }; + + return map[stage || ''] || 'planning'; +} + +function getPhaseArea(phaseType?: string | null) { + const map: Record = { + idea_intake: 'strategy', + feasibility: 'planning', + funding: 'funding', + land_property: 'property', + design_engineering: 'operations', + construction: 'operations', + legal_compliance: 'legal', + procurement: 'operations', + staffing: 'hiring', + training: 'hiring', + marketing: 'marketing', + operations: 'operations', + launch: 'launch', + }; + + return map[phaseType || ''] || 'planning'; +} + +function getAreaLabel(area?: string | null) { + const map: Record = { + strategy: 'Strategy', + planning: 'Planning', + funding: 'Funding', + property: 'Property', + legal: 'Legal', + operations: 'Operations', + hiring: 'Hiring', + marketing: 'Marketing', + launch: 'Launch', + }; + + return map[area || ''] || 'General'; +} + +function getProjectLabel(project?: ProjectRecord | null) { + if (!project) { + return 'Untitled workspace'; + } + + return project.project_name || project.business_idea?.idea_title || 'Untitled workspace'; +} + +function truncateLabel(value: string, maxLength = 34) { + if (!value) { + return 'Untitled'; + } + + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; +} + +function getToneClasses(value?: string) { + switch (value) { + case 'approved': + case 'completed': + case 'done': + case 'funded': + case 'succeeded': + case 'published': + case 'filled': + case 'operating': + case 'on_track': + return 'bg-emerald-100 text-emerald-700'; + case 'in_progress': + case 'running': + case 'submitted': + case 'planned': + case 'open': + return 'bg-blue-100 text-blue-700'; + case 'critical': + case 'blocked': + case 'rejected': + case 'failed': + case 'declined': + case 'renewal_due': + case 'off_track': + case 'over_target': + return 'bg-rose-100 text-rose-700'; + case 'high': + case 'paused': + case 'candidate': + case 'under_contract': + case 'at_risk': + case 'needs_target_date': + case 'needs_target_budget': + return 'bg-amber-100 text-amber-700'; + default: + return 'bg-slate-100 text-slate-700'; + } +} + +function buildStageGuide(stage?: string | null) { + const stageMap: Record = { + ideation: { + focus: 'Clarify the promise and customer problem', + summary: 'Validate the offer, customer need, and founder vision before spending heavily.', + }, + planning: { + focus: 'Turn the idea into a practical operating plan', + summary: 'Shape the budget, timeline, documents, and phase structure so daily execution is clear.', + }, + funding: { + focus: 'Secure capital and funding proof', + summary: 'Prepare lender or investor materials and make sure funding matches the real launch path.', + }, + site_selection: { + focus: 'Choose and de-risk the site', + summary: 'Compare property options, diligence, and zoning fit before committing the business.', + }, + legal_compliance: { + focus: 'Finish permits, registrations, and insurance', + summary: 'Treat compliance as a launch gate, especially for regulated or health-related operations.', + }, + design_build: { + focus: 'Coordinate design, vendors, and buildout', + summary: 'Move from concept into the physical systems, vendors, and assets needed to open.', + }, + staffing_training: { + focus: 'Hire and train the launch team', + summary: 'Make sure people, SOPs, and training exist before the opening date becomes fixed.', + }, + marketing_sales: { + focus: 'Build demand before opening day', + summary: 'Launch campaigns, content, partnerships, and opening offers so sales momentum is ready.', + }, + launch: { + focus: 'Run the final go-live checklist', + summary: 'Review blockers, staffing, compliance, marketing, and day-one operations together.', + }, + operating: { + focus: 'Operate, measure, and improve', + summary: 'Use the workspace as an operating system for follow-through, reporting, and refinement.', + }, + paused: { + focus: 'Resolve the reason the launch is paused', + summary: 'Find the stalled decision, missing resource, or blocker before pushing the timeline further.', + }, + completed: { + focus: 'Keep the business healthy after launch', + summary: 'Protect compliance, team rhythms, customer retention, and ongoing operating quality.', + }, + }; + + return ( + stageMap[stage || ''] || { + focus: 'Review the saved project data and set the next milestone', + summary: 'This workspace can only guide what has already been saved. Add the missing records to make it smarter.', + } + ); +} + +function sortPhases(left: ProjectRecord, right: ProjectRecord) { + const leftSort = Number(left.sort_order ?? phaseTypeOrder.indexOf(left.phase_type)); + const rightSort = Number(right.sort_order ?? phaseTypeOrder.indexOf(right.phase_type)); + + if (leftSort !== rightSort) { + return leftSort - rightSort; + } + + return new Date(left.start_at || left.createdAt || 0).getTime() - new Date(right.start_at || right.createdAt || 0).getTime(); +} + +function sortTasks(left: ProjectRecord, right: ProjectRecord) { + const leftPriority = priorityWeight[left.priority] || 0; + const rightPriority = priorityWeight[right.priority] || 0; + + if (leftPriority !== rightPriority) { + return rightPriority - leftPriority; + } + + const leftDue = left.due_at ? new Date(left.due_at).getTime() : Number.MAX_SAFE_INTEGER; + const rightDue = right.due_at ? new Date(right.due_at).getTime() : Number.MAX_SAFE_INTEGER; + + return leftDue - rightDue; +} + +function uniqueById(items: ProjectRecord[]) { + const seen = new Set(); + + return items.filter((item) => { + if (!item?.id || seen.has(item.id)) { + return false; + } + + seen.add(item.id); + return true; + }); +} + +function getStageCardClasses(isCurrent: boolean, status?: string) { + if (isCurrent) { + return 'border-blue-200 bg-blue-50/80 dark:border-blue-900/60 dark:bg-blue-950/20'; + } + + if (status === 'completed') { + return 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/60 dark:bg-emerald-950/20'; + } + + if (status === 'blocked') { + return 'border-rose-200 bg-rose-50/70 dark:border-rose-900/60 dark:bg-rose-950/20'; + } + + return 'border-slate-200 bg-slate-50/80 dark:border-dark-700 dark:bg-dark-800'; +} + +function getStageRunnerNoticeClasses(drift?: string, status?: string) { + if (status === 'blocked' || drift === 'ahead') { + return 'border-rose-200 bg-rose-50/90 dark:border-rose-900/60 dark:bg-rose-950/30'; + } + + if (drift === 'behind' || drift === 'paused') { + return 'border-amber-200 bg-amber-50/90 dark:border-amber-900/60 dark:bg-amber-950/30'; + } + + return 'border-blue-200 bg-blue-50/90 dark:border-blue-900/60 dark:bg-blue-950/30'; +} + +function getSummaryCardClasses(status?: string) { + switch (status) { + case 'completed': + case 'on_track': + case 'operating': + return 'border-emerald-200 bg-emerald-50/90 dark:border-emerald-900/60 dark:bg-emerald-950/30'; + case 'blocked': + case 'off_track': + case 'over_target': + return 'border-rose-200 bg-rose-50/90 dark:border-rose-900/60 dark:bg-rose-950/30'; + case 'at_risk': + case 'needs_target_date': + case 'needs_target_budget': + case 'paused': + return 'border-amber-200 bg-amber-50/90 dark:border-amber-900/60 dark:bg-amber-950/30'; + default: + return 'border-blue-200 bg-blue-50/90 dark:border-blue-900/60 dark:bg-blue-950/30'; + } +} + +function getGuidedLaneClasses(lane: GuidedQueueLane) { + switch (lane) { + case 'do_now': + return 'border-rose-200 bg-rose-50/80 dark:border-rose-900/60 dark:bg-rose-950/20'; + case 'waiting_blocker': + return 'border-amber-200 bg-amber-50/80 dark:border-amber-900/60 dark:bg-amber-950/20'; + default: + return 'border-blue-200 bg-blue-50/80 dark:border-blue-900/60 dark:bg-blue-950/20'; + } +} + +function getProgressFillClasses(status?: string) { + switch (status) { + case 'completed': + case 'on_track': + case 'operating': + return 'bg-emerald-500'; + case 'blocked': + case 'off_track': + case 'over_target': + return 'bg-rose-500'; + case 'at_risk': + case 'needs_target_date': + case 'needs_target_budget': + case 'paused': + return 'bg-amber-500'; + default: + return 'bg-blue-500'; + } +} + +function getSignalPressureWeight(status?: string) { + switch (status) { + case 'blocked': + case 'off_track': + case 'over_target': + return 4; + case 'at_risk': + case 'needs_target_date': + case 'needs_target_budget': + case 'paused': + return 3; + case 'planned': + return 1; + case 'completed': + case 'on_track': + case 'operating': + return 0; + default: + return 2; + } +} + +function StatusChip({ value }: { value?: string | null }) { + return ( + + {humanize(value || 'not_set')} + + ); +} + +function MetricCard({ helper, icon, label, value }: MetricCardProps) { + return ( + +
+
+ +
+
+
{label}
+
{value}
+

{helper}

+
+
+
+ ); +} + +function SectionCard({ action, children, eyebrow, icon, title }: SectionCardProps) { + return ( + +
+
+
+ +
+
+
{eyebrow}
+

{title}

+
+
+ {action} +
+
{children}
+
+ ); +} + +const BusinessCommandCenter = () => { + const router = useRouter(); + const { currentUser } = useAppSelector((state) => state.auth); + + const canReadProjects = hasPermission(currentUser, 'READ_PROJECTS'); + const canCreateProjects = hasPermission(currentUser, 'CREATE_PROJECTS'); + const canUpdateProjects = hasPermission(currentUser, 'UPDATE_PROJECTS'); + const canReadProjectPhases = hasPermission(currentUser, 'READ_PROJECT_PHASES'); + const canReadTasks = hasPermission(currentUser, 'READ_TASKS'); + const canReadFunding = hasPermission(currentUser, 'READ_FUNDING_ROUNDS'); + const canReadProperties = hasPermission(currentUser, 'READ_PROPERTIES'); + const canReadLegal = hasPermission(currentUser, 'READ_LEGAL_REQUIREMENTS'); + const canReadDocuments = hasPermission(currentUser, 'READ_DOCUMENTS'); + const canReadPositions = hasPermission(currentUser, 'READ_POSITIONS'); + const canReadTraining = hasPermission(currentUser, 'READ_TRAINING_PROGRAMS'); + const canReadMarketing = hasPermission(currentUser, 'READ_MARKETING_CAMPAIGNS'); + const canReadDesignAssets = hasPermission(currentUser, 'READ_DESIGN_ASSETS'); + const canReadAiRuns = hasPermission(currentUser, 'READ_AI_RUNS'); + const canReadBusinessIdeas = hasPermission(currentUser, 'READ_BUSINESS_IDEAS'); + + const [recentProjects, setRecentProjects] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(''); + const [project, setProject] = useState(null); + const [loadError, setLoadError] = useState(''); + const [isLoadingProjects, setIsLoadingProjects] = useState(true); + const [isLoadingProject, setIsLoadingProject] = useState(false); + + useEffect(() => { + const queryProjectId = typeof router.query.projectId === 'string' ? router.query.projectId : ''; + + if (queryProjectId && queryProjectId !== selectedProjectId) { + setSelectedProjectId(queryProjectId); + } + }, [router.query.projectId, selectedProjectId]); + + useEffect(() => { + if (!canReadProjects) { + setIsLoadingProjects(false); + return; + } + + let isMounted = true; + + const loadProjects = async () => { + try { + setLoadError(''); + setIsLoadingProjects(true); + + const response = await axios.get('/projects?page=0&limit=8&field=createdAt&sort=desc'); + const rows = toItems(response.data?.rows); + const queryProjectId = typeof router.query.projectId === 'string' ? router.query.projectId : ''; + + if (!isMounted) { + return; + } + + setRecentProjects(rows); + setSelectedProjectId((currentValue) => currentValue || queryProjectId || rows[0]?.id || ''); + } catch (error) { + console.error('Failed to load recent projects for business command center', error); + + if (isMounted) { + setLoadError('We could not load your saved projects right now.'); + } + } finally { + if (isMounted) { + setIsLoadingProjects(false); + } + } + }; + + loadProjects(); + + return () => { + isMounted = false; + }; + }, [canReadProjects, router.query.projectId]); + + useEffect(() => { + if (!canReadProjects || !selectedProjectId) { + setProject(null); + setIsLoadingProject(false); + return; + } + + let isMounted = true; + + const loadProject = async () => { + try { + setLoadError(''); + setIsLoadingProject(true); + setProject(null); + + const response = await axios.get(`/projects/${selectedProjectId}`); + + if (!isMounted) { + return; + } + + setProject(response.data || null); + } catch (error) { + console.error('Failed to load selected project for business command center', error); + + if (isMounted) { + setLoadError('We could not load the selected business workspace.'); + } + } finally { + if (isMounted) { + setIsLoadingProject(false); + } + } + }; + + loadProject(); + + return () => { + isMounted = false; + }; + }, [canReadProjects, selectedProjectId]); + + const projectLabel = useMemo(() => getProjectLabel(project), [project]); + const phases = useMemo(() => [...toItems(project?.project_phases_project)].sort(sortPhases), [project]); + const phaseNameById = useMemo(() => { + const map = new Map(); + + phases.forEach((phase) => { + if (phase.id) { + map.set(phase.id, phase.phase_name || humanize(phase.phase_type || 'phase')); + } + }); + + return map; + }, [phases]); + const phaseTypeById = useMemo(() => { + const map = new Map(); + + phases.forEach((phase) => { + if (phase.id) { + map.set(phase.id, phase.phase_type || ''); + } + }); + + return map; + }, [phases]); + const tasks = useMemo(() => [...toItems(project?.tasks_project)].sort(sortTasks), [project]); + const fundingRounds = useMemo( + () => [...toItems(project?.funding_rounds_project)].sort((left, right) => new Date(left.open_at || left.createdAt || 0).getTime() - new Date(right.open_at || right.createdAt || 0).getTime()), + [project], + ); + const properties = useMemo(() => toItems(project?.properties_project), [project]); + const legalRequirements = useMemo( + () => [...toItems(project?.legal_requirements_project)].sort((left, right) => new Date(left.due_at || left.createdAt || 0).getTime() - new Date(right.due_at || right.createdAt || 0).getTime()), + [project], + ); + const documents = useMemo(() => toItems(project?.documents_project), [project]); + const positions = useMemo(() => toItems(project?.positions_project), [project]); + const trainingPrograms = useMemo(() => toItems(project?.training_programs_project), [project]); + const marketingCampaigns = useMemo(() => toItems(project?.marketing_campaigns_project), [project]); + const designAssets = useMemo(() => toItems(project?.design_assets_project), [project]); + const aiRuns = useMemo( + () => [...toItems(project?.ai_runs_project)].sort((left, right) => new Date(right.started_at || right.createdAt || 0).getTime() - new Date(left.started_at || left.createdAt || 0).getTime()), + [project], + ); + + const actionableTasks = useMemo(() => tasks.filter((task) => !['done', 'canceled'].includes(task.status)), [tasks]); + const blockedTasks = useMemo(() => actionableTasks.filter((task) => task.status === 'blocked'), [actionableTasks]); + const completedTasks = useMemo(() => tasks.filter((task) => task.status === 'done').length, [tasks]); + const overdueTasks = useMemo(() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + return actionableTasks.filter((task) => { + if (!task.due_at) { + return false; + } + + const dueDate = new Date(task.due_at); + dueDate.setHours(0, 0, 0, 0); + + return dueDate.getTime() < today.getTime(); + }); + }, [actionableTasks]); + const blockedPhases = useMemo(() => phases.filter((phase) => phase.status === 'blocked'), [phases]); + const completedPhases = useMemo(() => phases.filter((phase) => phase.status === 'completed').length, [phases]); + const activePhase = useMemo( + () => phases.find((phase) => phase.status === 'in_progress') || phases.find((phase) => phase.status === 'blocked') || phases.find((phase) => phase.status !== 'completed') || phases[0] || null, + [phases], + ); + + const approvedLegalCount = useMemo(() => legalRequirements.filter((item) => item.status === 'approved').length, [legalRequirements]); + const dueSoonLegalCount = useMemo(() => { + const today = new Date(); + const nextThirtyDays = new Date(); + nextThirtyDays.setDate(nextThirtyDays.getDate() + 30); + + return legalRequirements.filter((item) => { + if (!item.due_at || item.status === 'approved') { + return false; + } + + const dueDate = new Date(item.due_at); + return dueDate.getTime() >= today.getTime() && dueDate.getTime() <= nextThirtyDays.getTime(); + }).length; + }, [legalRequirements]); + + const urgentLegalRequirements = useMemo( + () => legalRequirements.filter((item) => item.status !== 'approved').slice(0, 5), + [legalRequirements], + ); + + const approvedDocuments = useMemo(() => documents.filter((item) => item.status === 'approved').length, [documents]); + const draftedDocuments = useMemo(() => documents.filter((item) => item.status === 'draft').length, [documents]); + const openRoles = useMemo(() => positions.filter((item) => ['planned', 'open', 'interviewing', 'offered'].includes(item.status)).length, [positions]); + const publishedTrainingPrograms = useMemo(() => trainingPrograms.filter((item) => item.status === 'published').length, [trainingPrograms]); + const runningCampaigns = useMemo(() => marketingCampaigns.filter((item) => item.status === 'running').length, [marketingCampaigns]); + const plannedCampaigns = useMemo(() => marketingCampaigns.filter((item) => item.status === 'planned').length, [marketingCampaigns]); + const latestAiRun = useMemo(() => aiRuns[0] || null, [aiRuns]); + + const fundingTarget = useMemo(() => fundingRounds.reduce((sum, item) => sum + toNumber(item.target_amount), 0), [fundingRounds]); + const fundingCommitted = useMemo(() => fundingRounds.reduce((sum, item) => sum + toNumber(item.committed_amount), 0), [fundingRounds]); + const fundingFunded = useMemo(() => fundingRounds.reduce((sum, item) => sum + toNumber(item.funded_amount), 0), [fundingRounds]); + const leadFundingRound = useMemo( + () => fundingRounds.find((item) => !['declined', 'canceled'].includes(item.status)) || fundingRounds[0] || null, + [fundingRounds], + ); + const leadProperty = useMemo(() => properties[0] || null, [properties]); + const taskEstimatedCost = useMemo(() => tasks.reduce((sum, item) => sum + toNumber(item.estimated_cost), 0), [tasks]); + const taskActualCost = useMemo(() => tasks.reduce((sum, item) => sum + toNumber(item.actual_cost), 0), [tasks]); + const legalEstimatedFees = useMemo(() => legalRequirements.reduce((sum, item) => sum + toNumber(item.estimated_fees), 0), [legalRequirements]); + const marketingPlannedBudget = useMemo(() => marketingCampaigns.reduce((sum, item) => sum + toNumber(item.budget), 0), [marketingCampaigns]); + const leadPropertyTrackedCost = useMemo(() => toNumber(leadProperty?.purchase_price) || toNumber(leadProperty?.asking_price), [leadProperty]); + const fundingAvailable = useMemo(() => Math.max(fundingCommitted, fundingFunded), [fundingCommitted, fundingFunded]); + const today = useMemo(() => startOfDay(new Date()), []); + const projectEditHref = canUpdateProjects && project?.id ? `/projects/projects-edit/?id=${project.id}` : ''; + const projectViewHref = canReadProjects && project?.id ? `/projects/projects-view/?id=${project.id}` : ''; + + const stageRunner = useMemo(() => { + const planningDocumentCount = documents.filter((item) => ['business_plan', 'budget_model', 'pitch_deck'].includes(item.document_type)).length; + const approvedOrReviewDocumentCount = documents.filter((item) => ['approved', 'in_review'].includes(item.status)).length; + const approvedMarketingAssetCount = documents.filter((item) => item.document_type === 'marketing_asset' && ['approved', 'in_review'].includes(item.status)).length; + const activePropertyCount = properties.filter((item) => item.acquisition_status !== 'rejected').length; + const securedPropertyCount = properties.filter((item) => ['under_contract', 'purchased', 'leased'].includes(item.acquisition_status)).length; + const viableFundingCount = fundingRounds.filter((item) => !['declined', 'canceled'].includes(item.status)).length; + const fundingMomentum = fundingRounds.some((item) => ['in_progress', 'committed', 'funded'].includes(item.status)) || fundingCommitted > 0 || fundingFunded > 0; + const legalInFlight = legalRequirements.some((item) => ['in_progress', 'submitted', 'approved'].includes(item.status)); + const legalAlertCount = legalRequirements.filter((item) => ['rejected', 'renewal_due'].includes(item.status)).length; + const buildPhaseCount = phases.filter((phase) => ['design_engineering', 'construction', 'procurement'].includes(phase.phase_type)).length; + const buildReady = Boolean( + designAssets.length + || phases.some((phase) => phase.status === 'completed' && ['design_engineering', 'construction', 'procurement'].includes(phase.phase_type)) + || documents.some((item) => ['lease', 'purchase_agreement', 'permit_application'].includes(item.document_type) && ['approved', 'in_review'].includes(item.status)), + ); + const filledOrClosedRoles = positions.filter((item) => ['filled', 'closed'].includes(item.status)).length; + const hasIdeaRecord = Boolean(project?.business_idea?.id || project?.business_idea || project?.vision || project?.project_name); + const hasPlanningBasics = Boolean(project?.target_budget || project?.target_open_date || planningDocumentCount); + const launchPackReady = approvedDocuments > 0 && publishedTrainingPrograms > 0 && (runningCampaigns > 0 || plannedCampaigns > 0); + const operationsSignals = Boolean( + project?.stage === 'operating' + || phases.some((phase) => phase.phase_type === 'operations' && phase.status !== 'not_started') + || aiRuns.length, + ); + + const stages = [ + { + stage: 'ideation', + checks: [ + { + passed: hasIdeaRecord, + successLabel: 'The business idea or founder vision is saved.', + id: 'idea-record', + label: 'Capture the business idea and customer promise', + helper: 'This workspace still needs a clear offer, audience, or founder vision record.', + href: canReadBusinessIdeas ? '/business_ideas/business_ideas-list' : projectEditHref, + priority: 'critical', + }, + { + passed: Boolean(project?.project_name || project?.vision), + successLabel: 'The workspace already has a project name or vision.', + id: 'workspace-vision', + label: 'Name the workspace and save the launch vision', + helper: 'A clear project name and vision keep every later record tied to the same launch.', + href: projectEditHref, + priority: 'high', + }, + ], + }, + { + stage: 'planning', + checks: [ + { + passed: phases.length > 0, + successLabel: 'The launch roadmap already has project phases.', + id: 'phase-plan', + label: 'Break the launch into trackable phases', + helper: 'Add phases so the business can move step-by-step instead of staying a single big idea.', + href: canReadProjectPhases ? '/project_phases/project_phases-list' : '', + priority: 'critical', + }, + { + passed: tasks.length > 0, + successLabel: 'A task list already exists for execution.', + id: 'task-plan', + label: 'Create the first concrete action list', + helper: 'Without tasks, the project cannot turn the plan into weekly execution.', + href: canReadTasks ? '/tasks/tasks-list' : '', + priority: 'critical', + }, + { + passed: hasPlanningBasics, + successLabel: 'Budget, timing, or planning documents are already saved.', + id: 'planning-basics', + label: 'Save the opening budget, target date, or planning documents', + helper: 'The project still needs timing, budget, or planning documents before it can guide decisions well.', + href: canReadDocuments ? '/documents/documents-list' : projectEditHref, + priority: 'high', + }, + ], + }, + { + stage: 'funding', + checks: [ + { + passed: fundingRounds.length > 0, + successLabel: 'A funding path is already saved for the project.', + id: 'funding-path', + label: 'Create the first funding round or capital path', + helper: 'There is no saved capital path yet for how this business will actually open.', + href: canReadFunding ? '/funding_rounds/funding_rounds-list' : '', + priority: 'high', + }, + { + passed: fundingMomentum, + successLabel: 'Capital movement is recorded through in-progress, committed, or funded rounds.', + id: 'funding-momentum', + label: 'Record real progress toward committed or funded capital', + helper: 'The project still needs proof that funding conversations are moving beyond planning.', + href: canReadFunding ? '/funding_rounds/funding_rounds-list' : '', + priority: 'critical', + }, + { + blocked: fundingRounds.length > 0 && viableFundingCount === 0, + passed: viableFundingCount > 0, + successLabel: 'At least one funding option is still viable.', + id: 'funding-blocked', + label: 'Replace declined or canceled funding options', + helper: 'Every saved funding round is currently declined or canceled, so the capital path is blocked.', + href: canReadFunding ? '/funding_rounds/funding_rounds-list' : '', + priority: 'critical', + }, + ], + }, + { + stage: 'site_selection', + checks: [ + { + passed: activePropertyCount > 0, + successLabel: 'At least one active candidate site is already tracked.', + id: 'site-tracking', + label: 'Track at least one candidate property or site', + helper: 'There is no active site or property record attached to this launch yet.', + href: canReadProperties ? '/properties/properties-list' : '', + priority: 'high', + }, + { + passed: securedPropertyCount > 0, + successLabel: 'A property is under contract, purchased, or leased.', + id: 'site-secured', + label: 'Move one property into contract, purchase, or lease', + helper: 'The project still needs a site that is materially secured before later launch steps become reliable.', + href: canReadProperties ? '/properties/properties-list' : '', + priority: 'high', + }, + { + blocked: properties.length > 0 && activePropertyCount === 0, + passed: activePropertyCount > 0, + successLabel: 'There is still at least one viable property option.', + id: 'site-blocked', + label: 'Add new site options after prior locations were rejected', + helper: 'All saved properties are marked rejected, so site selection is currently blocked.', + href: canReadProperties ? '/properties/properties-list' : '', + priority: 'critical', + }, + ], + }, + { + stage: 'legal_compliance', + checks: [ + { + passed: legalRequirements.length > 0, + successLabel: 'A compliance checklist already exists for the project.', + id: 'legal-list', + label: 'Start the legal and compliance checklist', + helper: 'No permits, registrations, licensing, or compliance records are saved yet.', + href: canReadLegal ? '/legal_requirements/legal_requirements-list' : '', + priority: 'critical', + }, + { + passed: legalInFlight, + successLabel: 'Compliance work is already in progress or submitted.', + id: 'legal-progress', + label: 'Move compliance items into progress or submission', + helper: 'Saved compliance items still need real activity, submissions, or approvals.', + href: canReadLegal ? '/legal_requirements/legal_requirements-list' : '', + priority: 'critical', + }, + { + passed: legalRequirements.length > 0 && approvedLegalCount >= Math.max(1, Math.ceil(legalRequirements.length / 2)), + successLabel: 'A meaningful share of compliance items are already approved.', + id: 'legal-approval', + label: 'Secure approvals on the highest-risk compliance items', + helper: 'More approvals are needed before this launch can treat compliance as materially covered.', + href: canReadLegal ? '/legal_requirements/legal_requirements-list' : '', + priority: 'high', + }, + { + blocked: legalAlertCount > 0, + passed: legalAlertCount === 0, + successLabel: 'No rejected or renewal-due compliance items are saved.', + id: 'legal-blocked', + label: 'Resolve rejected or renewal-due compliance items', + helper: 'One or more compliance records are rejected or due for renewal and need human review.', + href: canReadLegal ? '/legal_requirements/legal_requirements-list' : '', + priority: 'critical', + }, + ], + }, + { + stage: 'design_build', + checks: [ + { + passed: buildPhaseCount > 0 || designAssets.length > 0, + successLabel: 'Design/build work is already represented in phases or assets.', + id: 'build-track', + label: 'Create design, build, or procurement work tracks', + helper: 'The project still needs design, construction, procurement, or asset work recorded.', + href: canReadProjectPhases ? '/project_phases/project_phases-list' : '', + priority: 'medium', + }, + { + passed: buildReady, + successLabel: 'Build assets, approvals, or related materials are already saved.', + id: 'build-materials', + label: 'Save build assets, approvals, or site materials', + helper: 'Later launch work is still missing the supporting build documents or assets.', + href: canReadDocuments ? '/documents/documents-list' : '', + priority: 'medium', + }, + ], + }, + { + stage: 'staffing_training', + checks: [ + { + passed: positions.length > 0, + successLabel: 'The project already has a hiring plan or role list.', + id: 'roles-plan', + label: 'Define the launch roles and hiring plan', + helper: 'There are no saved positions yet for who will operate the business.', + href: canReadPositions ? '/positions/positions-list' : '', + priority: 'high', + }, + { + passed: publishedTrainingPrograms > 0, + successLabel: 'Training material is already published for onboarding.', + id: 'training-plan', + label: 'Publish training for staff, compliance, or onboarding', + helper: 'The launch still needs published training material before operations can be consistent.', + href: canReadTraining ? '/training_programs/training_programs-list' : '', + priority: 'high', + }, + { + passed: positions.length > 0 && (filledOrClosedRoles > 0 || openRoles === 0), + successLabel: 'Critical roles are filled or the open-role queue is already closed.', + id: 'roles-coverage', + label: 'Close or fill the most important open roles', + helper: 'Important positions are still open, so staffing is not yet launch-ready.', + href: canReadPositions ? '/positions/positions-list' : '', + priority: 'high', + }, + ], + }, + { + stage: 'marketing_sales', + checks: [ + { + passed: marketingCampaigns.length > 0, + successLabel: 'A marketing plan is already saved in campaigns.', + id: 'marketing-plan', + label: 'Create the first marketing campaign plan', + helper: 'No campaign records are saved yet to build demand before launch.', + href: canReadMarketing ? '/marketing_campaigns/marketing_campaigns-list' : '', + priority: 'medium', + }, + { + passed: runningCampaigns > 0 || plannedCampaigns > 0, + successLabel: 'Demand-generation activity is already planned or running.', + id: 'marketing-activity', + label: 'Schedule or launch demand-generation activity', + helper: 'The project still needs demand generation to move beyond passive planning.', + href: canReadMarketing ? '/marketing_campaigns/marketing_campaigns-list' : '', + priority: 'medium', + }, + { + passed: approvedMarketingAssetCount > 0 || designAssets.length > 0, + successLabel: 'Creative assets already exist for launch communications.', + id: 'marketing-assets', + label: 'Save approved marketing assets or launch creatives', + helper: 'The business still needs creative assets or approved marketing material for launch messaging.', + href: canReadDocuments ? '/documents/documents-list' : '', + priority: 'medium', + }, + ], + }, + { + stage: 'launch', + checks: [ + { + passed: blockedPhases.length === 0 && blockedTasks.length === 0, + successLabel: 'No blocked phases or tasks are saved right now.', + id: 'launch-blockers', + label: 'Clear blocked tasks and blocked phases', + helper: 'The project still has blocked work items that should be cleared before launch timing is trusted.', + href: canReadTasks ? '/tasks/tasks-list' : canReadProjectPhases ? '/project_phases/project_phases-list' : '', + priority: 'critical', + }, + { + passed: dueSoonLegalCount === 0 && legalAlertCount === 0, + successLabel: 'There are no urgent compliance deadlines blocking launch.', + id: 'launch-compliance', + label: 'Clear urgent compliance deadlines before go-live', + helper: 'Urgent legal or compliance work still needs attention before launch can be treated as safe.', + href: canReadLegal ? '/legal_requirements/legal_requirements-list' : '', + priority: 'critical', + }, + { + passed: launchPackReady, + successLabel: 'Documents, training, and campaign prep are all represented in the workspace.', + id: 'launch-pack', + label: 'Finish the launch pack: docs, training, and demand plan', + helper: 'The workspace still needs launch documents, training, and demand generation lined up together.', + href: canReadDocuments ? '/documents/documents-list' : '', + priority: 'high', + }, + { + passed: securedPropertyCount > 0 && fundingMomentum, + successLabel: 'The site and capital path both show meaningful progress.', + id: 'launch-foundation', + label: 'Secure the site and capital path before opening day', + helper: 'A launch date is still risky until the project has both a viable site and real capital momentum.', + href: canReadFunding ? '/funding_rounds/funding_rounds-list' : canReadProperties ? '/properties/properties-list' : '', + priority: 'high', + }, + ], + }, + { + stage: 'operating', + checks: [ + { + passed: operationsSignals, + successLabel: 'An operating rhythm already exists in phases, AI runs, or the saved stage.', + id: 'ops-rhythm', + label: 'Create an operating rhythm for post-launch reviews', + helper: 'The project still needs a visible operating rhythm for ongoing management after launch.', + href: canReadAiRuns ? '/ai_runs/ai_runs-list' : canReadTasks ? '/tasks/tasks-list' : projectViewHref, + priority: 'medium', + }, + { + passed: approvedOrReviewDocumentCount > 0 && (aiRuns.length > 0 || actionableTasks.length > 0), + successLabel: 'Operating materials and active follow-through are already visible.', + id: 'ops-systems', + label: 'Keep operating systems, documents, and follow-through active', + helper: 'The workspace still needs ongoing operating materials or recurring follow-through to act like a real business system.', + href: canReadDocuments ? '/documents/documents-list' : projectViewHref, + priority: 'medium', + }, + ], + }, + ]; + + const normalizedStages: StageRunnerStage[] = stages.map((item) => { + const completedChecks = item.checks.filter((check) => check.passed).length; + const totalChecks = item.checks.length; + const blockedChecks = item.checks + .filter((check) => check.blocked && !check.passed) + .map(({ helper, href, id, label, priority }) => ({ helper, href, id, label, priority })); + const missingChecks = item.checks + .filter((check) => !check.passed && !check.blocked) + .map(({ helper, href, id, label, priority }) => ({ helper, href, id, label, priority })); + const passedChecks = item.checks.filter((check) => check.passed).map((check) => check.successLabel || check.label); + const readiness = totalChecks ? Math.round((completedChecks / totalChecks) * 100) : 0; + + let status = 'not_started'; + + if (blockedChecks.length > 0) { + status = 'blocked'; + } else if (completedChecks === totalChecks) { + status = 'completed'; + } else if (completedChecks > 0) { + status = 'in_progress'; + } + + return { + blockedChecks, + completedChecks, + missingChecks, + passedChecks, + readiness, + stage: item.stage, + status, + totalChecks, + }; + }); + + const currentStage = normalizedStages.find((item) => item.status !== 'completed') || normalizedStages[normalizedStages.length - 1]; + const currentStageIndex = liveStageOrder.indexOf(currentStage.stage); + const nextStage = currentStageIndex >= 0 ? normalizedStages[currentStageIndex + 1] || null : null; + const savedStageIndex = liveStageOrder.indexOf(project?.stage || ''); + + let drift = 'aligned'; + let message = 'This is the best-fit working stage based on the records already saved.'; + + if (!project?.stage) { + message = `No saved stage is set yet. Based on the records, ${humanize(currentStage.stage)} is the best current stage to work from.`; + } else if (project.stage === 'paused') { + drift = 'paused'; + message = `This workspace is marked paused. When you resume, start with ${humanize(currentStage.stage)}.`; + } else if (project.stage === 'completed') { + message = 'This workspace is marked completed. Use the stage runner below to verify the operating system that was actually saved.'; + } else if (savedStageIndex > currentStageIndex && currentStage.status !== 'completed') { + drift = 'ahead'; + message = `The saved stage is ahead of the evidence. Finish ${humanize(currentStage.stage)} before pushing farther down the roadmap.`; + } else if (savedStageIndex >= 0 && savedStageIndex < currentStageIndex) { + drift = 'behind'; + message = `Your records suggest the project has moved beyond the saved stage. Review and update the saved stage when you are comfortable.`; + } else if (currentStage.status === 'blocked') { + message = `Do not advance yet. ${humanize(currentStage.stage)} has a blocker that should be cleared first.`; + } else if (currentStage.missingChecks.length > 0) { + message = `Finish ${currentStage.missingChecks.length} remaining ${currentStage.missingChecks.length === 1 ? 'readiness item' : 'readiness items'} in ${humanize(currentStage.stage)} before treating the next stage as real.`; + } + + return { + currentStage, + currentStageIndex, + drift, + message, + nextStage, + savedStageIndex, + stages: normalizedStages, + }; + }, [ + actionableTasks.length, + aiRuns, + approvedDocuments, + approvedLegalCount, + blockedPhases, + blockedTasks, + canReadAiRuns, + canReadBusinessIdeas, + canReadDocuments, + canReadFunding, + canReadLegal, + canReadMarketing, + canReadPositions, + canReadProjectPhases, + canReadProperties, + canReadTasks, + canReadTraining, + designAssets, + documents, + dueSoonLegalCount, + fundingCommitted, + fundingFunded, + fundingRounds, + legalRequirements, + marketingCampaigns, + openRoles, + phases, + plannedCampaigns, + positions, + project, + projectEditHref, + projectViewHref, + properties, + publishedTrainingPrograms, + runningCampaigns, + tasks, + ]); + + const stageGuide = useMemo(() => buildStageGuide(stageRunner.currentStage.stage), [stageRunner.currentStage.stage]); + const currentStageActions = useMemo( + () => [...stageRunner.currentStage.blockedChecks, ...stageRunner.currentStage.missingChecks].slice(0, 4), + [stageRunner], + ); + const currentStageCompletedSignals = useMemo(() => stageRunner.currentStage.passedChecks.slice(0, 4), [stageRunner]); + + const timelineMilestones = useMemo(() => { + const items: TimelineMilestone[] = []; + + const pushMilestone = ({ + dateValue, + helper, + href, + id, + label, + source, + status, + }: { + dateValue: any; + helper: string; + href: string; + id: string; + label: string; + source: string; + status?: string; + }) => { + const date = parseValidDate(dateValue); + + if (!date) { + return; + } + + items.push({ + date, + helper, + href, + id, + label, + source, + status, + }); + }; + + if (project?.target_open_date) { + pushMilestone({ + dateValue: project.target_open_date, + helper: 'Saved target opening date for the business workspace.', + href: projectEditHref || projectViewHref, + id: `target-open-${project.id}`, + label: 'Target opening date', + source: 'Project target', + status: project.stage || stageRunner.currentStage.stage, + }); + } + + phases + .filter((phase) => phase.status !== 'completed') + .forEach((phase) => { + pushMilestone({ + dateValue: phase.end_at || phase.start_at, + helper: phase.summary || 'No phase summary saved yet.', + href: canReadProjectPhases ? `/project_phases/project_phases-view/?id=${phase.id}` : '', + id: `phase-target-${phase.id}`, + label: phase.phase_name || humanize(phase.phase_type || 'phase'), + source: phase.end_at ? 'Phase target' : 'Phase start', + status: phase.status, + }); + }); + + actionableTasks + .filter((task) => task.due_at) + .forEach((task) => { + pushMilestone({ + dateValue: task.due_at, + helper: [task.priority ? `${humanize(task.priority)} priority` : null, task.phaseId ? phaseNameById.get(task.phaseId) : null].filter(Boolean).join(' • '), + href: canReadTasks ? `/tasks/tasks-view/?id=${task.id}` : '', + id: `task-due-${task.id}`, + label: task.task_title || 'Untitled task', + source: 'Task due', + status: task.status, + }); + }); + + legalRequirements + .filter((item) => item.status !== 'approved' && (item.due_at || item.renewal_at)) + .forEach((item) => { + const dateValue = item.status === 'renewal_due' && item.renewal_at ? item.renewal_at : item.due_at || item.renewal_at; + + pushMilestone({ + dateValue, + helper: [item.requirement_type ? humanize(item.requirement_type) : null, item.authority_name].filter(Boolean).join(' • '), + href: canReadLegal ? `/legal_requirements/legal_requirements-view/?id=${item.id}` : '', + id: `legal-milestone-${item.id}`, + label: item.requirement_title || 'Compliance milestone', + source: item.status === 'renewal_due' ? 'Renewal due' : 'Compliance due', + status: item.status, + }); + }); + + fundingRounds + .filter((item) => !['declined', 'canceled'].includes(item.status)) + .forEach((item) => { + if (item.open_at) { + pushMilestone({ + dateValue: item.open_at, + helper: [item.funding_type ? humanize(item.funding_type) : null, item.target_amount ? `${formatCurrency(item.target_amount)} target` : null].filter(Boolean).join(' • '), + href: canReadFunding ? `/funding_rounds/funding_rounds-view/?id=${item.id}` : '', + id: `funding-open-${item.id}`, + label: item.round_name || 'Funding round opens', + source: 'Funding opens', + status: item.status, + }); + } + + if (item.close_at) { + pushMilestone({ + dateValue: item.close_at, + helper: [item.funding_type ? humanize(item.funding_type) : null, item.target_amount ? `${formatCurrency(item.target_amount)} target` : null].filter(Boolean).join(' • '), + href: canReadFunding ? `/funding_rounds/funding_rounds-view/?id=${item.id}` : '', + id: `funding-close-${item.id}`, + label: item.round_name || 'Funding round closes', + source: 'Funding closes', + status: item.status, + }); + } + }); + + properties + .filter((item) => item.acquisition_status !== 'rejected') + .forEach((item) => { + if (item.offer_date) { + pushMilestone({ + dateValue: item.offer_date, + helper: [item.property_type ? humanize(item.property_type) : null, item.property_name || 'Unnamed property'].filter(Boolean).join(' • '), + href: canReadProperties ? `/properties/properties-view/?id=${item.id}` : '', + id: `property-offer-${item.id}`, + label: item.property_name || 'Property offer date', + source: 'Property offer', + status: item.acquisition_status, + }); + } + + if (item.closing_date) { + pushMilestone({ + dateValue: item.closing_date, + helper: [item.property_type ? humanize(item.property_type) : null, item.property_name || 'Unnamed property'].filter(Boolean).join(' • '), + href: canReadProperties ? `/properties/properties-view/?id=${item.id}` : '', + id: `property-close-${item.id}`, + label: item.property_name || 'Property closing date', + source: 'Property close', + status: item.acquisition_status, + }); + } + }); + + positions + .filter((item) => !['filled', 'closed'].includes(item.status) && item.target_start_at) + .forEach((item) => { + pushMilestone({ + dateValue: item.target_start_at, + helper: [item.employment_type ? humanize(item.employment_type) : null, item.status ? humanize(item.status) : null].filter(Boolean).join(' • '), + href: canReadPositions ? `/positions/positions-view/?id=${item.id}` : '', + id: `position-start-${item.id}`, + label: item.position_title || 'Position start target', + source: 'Hiring target', + status: item.status, + }); + }); + + marketingCampaigns + .filter((item) => item.status !== 'completed') + .forEach((item) => { + if (item.start_at) { + pushMilestone({ + dateValue: item.start_at, + helper: [item.channel ? humanize(item.channel) : null, item.budget ? `${formatCurrency(item.budget)} budget` : null].filter(Boolean).join(' • '), + href: canReadMarketing ? `/marketing_campaigns/marketing_campaigns-view/?id=${item.id}` : '', + id: `campaign-start-${item.id}`, + label: item.campaign_name || 'Campaign start', + source: 'Campaign start', + status: item.status, + }); + } + + if (item.end_at && item.status === 'running') { + pushMilestone({ + dateValue: item.end_at, + helper: [item.channel ? humanize(item.channel) : null, item.budget ? `${formatCurrency(item.budget)} budget` : null].filter(Boolean).join(' • '), + href: canReadMarketing ? `/marketing_campaigns/marketing_campaigns-view/?id=${item.id}` : '', + id: `campaign-end-${item.id}`, + label: item.campaign_name || 'Campaign end', + source: 'Campaign end', + status: item.status, + }); + } + }); + + return uniqueById(items).sort((left, right) => left.date.getTime() - right.date.getTime()); + }, [ + actionableTasks, + canReadFunding, + canReadLegal, + canReadMarketing, + canReadPositions, + canReadProjectPhases, + canReadProperties, + canReadTasks, + fundingRounds, + legalRequirements, + marketingCampaigns, + phaseNameById, + phases, + positions, + project, + projectEditHref, + projectViewHref, + properties, + stageRunner.currentStage.stage, + ]); + + const overdueMilestones = useMemo( + () => + timelineMilestones + .filter( + (item) => + startOfDay(item.date).getTime() < today.getTime() && + !['completed', 'done', 'approved', 'funded', 'filled', 'published', 'operating'].includes(item.status || ''), + ) + .slice(0, 4), + [timelineMilestones, today], + ); + + const upcomingMilestones = useMemo( + () => timelineMilestones.filter((item) => startOfDay(item.date).getTime() >= today.getTime()).slice(0, 6), + [timelineMilestones, today], + ); + + const stageProgress = useMemo(() => { + const stageIndex = liveStageOrder.indexOf(stageRunner.currentStage.stage || ''); + + if (stageIndex < 0) { + return 0; + } + + return Math.round(((stageIndex + 1) / liveStageOrder.length) * 100); + }, [stageRunner.currentStage.stage]); + + const phaseProgress = useMemo(() => (phases.length ? Math.round((completedPhases / phases.length) * 100) : 0), [completedPhases, phases.length]); + const taskProgress = useMemo(() => (actionableTasks.length || completedTasks ? Math.round((completedTasks / Math.max(actionableTasks.length + completedTasks, 1)) * 100) : 0), [actionableTasks.length, completedTasks]); + const legalProgress = useMemo(() => (legalRequirements.length ? Math.round((approvedLegalCount / legalRequirements.length) * 100) : 0), [approvedLegalCount, legalRequirements.length]); + + const executionProgress = useMemo(() => { + const values = [stageProgress]; + + if (phases.length) { + values.push(phaseProgress); + } + + if (actionableTasks.length || completedTasks) { + values.push(taskProgress); + } + + if (legalRequirements.length) { + values.push(legalProgress); + } + + return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length); + }, [actionableTasks.length, completedTasks, legalProgress, legalRequirements.length, phaseProgress, phases.length, stageProgress, taskProgress]); + + const setupSuggestions = useMemo(() => { + const suggestions = []; + + if (!project?.business_idea && canReadBusinessIdeas) { + suggestions.push({ + helper: 'No business idea record is linked to this workspace yet.', + href: '/business_ideas/business_ideas-list', + id: 'business-idea', + label: 'Capture the business idea and customer promise', + priority: 'high', + }); + } + + if (!phases.length && canReadProjectPhases) { + suggestions.push({ + helper: 'Your command center becomes much smarter once phases are saved.', + href: '/project_phases/project_phases-list', + id: 'phases', + label: 'Break the project into trackable launch phases', + priority: 'high', + }); + } + + if (!actionableTasks.length && canReadTasks) { + suggestions.push({ + helper: 'No open tasks are currently moving the business forward.', + href: '/tasks/tasks-list', + id: 'tasks', + label: 'Create the next weekly action list', + priority: 'critical', + }); + } + + if (!legalRequirements.length && canReadLegal) { + suggestions.push({ + helper: 'No compliance checklist is saved yet. This matters most for regulated launches.', + href: '/legal_requirements/legal_requirements-list', + id: 'legal', + label: 'Start the legal and compliance checklist', + priority: 'critical', + }); + } + + if (!fundingRounds.length && canReadFunding) { + suggestions.push({ + helper: 'There is no funding plan saved for this project yet.', + href: '/funding_rounds/funding_rounds-list', + id: 'funding', + label: 'Create the first funding plan or capital path', + priority: 'high', + }); + } + + if (!properties.length && canReadProperties) { + suggestions.push({ + helper: 'No property or site record is attached to this launch yet.', + href: '/properties/properties-list', + id: 'property', + label: 'Begin site and property tracking', + priority: 'medium', + }); + } + + return suggestions; + }, [actionableTasks.length, canReadBusinessIdeas, canReadFunding, canReadLegal, canReadProjectPhases, canReadProperties, canReadTasks, fundingRounds.length, legalRequirements.length, phases.length, project?.business_idea, properties.length]); + + const nextActions = useMemo(() => { + const runnerActions = currentStageActions.map((item) => ({ + helper: [humanize(stageRunner.currentStage.stage), item.helper].filter(Boolean).join(' • '), + href: item.href, + id: `runner-${stageRunner.currentStage.stage}-${item.id}`, + label: item.label, + priority: item.priority || 'medium', + })); + + const taskActions = actionableTasks.slice(0, 5).map((task) => ({ + helper: [ + task.status === 'blocked' ? 'Blocked' : humanize(task.status || 'todo'), + task.priority ? `${humanize(task.priority)} priority` : null, + task.phaseId ? phaseNameById.get(task.phaseId) : null, + task.due_at ? `Due ${formatDate(task.due_at)}` : 'No due date', + ] + .filter(Boolean) + .join(' • '), + href: canReadTasks ? `/tasks/tasks-view/?id=${task.id}` : '', + id: task.id, + label: task.task_title || 'Untitled task', + priority: task.priority || 'medium', + })); + + return uniqueById([...runnerActions, ...taskActions, ...setupSuggestions]).slice(0, 6); + }, [actionableTasks, canReadTasks, currentStageActions, phaseNameById, setupSuggestions, stageRunner.currentStage.stage]); + + const blockers = useMemo(() => { + const blockerItems = [ + ...blockedPhases.map((phase) => ({ + helper: [phase.phase_type ? humanize(phase.phase_type) : null, phase.end_at ? `Target end ${formatDate(phase.end_at)}` : 'No target end date'].filter(Boolean).join(' • '), + href: canReadProjectPhases ? `/project_phases/project_phases-view/?id=${phase.id}` : '', + id: `phase-${phase.id}`, + label: phase.phase_name || 'Blocked phase', + status: phase.status || 'blocked', + })), + ...blockedTasks.map((task) => ({ + helper: [task.priority ? `${humanize(task.priority)} priority` : null, task.phaseId ? phaseNameById.get(task.phaseId) : null, task.due_at ? `Due ${formatDate(task.due_at)}` : 'No due date'].filter(Boolean).join(' • '), + href: canReadTasks ? `/tasks/tasks-view/?id=${task.id}` : '', + id: `task-${task.id}`, + label: task.task_title || 'Blocked task', + status: task.status || 'blocked', + })), + ...legalRequirements + .filter((item) => ['rejected', 'renewal_due'].includes(item.status)) + .map((item) => ({ + helper: [item.requirement_type ? humanize(item.requirement_type) : null, item.due_at ? `Due ${formatDate(item.due_at)}` : 'No due date'].filter(Boolean).join(' • '), + href: canReadLegal ? `/legal_requirements/legal_requirements-view/?id=${item.id}` : '', + id: `legal-${item.id}`, + label: item.requirement_title || 'Compliance issue', + status: item.status, + })), + ...stageRunner.stages.flatMap((stage) => + stage.blockedChecks.map((item) => ({ + helper: [humanize(stage.stage), item.helper].filter(Boolean).join(' • '), + href: item.href, + id: `runner-${stage.stage}-${item.id}`, + label: item.label, + status: 'blocked', + })), + ), + ]; + + return uniqueById(blockerItems).slice(0, 6); + }, [blockedPhases, blockedTasks, canReadLegal, canReadProjectPhases, canReadTasks, legalRequirements, phaseNameById, stageRunner.stages]); + + const openingForecast = useMemo(() => { + if (project?.stage === 'paused') { + return { + countdownLabel: 'Paused workspace', + message: `This project is marked paused. When you resume, restart from ${humanize(stageRunner.currentStage.stage)} and refresh the opening plan.`, + status: 'paused', + }; + } + + const targetDate = parseValidDate(project?.target_open_date); + + if (!targetDate) { + return { + countdownLabel: 'No target date saved', + message: 'Add a target opening date so the command center can warn you when milestones begin slipping against the calendar.', + status: 'needs_target_date', + }; + } + + const daysUntilOpen = diffInCalendarDays(today, targetDate); + const countdownLabel = daysUntilOpen > 0 + ? `${daysUntilOpen} days until target opening` + : daysUntilOpen === 0 + ? 'Target opening date is today' + : `${Math.abs(daysUntilOpen)} days past target opening`; + const severeSignals = [ + stageRunner.currentStage.status === 'blocked', + blockers.length > 0, + overdueTasks.length > 0, + daysUntilOpen < 0 && !['launch', 'operating', 'completed'].includes(project?.stage || stageRunner.currentStage.stage), + daysUntilOpen <= 14 && (dueSoonLegalCount > 0 || openRoles > 0), + ].filter(Boolean).length; + const moderateSignals = [ + currentStageActions.length > 0, + dueSoonLegalCount > 0, + daysUntilOpen <= 30 && stageRunner.currentStage.readiness < 70, + daysUntilOpen <= 45 && !['marketing_sales', 'launch', 'operating'].includes(stageRunner.currentStage.stage), + daysUntilOpen <= 30 && fundingAvailable <= 0 && stageRunner.currentStage.stage !== 'ideation', + daysUntilOpen <= 30 && !leadProperty && ['site_selection', 'legal_compliance', 'design_build', 'staffing_training', 'marketing_sales', 'launch'].includes(stageRunner.currentStage.stage), + ].filter(Boolean).length; + + let status = 'on_track'; + let message = `The opening date still looks believable if you keep clearing ${humanize(stageRunner.currentStage.stage)} and protect the milestone sequence below.`; + + if (daysUntilOpen < 0 && !['launch', 'operating', 'completed'].includes(project?.stage || stageRunner.currentStage.stage)) { + status = 'off_track'; + message = 'The target opening date is already behind today, so this schedule now needs either a reset or a deliberate recovery plan.'; + } else if (severeSignals >= 2 || (daysUntilOpen <= 14 && (blockers.length > 0 || dueSoonLegalCount > 0 || openRoles > 0))) { + status = 'off_track'; + message = 'The launch is under serious timing pressure. Clear blockers, overdue work, and launch-critical compliance before trusting this date.'; + } else if (severeSignals >= 1 || moderateSignals >= 2) { + status = 'at_risk'; + message = 'The date is still possible, but the plan is starting to slip. Tighten the next actions and milestone queue before the schedule drifts farther.'; + } + + return { + countdownLabel, + message, + status, + }; + }, [ + blockers.length, + currentStageActions.length, + dueSoonLegalCount, + fundingAvailable, + leadProperty, + openRoles, + overdueTasks.length, + project?.stage, + project?.target_open_date, + stageRunner.currentStage.readiness, + stageRunner.currentStage.stage, + stageRunner.currentStage.status, + today, + ]); + + const trackedCostDrivers = useMemo(() => { + const drivers: BudgetDriver[] = [ + { + amount: leadPropertyTrackedCost, + helper: leadProperty + ? `${leadProperty.property_name || 'Lead property'} ${leadProperty.purchase_price ? 'purchase' : 'asking'} value` + : 'No lead property cost saved yet.', + href: canReadProperties && leadProperty?.id ? `/properties/properties-view/?id=${leadProperty.id}` : '', + id: 'driver-property', + label: 'Lead site cost', + }, + { + amount: taskEstimatedCost, + helper: `${tasks.filter((item) => toNumber(item.estimated_cost) > 0).length} task ${tasks.filter((item) => toNumber(item.estimated_cost) > 0).length === 1 ? 'estimate' : 'estimates'} saved so far.`, + href: canReadTasks ? '/tasks/tasks-list' : '', + id: 'driver-tasks', + label: 'Task estimates', + }, + { + amount: legalEstimatedFees, + helper: `${legalRequirements.filter((item) => toNumber(item.estimated_fees) > 0).length} compliance fee ${legalRequirements.filter((item) => toNumber(item.estimated_fees) > 0).length === 1 ? 'estimate' : 'estimates'} tracked.`, + href: canReadLegal ? '/legal_requirements/legal_requirements-list' : '', + id: 'driver-legal', + label: 'Compliance fees', + }, + { + amount: marketingPlannedBudget, + helper: `${marketingCampaigns.filter((item) => toNumber(item.budget) > 0).length} campaign ${marketingCampaigns.filter((item) => toNumber(item.budget) > 0).length === 1 ? 'budget' : 'budgets'} saved.`, + href: canReadMarketing ? '/marketing_campaigns/marketing_campaigns-list' : '', + id: 'driver-marketing', + label: 'Marketing budgets', + }, + ]; + + return drivers.filter((item) => item.amount > 0).sort((left, right) => right.amount - left.amount).slice(0, 4); + }, [ + canReadLegal, + canReadMarketing, + canReadProperties, + canReadTasks, + leadProperty, + leadPropertyTrackedCost, + legalEstimatedFees, + legalRequirements, + marketingCampaigns, + marketingPlannedBudget, + taskEstimatedCost, + tasks, + ]); + + const budgetSnapshot = useMemo(() => { + const targetBudget = toNumber(project?.target_budget); + const trackedPlannedSpend = taskEstimatedCost + legalEstimatedFees + marketingPlannedBudget + leadPropertyTrackedCost; + const trackedFundingGap = Math.max(trackedPlannedSpend - fundingAvailable, 0); + const budgetUtilization = targetBudget > 0 ? Math.round((trackedPlannedSpend / targetBudget) * 100) : 0; + const fundingCoverage = trackedPlannedSpend > 0 ? Math.round((fundingAvailable / trackedPlannedSpend) * 100) : 0; + + let status = 'on_track'; + let message = 'Tracked cost fields still fit inside the saved budget target, but keep adding real numbers so this stays honest.'; + + if (!targetBudget) { + status = 'needs_target_budget'; + message = 'Save a target budget on the project so the command center can compare tracked spend against a real ceiling.'; + } else if (trackedPlannedSpend === 0) { + status = 'at_risk'; + message = 'Almost no tracked costs are saved yet, so the budget picture is still incomplete and likely understates the real launch needs.'; + } else if (trackedPlannedSpend > targetBudget) { + status = 'over_target'; + message = 'Tracked planned spend is already above the saved budget target. Reduce scope, raise more capital, or revise the budget before pushing ahead.'; + } else if (budgetUtilization >= 85 || (stageRunner.currentStage.stage !== 'ideation' && fundingCoverage < 60)) { + status = 'at_risk'; + message = 'The plan is consuming most of the budget or the current funding coverage is still thin for this stage.'; + } + + return { + budgetUtilization, + fundingCoverage, + message, + status, + targetBudget, + trackedActualSpend: taskActualCost, + trackedFundingGap, + trackedPlannedSpend, + }; + }, [ + fundingAvailable, + leadPropertyTrackedCost, + legalEstimatedFees, + marketingPlannedBudget, + project?.target_budget, + stageRunner.currentStage.stage, + taskActualCost, + taskEstimatedCost, + ]); + + const guidedLaunchQueue = useMemo(() => { + const items: GuidedQueueItem[] = []; + + const addItem = ({ + area, + dueDate, + helper, + href, + id, + label, + lane, + priority, + reason, + score = 0, + status, + }: Omit & { dueDate?: any; score?: number }) => { + const due = parseValidDate(dueDate); + let normalizedScore = score; + + normalizedScore += lane === 'do_now' ? 90 : lane === 'do_next' ? 60 : 35; + normalizedScore += (priorityWeight[priority] || 1) * 9; + + if (status && ['blocked', 'rejected', 'renewal_due', 'off_track', 'over_target'].includes(status)) { + normalizedScore += 24; + } + + if (due) { + const daysUntil = diffInCalendarDays(today, due); + + if (daysUntil < 0) { + normalizedScore += 30 + Math.min(Math.abs(daysUntil), 14); + } else if (daysUntil <= 7) { + normalizedScore += 18; + } else if (daysUntil <= 14) { + normalizedScore += 12; + } else if (daysUntil <= 30) { + normalizedScore += 6; + } + } + + items.push({ + area, + helper, + href, + id, + label, + lane, + priority, + reason, + score: normalizedScore, + status, + }); + }; + + blockedPhases.forEach((phase) => { + addItem({ + area: getPhaseArea(phase.phase_type), + dueDate: phase.end_at, + helper: [phase.phase_type ? humanize(phase.phase_type) : null, phase.end_at ? `Target end ${formatDate(phase.end_at)}` : 'No target end date'] + .filter(Boolean) + .join(' • '), + href: canReadProjectPhases ? `/project_phases/project_phases-view/?id=${phase.id}` : '', + id: `phase-${phase.id}`, + label: phase.phase_name || 'Blocked phase', + lane: 'waiting_blocker', + priority: 'critical', + reason: 'Blocked phase is stalling the launch sequence', + status: phase.status || 'blocked', + }); + }); + + blockedTasks.forEach((task) => { + addItem({ + area: getPhaseArea(phaseTypeById.get(task.phaseId)), + dueDate: task.due_at, + helper: [task.priority ? `${humanize(task.priority)} priority` : null, task.phaseId ? phaseNameById.get(task.phaseId) : null, task.due_at ? `Due ${formatDate(task.due_at)}` : 'No due date'] + .filter(Boolean) + .join(' • '), + href: canReadTasks ? `/tasks/tasks-view/?id=${task.id}` : '', + id: `task-${task.id}`, + label: task.task_title || 'Blocked task', + lane: 'waiting_blocker', + priority: task.priority || 'high', + reason: 'Blocked task still needs follow-through or an unblock decision', + status: task.status || 'blocked', + }); + }); + + stageRunner.currentStage.blockedChecks.forEach((item) => { + addItem({ + area: getStageArea(stageRunner.currentStage.stage), + helper: item.helper, + href: item.href, + id: `stage-${stageRunner.currentStage.stage}-${item.id}`, + label: item.label, + lane: 'waiting_blocker', + priority: item.priority || 'high', + reason: `Current ${humanize(stageRunner.currentStage.stage)} stage is blocked here`, + status: 'blocked', + }); + }); + + stageRunner.currentStage.missingChecks.forEach((item) => { + addItem({ + area: getStageArea(stageRunner.currentStage.stage), + helper: item.helper, + href: item.href, + id: `stage-${stageRunner.currentStage.stage}-${item.id}`, + label: item.label, + lane: ['critical', 'high'].includes(item.priority) || stageRunner.currentStage.readiness < 50 ? 'do_now' : 'do_next', + priority: item.priority || 'medium', + reason: `Needed before ${humanize(stageRunner.currentStage.stage)} is truly ready`, + }); + }); + + if (stageRunner.nextStage) { + stageRunner.nextStage.missingChecks.slice(0, 3).forEach((item) => { + addItem({ + area: getStageArea(stageRunner.nextStage?.stage), + helper: item.helper, + href: item.href, + id: `stage-${stageRunner.nextStage?.stage}-${item.id}`, + label: item.label, + lane: 'do_next', + priority: item.priority || 'medium', + reason: `Prepare the next stage: ${humanize(stageRunner.nextStage?.stage)}`, + score: 4, + }); + }); + } + + setupSuggestions.forEach((item) => { + addItem({ + area: getStageArea(stageRunner.currentStage.stage), + helper: item.helper, + href: item.href, + id: `setup-${item.id}`, + label: item.label, + lane: ['critical', 'high'].includes(item.priority) ? 'do_now' : 'do_next', + priority: item.priority || 'medium', + reason: 'Foundational launch record is still missing', + }); + }); + + const overdueTaskIds = new Set(overdueTasks.map((task) => task.id)); + + overdueTasks.slice(0, 6).forEach((task) => { + addItem({ + area: getPhaseArea(phaseTypeById.get(task.phaseId)), + dueDate: task.due_at, + helper: [task.priority ? `${humanize(task.priority)} priority` : null, task.phaseId ? phaseNameById.get(task.phaseId) : null, task.due_at ? `Due ${formatDate(task.due_at)}` : 'No due date'] + .filter(Boolean) + .join(' • '), + href: canReadTasks ? `/tasks/tasks-view/?id=${task.id}` : '', + id: `task-${task.id}`, + label: task.task_title || 'Untitled task', + lane: 'do_now', + priority: task.priority || 'high', + reason: 'Task is already overdue', + status: task.status, + }); + }); + + actionableTasks + .filter((task) => task.id && task.due_at && !overdueTaskIds.has(task.id)) + .forEach((task) => { + const dueDate = parseValidDate(task.due_at); + + if (!dueDate) { + return; + } + + const daysUntil = diffInCalendarDays(today, dueDate); + + if (daysUntil > 21) { + return; + } + + addItem({ + area: getPhaseArea(phaseTypeById.get(task.phaseId)), + dueDate: task.due_at, + helper: [task.priority ? `${humanize(task.priority)} priority` : null, task.phaseId ? phaseNameById.get(task.phaseId) : null, `Due ${formatDate(task.due_at)}`] + .filter(Boolean) + .join(' • '), + href: canReadTasks ? `/tasks/tasks-view/?id=${task.id}` : '', + id: `task-${task.id}`, + label: task.task_title || 'Untitled task', + lane: daysUntil <= 7 || ['critical', 'high'].includes(task.priority) ? 'do_now' : 'do_next', + priority: task.priority || (daysUntil <= 7 ? 'high' : 'medium'), + reason: daysUntil <= 7 ? 'Task deadline is approaching this week' : 'Task deadline is approaching soon', + status: task.status, + }); + }); + + phases + .filter((phase) => phase.status !== 'completed' && phase.status !== 'blocked' && phase.end_at) + .forEach((phase) => { + const targetDate = parseValidDate(phase.end_at); + + if (!targetDate) { + return; + } + + const daysUntil = diffInCalendarDays(today, targetDate); + + if (daysUntil > 21) { + return; + } + + addItem({ + area: getPhaseArea(phase.phase_type), + dueDate: phase.end_at, + helper: [phase.status ? humanize(phase.status) : null, `Target end ${formatDate(phase.end_at)}`].filter(Boolean).join(' • '), + href: canReadProjectPhases ? `/project_phases/project_phases-view/?id=${phase.id}` : '', + id: `phase-${phase.id}`, + label: phase.phase_name || humanize(phase.phase_type || 'phase'), + lane: daysUntil <= 7 || daysUntil < 0 ? 'do_now' : 'do_next', + priority: daysUntil <= 7 ? 'high' : 'medium', + reason: daysUntil < 0 ? 'Phase target is already behind schedule' : 'Phase milestone is approaching', + status: phase.status, + }); + }); + + legalRequirements + .filter((item) => item.status !== 'approved') + .forEach((item) => { + const dueDateValue = item.status === 'renewal_due' && item.renewal_at ? item.renewal_at : item.due_at || item.renewal_at; + const dueDate = parseValidDate(dueDateValue); + const daysUntil = dueDate ? diffInCalendarDays(today, dueDate) : null; + const isBlocked = ['rejected', 'renewal_due'].includes(item.status); + const isWaiting = ['submitted', 'in_progress', 'in_review'].includes(item.status); + + if (!isBlocked && !isWaiting && daysUntil !== null && daysUntil > 30) { + return; + } + + if (!isBlocked && !isWaiting && daysUntil === null) { + return; + } + + addItem({ + area: 'legal', + dueDate: dueDateValue, + helper: [item.requirement_type ? humanize(item.requirement_type) : null, item.authority_name, dueDateValue ? `Due ${formatDate(dueDateValue)}` : null] + .filter(Boolean) + .join(' • '), + href: canReadLegal ? `/legal_requirements/legal_requirements-view/?id=${item.id}` : '', + id: `legal-${item.id}`, + label: item.requirement_title || 'Compliance item', + lane: isBlocked || isWaiting ? 'waiting_blocker' : daysUntil !== null && daysUntil <= 14 ? 'do_now' : 'do_next', + priority: isBlocked ? 'critical' : daysUntil !== null && daysUntil <= 14 ? 'high' : 'medium', + reason: isBlocked ? 'Compliance blocker or renewal needs follow-up' : isWaiting ? 'Waiting on approval or external review' : 'Compliance deadline is coming up', + status: item.status, + }); + }); + + if (openingForecast.status !== 'on_track') { + addItem({ + area: 'launch', + helper: openingForecast.message, + href: projectEditHref || projectViewHref, + id: `forecast-${project?.id || 'workspace'}`, + label: + openingForecast.status === 'needs_target_date' + ? 'Save a real target opening date' + : openingForecast.status === 'paused' + ? 'Review the paused launch plan' + : openingForecast.status === 'off_track' + ? 'Reset the launch recovery plan' + : 'Tighten the opening timeline', + lane: ['off_track', 'needs_target_date', 'paused'].includes(openingForecast.status) ? 'do_now' : 'do_next', + priority: openingForecast.status === 'off_track' ? 'critical' : 'high', + reason: 'Opening forecast signal', + status: openingForecast.status, + }); + } + + if (budgetSnapshot.status !== 'on_track') { + addItem({ + area: 'funding', + helper: budgetSnapshot.message, + href: canReadFunding ? '/funding_rounds/funding_rounds-list' : projectEditHref || projectViewHref, + id: `budget-${project?.id || 'workspace'}`, + label: + budgetSnapshot.status === 'needs_target_budget' + ? 'Save the launch budget target' + : budgetSnapshot.status === 'over_target' + ? 'Close the tracked funding gap' + : 'Reconcile the budget pressure', + lane: ['needs_target_budget', 'over_target'].includes(budgetSnapshot.status) ? 'do_now' : 'do_next', + priority: budgetSnapshot.status === 'over_target' ? 'critical' : 'high', + reason: 'Budget pressure signal', + status: budgetSnapshot.status, + }); + } + + if (leadFundingRound && ['open', 'in_progress', 'committed', 'submitted'].includes(leadFundingRound.status)) { + addItem({ + area: 'funding', + dueDate: leadFundingRound.close_at || leadFundingRound.open_at, + helper: [ + leadFundingRound.funding_type ? humanize(leadFundingRound.funding_type) : null, + leadFundingRound.target_amount ? `${formatCurrency(leadFundingRound.target_amount)} target` : null, + leadFundingRound.close_at ? `Closes ${formatDate(leadFundingRound.close_at)}` : null, + ] + .filter(Boolean) + .join(' • '), + href: canReadFunding ? `/funding_rounds/funding_rounds-view/?id=${leadFundingRound.id}` : '', + id: `funding-${leadFundingRound.id}`, + label: leadFundingRound.round_name || 'Funding round in flight', + lane: 'waiting_blocker', + priority: 'medium', + reason: 'Monitor lender or investor progress and next deadline', + status: leadFundingRound.status, + }); + } + + if (leadProperty && !['purchased', 'leased', 'rejected'].includes(leadProperty.acquisition_status)) { + addItem({ + area: 'property', + dueDate: leadProperty.closing_date || leadProperty.offer_date, + helper: [ + leadProperty.property_name || 'Lead property', + leadProperty.property_type ? humanize(leadProperty.property_type) : null, + leadProperty.closing_date ? `Closing ${formatDate(leadProperty.closing_date)}` : leadProperty.offer_date ? `Offer ${formatDate(leadProperty.offer_date)}` : null, + ] + .filter(Boolean) + .join(' • '), + href: canReadProperties ? `/properties/properties-view/?id=${leadProperty.id}` : '', + id: `property-${leadProperty.id}`, + label: leadProperty.property_name || 'Lead site decision', + lane: 'waiting_blocker', + priority: 'medium', + reason: 'Monitor diligence, negotiation, or site commitment', + status: leadProperty.acquisition_status, + }); + } + + positions + .filter((item) => ['interviewing', 'offered'].includes(item.status)) + .slice(0, 3) + .forEach((item) => { + addItem({ + area: 'hiring', + dueDate: item.target_start_at, + helper: [item.employment_type ? humanize(item.employment_type) : null, item.target_start_at ? `Target start ${formatDate(item.target_start_at)}` : null] + .filter(Boolean) + .join(' • '), + href: canReadPositions ? `/positions/positions-view/?id=${item.id}` : '', + id: `position-${item.id}`, + label: item.position_title || 'Candidate pipeline', + lane: 'waiting_blocker', + priority: 'medium', + reason: 'Hiring pipeline needs a follow-up decision', + status: item.status, + }); + }); + + return uniqueById( + items.sort((left, right) => { + if (left.score !== right.score) { + return right.score - left.score; + } + + return (priorityWeight[right.priority] || 0) - (priorityWeight[left.priority] || 0); + }), + ); + }, [ + actionableTasks, + blockedPhases, + blockedTasks, + budgetSnapshot, + canReadFunding, + canReadLegal, + canReadPositions, + canReadProjectPhases, + canReadProperties, + canReadTasks, + leadFundingRound, + leadProperty, + openingForecast, + overdueTasks, + phaseNameById, + phaseTypeById, + phases, + positions, + project?.id, + projectEditHref, + projectViewHref, + setupSuggestions, + stageRunner, + today, + legalRequirements, + ]); + + const guidedLaunchQueueByLane = useMemo(() => { + const lanes: GuidedQueueLane[] = ['do_now', 'do_next', 'waiting_blocker']; + + return lanes.map((lane) => { + const items = guidedLaunchQueue.filter((item) => item.lane === lane); + + return { + items: items.slice(0, 4), + lane, + total: items.length, + }; + }); + }, [guidedLaunchQueue]); + + const weeklyExecutionBrief = useMemo(() => { + const weekEnd = new Date(today); + weekEnd.setDate(weekEnd.getDate() + 6); + + const mustMoveItems = guidedLaunchQueue.filter((item) => item.lane === 'do_now'); + const prepareNextItems = guidedLaunchQueue.filter((item) => item.lane === 'do_next'); + const waitingOnOthersItems = guidedLaunchQueue.filter((item) => item.lane === 'waiting_blocker'); + const tasksDueThisWeekItems = actionableTasks.filter((task) => { + const dueDate = parseValidDate(task.due_at); + + if (!dueDate) { + return false; + } + + const dueTime = startOfDay(dueDate).getTime(); + return dueTime >= today.getTime() && dueTime <= weekEnd.getTime(); + }); + const milestonesThisWeekItems = upcomingMilestones.filter((item) => { + const dateValue = startOfDay(item.date).getTime(); + return dateValue >= today.getTime() && dateValue <= weekEnd.getTime(); + }); + const mustMove = mustMoveItems.slice(0, 3); + const prepareNext = prepareNextItems.slice(0, 3); + const waitingOnOthers = waitingOnOthersItems.slice(0, 3); + const tasksDueThisWeek = tasksDueThisWeekItems.slice(0, 4); + const milestonesThisWeek = milestonesThisWeekItems.slice(0, 4); + + let status = 'on_track'; + let headline = `Advance ${humanize(stageRunner.currentStage.stage)} this week`; + let summary = `Focus the week on ${stageGuide.focus.toLowerCase()}. Keep the workload narrow enough that the top priorities really move.`; + + if (openingForecast.status === 'off_track' || budgetSnapshot.status === 'over_target' || blockers.length > 0 || overdueTasks.length > 0) { + status = 'off_track'; + headline = `Stabilize ${humanize(stageRunner.currentStage.stage)} before adding more work`; + summary = 'The plan is carrying real strain. This week should be about clearing blockers, overdue work, and launch-critical pressure before expanding scope.'; + } else if (openingForecast.status !== 'on_track' || budgetSnapshot.status !== 'on_track' || waitingOnOthersItems.length > 0 || stageRunner.currentStage.readiness < 70) { + status = 'at_risk'; + headline = `Tighten execution in ${humanize(stageRunner.currentStage.stage)}`; + summary = 'The launch path is still moving, but a few weak spots need deliberate follow-through this week so the next stage does not drift.'; + } else if (stageRunner.nextStage && stageRunner.currentStage.readiness >= 85) { + headline = `Finish ${humanize(stageRunner.currentStage.stage)} and pre-stage ${humanize(stageRunner.nextStage.stage)}`; + summary = 'The current stage looks healthy. Use this week to close the last essentials and quietly set up the next stage.'; + } + + const goodWeekLooksLike = [ + mustMoveItems.length > 0 + ? `Close or materially unblock ${Math.min(mustMoveItems.length, 3)} top-priority ${mustMoveItems.length === 1 ? 'item' : 'items'} from the launch queue.` + : actionableTasks.length > 0 + ? 'Reduce open work into a smaller, real weekly list so execution becomes easier to trust.' + : 'Create the first real weekly action list so the command center can turn strategy into execution.', + tasksDueThisWeekItems.length > 0 || milestonesThisWeekItems.length > 0 + ? `Protect this week's calendar pressure: ${tasksDueThisWeekItems.length} ${tasksDueThisWeekItems.length === 1 ? 'task deadline' : 'task deadlines'} and ${milestonesThisWeekItems.length} ${milestonesThisWeekItems.length === 1 ? 'milestone' : 'milestones'} are already close.` + : `Use spare capacity to prepare ${humanize(stageRunner.nextStage?.stage || stageRunner.currentStage.stage)} without widening the scope too early.`, + openingForecast.status === 'on_track' + ? budgetSnapshot.status === 'on_track' + ? 'Keep the opening date believable and the budget inside the saved plan while you execute.' + : budgetSnapshot.status === 'needs_target_budget' + ? 'Save the budget target so weekly choices can be judged against a real ceiling.' + : 'Reduce budget pressure before approving more launch scope.' + : openingForecast.status === 'needs_target_date' + ? 'Save a real target opening date so the week has a fixed finish line.' + : 'Lower schedule risk before adding stretch goals or side work.', + ]; + + const capacityGuardrail = + mustMoveItems.length > 3 || overdueTasks.length > 0 || blockers.length > 0 + ? 'Keep this week narrow. Clear the blocked and overdue items first, then use any leftover time for preparation work.' + : prepareNextItems.length > 0 + ? 'Capacity is opening up. Finish the current must-do items first, then pull one or two preparation items from the next lane.' + : 'The current queue is fairly light. Use the week to capture missing dates, budgets, or task records so the guidance stays honest.'; + + return { + capacityGuardrail, + goodWeekLooksLike, + headline, + milestonesThisWeek, + milestonesThisWeekCount: milestonesThisWeekItems.length, + mustMove, + mustMoveCount: mustMoveItems.length, + prepareNext, + prepareNextCount: prepareNextItems.length, + status, + summary, + tasksDueThisWeek, + tasksDueThisWeekCount: tasksDueThisWeekItems.length, + waitingOnOthers, + waitingOnOthersCount: waitingOnOthersItems.length, + weekLabel: `${formatDate(today)} – ${formatDate(weekEnd)}`, + }; + }, [ + actionableTasks, + blockers.length, + budgetSnapshot.status, + guidedLaunchQueue, + openingForecast.status, + overdueTasks.length, + stageGuide.focus, + stageRunner.currentStage.readiness, + stageRunner.currentStage.stage, + stageRunner.nextStage, + today, + upcomingMilestones, + ]); + + const businessAreaReadiness = useMemo(() => { + const currentStageIndex = Math.max(liveStageOrder.indexOf(stageRunner.currentStage.stage || ''), 0); + const hasIdeaRecord = Boolean(project?.business_idea?.id || project?.business_idea || project?.vision); + const hasNarrative = Boolean(project?.business_idea?.elevator_pitch || project?.vision); + const hasPrimaryLocation = Boolean( + project?.primary_location?.city + || project?.primary_location?.state + || project?.primary_location?.county + || project?.primary_location?.label + || project?.primary_location?.country, + ); + const marketingAssetCount = documents.filter((item) => item.document_type === 'marketing_asset' && !['archived', 'rejected'].includes(item.status)).length; + const operationalPhaseCount = phases.filter((phase) => ['design_engineering', 'construction', 'procurement', 'operations'].includes(phase.phase_type)).length; + const filledRolesCount = positions.filter((item) => ['filled', 'closed'].includes(item.status)).length; + + const items = businessAreaOrder.map((area) => { + const areaQueueItems = guidedLaunchQueue.filter((item) => item.area === area); + const urgentCount = areaQueueItems.filter((item) => item.lane === 'do_now').length; + const waitingCount = areaQueueItems.filter((item) => item.lane === 'waiting_blocker').length; + const topAreaItem = areaQueueItems[0] || null; + const activationStage = businessAreaActivationStage[area] || 'planning'; + const activationIndex = Math.max(liveStageOrder.indexOf(activationStage), 0); + const isInPlay = currentStageIndex >= activationIndex; + + let score = 20; + let status = 'on_track'; + let summary = ''; + let nextFocus = topAreaItem?.label || 'Review this lane'; + let openLabel = 'Open'; + let href = projectEditHref || projectViewHref; + let severeSignal = false; + const signals: string[] = []; + + switch (area) { + case 'strategy': + score = 20 + (hasIdeaRecord ? 35 : 0) + (hasNarrative ? 25 : 0) + (project?.project_name ? 10 : 0) + (project?.stage && project.stage !== 'ideation' ? 10 : 0); + summary = hasIdeaRecord + ? 'The business idea is saved, so the main goal here is keeping the promise and customer value clear as execution gets busier.' + : 'The offer is not clearly captured yet, which makes later planning and funding decisions harder to trust.'; + nextFocus = topAreaItem?.label || (hasIdeaRecord ? 'Refine the customer promise and market position' : 'Capture the business idea and customer promise'); + openLabel = 'Open idea'; + href = canReadBusinessIdeas ? '/business_ideas/business_ideas-list' : projectEditHref || projectViewHref; + if (hasIdeaRecord) { + signals.push('Idea record saved'); + } + if (hasNarrative) { + signals.push('Vision or pitch saved'); + } + if (!hasIdeaRecord) { + signals.push('No clear strategy record'); + } + if (!hasIdeaRecord && currentStageIndex >= 1) { + severeSignal = true; + } + break; + + case 'planning': { + const totalTrackedTasks = actionableTasks.length + completedTasks; + const hasTargetDate = Boolean(parseValidDate(project?.target_open_date)); + const missingPlanningPieces = [!phases.length, !totalTrackedTasks, !hasTargetDate].filter(Boolean).length; + + score = 20 + (hasTargetDate ? 20 : 0) + (phases.length ? 25 : 0) + (totalTrackedTasks ? 20 : 0) + Math.round(stageRunner.currentStage.readiness * 0.15); + summary = phases.length && totalTrackedTasks + ? 'Planning is visible in real phases and tasks, so this lane is mostly about keeping dates and readiness honest.' + : 'The plan is still too thin in the saved records, which makes weekly execution feel heavier than it needs to be.'; + nextFocus = topAreaItem?.label + || (!phases.length + ? 'Break the launch into trackable phases' + : !totalTrackedTasks + ? 'Create the next weekly action list' + : !hasTargetDate + ? 'Save the target opening date' + : 'Keep the weekly plan current'); + openLabel = 'Open plan'; + href = canReadProjectPhases ? '/project_phases/project_phases-list' : canReadTasks ? '/tasks/tasks-list' : projectEditHref || projectViewHref; + signals.push(phases.length ? `${phases.length} ${phases.length === 1 ? 'phase' : 'phases'} saved` : 'No phases saved'); + signals.push(totalTrackedTasks ? `${totalTrackedTasks} ${totalTrackedTasks === 1 ? 'task' : 'tasks'} tracked` : 'No tasks tracked'); + signals.push(hasTargetDate ? `Target open ${formatDate(project?.target_open_date)}` : 'No target opening date'); + + if (currentStageIndex >= 1 && missingPlanningPieces >= 2) { + severeSignal = true; + } + break; + } + + case 'funding': + score = 10 + (fundingRounds.length ? 30 : 0) + (fundingAvailable > 0 ? 25 : 0) + (fundingTarget > 0 ? 15 : 0) + (budgetSnapshot.status === 'on_track' ? 20 : budgetSnapshot.status === 'needs_target_budget' ? 5 : 0); + summary = fundingRounds.length + ? 'Capital is being tracked, so the main job is making sure the saved funding path still matches the launch scope.' + : 'There is no real funding path saved yet, so this lane still relies too much on assumptions.'; + nextFocus = topAreaItem?.label + || (!fundingRounds.length + ? 'Create the first funding plan or capital path' + : budgetSnapshot.status !== 'on_track' + ? 'Reconcile the tracked funding gap' + : 'Keep the funding assumptions current'); + openLabel = 'Open funding'; + href = canReadFunding ? '/funding_rounds/funding_rounds-list' : projectEditHref || projectViewHref; + signals.push(fundingRounds.length ? `${fundingRounds.length} ${fundingRounds.length === 1 ? 'round' : 'rounds'} tracked` : 'No funding rounds saved'); + if (fundingTarget > 0) { + signals.push(`${formatCurrency(fundingTarget)} target`); + } + if (fundingAvailable > 0) { + signals.push(`${formatCurrency(fundingAvailable)} available`); + } + + if (budgetSnapshot.status === 'over_target' || (currentStageIndex >= 2 && !fundingRounds.length)) { + severeSignal = true; + } + break; + + case 'property': { + const propertySecured = ['under_contract', 'purchased', 'leased'].includes(leadProperty?.acquisition_status); + + score = 10 + (hasPrimaryLocation ? 15 : 0) + (leadProperty ? 35 : 0) + (propertySecured ? 20 : 0) + (leadPropertyTrackedCost > 0 ? 10 : 0); + summary = leadProperty + ? 'A lead site is saved, so the risk here is mostly diligence, negotiation timing, and commitment quality.' + : hasPrimaryLocation + ? 'You have a location direction, but there is not yet a tracked property decision to anchor the opening path.' + : 'Site assumptions are still loose, so hidden property risk can surprise the timeline and budget later.'; + nextFocus = topAreaItem?.label || (leadProperty ? 'Keep the site diligence and commitment milestones moving' : 'Begin site and property tracking'); + openLabel = 'Open property'; + href = canReadProperties ? '/properties/properties-list' : projectEditHref || projectViewHref; + signals.push(hasPrimaryLocation ? truncateLabel(formatLocationSummary(project?.primary_location), 28) : 'No location direction saved'); + if (leadProperty) { + signals.push(humanize(leadProperty.acquisition_status || 'candidate')); + } + if (leadPropertyTrackedCost > 0) { + signals.push(formatCurrency(leadPropertyTrackedCost)); + } + + if (currentStageIndex >= 3 && !leadProperty) { + severeSignal = true; + } + break; + } + + case 'legal': { + const legalApprovalRate = legalRequirements.length ? Math.round((approvedLegalCount / legalRequirements.length) * 100) : 0; + + score = 10 + (legalRequirements.length ? 25 : 0) + Math.round(legalApprovalRate * 0.3) + (documents.length ? 15 : 0) + (approvedDocuments ? 10 : 0); + summary = legalRequirements.length + ? 'Compliance is at least being tracked, so the main issue is whether due items and approvals are staying ahead of the opening date.' + : 'No real compliance checklist is saved yet, which is especially risky for a health-related business.'; + nextFocus = topAreaItem?.label + || (!legalRequirements.length + ? 'Start the legal and compliance checklist' + : dueSoonLegalCount > 0 + ? 'Resolve due-soon compliance items' + : 'Keep approvals and document statuses current'); + openLabel = 'Open compliance'; + href = canReadLegal ? '/legal_requirements/legal_requirements-list' : canReadDocuments ? '/documents/documents-list' : projectEditHref || projectViewHref; + signals.push(legalRequirements.length ? `${approvedLegalCount}/${legalRequirements.length} approved` : 'No compliance checklist'); + if (dueSoonLegalCount > 0) { + signals.push(`${dueSoonLegalCount} due soon`); + } + if (urgentLegalRequirements.length > 0) { + signals.push(`${urgentLegalRequirements.length} urgent issue${urgentLegalRequirements.length === 1 ? '' : 's'}`); + } + + if (urgentLegalRequirements.length > 0 || (currentStageIndex >= 4 && !legalRequirements.length)) { + severeSignal = true; + } + break; + } + + case 'operations': + score = 15 + (operationalPhaseCount ? 20 : 0) + Math.round(taskProgress * 0.25) + (designAssets.length ? 15 : 0) + (documents.length ? 10 : 0) + (blockedPhases.length ? 0 : 10); + summary = operationalPhaseCount || actionableTasks.length || completedTasks + ? 'Execution is visible in phases and tasks, so the real question is whether operators can keep momentum without creating hidden blockers.' + : 'The operating buildout is still thin in the saved records, so the day-to-day delivery path is hard to trust.'; + nextFocus = topAreaItem?.label || (operationalPhaseCount ? 'Keep execution moving and clear operational blockers' : 'Create build, procurement, or operations phases'); + openLabel = 'Open operations'; + href = canReadTasks ? '/tasks/tasks-list' : canReadProjectPhases ? '/project_phases/project_phases-list' : projectEditHref || projectViewHref; + signals.push(`${taskProgress}% task progress`); + if (operationalPhaseCount > 0) { + signals.push(`${operationalPhaseCount} operating phases`); + } + if (blockedPhases.length > 0) { + signals.push(`${blockedPhases.length} blocked phase${blockedPhases.length === 1 ? '' : 's'}`); + } + + if ( + blockedPhases.some((phase) => ['design_engineering', 'construction', 'procurement', 'operations'].includes(phase.phase_type)) + || (currentStageIndex >= 5 && !operationalPhaseCount && !actionableTasks.length) + ) { + severeSignal = true; + } + break; + + case 'hiring': + score = 10 + (positions.length ? 25 : 0) + (filledRolesCount ? 15 : 0) + (trainingPrograms.length ? 20 : 0) + (publishedTrainingPrograms ? 15 : 0) + (openRoles === 0 && positions.length ? 10 : 0); + summary = positions.length + ? 'Team planning exists, so this lane is about converting open roles and training into launch-ready staffing.' + : 'The people side is still mostly unsaved, which can become a late-stage surprise.'; + nextFocus = topAreaItem?.label + || (!positions.length + ? 'Define the first launch roles' + : !trainingPrograms.length + ? 'Create onboarding and training programs' + : 'Keep candidate and start-date decisions moving'); + openLabel = 'Open hiring'; + href = canReadPositions ? '/positions/positions-list' : canReadTraining ? '/training_programs/training_programs-list' : projectEditHref || projectViewHref; + signals.push(positions.length ? `${positions.length} ${positions.length === 1 ? 'role' : 'roles'} saved` : 'No roles saved'); + if (openRoles > 0) { + signals.push(`${openRoles} still open`); + } + if (publishedTrainingPrograms > 0) { + signals.push(`${publishedTrainingPrograms} published training`); + } + + if (currentStageIndex >= 6 && (openRoles > 0 || !trainingPrograms.length)) { + severeSignal = true; + } + break; + + case 'marketing': + score = 10 + (marketingCampaigns.length ? 25 : 0) + (plannedCampaigns ? 10 : 0) + (runningCampaigns ? 20 : 0) + (designAssets.length ? 15 : 0) + (marketingAssetCount ? 10 : 0) + (aiRuns.length ? 5 : 0); + summary = marketingCampaigns.length + ? 'Demand-building is visible in the workspace, so this lane is about timing, creative quality, and staying consistent before launch.' + : 'Pre-launch demand generation is still light in the saved records, which can leave the opening day underpowered.'; + nextFocus = topAreaItem?.label + || (!marketingCampaigns.length + ? 'Plan the first launch campaign' + : !designAssets.length && !marketingAssetCount + ? 'Create or attach launch creatives' + : 'Keep campaigns and launch creatives aligned'); + openLabel = 'Open marketing'; + href = canReadMarketing ? '/marketing_campaigns/marketing_campaigns-list' : canReadDesignAssets ? '/design_assets/design_assets-list' : projectEditHref || projectViewHref; + signals.push(marketingCampaigns.length ? `${runningCampaigns} live / ${plannedCampaigns} planned` : 'No campaigns saved'); + if (designAssets.length > 0) { + signals.push(`${designAssets.length} design assets`); + } + if (aiRuns.length > 0) { + signals.push(`${aiRuns.length} AI runs`); + } + + if (currentStageIndex >= 7 && (!marketingCampaigns.length || (!designAssets.length && !marketingAssetCount))) { + severeSignal = true; + } + break; + + case 'launch': + score = 20 + Math.round(stageRunner.currentStage.readiness * 0.35) + (parseValidDate(project?.target_open_date) ? 15 : 0) + (openingForecast.status === 'on_track' ? 20 : openingForecast.status === 'at_risk' ? 8 : 0) + (budgetSnapshot.status === 'on_track' ? 10 : 0) + (blockers.length === 0 ? 5 : 0); + summary = openingForecast.message; + nextFocus = topAreaItem?.label + || (openingForecast.status === 'needs_target_date' + ? 'Save the target opening date' + : blockers.length > 0 + ? 'Clear the remaining blockers before trusting the opening plan' + : 'Keep the launch path believable'); + openLabel = 'Review launch'; + href = projectEditHref || projectViewHref; + signals.push(openingForecast.countdownLabel); + signals.push(`${stageRunner.currentStage.readiness}% stage readiness`); + if (blockers.length > 0) { + signals.push(`${blockers.length} blocker${blockers.length === 1 ? '' : 's'}`); + } + + if (['off_track', 'needs_target_date'].includes(openingForecast.status) || blockers.length > 0) { + severeSignal = true; + } + break; + + default: + break; + } + + score = clampScore(score - urgentCount * 12 - waitingCount * 5); + + if (!isInPlay && urgentCount === 0 && waitingCount === 0) { + status = 'planned'; + } else if (severeSignal || (urgentCount > 0 && (topAreaItem?.priority === 'critical' || score < 45))) { + status = 'off_track'; + } else if (urgentCount > 0 || waitingCount > 0 || score < 70) { + status = 'at_risk'; + } + + const timingLabel = !isInPlay && urgentCount === 0 && waitingCount === 0 + ? `Coming up in ${humanize(activationStage)}` + : currentStageIndex === activationIndex + ? 'In play now' + : currentStageIndex > activationIndex + ? 'Active lane' + : 'Early prep'; + + return { + area, + href, + isInPlay, + nextFocus, + openLabel, + score, + signals: signals.slice(0, 3), + status, + summary, + timingLabel, + urgentCount, + waitingCount, + } as BusinessAreaReadinessItem; + }); + + const activeOrPressuredItems = items.filter((item) => item.isInPlay || item.urgentCount > 0 || item.waitingCount > 0); + const summaryPool = activeOrPressuredItems.length ? activeOrPressuredItems : items; + const strongestArea = summaryPool.slice().sort((left, right) => right.score - left.score)[0] || null; + const mostPressuredArea = summaryPool.slice().sort((left, right) => { + const severityDiff = (readinessStatusWeight[right.status] || 0) - (readinessStatusWeight[left.status] || 0); + + if (severityDiff !== 0) { + return severityDiff; + } + + if (right.urgentCount !== left.urgentCount) { + return right.urgentCount - left.urgentCount; + } + + if (right.waitingCount !== left.waitingCount) { + return right.waitingCount - left.waitingCount; + } + + return left.score - right.score; + })[0] || null; + + return { + attentionNowCount: items.filter((item) => ['at_risk', 'off_track'].includes(item.status)).length, + inPlayCount: items.filter((item) => item.isInPlay).length, + items, + mostPressuredArea, + strongestArea, + waitingAreasCount: items.filter((item) => item.waitingCount > 0).length, + }; + }, [ + actionableTasks, + aiRuns, + approvedDocuments, + approvedLegalCount, + blockedPhases, + blockers.length, + budgetSnapshot, + canReadBusinessIdeas, + canReadDesignAssets, + canReadDocuments, + canReadFunding, + canReadLegal, + canReadMarketing, + canReadPositions, + canReadProjectPhases, + canReadProperties, + canReadTasks, + canReadTraining, + completedTasks, + designAssets, + documents, + dueSoonLegalCount, + fundingAvailable, + fundingRounds, + fundingTarget, + guidedLaunchQueue, + leadProperty, + leadPropertyTrackedCost, + legalRequirements, + marketingCampaigns, + openRoles, + openingForecast, + phases, + plannedCampaigns, + positions, + project, + projectEditHref, + projectViewHref, + publishedTrainingPrograms, + runningCampaigns, + stageRunner, + taskProgress, + trainingPrograms, + urgentLegalRequirements, + ]); + + const founderDecisionDesk = useMemo(() => { + const decisions: FounderDecisionItem[] = []; + const monitors: FounderDecisionItem[] = []; + const currentStageIndex = Math.max(liveStageOrder.indexOf(stageRunner.currentStage.stage || ''), 0); + const fundingStageIndex = Math.max(liveStageOrder.indexOf('funding'), 0); + const siteSelectionStageIndex = Math.max(liveStageOrder.indexOf('site_selection'), 0); + const legalStageIndex = Math.max(liveStageOrder.indexOf('legal_compliance'), 0); + const staffingStageIndex = Math.max(liveStageOrder.indexOf('staffing_training'), 0); + const marketingStageIndex = Math.max(liveStageOrder.indexOf('marketing_sales'), 0); + const targetOpenDate = parseValidDate(project?.target_open_date); + const daysUntilOpen = targetOpenDate ? diffInCalendarDays(today, targetOpenDate) : null; + const hasPrimaryLocation = Boolean( + project?.primary_location?.city + || project?.primary_location?.state + || project?.primary_location?.county + || project?.primary_location?.label + || project?.primary_location?.country, + ); + const criticalLegalDecision = + legalRequirements.find((item) => ['rejected', 'renewal_due'].includes(item.status)) + || legalRequirements.find((item) => { + const dueDate = parseValidDate(item.due_at); + + if (item.status === 'approved' || !dueDate) { + return false; + } + + return diffInCalendarDays(today, dueDate) <= 21; + }) + || urgentLegalRequirements[0] + || null; + const leadHiringDecision = + positions.find((item) => item.status === 'offered') + || positions.find((item) => item.status === 'interviewing') + || positions.find((item) => item.status === 'open') + || null; + + const addItem = ( + collection: FounderDecisionItem[], + { + area, + dueDate, + helper, + href, + id, + impact, + label, + openLabel, + priority, + score = 0, + status, + }: Omit & { dueDate?: any; score?: number }, + ) => { + const due = parseValidDate(dueDate); + let normalizedScore = score + (priorityWeight[priority] || 1) * 14; + + if (status && ['blocked', 'rejected', 'renewal_due', 'off_track', 'over_target', 'needs_target_date', 'needs_target_budget'].includes(status)) { + normalizedScore += 24; + } + + if (due) { + const daysUntil = diffInCalendarDays(today, due); + + if (daysUntil < 0) { + normalizedScore += 24 + Math.min(Math.abs(daysUntil), 14); + } else if (daysUntil <= 7) { + normalizedScore += 16; + } else if (daysUntil <= 14) { + normalizedScore += 10; + } else if (daysUntil <= 30) { + normalizedScore += 4; + } + } + + collection.push({ + area, + helper, + href, + id, + impact, + label, + openLabel, + priority, + score: normalizedScore, + status, + }); + }; + + if (openingForecast.status !== 'on_track') { + addItem(decisions, { + area: 'launch', + dueDate: project?.target_open_date, + helper: openingForecast.message, + href: projectEditHref || projectViewHref, + id: `decision-opening-${project?.id || 'workspace'}`, + impact: + openingForecast.status === 'needs_target_date' + ? 'This gives the rest of the plan a real finish line instead of a moving target.' + : 'This choice determines whether the launch keeps pushing ahead or first stabilizes the real blockers.', + label: + openingForecast.status === 'needs_target_date' + ? 'Choose the target opening date' + : openingForecast.status === 'off_track' + ? 'Decide how to recover the opening plan' + : openingForecast.status === 'paused' + ? 'Decide what the paused launch is waiting on' + : 'Tighten the opening plan before adding more work', + openLabel: projectEditHref ? 'Review launch' : 'Open workspace', + priority: ['off_track', 'needs_target_date', 'paused'].includes(openingForecast.status) ? 'critical' : 'high', + status: openingForecast.status, + }); + } + + if (budgetSnapshot.status !== 'on_track') { + addItem(decisions, { + area: 'funding', + helper: budgetSnapshot.message, + href: canReadFunding ? '/funding_rounds/funding_rounds-list' : projectEditHref || projectViewHref, + id: `decision-budget-${project?.id || 'workspace'}`, + impact: + budgetSnapshot.status === 'needs_target_budget' + ? 'Without a saved budget ceiling, spending choices are harder to judge honestly.' + : 'This decision keeps the startup from quietly outrunning its real money picture.', + label: + budgetSnapshot.status === 'needs_target_budget' + ? 'Set the launch budget ceiling' + : budgetSnapshot.status === 'over_target' + ? 'Cut scope or fund the budget gap' + : 'Review budget pressure before approving more spend', + openLabel: canReadFunding ? 'Review budget' : 'Open workspace', + priority: budgetSnapshot.status === 'over_target' ? 'critical' : 'high', + status: budgetSnapshot.status, + }); + } + + if (!fundingRounds.length && currentStageIndex >= fundingStageIndex) { + addItem(decisions, { + area: 'funding', + helper: fundingTarget > 0 + ? `${formatCurrency(fundingTarget)} target is saved, but no active funding path is being tracked yet.` + : 'No funding rounds are saved even though the launch has reached a stage that depends on real capital.' , + href: canReadFunding ? '/funding_rounds/funding_rounds-list' : projectEditHref || projectViewHref, + id: `decision-funding-path-${project?.id || 'workspace'}`, + impact: 'This sets the realistic pace and scope the business can support from here.', + label: 'Choose the actual funding path', + openLabel: canReadFunding ? 'Open funding' : 'Review funding', + priority: 'high', + status: 'at_risk', + }); + } + + if (currentStageIndex >= siteSelectionStageIndex) { + if (leadProperty && !['purchased', 'leased'].includes(leadProperty.acquisition_status)) { + addItem(decisions, { + area: 'property', + dueDate: leadProperty.closing_date || leadProperty.offer_date, + helper: [ + leadProperty.property_name || 'Lead property', + leadProperty.property_type ? humanize(leadProperty.property_type) : null, + leadProperty.closing_date ? `Closing ${formatDate(leadProperty.closing_date)}` : leadProperty.offer_date ? `Offer ${formatDate(leadProperty.offer_date)}` : null, + ] + .filter(Boolean) + .join(' • '), + href: canReadProperties ? `/properties/properties-view/?id=${leadProperty.id}` : projectEditHref || projectViewHref, + id: `decision-property-${leadProperty.id}`, + impact: 'Property certainty affects compliance, buildout, and the credibility of the opening date.', + label: ['candidate', 'under_review'].includes(leadProperty.acquisition_status) ? 'Advance or replace the lead site' : 'Review the lead site commitment', + openLabel: canReadProperties ? 'Review site' : 'Open workspace', + priority: daysUntilOpen !== null && daysUntilOpen <= 90 ? 'high' : 'medium', + status: leadProperty.acquisition_status, + score: hasPrimaryLocation ? 6 : 0, + }); + } else if (!leadProperty) { + addItem(decisions, { + area: 'property', + helper: hasPrimaryLocation + ? 'A preferred geography is saved, but no real property options are being tracked yet.' + : 'The workspace still needs a location direction before permits, buildout, and launch timing become concrete.', + href: canReadProperties ? '/properties/properties-list' : projectEditHref || projectViewHref, + id: `decision-property-direction-${project?.id || 'workspace'}`, + impact: 'Without a site direction, several downstream decisions stay theoretical.', + label: hasPrimaryLocation ? 'Turn the preferred location into real site options' : 'Choose the location direction', + openLabel: canReadProperties ? 'Open property' : 'Open workspace', + priority: 'high', + status: 'at_risk', + }); + } + } + + if (criticalLegalDecision && (currentStageIndex >= legalStageIndex || dueSoonLegalCount > 0)) { + addItem(decisions, { + area: 'legal', + dueDate: criticalLegalDecision.due_at, + helper: [ + criticalLegalDecision.requirement_title || 'Compliance requirement', + criticalLegalDecision.requirement_type ? humanize(criticalLegalDecision.requirement_type) : null, + criticalLegalDecision.due_at ? `Due ${formatDate(criticalLegalDecision.due_at)}` : null, + ] + .filter(Boolean) + .join(' • '), + href: canReadLegal ? `/legal_requirements/legal_requirements-view/?id=${criticalLegalDecision.id}` : projectEditHref || projectViewHref, + id: `decision-legal-${criticalLegalDecision.id || project?.id || 'workspace'}`, + impact: 'For a natural-health business, compliance choices should be treated as launch gates, not later cleanup.', + label: + criticalLegalDecision.status === 'rejected' + ? 'Choose the compliance recovery path' + : criticalLegalDecision.status === 'renewal_due' + ? 'Approve the renewal response' + : 'Approve the next compliance move', + openLabel: canReadLegal ? 'Review compliance' : 'Open workspace', + priority: ['rejected', 'renewal_due'].includes(criticalLegalDecision.status) ? 'critical' : 'high', + status: criticalLegalDecision.status || 'at_risk', + }); + } + + if (leadHiringDecision && (currentStageIndex >= staffingStageIndex || openRoles > 0)) { + addItem(decisions, { + area: 'hiring', + dueDate: leadHiringDecision.target_start_at, + helper: [ + leadHiringDecision.position_title || 'Launch role', + leadHiringDecision.employment_type ? humanize(leadHiringDecision.employment_type) : null, + leadHiringDecision.target_start_at ? `Target start ${formatDate(leadHiringDecision.target_start_at)}` : null, + ] + .filter(Boolean) + .join(' • '), + href: canReadPositions ? `/positions/positions-view/?id=${leadHiringDecision.id}` : projectEditHref || projectViewHref, + id: `decision-hiring-${leadHiringDecision.id}`, + impact: publishedTrainingPrograms > 0 + ? 'Making the hiring call now leaves real time for onboarding and training before go-live.' + : 'Even a small launch team needs enough lead time for onboarding, not last-minute scrambling.', + label: + leadHiringDecision.status === 'offered' + ? 'Accept, revise, or replace the current offer' + : leadHiringDecision.status === 'interviewing' + ? 'Choose who moves forward in hiring' + : 'Choose the next launch-critical hire', + openLabel: canReadPositions ? 'Review hiring' : 'Open workspace', + priority: leadHiringDecision.status === 'offered' ? 'high' : 'medium', + status: leadHiringDecision.status, + score: openRoles > 1 ? 6 : 0, + }); + } else if (!positions.length && currentStageIndex >= staffingStageIndex) { + addItem(decisions, { + area: 'hiring', + helper: 'The project is entering a people-heavy stage, but no launch roles are saved yet.', + href: canReadPositions ? '/positions/positions-list' : projectEditHref || projectViewHref, + id: `decision-hiring-plan-${project?.id || 'workspace'}`, + impact: 'Even a lean launch team needs lead time for recruiting, onboarding, and training.', + label: 'Decide the first launch roles', + openLabel: canReadPositions ? 'Open roles' : 'Open workspace', + priority: 'high', + status: 'at_risk', + }); + } + + if ( + (currentStageIndex >= marketingStageIndex || (daysUntilOpen !== null && daysUntilOpen <= 45)) + && ((plannedCampaigns > 0 && runningCampaigns === 0) || (!marketingCampaigns.length && daysUntilOpen !== null && daysUntilOpen <= 45)) + ) { + addItem(decisions, { + area: 'marketing', + helper: plannedCampaigns > 0 + ? `${plannedCampaigns} planned ${plannedCampaigns === 1 ? 'campaign is' : 'campaigns are'} saved, but nothing is live yet.` + : 'No launch campaign is saved even though the opening window is getting close.', + href: canReadMarketing ? '/marketing_campaigns/marketing_campaigns-list' : projectEditHref || projectViewHref, + id: `decision-marketing-${project?.id || 'workspace'}`, + impact: 'Demand usually needs lead time. Waiting too long makes opening day much harder to fill.', + label: plannedCampaigns > 0 ? 'Approve the first live launch campaign' : 'Choose how launch demand will start', + openLabel: canReadMarketing ? 'Open marketing' : 'Open workspace', + priority: daysUntilOpen !== null && daysUntilOpen <= 30 ? 'high' : 'medium', + status: plannedCampaigns > 0 ? 'planned' : 'at_risk', + }); + } + + guidedLaunchQueue + .filter((item) => item.lane === 'waiting_blocker') + .forEach((item) => { + addItem(monitors, { + area: item.area, + helper: item.helper, + href: item.href, + id: `monitor-${item.id}`, + impact: item.reason, + label: item.label, + openLabel: 'Open', + priority: item.priority, + status: item.status, + score: item.score, + }); + }); + + const sortedDecisions = decisions.sort((left, right) => right.score - left.score); + const sortedMonitors = monitors.sort((left, right) => right.score - left.score); + const biggestUnlock = sortedDecisions[0] || null; + const criticalCount = sortedDecisions.filter((item) => item.priority === 'critical').length; + + let status = 'on_track'; + let headline = 'Decision load is light right now'; + let summary = 'The saved records are not showing a major founder bottleneck at the moment. Keep the plan current and protect weekly focus.'; + + if (criticalCount > 0 || sortedDecisions.length >= 4) { + status = 'off_track'; + headline = 'Too many owner decisions are still open'; + summary = 'The launch is carrying more founder-level judgment calls than it should. Resolve the few that change timing, money, or compliance first.'; + } else if (sortedDecisions.length > 0 || sortedMonitors.length > 0) { + status = 'at_risk'; + headline = 'A few owner decisions still need attention'; + summary = 'Execution can keep moving, but several choices or follow-ups still need explicit leadership attention this week.'; + } + + const guardrail = + criticalCount > 0 + ? 'Start with the decision that changes the opening date, budget, or compliance risk. Smaller admin choices can wait.' + : sortedDecisions.length > 2 + ? 'Keep the decision list short. Make the top 1–3 calls, then turn the rest into delegated follow-up.' + : sortedDecisions.length > 0 + ? 'Make the call explicitly, assign the follow-up owner, and set the next check-in date while the context is fresh.' + : sortedMonitors.length > 0 + ? 'Major choices are fairly contained right now, so focus on chasing replies and clearing blocked handoffs.' + : 'Use the extra room to save missing dates, budgets, and records so the next hard decision shows up earlier.'; + + const decisionOutcomes = [ + sortedDecisions.length > 0 + ? `Make ${Math.min(sortedDecisions.length, 3)} named owner decision${Math.min(sortedDecisions.length, 3) === 1 ? '' : 's'} instead of letting them stay as vague follow-up.` + : 'No obvious owner-level decision is being pushed by the saved records right now.', + sortedMonitors.length > 0 + ? `Assign a next check-in date to ${Math.min(sortedMonitors.length, 3)} waiting or blocked ${Math.min(sortedMonitors.length, 3) === 1 ? 'item' : 'items'} so momentum does not disappear between meetings.` + : 'Waiting items are not dominating the launch at the moment.', + biggestUnlock + ? `Start with ${getAreaLabel(biggestUnlock.area)}: ${truncateLabel(biggestUnlock.label, 60)}.` + : 'If this desk stays quiet, use the time to improve the saved records rather than inventing extra work.', + ]; + + return { + biggestUnlock, + criticalCount, + decisionCount: sortedDecisions.length, + decisionOutcomes, + decisions: sortedDecisions.slice(0, 4), + guardrail, + headline, + monitorCount: sortedMonitors.length, + monitors: sortedMonitors.slice(0, 4), + status, + summary, + }; + }, [ + budgetSnapshot, + canReadFunding, + canReadLegal, + canReadMarketing, + canReadPositions, + canReadProperties, + dueSoonLegalCount, + fundingAvailable, + fundingRounds, + fundingTarget, + guidedLaunchQueue, + leadProperty, + legalRequirements, + marketingCampaigns, + openRoles, + openingForecast, + plannedCampaigns, + positions, + project, + projectEditHref, + projectViewHref, + publishedTrainingPrograms, + runningCampaigns, + stageRunner.currentStage.stage, + today, + urgentLegalRequirements, + ]); + + const goLiveGateBoard = useMemo(() => { + const currentStageIndex = Math.max(liveStageOrder.indexOf(stageRunner.currentStage.stage || ''), 0); + const siteStageIndex = Math.max(liveStageOrder.indexOf('site_selection'), 0); + const legalStageIndex = Math.max(liveStageOrder.indexOf('legal_compliance'), 0); + const staffingStageIndex = Math.max(liveStageOrder.indexOf('staffing_training'), 0); + const marketingStageIndex = Math.max(liveStageOrder.indexOf('marketing_sales'), 0); + const targetOpenDate = parseValidDate(project?.target_open_date); + const daysUntilOpen = targetOpenDate ? diffInCalendarDays(today, targetOpenDate) : null; + const launchWindowTight = daysUntilOpen !== null && daysUntilOpen <= 45; + const activeLegalAlert = legalRequirements.find((item) => ['rejected', 'renewal_due'].includes(item.status)) || null; + const legalApprovalRate = legalRequirements.length ? Math.round((approvedLegalCount / legalRequirements.length) * 100) : 0; + const propertySecured = ['under_contract', 'purchased', 'leased'].includes(leadProperty?.acquisition_status); + const propertyInMotion = ['offer_submitted', 'under_review', 'negotiating', 'due_diligence', 'candidate', 'identified', 'listed'].includes( + leadProperty?.acquisition_status, + ); + const fundingMomentum = fundingRounds.some((item) => ['in_progress', 'committed', 'funded'].includes(item.status)) || fundingAvailable > 0 || fundingTarget > 0; + const rolesTrackedCount = positions.length; + const filledRolesCount = positions.filter((item) => ['filled', 'closed'].includes(item.status)).length; + const launchAssetCount = designAssets.length + approvedDocuments; + const demandSignalsCount = runningCampaigns + plannedCampaigns; + const gates: GoLiveGateItem[] = []; + + const addGate = ({ + area, + evidence, + href, + id, + label, + nextFocus, + openLabel, + score, + status, + }: Omit & { score: number }) => { + gates.push({ + area, + evidence, + href, + id, + label, + nextFocus, + openLabel, + score: clampScore(score), + status, + }); + }; + + addGate({ + area: 'launch', + evidence: openingForecast.countdownLabel, + href: projectEditHref || projectViewHref, + id: `gate-calendar-${project?.id || 'workspace'}`, + label: 'Calendar and target opening', + nextFocus: openingForecast.message, + openLabel: projectEditHref ? 'Review launch date' : 'Open workspace', + score: + openingForecast.status === 'on_track' + ? Math.max(daysUntilOpen !== null && daysUntilOpen > 90 ? 72 : 80, stageRunner.currentStage.readiness) + : openingForecast.status === 'at_risk' + ? 58 + : openingForecast.status === 'paused' + ? 28 + : openingForecast.status === 'needs_target_date' + ? 18 + : 24, + status: openingForecast.status, + }); + + let capitalStatus = budgetSnapshot.status; + let capitalScore = + budgetSnapshot.status === 'on_track' + ? budgetSnapshot.fundingCoverage >= 100 + ? 88 + : 78 + : budgetSnapshot.status === 'at_risk' + ? 56 + : budgetSnapshot.status === 'needs_target_budget' + ? 22 + : budgetSnapshot.status === 'over_target' + ? 24 + : 44; + + if (!fundingMomentum && currentStageIndex >= siteStageIndex) { + capitalStatus = launchWindowTight ? 'off_track' : capitalStatus === 'over_target' ? capitalStatus : 'at_risk'; + capitalScore = Math.min(capitalScore, launchWindowTight ? 26 : 44); + } + + addGate({ + area: 'funding', + evidence: !project?.target_budget + ? 'No budget ceiling is saved yet.' + : `${formatCurrency(budgetSnapshot.trackedPlannedSpend)} tracked planned spend • ${budgetSnapshot.fundingCoverage}% funding coverage`, + href: canReadFunding ? '/funding_rounds/funding_rounds-list' : projectEditHref || projectViewHref, + id: `gate-capital-${project?.id || 'workspace'}`, + label: 'Capital coverage', + nextFocus: !fundingMomentum && currentStageIndex >= siteStageIndex + ? 'Funding still needs a real path in motion before later-stage commitments become believable.' + : budgetSnapshot.message, + openLabel: canReadFunding ? 'Open funding' : 'Open workspace', + score: capitalScore, + status: capitalStatus, + }); + + let propertyStatus = currentStageIndex < siteStageIndex ? 'planned' : launchWindowTight ? 'off_track' : 'at_risk'; + let propertyScore = currentStageIndex < siteStageIndex ? 55 : launchWindowTight ? 24 : 32; + + if (propertySecured) { + propertyStatus = 'on_track'; + propertyScore = 88; + } else if (leadProperty && propertyInMotion) { + propertyStatus = currentStageIndex >= siteStageIndex ? 'at_risk' : 'planned'; + propertyScore = currentStageIndex >= siteStageIndex ? 62 : 70; + } else if (leadProperty) { + propertyStatus = currentStageIndex >= siteStageIndex ? 'at_risk' : 'planned'; + propertyScore = currentStageIndex >= siteStageIndex ? 48 : 58; + } + + addGate({ + area: 'property', + evidence: leadProperty + ? [ + leadProperty.property_name || 'Lead property', + humanize(leadProperty.acquisition_status || 'candidate'), + leadProperty.purchase_price ? formatCurrency(leadProperty.purchase_price) : leadProperty.asking_price ? formatCurrency(leadProperty.asking_price) : null, + ] + .filter(Boolean) + .join(' • ') + : 'No lead property or site record is saved yet.', + href: canReadProperties ? '/properties/properties-list' : projectEditHref || projectViewHref, + id: `gate-property-${project?.id || 'workspace'}`, + label: 'Site control', + nextFocus: propertySecured + ? 'Keep diligence, permits, and build scope moving against the chosen site.' + : leadProperty + ? 'Decide whether this is the real lead site and move the negotiation, diligence, or lease forward.' + : 'Start or narrow site tracking so the launch stops depending on a placeholder location.', + openLabel: canReadProperties ? 'Open properties' : 'Open workspace', + score: propertyScore, + status: propertyStatus, + }); + + let complianceStatus = currentStageIndex < legalStageIndex && !launchWindowTight ? 'planned' : launchWindowTight ? 'off_track' : 'at_risk'; + let complianceScore = currentStageIndex < legalStageIndex && !launchWindowTight ? 52 : launchWindowTight ? 18 : 30; + + if (legalRequirements.length > 0) { + if (activeLegalAlert) { + complianceStatus = 'off_track'; + complianceScore = 22; + } else if (dueSoonLegalCount > 0) { + complianceStatus = 'at_risk'; + complianceScore = 48; + } else if (legalApprovalRate >= 70) { + complianceStatus = 'on_track'; + complianceScore = Math.max(78, legalApprovalRate); + } else if (currentStageIndex < legalStageIndex) { + complianceStatus = 'planned'; + complianceScore = Math.max(56, legalApprovalRate); + } else { + complianceStatus = 'at_risk'; + complianceScore = Math.max(42, legalApprovalRate); + } + } + + addGate({ + area: 'legal', + evidence: legalRequirements.length > 0 + ? `${approvedLegalCount}/${legalRequirements.length} approved • ${dueSoonLegalCount} due soon` + : 'No compliance checklist is saved yet.', + href: canReadLegal ? '/legal_requirements/legal_requirements-list' : projectEditHref || projectViewHref, + id: `gate-legal-${project?.id || 'workspace'}`, + label: 'Compliance clearance', + nextFocus: activeLegalAlert + ? `Resolve ${activeLegalAlert.requirement_title || 'the legal blocker'} before trusting the opening plan.` + : dueSoonLegalCount > 0 + ? 'Clear the compliance items due soon so approvals do not become the reason the launch slips.' + : legalRequirements.length === 0 + ? 'Start the legal and compliance checklist now so regulated work is visible long before opening day.' + : 'Keep each approval documented and push the remaining requirements to a named owner and date.', + openLabel: canReadLegal ? 'Open compliance' : 'Open workspace', + score: complianceScore, + status: complianceStatus, + }); + + let peopleStatus = currentStageIndex < staffingStageIndex && !launchWindowTight ? 'planned' : launchWindowTight ? 'off_track' : 'at_risk'; + let peopleScore = currentStageIndex < staffingStageIndex && !launchWindowTight ? 54 : launchWindowTight ? 20 : 34; + + if (rolesTrackedCount > 0 || publishedTrainingPrograms > 0) { + if (openRoles > 0 && launchWindowTight) { + peopleStatus = 'off_track'; + peopleScore = 30; + } else if (openRoles > 0) { + peopleStatus = 'at_risk'; + peopleScore = 52; + } else if (rolesTrackedCount > 0 && publishedTrainingPrograms === 0) { + peopleStatus = 'at_risk'; + peopleScore = 58; + } else { + peopleStatus = 'on_track'; + peopleScore = 74 + Math.min(filledRolesCount * 4 + publishedTrainingPrograms * 6, 16); + } + } + + addGate({ + area: 'hiring', + evidence: rolesTrackedCount > 0 || publishedTrainingPrograms > 0 + ? `${rolesTrackedCount} roles tracked • ${filledRolesCount} filled or closed • ${publishedTrainingPrograms} training published` + : 'No roles or published training are saved yet.', + href: canReadPositions ? '/positions/positions-list' : canReadTraining ? '/training_programs/training_programs-list' : projectEditHref || projectViewHref, + id: `gate-people-${project?.id || 'workspace'}`, + label: 'People and operating readiness', + nextFocus: openRoles > 0 && launchWindowTight + ? 'Close the open hiring decisions and publish onboarding fast enough that operations are not improvised at the last minute.' + : rolesTrackedCount === 0 && currentStageIndex >= staffingStageIndex + ? 'Name the critical opening-day roles and owners so the founder is not holding every operational responsibility.' + : publishedTrainingPrograms === 0 && rolesTrackedCount > 0 + ? 'Turn the hiring plan into repeatable onboarding and training so new people can actually ramp.' + : 'Keep staffing ownership, training materials, and opening-day operating handoffs current as launch gets closer.', + openLabel: canReadPositions ? 'Open roles' : canReadTraining ? 'Open training' : 'Open workspace', + score: peopleScore, + status: peopleStatus, + }); + + let demandStatus = currentStageIndex < marketingStageIndex && !launchWindowTight ? 'planned' : launchWindowTight ? 'off_track' : 'at_risk'; + let demandScore = currentStageIndex < marketingStageIndex && !launchWindowTight ? 56 : launchWindowTight ? 18 : 34; + + if (demandSignalsCount > 0 || launchAssetCount > 0) { + if (runningCampaigns > 0) { + demandStatus = 'on_track'; + demandScore = 84; + } else if (plannedCampaigns > 0 || launchAssetCount > 0) { + demandStatus = launchWindowTight ? 'at_risk' : 'planned'; + demandScore = launchWindowTight ? 56 : 68; + } + } + + addGate({ + area: 'marketing', + evidence: demandSignalsCount > 0 || launchAssetCount > 0 + ? `${runningCampaigns} live • ${plannedCampaigns} planned • ${designAssets.length} design assets` + : 'No launch demand motion or marketing assets are saved yet.', + href: canReadMarketing ? '/marketing_campaigns/marketing_campaigns-list' : projectEditHref || projectViewHref, + id: `gate-demand-${project?.id || 'workspace'}`, + label: 'Demand and launch message', + nextFocus: runningCampaigns > 0 + ? 'Keep measuring live demand and protect the campaign calendar all the way to opening day.' + : plannedCampaigns > 0 + ? 'Approve the first live campaign and tie it directly to the opening window.' + : 'Decide how demand will start before the opening window gets too close for real lead time.', + openLabel: canReadMarketing ? 'Open marketing' : 'Open workspace', + score: demandScore, + status: demandStatus, + }); + + const hardBlockCount = gates.filter((item) => ['blocked', 'off_track', 'over_target'].includes(item.status)).length; + const riskCount = gates.filter((item) => ['at_risk', 'needs_target_date', 'needs_target_budget', 'paused'].includes(item.status)).length; + const readyCount = gates.filter((item) => ['completed', 'on_track', 'operating'].includes(item.status)).length; + const comingLaterCount = gates.filter((item) => item.status === 'planned').length; + const overallScore = clampScore(gates.length ? gates.reduce((sum, item) => sum + item.score, 0) / gates.length : 0); + const gatePriority = [...gates].sort((left, right) => { + const leftWeight = getSignalPressureWeight(left.status); + const rightWeight = getSignalPressureWeight(right.status); + + if (leftWeight !== rightWeight) { + return rightWeight - leftWeight; + } + + return left.score - right.score; + }); + const biggestBarrier = gatePriority.find((item) => getSignalPressureWeight(item.status) >= 3) || gatePriority.find((item) => getSignalPressureWeight(item.status) > 0) || null; + + let status = 'on_track'; + let headline = 'Go-live foundations are holding together'; + let summary = 'The saved records suggest the launch has a believable path. Keep each gate updated so surprises show up early instead of late.'; + + if (hardBlockCount > 0) { + status = 'off_track'; + headline = 'One or more launch gates are still hard blockers'; + summary = 'The opening path is carrying real gating risk. Clear the blocked gate first before expanding scope or adding fresh commitments.'; + } else if (riskCount > 0) { + status = 'at_risk'; + headline = 'Several launch gates still need deliberate work'; + summary = 'The business can keep moving, but a few gates still need named ownership and follow-through before opening looks fully believable.'; + } else if (comingLaterCount > 0) { + headline = 'Future launch gates are visible early'; + summary = 'That is healthy at this stage. The app is showing what will need to become true later, before those items turn into last-minute pressure.'; + } + + return { + biggestBarrier, + comingLaterCount, + gates, + hardBlockCount, + headline, + overallScore, + readyCount, + riskCount, + status, + summary, + }; + }, [ + approvedDocuments, + approvedLegalCount, + budgetSnapshot, + canReadFunding, + canReadLegal, + canReadMarketing, + canReadPositions, + canReadProperties, + canReadTraining, + designAssets.length, + dueSoonLegalCount, + fundingAvailable, + fundingRounds, + fundingTarget, + leadProperty, + legalRequirements, + openRoles, + openingForecast, + plannedCampaigns, + positions, + project, + projectEditHref, + projectViewHref, + publishedTrainingPrograms, + runningCampaigns, + stageRunner.currentStage.readiness, + stageRunner.currentStage.stage, + today, + ]); + + const goLiveSprint = useMemo(() => { + const targetOpenDate = parseValidDate(project?.target_open_date); + const daysUntilOpen = targetOpenDate ? diffInCalendarDays(today, targetOpenDate) : null; + + const mapDecision = (item: FounderDecisionItem): OpeningPathItem => ({ + area: item.area, + helper: item.impact || item.helper, + href: item.href, + id: `opening-decision-${item.id}`, + label: item.label, + openLabel: item.openLabel || 'Open', + priority: item.priority, + status: item.status, + }); + + const mapQueueItem = (item: GuidedQueueItem): OpeningPathItem => ({ + area: item.area, + helper: `${item.reason}. ${item.helper}`, + href: item.href, + id: `opening-queue-${item.id}`, + label: item.label, + openLabel: 'Open', + priority: item.priority, + status: item.status, + }); + + const getMilestoneArea = (item: TimelineMilestone) => { + const source = (item.source || '').toLowerCase(); + + if (source.includes('compliance') || source.includes('renewal')) { + return 'legal'; + } + + if (source.includes('funding')) { + return 'funding'; + } + + if (source.includes('property') || source.includes('offer') || source.includes('lease')) { + return 'property'; + } + + if (source.includes('campaign')) { + return 'marketing'; + } + + if (source.includes('target')) { + return 'launch'; + } + + return getStageArea(stageRunner.currentStage.stage); + }; + + const mapMilestone = (item: TimelineMilestone): OpeningPathItem => ({ + area: getMilestoneArea(item), + helper: [item.source, formatDate(item.date), item.helper].filter(Boolean).join(' • '), + href: item.href, + id: `opening-milestone-${item.id}`, + label: item.label, + openLabel: 'Open', + priority: ['blocked', 'rejected', 'renewal_due'].includes(item.status || '') ? 'critical' : 'high', + status: item.status, + }); + + const mapTask = (task: ProjectRecord): OpeningPathItem => ({ + area: task.phaseId ? getStageArea(phaseTypeById.get(task.phaseId) || stageRunner.currentStage.stage) : getStageArea(stageRunner.currentStage.stage), + helper: [ + task.phaseId ? phaseNameById.get(task.phaseId) : null, + task.due_at ? `Due ${formatDate(task.due_at)}` : null, + task.priority ? `${humanize(task.priority)} priority` : null, + ] + .filter(Boolean) + .join(' • '), + href: canReadTasks ? `/tasks/tasks-view/?id=${task.id}` : '', + id: `opening-task-${task.id}`, + label: task.task_title || 'Untitled task', + openLabel: 'Open', + priority: task.priority || 'high', + status: task.status, + }); + + const lockNow = uniqueById([ + ...founderDecisionDesk.decisions.slice(0, 2).map(mapDecision), + ...weeklyExecutionBrief.mustMove.slice(0, 3).map(mapQueueItem), + ]).slice(0, 4) as OpeningPathItem[]; + + const buildNext = uniqueById([ + ...weeklyExecutionBrief.prepareNext.slice(0, 3).map(mapQueueItem), + ...currentStageActions.map((item) => ({ + area: getStageArea(stageRunner.currentStage.stage), + helper: item.helper, + href: item.href, + id: `opening-stage-${item.id}`, + label: item.label, + openLabel: 'Open', + priority: item.priority || 'medium', + status: stageRunner.currentStage.status === 'blocked' ? 'blocked' : 'planned', + })), + ]).slice(0, 4) as OpeningPathItem[]; + + const protectUntilOpen = uniqueById([ + ...weeklyExecutionBrief.tasksDueThisWeek.map(mapTask), + ...weeklyExecutionBrief.milestonesThisWeek.map(mapMilestone), + ...founderDecisionDesk.monitors.slice(0, 2).map(mapDecision), + ]).slice(0, 4) as OpeningPathItem[]; + + const windows: OpeningPathWindow[] = [ + { + description: 'These are the owner decisions and urgent moves that should become explicit first.', + empty: 'Nothing urgent is demanding founder attention right now. Keep the workspace updated so this stays trustworthy.', + id: 'lock_now', + items: lockNow, + title: 'Lock now', + }, + { + description: 'These are the next actions that should be lined up once the immediate locks are handled.', + empty: 'The next lane is unusually clear. Use the space to strengthen dates, costs, and task ownership.', + id: 'build_next', + items: buildNext, + title: 'Build next', + }, + { + description: 'These are the dates, approvals, and waiting items that need to stay visible all the way to opening.', + empty: 'No major timed items or waiting follow-up are crowding the opening path right now.', + id: 'protect_until_open', + items: protectUntilOpen, + title: 'Protect to open', + }, + ]; + + let headline = 'Keep the opening path narrow and visible'; + let summary = 'This turns the weekly view into a slightly longer founder runway, so the next several weeks do not become a blur of disconnected tasks.'; + + if (daysUntilOpen === null) { + headline = 'Set the opening date before trusting a final sprint'; + summary = 'Without a saved target opening date, the app can still prioritize work, but it cannot build a reliable closing sprint against a real finish line.'; + } else if (daysUntilOpen < 0) { + headline = 'Rebuild the closing sprint around a reset opening date'; + summary = 'The saved opening date is already behind today, so the next path should stabilize the plan first and then reset the calendar honestly.'; + } else if (daysUntilOpen <= 30) { + headline = `Use the next ${daysUntilOpen} days as a deliberate closing sprint`; + summary = 'The opening window is close enough that every new commitment should either remove risk, confirm readiness, or directly support launch demand.'; + } else if (daysUntilOpen <= 90) { + headline = `The next ${daysUntilOpen} days should steadily remove opening risk`; + summary = 'There is still some runway, but the next month should convert the biggest gates into named owners, dates, and evidence in the workspace.'; + } + + const guardrail = + founderDecisionDesk.criticalCount > 0 + ? 'Keep the sprint anchored to the founder decisions that change timing, money, or compliance. Everything else should support those calls or wait.' + : goLiveGateBoard.hardBlockCount > 0 + ? 'Treat the biggest blocked gate as the main sprint objective. Extra tasks are noise until that gate moves.' + : lockNow.length > 3 + ? 'Do not overload the sprint. A short list that actually closes is safer than a long list that mostly rolls forward.' + : protectUntilOpen.length > 0 + ? 'Review the timed items every week so approvals, deadlines, and external replies do not go quiet.' + : 'Use the extra space to improve cost tracking, dates, and operating evidence while the runway is still manageable.'; + + const outcomes = [ + lockNow.length > 0 + ? `Lock ${Math.min(lockNow.length, 3)} founder-level move${Math.min(lockNow.length, 3) === 1 ? '' : 's'} instead of letting them stay vague.` + : 'There is no obvious founder lock item pressing right now.', + buildNext.length > 0 + ? `Line up ${Math.min(buildNext.length, 3)} next-step move${Math.min(buildNext.length, 3) === 1 ? '' : 's'} so momentum continues after the urgent work clears.` + : 'The next-step lane is light enough to focus on strengthening the saved records.', + protectUntilOpen.length > 0 + ? `Keep ${Math.min(protectUntilOpen.length, 3)} timed or waiting item${Math.min(protectUntilOpen.length, 3) === 1 ? '' : 's'} visible until opening.` + : 'No major timed pressure is dominating the opening path right now.', + ]; + + return { + guardrail, + headline, + outcomes, + runwayLabel: openingForecast.countdownLabel, + score: goLiveGateBoard.overallScore, + status: goLiveGateBoard.status, + summary, + windows, + }; + }, [ + currentStageActions, + founderDecisionDesk, + goLiveGateBoard.hardBlockCount, + goLiveGateBoard.overallScore, + goLiveGateBoard.status, + openingForecast.countdownLabel, + project?.target_open_date, + stageRunner.currentStage.stage, + stageRunner.currentStage.status, + today, + weeklyExecutionBrief, + ]); + + const isLoading = isLoadingProjects || isLoadingProject; + + return ( + <> + + {getPageTitle('Business Command Center')} + + + + + {canCreateProjects ? ( + + ) : ( + '' + )} + + + + } + color="warning" + icon={mdiAlertCircle} + > + This page turns saved project records into a weekly operating view. It helps you prioritize the next moves, but it does not replace human review for legal, tax, insurance, property, or health-claim decisions. + + + {!canReadProjects && ( + +

Project access is required

+

+ Your current role can sign in, but it does not have permission to read projects. Ask an administrator to grant project access so this command center can show your launch workspace. +

+
+ )} + + {canReadProjects && isLoading && ( + +
+
+ +
+
+

Loading your saved business workspace

+

+ The command center is pulling your latest project, phases, tasks, compliance items, and launch signals now. +

+
+
+
+ )} + + {canReadProjects && !!loadError && !isLoading && ( + + {loadError} + + )} + + {canReadProjects && !isLoading && !loadError && recentProjects.length === 0 && ( + +

No saved business workspaces yet

+

+ Start with Legacy Launchpad to turn your real business idea into a first working blueprint. Once a project exists, this page will become your command center for priorities, blockers, and next actions. +

+
+ {canCreateProjects && } + +
+
+ )} + + {canReadProjects && !isLoading && !loadError && project && ( + <> + +
+
+
Active business workspace
+

{projectLabel}

+
+ + + + {formatLocationSummary(project.primary_location)} + +
+

+ {project.business_idea?.elevator_pitch || project.vision || 'This workspace has no short summary yet. Use the project vision or business idea records to make the command center more specific.'} +

+ +
+
+
Saved stage
+
{humanize(project.stage || 'not_set')}
+
+
+
Target open date
+
{formatDate(project.target_open_date)}
+
+
+
Target budget
+
{formatCurrency(project.target_budget)}
+
+
+
Saved on
+
{formatDate(project.created_on || project.createdAt)}
+
+
+ + {recentProjects.length > 1 && ( +
+
Switch saved workspace
+ + {recentProjects.map((item) => ( + setSelectedProjectId(item.id)} + outline={selectedProjectId !== item.id} + small + /> + ))} + +
+ )} +
+ +
+
Current focus
+

{activePhase?.phase_name || stageGuide.focus}

+

{activePhase?.summary || stageGuide.summary}

+
+ + + Stage runner: {humanize(stageRunner.currentStage.stage || 'not_set')} + + {project?.stage ? ( + + Saved stage: {humanize(project.stage)} + + ) : null} +
+

{stageRunner.message}

+ +
+ Execution progress + {executionProgress}% +
+
+
+
+
+
+
Open actions
+
{actionableTasks.length}
+
+
+
Current blockers
+
{blockers.length}
+
+
+ +
+ + {projectEditHref ? : null} + + {canCreateProjects && } +
+
+
+ + +
+ + + + + + + +
+ +
+ : undefined} + eyebrow="Automatic stage runner" + icon={mdiChartTimelineVariant} + title="Data-driven stage check" + > +
+ : projectViewHref ? : undefined} + eyebrow="Step 8 • Opening control" + icon={mdiCheckCircle} + title="Go-live gate board" + > +
+ This board answers the founder question “Could this actually open with what is saved right now?” It separates the gates that look healthy from the ones that still need real proof, decisions, or follow-through. +
+ +
+
+
Overall go-live score
+
{goLiveGateBoard.overallScore}
+

{goLiveGateBoard.summary}

+
+ +
+
Gates on track
+
{goLiveGateBoard.readyCount}
+

+ {goLiveGateBoard.comingLaterCount > 0 + ? `${goLiveGateBoard.comingLaterCount} ${goLiveGateBoard.comingLaterCount === 1 ? 'gate is' : 'gates are'} visible for later-stage work.` + : 'No future gate is sitting completely outside today’s view.'} +

+
+ +
+
Need work now
+
{goLiveGateBoard.hardBlockCount + goLiveGateBoard.riskCount}
+

+ {goLiveGateBoard.hardBlockCount > 0 + ? `${goLiveGateBoard.hardBlockCount} ${goLiveGateBoard.hardBlockCount === 1 ? 'gate is' : 'gates are'} acting like a hard blocker right now.` + : 'No gate is fully blocked, but the cards below still show where proof is thin.'} +

+
+ +
+
Biggest gate to solve
+
+ {goLiveGateBoard.biggestBarrier ? goLiveGateBoard.biggestBarrier.label : 'No major blocker'} +
+

+ {goLiveGateBoard.biggestBarrier + ? goLiveGateBoard.biggestBarrier.nextFocus + : 'The current records do not show a single gate dominating the launch right now.'} +

+
+
+ +
+ {goLiveGateBoard.gates.map((gate) => ( +
+
+
+
{getAreaLabel(gate.area)}
+
{gate.label}
+
+ +
+ +
+
+
Gate score
+
{gate.score}
+
+
+ {gate.score >= 80 ? 'Strong' : gate.score >= 60 ? 'Moving' : gate.score >= 40 ? 'Thin' : 'Fragile'} +
+
+ +
+
+
+ +
Current evidence
+

{gate.evidence}

+ +
Next focus
+

{gate.nextFocus}

+ + {gate.href ? ( + + ) : null} +
+ ))} +
+ +
+ +
+ : projectEditHref ? : undefined} + eyebrow="Step 9 • Closing sprint" + icon={mdiChartTimelineVariant} + title="30-day opening path" + > +
+
+
Runway view
+
+ + + Score {goLiveSprint.score} + +
+
{goLiveSprint.runwayLabel}
+
{goLiveSprint.headline}
+

{goLiveSprint.summary}

+ +
+ {goLiveSprint.outcomes.map((item) => ( +
+ +
{item}
+
+ ))} +
+ +
+
Founder guardrail
+

{goLiveSprint.guardrail}

+
+
+ +
+ {goLiveSprint.windows.map((window) => { + const windowMeta = { + lock_now: { + buttonColor: 'danger', + cardClasses: 'border-rose-200 bg-rose-50/80 dark:border-rose-900/60 dark:bg-rose-950/20', + }, + build_next: { + buttonColor: 'info', + cardClasses: 'border-blue-200 bg-blue-50/80 dark:border-blue-900/60 dark:bg-blue-950/20', + }, + protect_until_open: { + buttonColor: 'warning', + cardClasses: 'border-amber-200 bg-amber-50/80 dark:border-amber-900/60 dark:bg-amber-950/20', + }, + }[window.id as 'lock_now' | 'build_next' | 'protect_until_open']; + + return ( +
+
+
+
{window.title}
+

{window.description}

+
+
{window.items.length}
+
+ +
+ {window.items.length > 0 ? ( + window.items.map((item) => ( +
+
+ + {item.status ? : null} + + {getAreaLabel(item.area)} + +
+
{item.label}
+

{item.helper}

+ {item.href ? : null} +
+ )) + ) : ( +
{window.empty}
+ )} +
+
+ ); + })} +
+
+
+
+ +
+
+
+
Working stage
+
+
{humanize(stageRunner.currentStage.stage || 'ideation')}
+ +
+

{stageRunner.message}

+
+
+
Saved stage
+
{humanize(project?.stage || 'not_set')}
+
+
+
Readiness in this stage
+
{stageRunner.currentStage.readiness}%
+
+
+
+
+ +
+
+ {stageRunner.stages.map((item) => ( +
+
+
{humanize(item.stage)}
+ +
+
{item.readiness}%
+

{item.completedChecks}/{item.totalChecks} signals ready

+
+ ))} +
+ +
+
+
Before advancing
+
+ {currentStageActions.length > 0 ? ( + currentStageActions.map((item) => ( +
+
+
+ +
{item.label}
+

{item.helper}

+
+ {item.href ? : null} +
+
+ )) + ) : ( +
+ This stage looks fully covered by the saved data. {stageRunner.nextStage ? `Start preparing ${humanize(stageRunner.nextStage.stage)} next.` : 'You are already at the operating end of the roadmap.'} +
+ )} +
+
+ +
+
Already in place
+
+ {currentStageCompletedSignals.length > 0 ? ( + currentStageCompletedSignals.map((item) => ( +
+
+ +
{item}
+
+
+ )) + ) : ( +
+ No strong readiness signals are saved yet for this stage. That is normal early on — start with the action list on the left. +
+ )} +
+
+
+
+
+
+
+ +
+
+ : undefined} + eyebrow="Critical path" + icon={mdiChartTimelineVariant} + title="Opening forecast" + > +
+
+
Launch timing
+
+ +
{formatDate(project.target_open_date)}
+
+
{openingForecast.countdownLabel}
+

{openingForecast.message}

+ +
+
+
Overdue tasks
+
{overdueTasks.length}
+
+
+
Due soon compliance
+
{dueSoonLegalCount}
+
+
+
Open blockers
+
{blockers.length}
+
+
+
+ +
+ {overdueMilestones.length > 0 && ( +
+
Past due milestones
+
+ {overdueMilestones.map((item) => ( +
+
+
+
+ {item.source} + {item.status ? : null} +
+
{item.label}
+

{[formatDate(item.date), item.helper].filter(Boolean).join(' • ')}

+
+ {item.href ? : null} +
+
+ ))} +
+
+ )} + +
+
Upcoming milestone queue
+
+ {upcomingMilestones.length > 0 ? ( + upcomingMilestones.map((item) => ( +
+
+
+
+ {item.source} + {item.status ? : null} +
+
{item.label}
+

{[formatDate(item.date), item.helper].filter(Boolean).join(' • ')}

+
+ {item.href ? : null} +
+
+ )) + ) : ( +
+ No dated milestones are saved yet. Add due dates, target dates, or close dates to make the forecast much more useful. +
+ )} +
+
+
+
+
+
+ +
+ : undefined} + eyebrow="Budget reality" + icon={mdiCashMultiple} + title="Tracked budget pressure" + > +
+
Tracked planned spend
+
+
{formatCurrencyWithZero(budgetSnapshot.trackedPlannedSpend)}
+ +
+

{budgetSnapshot.message}

+ +
+
+
+ Planned vs target + {budgetSnapshot.targetBudget > 0 ? `${budgetSnapshot.budgetUtilization}%` : 'No target saved'} +
+
+
+
+
+ +
+
+ Funding coverage + {budgetSnapshot.trackedPlannedSpend > 0 ? `${budgetSnapshot.fundingCoverage}% covered` : 'No tracked spend yet'} +
+
+
+
+
+
+
+ +
+
+
Target budget
+
{formatCurrency(budgetSnapshot.targetBudget)}
+
+
+
Funding available
+
{formatCurrencyWithZero(fundingAvailable)}
+
+
+
Tracked funding gap
+
{formatCurrencyWithZero(budgetSnapshot.trackedFundingGap)}
+
+
+
Actual costs booked
+
{formatCurrencyWithZero(budgetSnapshot.trackedActualSpend)}
+
+
+ +
+
Largest tracked cost drivers
+
+ {trackedCostDrivers.length > 0 ? ( + trackedCostDrivers.map((item) => ( +
+
+
+
{item.label}
+

{item.helper}

+
+
+
{formatCurrencyWithZero(item.amount)}
+ {item.href ? : null} +
+
+
+ )) + ) : ( +
+ There are not enough saved cost fields yet to rank the main budget drivers. Add task estimates, compliance fees, campaign budgets, or site costs to improve this view. +
+ )} +
+
+ +
+
+ +
+ : undefined} + eyebrow="Guided workflow" + icon={mdiViewDashboardOutline} + title="Guided launch queue" + > +
+ This queue turns the saved records into a practical operator view: what needs action now, what should be lined up next, and what is waiting on approvals, responses, or blocked work. +
+ +
+ {guidedLaunchQueueByLane.map((laneGroup) => { + const laneMeta = { + do_now: { + buttonColor: 'danger', + empty: 'No immediate fires are showing from the saved records right now.', + helper: 'These items most directly threaten this week’s progress, stage readiness, or launch timing.', + title: 'Do now', + }, + do_next: { + buttonColor: 'info', + empty: 'The next-step lane is clear right now. Keep feeding the workspace with fresh dates and tasks.', + helper: 'These are the next moves to line up so the launch does not drift once today’s work is done.', + title: 'Do next', + }, + waiting_blocker: { + buttonColor: 'warning', + empty: 'No blocked or waiting items are visible in the saved records.', + helper: 'These items are blocked or waiting on external approvals, negotiations, or follow-up decisions.', + title: 'Waiting / blocker', + }, + }[laneGroup.lane]; + + return ( +
+
+
+
{laneMeta.title}
+

{laneMeta.helper}

+
+
{laneGroup.total}
+
+ +
+ {laneGroup.items.length > 0 ? ( + laneGroup.items.map((item) => ( +
+
+ + {item.status ? : null} + + {getAreaLabel(item.area)} + +
+
{item.label}
+

{item.helper}

+

Why it is here: {item.reason}

+ {item.href ? : null} +
+ )) + ) : ( +
{laneMeta.empty}
+ )} +
+
+ ); + })} +
+
+
+ +
+ : projectEditHref ? : undefined} + eyebrow="Step 5 • Weekly rhythm" + icon={mdiCheckCircle} + title="Weekly execution brief" + > +
+
+
Week window
+
+ + + {getAreaLabel(weeklyExecutionBrief.mustMove[0]?.area || getStageArea(stageRunner.currentStage.stage))} + +
+
{weeklyExecutionBrief.weekLabel}
+
{weeklyExecutionBrief.headline}
+

{weeklyExecutionBrief.summary}

+ +
+
+
Must move
+
{weeklyExecutionBrief.mustMoveCount}
+

Highest-priority work to actually advance this week.

+
+
+
Calendar load
+
{weeklyExecutionBrief.tasksDueThisWeekCount + weeklyExecutionBrief.milestonesThisWeekCount}
+

Deadlines and milestones falling inside the next 7 days.

+
+
+
Waiting on others
+
{weeklyExecutionBrief.waitingOnOthersCount}
+

Approvals, replies, hiring decisions, or blocked work to monitor.

+
+
+ +
+
A good week looks like
+
+ {weeklyExecutionBrief.goodWeekLooksLike.map((item) => ( +
+
+ +
{item}
+
+
+ ))} +
+
+
+ +
+
+
This week must move
+
+ {weeklyExecutionBrief.mustMove.length > 0 ? ( + weeklyExecutionBrief.mustMove.map((item) => ( +
+
+ + {item.status ? : null} + {getAreaLabel(item.area)} +
+
{item.label}
+

{item.helper}

+ {item.href ? : null} +
+ )) + ) : ( +
+ No urgent queue items are showing right now. If that matches reality, use the week to tighten data quality and prepare the next stage deliberately. +
+ )} +
+
+ +
+
Calendar pressure this week
+
+
+
Task deadlines
+
+ {weeklyExecutionBrief.tasksDueThisWeek.length > 0 ? ( + weeklyExecutionBrief.tasksDueThisWeek.map((task) => ( +
+
+ {task.priority ? : null} + {task.status ? : null} +
+
{task.task_title || 'Untitled task'}
+

+ {[task.phaseId ? phaseNameById.get(task.phaseId) : null, task.due_at ? `Due ${formatDate(task.due_at)}` : null].filter(Boolean).join(' • ')} +

+ {canReadTasks ? : null} +
+ )) + ) : ( +
+ No task due dates are landing inside the next 7 days. +
+ )} +
+
+ +
+
Milestones
+
+ {weeklyExecutionBrief.milestonesThisWeek.length > 0 ? ( + weeklyExecutionBrief.milestonesThisWeek.map((item) => ( +
+
+ {item.source} + {item.status ? : null} +
+
{item.label}
+

{[formatDate(item.date), item.helper].filter(Boolean).join(' • ')}

+ {item.href ? : null} +
+ )) + ) : ( +
+ No saved milestones fall inside the next 7 days yet. +
+ )} +
+
+
+
+
+
+ +
+
+
Prepare next
+
+ {weeklyExecutionBrief.prepareNext.length > 0 ? ( + weeklyExecutionBrief.prepareNext.map((item) => ( +
+
+ + {getAreaLabel(item.area)} +
+
{item.label}
+

{item.helper}

+ {item.href ? : null} +
+ )) + ) : ( +
+ The next-stage lane is fairly quiet right now. Keep the weekly plan focused instead of inventing extra work. +
+ )} +
+
+ +
+
Waiting on others
+
+ {weeklyExecutionBrief.waitingOnOthers.length > 0 ? ( + weeklyExecutionBrief.waitingOnOthers.map((item) => ( +
+
+ + {item.status ? : null} + {getAreaLabel(item.area)} +
+
{item.label}
+

{item.helper}

+

Why it is here: {item.reason}

+ {item.href ? : null} +
+ )) + ) : ( +
+ No waiting items are visible from the saved records right now. That usually means you can spend more energy on direct execution. +
+ )} +
+
+ +
+
Capacity guardrail
+

{weeklyExecutionBrief.capacityGuardrail}

+ +
+
+ Opening forecast + +
+
+ Budget pressure + +
+
+ Current stage readiness + {stageRunner.currentStage.readiness}% +
+
+
+
+
+
+ +
+ : projectViewHref ? : undefined} + eyebrow="Step 6 • Leadership view" + icon={mdiChartTimelineVariant} + title="Business area readiness map" + > +
+ This map turns the saved records into business lanes so you can quickly see what looks steady, what needs attention, and which areas are simply not in play yet for the current stage. +
+ +
+
+
Lanes in play now
+
{businessAreaReadiness.inPlayCount}
+

+ {businessAreaReadiness.waitingAreasCount} {businessAreaReadiness.waitingAreasCount === 1 ? 'lane is' : 'lanes are'} waiting on outside movement or follow-up. +

+
+ +
+
Need attention now
+
{businessAreaReadiness.attentionNowCount}
+

+ {businessAreaReadiness.attentionNowCount > 0 + ? 'These lanes have real pressure from readiness gaps, urgent work, or waiting signals.' + : 'No active lane looks strained right now. Keep the saved records current so this stays honest.'} +

+
+ +
+
Strongest lane
+
+ {businessAreaReadiness.strongestArea ? getAreaLabel(businessAreaReadiness.strongestArea.area) : 'No signal yet'} +
+

+ {businessAreaReadiness.strongestArea + ? `${businessAreaReadiness.strongestArea.score}/100 readiness. ${businessAreaReadiness.strongestArea.summary}` + : 'Save more project records to get a clearer cross-functional picture.'} +

+
+ +
+
Most pressured lane
+
+ {businessAreaReadiness.mostPressuredArea ? getAreaLabel(businessAreaReadiness.mostPressuredArea.area) : 'No signal yet'} +
+

+ {businessAreaReadiness.mostPressuredArea + ? businessAreaReadiness.mostPressuredArea.nextFocus + : 'As more records are saved, this card will highlight the lane that needs the most leadership attention.'} +

+
+
+ +
+ {businessAreaReadiness.items.map((item) => ( +
+
+
+
{item.timingLabel}
+
{getAreaLabel(item.area)}
+
+ +
+ +
+
+
Readiness score
+
{item.score}
+
+
+ {item.urgentCount > 0 ? `${item.urgentCount} urgent` : item.waitingCount > 0 ? `${item.waitingCount} waiting` : 'Steady'} +
+
+ +
+
+
+ +

{item.summary}

+ +
+
+
Urgent now
+
{item.urgentCount}
+
+
+
Waiting
+
{item.waitingCount}
+
+
+ +
+
Next focus
+

{item.nextFocus}

+
+ + {item.signals.length > 0 ? ( +
+ {item.signals.map((signal) => ( + + {signal} + + ))} +
+ ) : null} + + {item.href ? ( + + ) : null} +
+ ))} +
+ +
+ +
+ : projectViewHref ? : undefined} + eyebrow="Step 7 • Founder focus" + icon={mdiAccountTieOutline} + title="Founder decision desk" + > +
+ This desk separates owner-level calls from normal task follow-up. It highlights the few decisions that change launch timing, money, compliance, property, hiring, or demand creation so you can decide what matters instead of reacting to everything. +
+ +
+
+
+
+
Decision load
+
{founderDecisionDesk.headline}
+
+ +
+

{founderDecisionDesk.summary}

+ +
+
Biggest unlock
+
+ {founderDecisionDesk.biggestUnlock ? founderDecisionDesk.biggestUnlock.label : 'No obvious founder bottleneck'} +
+

+ {founderDecisionDesk.biggestUnlock + ? founderDecisionDesk.biggestUnlock.impact + : 'If that matches reality, keep the saved records current and protect weekly focus instead of inventing extra work.'} +

+
+
+ +
+
+
Decide now
+
{founderDecisionDesk.decisionCount}
+

+ {founderDecisionDesk.decisionCount > 0 + ? 'These items still need real founder judgment, not just more follow-up.' + : 'No obvious owner-level call is stacked up right now.'} +

+
+ +
+
Critical calls
+
{founderDecisionDesk.criticalCount}
+

+ {founderDecisionDesk.criticalCount > 0 + ? 'Resolve these first because they affect launch timing, budget, or compliance risk.' + : 'No critical founder call is standing above the rest at the moment.'} +

+
+ +
+
Watch / follow up
+
{founderDecisionDesk.monitorCount}
+

+ {founderDecisionDesk.monitorCount > 0 + ? 'These items mostly need check-ins, replies, approvals, or blocked handoffs kept visible.' + : 'Outside follow-up is not dominating the launch right now.'} +

+
+
+
+ +
+
+
+
+
+
Decide now
+
Owner decisions that change the plan
+
+ + {founderDecisionDesk.decisionCount} total + +
+ +
+ {founderDecisionDesk.decisions.length > 0 ? ( + founderDecisionDesk.decisions.map((item) => ( +
+
+
+
+ + + {getAreaLabel(item.area)} + +
+
{item.label}
+

{item.helper}

+

+ Why it matters: {item.impact} +

+
+ {item.href ? ( + + ) : null} +
+
+ )) + ) : ( +
+ The saved records are not showing a clear founder decision bottleneck right now. If that feels wrong, the next best move is usually to add the missing dates, budgets, roles, or compliance records so this desk has something real to judge. +
+ )} +
+
+
+ +
+
+
+
+
Watch / follow up
+
Keep these moving without over-owning them
+
+ + {founderDecisionDesk.monitorCount} total + +
+ +
+ {founderDecisionDesk.monitors.length > 0 ? ( + founderDecisionDesk.monitors.map((item) => ( +
+
+
+
+ + + {getAreaLabel(item.area)} + +
+
{item.label}
+

{item.helper}

+

{item.impact}

+
+ {item.href ? : null} +
+
+ )) + ) : ( +
+ There are not many blocked or externally waiting items saved right now. If that is true in real life, this is a good sign. +
+ )} +
+
+ +
+
Decision guardrail
+
Keep the founder workload narrow
+

{founderDecisionDesk.guardrail}

+ +
+ {founderDecisionDesk.decisionOutcomes.map((item) => ( +
+ +
{item}
+
+ ))} +
+
+
+
+
+
+ +
+
+ : undefined} + eyebrow="This week" + icon={mdiCheckCircle} + title="Next best actions" + > + {nextActions.length > 0 ? ( +
+ {nextActions.map((item) => ( +
+
+
+
+ + Priority action +
+
{item.label}
+

{item.helper}

+
+ {item.href ? : null} +
+
+ ))} +
+ ) : ( +

+ This workspace does not have open tasks yet. Add a few concrete next actions to turn the plan into an operating rhythm. +

+ )} +
+
+ +
+ : undefined} + eyebrow="Watch closely" + icon={mdiAlertCircle} + title="Blockers and urgent compliance" + > +
+ {blockers.length > 0 ? ( +
+
Current blockers
+
+ {blockers.map((item) => ( +
+
+
+ +
{item.label}
+

{item.helper}

+
+ {item.href ? : null} +
+
+ ))} +
+
+ ) : ( +
+
+ + No hard blockers are saved right now. +
+

+ Keep updating task and phase statuses so this stays honest instead of overly optimistic. +

+
+ )} + +
+
Urgent compliance items
+
+ {urgentLegalRequirements.length > 0 ? ( + urgentLegalRequirements.map((item) => ( +
+
+
+ +
{item.requirement_title || 'Compliance item'}
+

+ {[item.requirement_type ? humanize(item.requirement_type) : null, item.authority_name, item.due_at ? `Due ${formatDate(item.due_at)}` : 'No due date saved'] + .filter(Boolean) + .join(' • ')} +

+
+ {canReadLegal ? : null} +
+
+ )) + ) : ( +
+ No legal or compliance records are saved yet for this project. +
+ )} +
+
+
+
+
+ +
+ : undefined} + eyebrow="Roadmap" + icon={mdiChartTimelineVariant} + title="Stage roadmap" + > + {phases.length > 0 ? ( +
+ {phases.map((phase) => ( +
+
+
+
{humanize(phase.phase_type || 'phase')}
+
{phase.phase_name || humanize(phase.phase_type || 'phase')}
+

{phase.summary || 'No phase summary saved yet.'}

+
+
+ +
{formatDate(phase.start_at)} → {formatDate(phase.end_at)}
+ {canReadProjectPhases ? : null} +
+
+
+ ))} +
+ ) : ( +

+ No project phases are saved yet. Add phases to make the stage roadmap, blocker detection, and progress scoring more useful. +

+ )} +
+
+ +
+ +
+
+
+ + Funding and runway +
+
{formatCurrency(fundingFunded)}
+

+ {[leadFundingRound?.round_name || 'No funding round saved yet', fundingTarget ? `${formatCurrency(fundingTarget)} target` : null, fundingCommitted ? `${formatCurrency(fundingCommitted)} committed` : null] + .filter(Boolean) + .join(' • ')} +

+ {canReadFunding ? : null} +
+ +
+
+ + Site and property +
+
{leadProperty ? humanize(leadProperty.acquisition_status || 'candidate') : 'No site saved'}
+

+ {leadProperty + ? [leadProperty.property_name || 'Unnamed property', leadProperty.property_type ? humanize(leadProperty.property_type) : null, leadProperty.purchase_price ? formatCurrency(leadProperty.purchase_price) : leadProperty.asking_price ? formatCurrency(leadProperty.asking_price) : null] + .filter(Boolean) + .join(' • ') + : 'Track candidate sites, purchase terms, diligence files, or leases here.'} +

+ {canReadProperties ? : null} +
+ +
+
+ + Compliance and documents +
+
{approvedLegalCount}/{legalRequirements.length || 0} approved
+

+ {approvedDocuments} approved documents and {draftedDocuments} drafts are saved for the launch. +

+
+ {canReadLegal ? : null} + {canReadDocuments ? : null} +
+
+ +
+
+ + People and training +
+
{openRoles} open roles
+

+ {publishedTrainingPrograms} published training programs are ready for onboarding. +

+
+ {canReadPositions ? : null} + {canReadTraining ? : null} +
+
+ +
+
+
+
+ + Marketing, assets, and AI monitoring +
+
{runningCampaigns} live campaigns
+

+ {plannedCampaigns} planned campaigns, {designAssets.length} design assets, and {aiRuns.length} AI runs are saved in this workspace. +

+

+ {latestAiRun + ? `Latest AI activity: ${humanize(latestAiRun.run_type || 'run')} • ${humanize(latestAiRun.status || 'queued')} • ${formatDate(latestAiRun.finished_at || latestAiRun.started_at || latestAiRun.createdAt)}` + : 'No AI activity has been logged yet for this project.'} +

+
+
+ {canReadMarketing ? : null} + {canReadAiRuns ? : null} +
+
+
+
+
+
+
+ + )} + + + ); +}; + +BusinessCommandCenter.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default BusinessCommandCenter; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 8376e53..4e6d736 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -154,6 +154,39 @@ const Dashboard = () => {
)} + + {!showLaunchpadStarter && hasPermission(currentUser, 'READ_PROJECTS') && ( + +
+
+

+ New main workspace +

+

+ Run your business from the new Command Center +

+

+ It automatically centers on your latest saved project and shows current stage, blockers, compliance items, funding signals, and next best actions in one place. +

+
+
+ + +
+
+
+ )} {hasPermission(currentUser, 'CREATE_ROLES') &&