app/pages/index.vue

416 lines
14 KiB
Vue

<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Dashboard</h2>
<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="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." />
<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>
<!-- Alerts Section -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Alerts</h3>
</template>
<div class="space-y-3">
<UAlert
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') }]" />
<UAlert
color="orange"
variant="subtle"
icon="i-heroicons-calendar"
title="Cash Cushion Breach Forecast"
:description="cashBreachDescription"
:actions="[
{ label: 'View Calendar', click: () => navigateTo('/cash') },
]" />
<UAlert
color="yellow"
variant="subtle"
icon="i-heroicons-banknotes"
title="Savings Below Target"
description="Build savings to your target before increasing paid hours."
:actions="[
{ label: 'View Progress', click: () => navigateTo('/budget') },
]" />
<UAlert
color="amber"
variant="subtle"
icon="i-heroicons-clock"
title="Over-Deferred Member"
description="Alex has reached 85% of quarterly deferred cap." />
</div>
</UCard>
<!-- Scenario Snapshots -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Scenario Snapshots</h3>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<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>
</div>
<div class="text-2xl font-bold text-orange-600 mb-1">
{{ scenarioMetrics.current.runway }} months
</div>
<p class="text-xs text-neutral-600">Continue existing plan</p>
</div>
<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>
</div>
<div class="text-2xl font-bold text-red-600 mb-1">
{{ scenarioMetrics.quitJobs.runway }} months
</div>
<p class="text-xs text-neutral-600">Full-time co-op work</p>
</div>
<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>
</div>
<div class="text-2xl font-bold text-yellow-600 mb-1">
{{ scenarioMetrics.startProduction.runway }} months
</div>
<p class="text-xs text-neutral-600">Launch development</p>
</div>
</div>
<div class="mt-4">
<UButton variant="outline" @click="navigateTo('/scenarios')">
Compare All Scenarios
</UButton>
</div>
</UCard>
<!-- Next Value Accounting Session -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Next Value Accounting Session</h3>
<UBadge color="blue" variant="subtle">January 2024</UBadge>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="font-medium mb-3">Session Preparation</h4>
<div class="space-y-2">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm">Month closed & reviewed</span>
</div>
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm">Contributions logged</span>
</div>
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
<span class="text-sm text-neutral-600">Surplus calculated</span>
</div>
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
<span class="text-sm text-neutral-600"
>Member needs reviewed</span
>
</div>
</div>
<div class="mt-4">
<UProgress value="50" :max="100" color="blue" />
<p class="text-xs text-neutral-600 mt-1">2 of 4 items complete</p>
</div>
</div>
<div>
<h4 class="font-medium mb-3">Available for Distribution</h4>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Surplus</span>
<span class="font-medium text-green-600">{{
$format.currency(metrics.finances.surplus || 0)
}}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Deferred owed</span>
<span class="font-medium text-orange-600">{{
$format.currency(
metrics.finances.deferredLiabilities.totalDeferred
)
}}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Savings gap</span>
<span class="font-medium text-blue-600">{{
$format.currency(metrics.finances.savingsGap || 0)
}}</span>
</div>
</div>
<div class="mt-4">
<UButton color="primary" @click="navigateTo('/session')">
Start Session
</UButton>
</div>
</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>
<UButton
block
variant="ghost"
class="justify-start h-auto p-4"
@click="navigateTo('/cash')">
<div class="text-left">
<div class="font-medium">Cash Calendar</div>
<div class="text-xs text-neutral-500">13-week cash flow</div>
</div>
</UButton>
<UButton
block
variant="ghost"
class="justify-start h-auto p-4"
@click="navigateTo('/scenarios')">
<div class="text-left">
<div class="font-medium">Scenarios</div>
<div class="text-xs text-neutral-500">What-if analysis</div>
</div>
</UButton>
<UButton
block
color="primary"
class="justify-start h-auto p-4"
@click="navigateTo('/session')">
<div class="text-left">
<div class="font-medium">Next Session</div>
<div class="text-xs">Value Accounting</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();
// 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
);
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;
return {
totalTargetHours,
totalTargetRevenue,
monthlyPayroll,
monthlyBurn,
runway,
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 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";
});
// Calculate scenario metrics
const scenarioMetrics = computed(() => {
const baseRunway = metrics.value.runway;
return {
current: {
runway: Math.round(baseRunway * 100) / 100 || 0,
},
quitJobs: {
runway: Math.round(baseRunway * 0.7 * 100) / 100 || 0, // Shorter runway due to higher costs
},
startProduction: {
runway: Math.round(baseRunway * 0.8 * 100) / 100 || 0, // Moderate impact
},
};
});
// 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();
</script>