416 lines
14 KiB
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>
|