255 lines
No EOL
9.3 KiB
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> |