refactor: enhance routing and state management in CoopBuilder, add migration checks on startup, and update Tailwind configuration for improved component styling

This commit is contained in:
Jennie Robinson Faber 2025-08-23 18:24:31 +01:00
parent 848386e3dd
commit 4cea1f71fe
55 changed files with 4053 additions and 1486 deletions

View file

@ -1,7 +1,20 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Dashboard</h2>
<div>
<h2 class="text-2xl font-semibold">Dashboard</h2>
<div class="flex items-center gap-2 mt-1">
<UBadge
:color="policiesStore.operatingMode === 'target' ? 'primary' : 'gray'"
size="xs"
>
{{ policiesStore.operatingMode === 'target' ? '🎯 Target Mode' : '⚡ Min Mode' }}
</UBadge>
<span class="text-xs text-gray-500">
Runway: {{ Math.round(metrics.runway) }}mo
</span>
</div>
</div>
<div class="flex gap-2">
<UButton
icon="i-heroicons-arrow-down-tray"
@ -29,10 +42,10 @@
description="Funded hours vs target capacity across all members." />
<ReserveMeter
:current-savings="metrics.finances.currentBalances.savings"
:savings-target-months="metrics.finances.policies.savingsTargetMonths"
:monthly-burn="metrics.monthlyBurn"
description="Build savings to your target before increasing paid hours." />
:current-savings="savingsProgress.current"
:savings-target-months="savingsProgress.targetMonths"
:monthly-burn="getMonthlyBurn()"
:description="`${savingsProgress.progressPct.toFixed(0)}% of savings target reached. ${savingsProgress.gap > 0 ? 'Gap: ' + $format.currency(savingsProgress.gap) : 'Target achieved!'}`" />
<UCard>
<div class="text-center space-y-3">
@ -58,43 +71,98 @@
</UCard>
</div>
<!-- Quick Wins Dashboard Components -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Needs Coverage Bars -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Member Coverage</h3>
</template>
<NeedsCoverageBars />
</UCard>
<!-- Revenue Mix -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Revenue Mix</h3>
</template>
<RevenueMixTable />
</UCard>
<!-- Milestone-Runway Overlay -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Runway vs Milestones</h3>
</template>
<MilestoneRunwayOverlay />
</UCard>
</div>
<!-- Alerts Section -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Alerts</h3>
</template>
<div class="space-y-3">
<!-- Concentration Risk Alert -->
<UAlert
v-if="topSourcePct > 50"
color="red"
variant="subtle"
icon="i-heroicons-exclamation-triangle"
title="Revenue Concentration Risk"
description="Most of your money comes from one place. Add another stream to reduce risk."
:actions="[{ label: 'Plan Mix', click: () => navigateTo('/mix') }]" />
:description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
:actions="[
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') },
{ label: 'Scenarios', click: () => handleAlertNavigation('/scenarios', 'diversification') }
]" />
<!-- Cushion Breach Alert -->
<UAlert
v-if="alerts.cushionBreach"
color="orange"
variant="subtle"
icon="i-heroicons-calendar"
title="Cash Cushion Breach Forecast"
:description="cashBreachDescription"
:description="`Projected to breach minimum cushion in week ${cushionForecast.firstBreachWeek || 'unknown'}`"
:actions="[
{ label: 'View Calendar', click: () => navigateTo('/cash') },
{ label: 'View Calendar', click: () => handleAlertNavigation('/cash', 'breach-forecast') },
{ label: 'Adjust Budget', click: () => handleAlertNavigation('/budget', 'expenses') }
]" />
<!-- Savings Below Target Alert -->
<UAlert
v-if="alerts.savingsBelowTarget"
color="yellow"
variant="subtle"
icon="i-heroicons-banknotes"
title="Savings Below Target"
description="Build savings to your target before increasing paid hours."
:description="`${savingsProgress.progressPct.toFixed(0)}% of target reached. Build savings before increasing paid hours.`"
:actions="[
{ label: 'View Progress', click: () => navigateTo('/budget') },
{ label: 'View Progress', click: () => handleAlertNavigation('/budget', 'savings') },
{ label: 'Adjust Policies', click: () => handleAlertNavigation('/coop-builder', 'policies') }
]" />
<!-- Over-Deferred Member Alert -->
<UAlert
color="amber"
v-if="deferredAlert.show"
color="purple"
variant="subtle"
icon="i-heroicons-clock"
title="Over-Deferred Member"
description="Alex has reached 85% of quarterly deferred cap." />
icon="i-heroicons-user-group"
title="Member Over-Deferred"
:description="deferredAlert.description"
:actions="[
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
{ label: 'Value Session', click: () => handleAlertNavigation('/session', 'distributions') }
]" />
<!-- Success message when no alerts -->
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
class="text-center py-8 text-gray-500">
<UIcon name="i-heroicons-check-circle" class="w-8 h-8 mx-auto mb-2 text-green-500" />
<p class="font-medium">All systems looking good!</p>
<p class="text-sm">No critical alerts at this time.</p>
</div>
</div>
</UCard>
@ -104,37 +172,46 @@
<h3 class="text-lg font-medium">Scenario Snapshots</h3>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Current Scenario -->
<div class="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">Operate Current</h4>
<UBadge color="green" variant="subtle" size="xs">Active</UBadge>
<h4 class="font-medium text-sm">{{ scenarios.current.name }}</h4>
<UBadge color="green" variant="subtle" size="xs">{{ scenarios.current.status }}</UBadge>
</div>
<div class="text-2xl font-bold text-orange-600 mb-1">
{{ scenarioMetrics.current.runway }} months
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.current.runway)">
{{ Math.round(scenarios.current.runway * 10) / 10 }} months
</div>
<p class="text-xs text-neutral-600">Continue existing plan</p>
<p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.current.monthlyNet) }}/mo
</p>
</div>
<!-- Quit Jobs Scenario -->
<div class="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">Quit Day Jobs</h4>
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge>
<h4 class="font-medium text-sm">{{ scenarios.quitJobs.name }}</h4>
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.quitJobs.status }}</UBadge>
</div>
<div class="text-2xl font-bold text-red-600 mb-1">
{{ scenarioMetrics.quitJobs.runway }} months
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.quitJobs.runway)">
{{ Math.round(scenarios.quitJobs.runway * 10) / 10 }} months
</div>
<p class="text-xs text-neutral-600">Full-time co-op work</p>
<p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.quitJobs.monthlyNet) }}/mo
</p>
</div>
<!-- Start Production Scenario -->
<div class="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">Start Production</h4>
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge>
<h4 class="font-medium text-sm">{{ scenarios.startProduction.name }}</h4>
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.startProduction.status }}</UBadge>
</div>
<div class="text-2xl font-bold text-yellow-600 mb-1">
{{ scenarioMetrics.startProduction.runway }} months
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.startProduction.runway)">
{{ Math.round(scenarios.startProduction.runway * 10) / 10 }} months
</div>
<p class="text-xs text-neutral-600">Launch development</p>
<p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo
</p>
</div>
</div>
<div class="mt-4">
@ -214,6 +291,100 @@
</div>
</UCard>
<!-- Advanced Planning Panel -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Advanced Planning</h3>
<UButton
variant="ghost"
size="sm"
@click="showAdvanced = !showAdvanced"
:icon="showAdvanced ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
>
{{ showAdvanced ? 'Hide' : 'Show' }} Advanced
</UButton>
</div>
</template>
<div v-show="showAdvanced" class="space-y-6">
<!-- Stress Tests -->
<div class="border rounded-lg p-4">
<h4 class="font-medium mb-3">Stress Tests</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="text-sm font-medium text-gray-700 mb-1 block">Revenue Delay (months)</label>
<UInput
v-model="stressTests.revenueDelay"
type="number"
min="0"
max="6"
size="sm"
@input="updateStressTest"
/>
</div>
<div>
<label class="text-sm font-medium text-gray-700 mb-1 block">Cost Shock (%)</label>
<UInput
v-model="stressTests.costShockPct"
type="number"
min="0"
max="100"
size="sm"
@input="updateStressTest"
/>
</div>
<div>
<label class="text-sm font-medium text-gray-700 mb-1 block">Major Grant Lost</label>
<UToggle
v-model="stressTests.grantLost"
@update:model-value="updateStressTest"
/>
</div>
</div>
<!-- Stress Test Results -->
<div v-if="hasStressTest" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
<div class="flex items-center justify-between">
<div>
<h5 class="font-medium text-yellow-800">Stress Test Results</h5>
<p class="text-sm text-yellow-700">
Runway under stress: {{ Math.round(stressedRunway * 10) / 10 }} months
({{ Math.round((stressedRunway - metrics.runway) * 10) / 10 }} month change)
</p>
</div>
<UButton size="xs" @click="applyStressTest">Apply to Plan</UButton>
</div>
</div>
</div>
<!-- Policy Sandbox -->
<div class="border rounded-lg p-4">
<h4 class="font-medium mb-3">Policy Sandbox</h4>
<p class="text-sm text-gray-600 mb-3">
Try different pay relationships without overwriting your current plan.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-700 mb-1 block">Test Pay Policy</label>
<USelect
v-model="sandboxPolicy"
:options="policyOptions"
size="sm"
@update:model-value="updateSandboxPolicy"
/>
</div>
<div v-if="sandboxRunway">
<label class="text-sm font-medium text-gray-700 mb-1 block">Projected Runway</label>
<div class="text-lg font-bold" :class="getRunwayColor(sandboxRunway)">
{{ Math.round(sandboxRunway * 10) / 10 }} months
</div>
</div>
</div>
</div>
</div>
</UCard>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<UButton
@ -274,6 +445,34 @@ const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const cashStore = useCashStore();
// Runway composable with operating mode integration
const { getDualModeRunway, getMonthlyBurn } = useRunway();
// Cushion forecast and savings progress
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
// Scenario calculations
const { scenarios } = useScenarios();
// Advanced panel state
const showAdvanced = ref(false);
// Stress testing
const stressTests = ref({
revenueDelay: 0,
costShockPct: 0,
grantLost: false
});
// Policy sandbox
const sandboxPolicy = ref('equal-pay');
const policyOptions = [
{ label: 'Equal Pay', value: 'equal-pay' },
{ label: 'Needs Weighted', value: 'needs-weighted' },
{ label: 'Hours Weighted', value: 'hours-weighted' },
{ label: 'Role Banded', value: 'role-banded' }
];
// Calculate metrics from real store data
const metrics = computed(() => {
const totalTargetHours = membersStore.members.reduce(
@ -291,24 +490,26 @@ const metrics = computed(() => {
0
);
const monthlyPayroll =
totalTargetHours *
policiesStore.equalHourlyWage *
(1 + policiesStore.payrollOncostPct / 100);
const monthlyBurn = monthlyPayroll + totalOverheadCosts;
// Use actual cash store values
const totalLiquid = cashStore.currentCash + cashStore.currentSavings;
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0;
// Use integrated runway calculations that respect operating mode
const currentMode = policiesStore.operatingMode || 'minimum';
const monthlyBurn = getMonthlyBurn(currentMode);
// Use actual cash store values with fallback
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
const totalLiquid = cash + savings;
// Get dual-mode runway data
const runwayData = getDualModeRunway(cash, savings);
const runway = currentMode === 'target' ? runwayData.target : runwayData.minimum;
return {
totalTargetHours,
totalTargetRevenue,
monthlyPayroll,
monthlyPayroll: runwayData.minBurn, // Use actual calculated payroll
monthlyBurn,
runway,
runwayData, // Include dual-mode data
finances: {
currentBalances: {
cash: cashStore.currentCash,
@ -346,6 +547,14 @@ const topSourcePct = computed(() => {
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
});
const topStreamName = computed(() => {
if (streamsStore.streams.length === 0) return 'No streams';
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
const maxAmount = Math.max(...amounts);
const topStream = streamsStore.streams.find(s => (s.targetMonthlyAmount || 0) === maxAmount);
return topStream?.name || 'Unknown stream';
});
const concentrationStatus = computed(() => {
if (topSourcePct.value > 50) return "red";
if (topSourcePct.value > 35) return "yellow";
@ -358,6 +567,12 @@ const concentrationColor = computed(() => {
return "text-green-600";
});
function getRunwayColor(months: number): string {
if (months >= 6) return 'text-green-600'
if (months >= 3) return 'text-yellow-600'
return 'text-red-600'
}
// Calculate scenario metrics
const scenarioMetrics = computed(() => {
const baseRunway = metrics.value.runway;
@ -413,4 +628,169 @@ const onImport = async () => {
};
const { exportAll, importAll } = useFixtureIO();
// Advanced panel computed properties and methods
const hasStressTest = computed(() => {
return stressTests.value.revenueDelay > 0 ||
stressTests.value.costShockPct > 0 ||
stressTests.value.grantLost;
});
const stressedRunway = computed(() => {
if (!hasStressTest.value) return metrics.value.runway;
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
// Apply stress test adjustments
let adjustedRevenue = streamsStore.streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0);
let adjustedCosts = getMonthlyBurn();
// Revenue delay impact (reduce revenue by delay percentage)
if (stressTests.value.revenueDelay > 0) {
adjustedRevenue *= Math.max(0, 1 - (stressTests.value.revenueDelay / 12));
}
// Cost shock impact
if (stressTests.value.costShockPct > 0) {
adjustedCosts *= (1 + stressTests.value.costShockPct / 100);
}
// Grant lost (remove largest revenue stream if it's a grant)
if (stressTests.value.grantLost) {
const grantStreams = streamsStore.streams.filter(s =>
s.category?.toLowerCase().includes('grant') ||
s.name.toLowerCase().includes('grant')
);
if (grantStreams.length > 0) {
const largestGrant = Math.max(...grantStreams.map(s => s.targetMonthlyAmount || 0));
adjustedRevenue -= largestGrant;
}
}
const netMonthly = adjustedRevenue - adjustedCosts;
const burnRate = netMonthly < 0 ? Math.abs(netMonthly) : adjustedCosts;
return burnRate > 0 ? (cash + savings) / burnRate : Infinity;
});
const sandboxRunway = computed(() => {
if (!sandboxPolicy.value || sandboxPolicy.value === policiesStore.payPolicy?.relationship) {
return null;
}
// Calculate runway with sandbox policy
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
// Create sandbox policy object
const testPolicy = {
relationship: sandboxPolicy.value,
equalHourlyWage: policiesStore.equalHourlyWage,
roleBands: policiesStore.payPolicy?.roleBands || []
};
// Use scenario calculation with sandbox policy
const { calculateScenarioRunway } = useScenarios();
const result = calculateScenarioRunway(membersStore.members, streamsStore.streams);
// Apply simple adjustment based on policy type
let policyMultiplier = 1;
switch (sandboxPolicy.value) {
case 'needs-weighted':
policyMultiplier = 0.9; // Slightly higher costs
break;
case 'role-banded':
policyMultiplier = 0.85; // Higher costs due to senior roles
break;
case 'hours-weighted':
policyMultiplier = 0.95; // Moderate increase
break;
}
return result.runway * policyMultiplier;
});
function updateStressTest() {
// Reactive computed will handle updates automatically
}
function updateSandboxPolicy() {
// Reactive computed will handle updates automatically
}
function applyStressTest() {
// Apply stress test adjustments to the actual plan
if (stressTests.value.revenueDelay > 0) {
// Reduce all stream targets by delay impact
streamsStore.streams.forEach(stream => {
const reduction = (stressTests.value.revenueDelay / 12) * (stream.targetMonthlyAmount || 0);
streamsStore.updateStream(stream.id, {
targetMonthlyAmount: Math.max(0, (stream.targetMonthlyAmount || 0) - reduction)
});
});
}
if (stressTests.value.costShockPct > 0) {
// Increase overhead costs
const shockMultiplier = 1 + (stressTests.value.costShockPct / 100);
budgetStore.overheadCosts.forEach(cost => {
budgetStore.updateOverheadCost(cost.id, {
amount: (cost.amount || 0) * shockMultiplier
});
});
}
if (stressTests.value.grantLost) {
// Remove or reduce grant streams
const grantStreams = streamsStore.streams.filter(s =>
s.category?.toLowerCase().includes('grant') ||
s.name.toLowerCase().includes('grant')
);
if (grantStreams.length > 0) {
const largestGrant = grantStreams.reduce((prev, current) =>
(prev.targetMonthlyAmount || 0) > (current.targetMonthlyAmount || 0) ? prev : current
);
streamsStore.updateStream(largestGrant.id, { targetMonthlyAmount: 0 });
}
}
// Reset stress tests
stressTests.value = { revenueDelay: 0, costShockPct: 0, grantLost: false };
};
// Deferred alert logic
const deferredAlert = computed(() => {
const maxDeferredRatio = 1.5; // From CLAUDE.md - flag if >1.5× monthly payroll
const monthlyPayrollCost = getMonthlyBurn() * 0.7; // Estimate payroll as 70% of burn
const totalDeferred = membersStore.members.reduce(
(sum, m) => sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
0
);
const deferredRatio = monthlyPayrollCost > 0 ? totalDeferred / monthlyPayrollCost : 0;
const show = deferredRatio > maxDeferredRatio;
const overDeferredMembers = membersStore.members.filter(m => {
const memberDeferred = (m.deferredHours || 0) * policiesStore.equalHourlyWage;
const memberMonthlyPay = m.monthlyPayPlanned || 0;
return memberDeferred > memberMonthlyPay * 2; // Member has >2 months of pay deferred
});
return {
show,
description: show
? `${overDeferredMembers.length} member(s) over deferred cap. Total: ${(deferredRatio * 100).toFixed(0)}% of monthly payroll.`
: ''
};
});
// Alert navigation with context
function handleAlertNavigation(path: string, section?: string) {
// Store alert context for target page to highlight relevant section
if (section) {
localStorage.setItem('urgent-tools-alert-context', JSON.stringify({ section, timestamp: Date.now() }));
}
navigateTo(path);
};
</script>