app/components/UnifiedCashFlowDashboard.vue

255 lines
No EOL
9.3 KiB
Vue

<template>
<div class="space-y-6">
<!-- Header with key metrics -->
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
13-Week Cash Flow
</h2>
<p class="text-gray-600 dark:text-gray-400">
Weekly cash flow analysis with one-off transactions
</p>
</div>
</div>
<!-- Key metrics cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center">
<div class="text-3xl font-bold" :class="runwayWeeks >= 8 ? 'text-green-600' : runwayWeeks >= 4 ? 'text-yellow-600' : 'text-red-600'">
{{ runwayWeeks.toFixed(1) }}
</div>
<div class="text-sm text-gray-500">Weeks Runway</div>
<div class="text-xs text-gray-400 mt-1">
{{ getRunwayStatus(runwayWeeks) }}
</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-gray-900 dark:text-white">
{{ formatCurrency(weeklyBurn) }}
</div>
<div class="text-sm text-gray-500">Weekly Burn</div>
<div class="text-xs text-gray-400 mt-1">
Average outflow
</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold" :class="finalBalance >= 0 ? 'text-green-600' : 'text-red-600'">
{{ formatCurrency(finalBalance) }}
</div>
<div class="text-sm text-gray-500">Week 13 Balance</div>
<div class="text-xs text-gray-400 mt-1">
End of quarter
</div>
</div>
</div>
</div>
<!-- Alerts panel -->
<div v-if="alerts.length > 0" class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
<UIcon name="i-heroicons-exclamation-triangle" class="mr-2 text-yellow-500" />
Cash Flow Alerts
</h3>
<div class="space-y-3">
<div
v-for="alert in alerts"
:key="alert.id"
class="p-4 rounded-lg border"
:class="{
'border-red-200 bg-red-50 dark:bg-red-900/20': alert.severity === 'high',
'border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20': alert.severity === 'medium',
'border-blue-200 bg-blue-50 dark:bg-blue-900/20': alert.severity === 'low'
}"
>
<div class="flex items-start gap-3">
<UBadge
:color="getAlertColor(alert.severity)"
variant="subtle"
>
{{ alert.severity.toUpperCase() }}
</UBadge>
<div class="flex-1">
<h4 class="font-medium text-gray-900 dark:text-white">
{{ alert.title }}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ alert.description }}
</p>
<p v-if="alert.suggestion" class="text-sm font-medium text-gray-900 dark:text-white mt-2">
💡 {{ alert.suggestion }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Weekly breakdown table -->
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
13-Week Breakdown
</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Week</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dates</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Inflow</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Outflow</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Net Flow</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Balance</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Transactions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="week in weeklyProjections" :key="week.number">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
Week {{ week.number }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ formatDate(week.weekStart) }} - {{ formatDate(week.weekEnd) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-green-600">
{{ formatCurrency(week.inflow) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-red-600">
{{ formatCurrency(week.outflow) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm" :class="week.net >= 0 ? 'text-green-600' : 'text-red-600'">
{{ week.net >= 0 ? '+' : '' }}{{ formatCurrency(week.net) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium" :class="week.balance >= 0 ? 'text-green-600' : 'text-red-600'">
{{ formatCurrency(week.balance) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div v-if="week.oneOffEvents && week.oneOffEvents.length > 0" class="space-y-1">
<div v-for="event in week.oneOffEvents" :key="event.id" class="text-xs">
{{ event.name }} ({{ formatCurrency(event.amount) }})
</div>
</div>
<span v-else class="text-gray-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const cashStore = useCashStore()
const budgetStore = useBudgetStore()
// Computed
const { weeklyProjections } = storeToRefs(cashStore)
const runwayWeeks = computed(() => {
const projections = weeklyProjections.value
for (let i = 0; i < projections.length; i++) {
if (projections[i].balance < 0) {
// Linear interpolation for fractional week
const prevBalance = i === 0 ? 0 : projections[i-1].balance
const currentNet = projections[i].net
if (currentNet !== 0) {
const fraction = prevBalance / Math.abs(currentNet)
return Math.max(0, i + fraction)
}
return i + 1
}
}
return 13 // Survived all 13 weeks
})
const weeklyBurn = computed(() => {
const totalOutflow = weeklyProjections.value.reduce((sum, p) => sum + p.outflow, 0)
return totalOutflow / 13
})
const finalBalance = computed(() => {
const projections = weeklyProjections.value
return projections.length > 0 ? projections[projections.length - 1].balance : 0
})
const alerts = computed(() => {
const alertsList = []
// Check for negative cash flow periods
const negativeWeeks = weeklyProjections.value.filter(p => p.balance < 0).length
if (negativeWeeks > 0) {
alertsList.push({
id: 'negative-cashflow',
severity: negativeWeeks > 6 ? 'high' : 'medium',
title: 'Negative Cash Flow Detected',
description: `Your cash flow goes negative in ${negativeWeeks} weeks of the quarter.`,
suggestion: 'Consider increasing confirmed revenue sources or reducing fixed costs.'
})
}
// Check for low runway
if (runwayWeeks.value < 4) {
alertsList.push({
id: 'low-runway',
severity: 'high',
title: 'Critical: Very Low Runway',
description: `You have less than 4 weeks of runway (${runwayWeeks.value.toFixed(1)} weeks).`,
suggestion: 'Urgent action needed: secure immediate funding or dramatically reduce expenses.'
})
} else if (runwayWeeks.value < 8) {
alertsList.push({
id: 'medium-runway',
severity: 'medium',
title: 'Warning: Limited Runway',
description: `You have ${runwayWeeks.value.toFixed(1)} weeks of runway.`,
suggestion: 'Start fundraising or revenue diversification efforts soon.'
})
}
return alertsList
})
// Methods
function getRunwayStatus(weeks: number): string {
if (weeks < 4) return 'Critical'
if (weeks < 8) return 'Warning'
if (weeks < 13) return 'Healthy'
return 'Strong'
}
function getAlertColor(severity: string): string {
switch (severity) {
case 'high': return 'red'
case 'medium': return 'yellow'
case 'low': return 'blue'
default: return 'gray'
}
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(amount)
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
</script>