app/pages/index.vue

382 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<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"
color="gray"
@click="onExport"
>Export JSON</UButton
>
<UButton icon="i-heroicons-arrow-up-tray" color="gray" @click="onImport"
>Import JSON</UButton
>
</div>
</div>
<!-- Key Metrics Row -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<RunwayMeter
:months="metrics.runway"
:description="`You have ${$format.number(
metrics.runway
)} months of runway with current spending.`" />
<CoverageMeter
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
:target-hours="metrics.totalTargetHours"
description="Funded hours vs target capacity across all members." />
<ReserveMeter
: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">
<div class="text-3xl font-bold" :class="concentrationColor">
{{ topSourcePct }}%
</div>
<div class="text-sm text-neutral-600">
<GlossaryTooltip
term="Concentration"
term-id="concentration"
definition="Dependence on few revenue sources. UI shows top source percentage." />
</div>
<ConcentrationChip
:status="concentrationStatus"
:top-source-pct="topSourcePct"
:show-percentage="false"
variant="soft" />
<p class="text-xs text-neutral-500 mt-2">
Most of your money comes from one place. Add another stream to
reduce risk.
</p>
</div>
</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="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
:actions="[
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') }
]" />
<!-- Cushion Breach Alert -->
<UAlert
v-if="alerts.cushionBreach"
color="orange"
variant="subtle"
icon="i-heroicons-calendar"
title="Cash Cushion Breach Forecast"
:description="`Projected to breach minimum cushion in week ${cushionForecast.firstBreachWeek || 'unknown'}`"
:actions="[
{ 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="`${savingsProgress.progressPct.toFixed(0)}% of target reached. Build savings before increasing paid hours.`"
:actions="[
{ label: 'View Progress', click: () => handleAlertNavigation('/budget', 'savings') },
{ label: 'Adjust Policies', click: () => handleAlertNavigation('/coop-builder', 'policies') }
]" />
<!-- Over-Deferred Member Alert -->
<UAlert
v-if="deferredAlert.show"
color="purple"
variant="subtle"
icon="i-heroicons-user-group"
title="Member Over-Deferred"
:description="deferredAlert.description"
:actions="[
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
]" />
<!-- 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>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<UButton
block
variant="ghost"
class="justify-start h-auto p-4"
@click="navigateTo('/mix')">
<div class="text-left">
<div class="font-medium">Revenue Mix</div>
<div class="text-xs text-neutral-500">Plan revenue streams</div>
</div>
</UButton>
</div>
</section>
</template>
<script setup lang="ts">
// Dashboard page
const { $format } = useNuxtApp();
// Use real store data instead of fixtures
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
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();
// Calculate metrics from real store data
const metrics = computed(() => {
const totalTargetHours = membersStore.members.reduce(
(sum, member) => sum + (member.capacity?.targetHours || 0),
0
);
const totalTargetRevenue = streamsStore.streams.reduce(
(sum, stream) => sum + (stream.targetMonthlyAmount || 0),
0
);
const totalOverheadCosts = budgetStore.overheadCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
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: runwayData.minBurn, // Use actual calculated payroll
monthlyBurn,
runway,
runwayData, // Include dual-mode data
finances: {
currentBalances: {
cash: cashStore.currentCash,
savings: cashStore.currentSavings,
totalLiquid,
},
policies: {
equalHourlyWage: policiesStore.equalHourlyWage,
payrollOncostPct: policiesStore.payrollOncostPct,
savingsTargetMonths: policiesStore.savingsTargetMonths,
minCashCushionAmount: policiesStore.minCashCushionAmount,
},
deferredLiabilities: {
totalDeferred: membersStore.members.reduce(
(sum, m) =>
sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
0
),
},
surplus: Math.max(0, totalTargetRevenue - monthlyBurn),
savingsGap: Math.max(
0,
policiesStore.savingsTargetMonths * monthlyBurn -
cashStore.currentSavings
),
},
};
});
// Calculate concentration metrics
const topSourcePct = computed(() => {
if (streamsStore.streams.length === 0) return 0;
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
const total = amounts.reduce((sum, amt) => sum + amt, 0);
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";
return "green";
});
const concentrationColor = computed(() => {
if (topSourcePct.value > 50) return "text-red-600";
if (topSourcePct.value > 35) return "text-yellow-600";
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'
}
// Cash breach description
const cashBreachDescription = computed(() => {
// Check cash store for first breach week from projections
const breachWeek = cashStore.weeklyProjections.find(
(week) => week.breachesCushion
);
if (breachWeek) {
return `Week ${breachWeek.number} would drop below your minimum cushion.`;
}
return "No cushion breach currently projected.";
});
const onExport = () => {
const data = exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "urgent-tools.json";
a.click();
URL.revokeObjectURL(url);
};
const onImport = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const text = await file.text();
importAll(JSON.parse(text));
};
input.click();
};
const { exportAll, importAll } = useFixtureIO();
// 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>