refactor: update routing paths in app.vue, enhance AnnualBudget component layout, and streamline dashboard and budget pages for improved user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-08 09:39:30 +01:00
parent 09d8794d72
commit 864a81065c
23 changed files with 3211 additions and 1978 deletions

View file

@ -3,13 +3,18 @@
<!-- Annual Budget Overview -->
<div class="space-y-4">
<h2 class="text-2xl font-bold">Annual Budget Overview</h2>
<div class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="border border-black bg-white">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b-2 border-black bg-gray-100">
<th class="border-r-2 border-black px-4 py-3 text-left font-bold">Category</th>
<th class="border-r border-gray-400 px-4 py-3 text-right font-bold">Planned</th>
<th class="border-r-1 border-black px-4 py-3 text-left font-bold">
Category
</th>
<th
class="border-r border-gray-400 px-4 py-3 text-right font-bold">
Planned
</th>
<th class="px-4 py-3 text-right font-bold">%</th>
</tr>
</thead>
@ -18,24 +23,25 @@
<tr class="bg-black text-white">
<td class="px-4 py-2 font-bold" colspan="3">REVENUE</td>
</tr>
<!-- Revenue Categories -->
<tr v-for="(category, index) in revenueCategories"
:key="`rev-${index}`"
class="border-t border-gray-200"
v-show="category.planned > 0">
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
<tr
v-for="(category, index) in revenueCategories"
:key="`rev-${index}`"
class="border-t border-gray-200"
v-show="category.planned > 0">
<td class="border-r-1 border-black px-4 py-2">
{{ category.name }}
</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
{{ formatCurrency(category.planned) }}
</td>
<td class="px-4 py-2 text-right">
{{ category.percentage }}%
</td>
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
</tr>
<!-- Total Revenue -->
<tr class="border-t-2 border-black font-semibold bg-gray-50">
<td class="border-r-2 border-black px-4 py-2">Total Revenue</td>
<td class="border-r-1 border-black px-4 py-2">Total Revenue</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
{{ formatCurrency(totalRevenuePlanned) }}
</td>
@ -47,14 +53,15 @@
<td colspan="3" class="border-t border-gray-300 px-4 py-3">
<div class="text-sm">
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
<p class="text-gray-600 mb-2" v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(', ') }}
<p
class="text-gray-600 mb-2"
v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(", ") }}
</p>
<p class="text-xs">
<NuxtLink
to="/help#revenue-diversification"
class="text-blue-600 hover:text-blue-800 underline"
>
<NuxtLink
to="/help#revenue-diversification"
class="text-blue-600 hover:text-blue-800 underline">
Learn how to develop these revenue streams
</NuxtLink>
</p>
@ -62,33 +69,29 @@
</td>
</tr>
<!-- Spacer -->
<tr>
<td colspan="3" class="h-2"></td>
</tr>
<!-- Expenses Section -->
<tr class="bg-black text-white">
<td class="px-4 py-2 font-bold" colspan="3">EXPENSES</td>
</tr>
<!-- Expense Categories -->
<tr v-for="(category, index) in expenseCategories"
:key="`exp-${index}`"
class="border-t border-gray-200"
v-show="category.planned > 0">
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
<tr
v-for="(category, index) in expenseCategories"
:key="`exp-${index}`"
class="border-t border-gray-200"
v-show="category.planned > 0">
<td class="border-r-1 border-black px-4 py-2">
{{ category.name }}
</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
{{ formatCurrency(category.planned) }}
</td>
<td class="px-4 py-2 text-right">
{{ category.percentage }}%
</td>
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
</tr>
<!-- Total Expenses -->
<tr class="border-t-2 border-black font-semibold bg-gray-50">
<td class="border-r-2 border-black px-4 py-2">Total Expenses</td>
<td class="border-r-1 border-black px-4 py-2">Total Expenses</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
{{ formatCurrency(totalExpensesPlanned) }}
</td>
@ -96,8 +99,10 @@
</tr>
<!-- Net Total -->
<tr class="border-t-2 border-black font-bold text-lg" :class="netTotalClass">
<td class="border-r-2 border-black px-4 py-3">NET TOTAL</td>
<tr
class="border-t-2 border-black font-bold text-lg"
:class="netTotalClass">
<td class="border-r-1 border-black px-4 py-3">NET TOTAL</td>
<td class="border-r border-gray-400 px-4 py-3 text-right">
{{ formatCurrency(netTotal) }}
</td>
@ -107,7 +112,6 @@
</table>
</div>
</div>
</div>
</template>
@ -118,7 +122,7 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
year: () => new Date().getFullYear()
year: () => new Date().getFullYear(),
});
// Get budget data from store
@ -127,19 +131,54 @@ const budgetStore = useBudgetStore();
// Revenue categories with calculations
const revenueCategories = computed(() => {
const categories = [
{ key: 'gamesProducts', name: 'Games & Products', planned: 0, percentage: 0 },
{ key: 'servicesContracts', name: 'Services & Contracts', planned: 0, percentage: 0 },
{ key: 'grantsFunding', name: 'Grants & Funding', planned: 0, percentage: 0 },
{ key: 'communitySupport', name: 'Community Support', planned: 0, percentage: 0 },
{ key: 'partnerships', name: 'Partnerships', planned: 0, percentage: 0 },
{ key: 'investmentIncome', name: 'Investment Income', planned: 0, percentage: 0 },
{ key: 'inKindContributions', name: 'In-Kind Contributions', planned: 0, percentage: 0 },
{
key: "gamesProducts",
name: "Games & Products",
planned: 0,
percentage: 0,
},
{
key: "servicesContracts",
name: "Services & Contracts",
planned: 0,
percentage: 0,
},
{
key: "grantsFunding",
name: "Grants & Funding",
planned: 0,
percentage: 0,
},
{
key: "communitySupport",
name: "Community Support",
planned: 0,
percentage: 0,
},
{ key: "partnerships", name: "Partnerships", planned: 0, percentage: 0 },
{
key: "investmentIncome",
name: "Investment Income",
planned: 0,
percentage: 0,
},
{
key: "inKindContributions",
name: "In-Kind Contributions",
planned: 0,
percentage: 0,
},
];
// Calculate planned amounts for each category
budgetStore.budgetWorksheet.revenue.forEach(item => {
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
budgetStore.budgetWorksheet.revenue.forEach((item) => {
const annualPlanned = Object.values(item.monthlyValues || {}).reduce(
(sum, val) => sum + (val || 0),
0
);
const categoryIndex = categories.findIndex(
(cat) => cat.name === item.mainCategory
);
if (categoryIndex !== -1) {
categories[categoryIndex].planned += annualPlanned;
}
@ -147,30 +186,34 @@ const revenueCategories = computed(() => {
// Calculate percentages
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
categories.forEach(cat => {
categories.forEach((cat) => {
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
});
return categories;
});
// Expense categories with calculations
const expenseCategories = computed(() => {
const categories = [
{ name: 'Salaries & Benefits', planned: 0, percentage: 0 },
{ name: 'Development Costs', planned: 0, percentage: 0 },
{ name: 'Equipment & Technology', planned: 0, percentage: 0 },
{ name: 'Marketing & Outreach', planned: 0, percentage: 0 },
{ name: 'Office & Operations', planned: 0, percentage: 0 },
{ name: 'Legal & Professional', planned: 0, percentage: 0 },
{ name: 'Other Expenses', planned: 0, percentage: 0 },
{ name: "Salaries & Benefits", planned: 0, percentage: 0 },
{ name: "Development Costs", planned: 0, percentage: 0 },
{ name: "Equipment & Technology", planned: 0, percentage: 0 },
{ name: "Marketing & Outreach", planned: 0, percentage: 0 },
{ name: "Office & Operations", planned: 0, percentage: 0 },
{ name: "Legal & Professional", planned: 0, percentage: 0 },
{ name: "Other Expenses", planned: 0, percentage: 0 },
];
// Calculate planned amounts for each category
budgetStore.budgetWorksheet.expenses.forEach(item => {
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
budgetStore.budgetWorksheet.expenses.forEach((item) => {
const annualPlanned = Object.values(item.monthlyValues || {}).reduce(
(sum, val) => sum + (val || 0),
0
);
const categoryIndex = categories.findIndex(
(cat) => cat.name === item.mainCategory
);
if (categoryIndex !== -1) {
categories[categoryIndex].planned += annualPlanned;
}
@ -178,7 +221,7 @@ const expenseCategories = computed(() => {
// Calculate percentages
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
categories.forEach(cat => {
categories.forEach((cat) => {
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
});
@ -186,33 +229,39 @@ const expenseCategories = computed(() => {
});
// Totals
const totalRevenuePlanned = computed(() =>
const totalRevenuePlanned = computed(() =>
revenueCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
);
const totalExpensesPlanned = computed(() =>
const totalExpensesPlanned = computed(() =>
expenseCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
);
const netTotal = computed(() =>
totalRevenuePlanned.value - totalExpensesPlanned.value
const netTotal = computed(
() => totalRevenuePlanned.value - totalExpensesPlanned.value
);
const netTotalClass = computed(() => {
if (netTotal.value > 0) return 'bg-green-50';
if (netTotal.value < 0) return 'bg-red-50';
return 'bg-gray-50';
if (netTotal.value > 0) return "bg-green-50";
if (netTotal.value < 0) return "bg-red-50";
return "bg-gray-50";
});
// Diversification guidance
const diversificationGuidance = computed(() => {
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
const topCategory = categoriesWithRevenue.reduce((max, cat) => cat.percentage > max.percentage ? cat : max, { percentage: 0, name: '' });
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
const categoriesWithRevenue = revenueCategories.value.filter(
(cat) => cat.percentage > 0
);
const topCategory = categoriesWithRevenue.reduce(
(max, cat) => (cat.percentage > max.percentage ? cat : max),
{ percentage: 0, name: "" }
);
const categoriesAbove20 = categoriesWithRevenue.filter(
(cat) => cat.percentage >= 20
).length;
let guidance = "";
// Concentration Risk
if (topCategory.percentage >= 70) {
guidance += `Very high concentration risk: most of your revenue is from ${topCategory.name} (${topCategory.percentage}%). `;
@ -221,107 +270,141 @@ const diversificationGuidance = computed(() => {
} else {
guidance += "No single category dominates your revenue. ";
}
// Diversification Benchmark
if (categoriesAbove20 >= 3) {
guidance += "Your mix is reasonably balanced across multiple sources.";
} else if (categoriesAbove20 === 2) {
guidance += "Your mix is split, but still reliant on just two sources.";
} else {
guidance += "Your revenue is concentrated; aim to grow at least 23 other categories.";
guidance +=
"Your revenue is concentrated; aim to grow at least 23 other categories.";
}
// Optional Positive Nudges
const grantsCategory = categoriesWithRevenue.find(cat => cat.name === 'Grants & Funding');
const servicesCategory = categoriesWithRevenue.find(cat => cat.name === 'Services & Contracts');
const productsCategory = categoriesWithRevenue.find(cat => cat.name === 'Games & Products');
const grantsCategory = categoriesWithRevenue.find(
(cat) => cat.name === "Grants & Funding"
);
const servicesCategory = categoriesWithRevenue.find(
(cat) => cat.name === "Services & Contracts"
);
const productsCategory = categoriesWithRevenue.find(
(cat) => cat.name === "Games & Products"
);
if (grantsCategory && grantsCategory.percentage >= 20) {
guidance += " You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
} else if (servicesCategory && servicesCategory.percentage >= 20 && productsCategory && productsCategory.percentage >= 20) {
guidance += " Strong foundation in both services and products — this balance helps smooth revenue timing.";
guidance +=
" You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
} else if (
servicesCategory &&
servicesCategory.percentage >= 20 &&
productsCategory &&
productsCategory.percentage >= 20
) {
guidance +=
" Strong foundation in both services and products — this balance helps smooth revenue timing.";
}
return guidance;
});
const guidanceBackgroundClass = computed(() => {
const topCategory = revenueCategories.value.reduce((max, cat) => cat.percentage > max.percentage ? cat : max, { percentage: 0 });
const topCategory = revenueCategories.value.reduce(
(max, cat) => (cat.percentage > max.percentage ? cat : max),
{ percentage: 0 }
);
if (topCategory.percentage >= 70) {
return 'bg-red-50';
return "bg-red-50";
} else if (topCategory.percentage >= 50) {
return 'bg-red-50';
return "bg-red-50";
} else {
const categoriesAbove20 = revenueCategories.value.filter(cat => cat.percentage >= 20).length;
const categoriesAbove20 = revenueCategories.value.filter(
(cat) => cat.percentage >= 20
).length;
if (categoriesAbove20 >= 3) {
return 'bg-green-50';
return "bg-green-50";
} else {
return 'bg-yellow-50';
return "bg-yellow-50";
}
}
});
// Suggested categories to develop
const suggestedCategories = computed(() => {
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
const categoriesWithoutRevenue = revenueCategories.value.filter(cat => cat.percentage === 0);
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
const categoriesWithRevenue = revenueCategories.value.filter(
(cat) => cat.percentage > 0
);
const categoriesWithoutRevenue = revenueCategories.value.filter(
(cat) => cat.percentage === 0
);
const categoriesAbove20 = categoriesWithRevenue.filter(
(cat) => cat.percentage >= 20
).length;
// If we have fewer than 3 categories above 20%, suggest developing others
if (categoriesAbove20 < 3) {
// Prioritize categories that complement existing strengths
const suggestions = [];
// If they have services, suggest products for balance
if (categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts') &&
!categoriesWithRevenue.some(cat => cat.name === 'Games & Products')) {
suggestions.push('Games & Products');
if (
categoriesWithRevenue.some(
(cat) => cat.name === "Services & Contracts"
) &&
!categoriesWithRevenue.some((cat) => cat.name === "Games & Products")
) {
suggestions.push("Games & Products");
}
// If they have products, suggest services for stability
if (categoriesWithRevenue.some(cat => cat.name === 'Games & Products') &&
!categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts')) {
suggestions.push('Services & Contracts');
if (
categoriesWithRevenue.some((cat) => cat.name === "Games & Products") &&
!categoriesWithRevenue.some((cat) => cat.name === "Services & Contracts")
) {
suggestions.push("Services & Contracts");
}
// Always suggest grants if not present
if (!categoriesWithRevenue.some(cat => cat.name === 'Grants & Funding')) {
suggestions.push('Grants & Funding');
if (!categoriesWithRevenue.some((cat) => cat.name === "Grants & Funding")) {
suggestions.push("Grants & Funding");
}
// Add community support for stability
if (!categoriesWithRevenue.some(cat => cat.name === 'Community Support')) {
suggestions.push('Community Support');
if (
!categoriesWithRevenue.some((cat) => cat.name === "Community Support")
) {
suggestions.push("Community Support");
}
return suggestions.slice(0, 3); // Limit to 3 suggestions
}
return [];
});
// Utility functions
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount || 0);
}
function getPercentageClass(percentage: number): string {
if (percentage > 50) return 'text-red-600 font-bold';
if (percentage > 35) return 'text-yellow-600 font-semibold';
if (percentage > 20) return 'text-black font-medium';
return 'text-gray-500';
if (percentage > 50) return "text-red-600 font-bold";
if (percentage > 35) return "text-yellow-600 font-semibold";
if (percentage > 20) return "text-black font-medium";
return "text-gray-500";
}
// Initialize
onMounted(() => {
console.log(`Annual budget view for org: ${props.orgId}, year: ${props.year}`);
console.log(
`Annual budget view for org: ${props.orgId}, year: ${props.year}`
);
});
</script>
@ -336,4 +419,4 @@ input[type="number"]::-webkit-outer-spin-button {
input[type="number"] {
-moz-appearance: textfield;
}
</style>
</style>

View file

@ -0,0 +1,295 @@
<template>
<div class="w-full h-full">
<canvas
ref="chartCanvas"
class="w-full h-full"
:width="width"
:height="height"
></canvas>
</div>
</template>
<script setup lang="ts">
interface ChartData {
month: number
monthName: string
revenue: number
expenses: number
netCashFlow: number
runningBalance: number
oneOffEvents?: Array<{ type: string, amount: number, name: string }>
}
interface Props {
data: ChartData[]
viewMode: 'combined' | 'runway' | 'cashflow'
width?: number
height?: number
}
const props = withDefaults(defineProps<Props>(), {
width: 800,
height: 320
})
const chartCanvas = ref<HTMLCanvasElement | null>(null)
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function drawChart() {
if (!chartCanvas.value || !props.data.length) return
const canvas = chartCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
const padding = 60
const chartWidth = canvas.width - padding * 2
const chartHeight = canvas.height - padding * 2
const dataLength = props.data.length
const scaleX = chartWidth / (dataLength - 1)
// Calculate data ranges based on view mode
let maxValue = 0
let minValue = 0
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
const balances = props.data.map(d => d.runningBalance)
maxValue = Math.max(maxValue, ...balances)
minValue = Math.min(minValue, ...balances)
}
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
const revenues = props.data.map(d => d.revenue)
const expenses = props.data.map(d => d.expenses)
maxValue = Math.max(maxValue, ...revenues, ...expenses)
}
// Add some padding to the range
const range = maxValue - minValue
maxValue += range * 0.1
minValue -= range * 0.1
// Ensure we show zero line
if (minValue > 0) minValue = Math.min(0, minValue)
if (maxValue < 0) maxValue = Math.max(0, maxValue)
const valueRange = maxValue - minValue
const scaleY = valueRange > 0 ? chartHeight / valueRange : 1
// Helper functions
const getX = (index: number) => padding + (index * scaleX)
const getY = (value: number) => padding + chartHeight - ((value - minValue) * scaleY)
// Draw background for negative values
if (minValue < 0) {
const zeroY = getY(0)
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
ctx.fillRect(padding, zeroY, chartWidth, canvas.height - zeroY - padding)
}
// Draw grid
drawGrid(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange, scaleY)
// Draw data based on view mode
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
drawCashFlowData(ctx, getX, getY)
}
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
drawRunwayData(ctx, getX, getY)
}
// Draw one-off events
drawOneOffEvents(ctx, getX, getY)
// Draw axis labels
drawAxisLabels(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange)
}
function drawGrid(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number, scaleY: number) {
ctx.strokeStyle = '#e5e7eb'
ctx.lineWidth = 1
// Horizontal grid lines
const gridLines = 5
for (let i = 0; i <= gridLines; i++) {
const y = padding + (chartHeight / gridLines) * i
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(padding + chartWidth, y)
ctx.stroke()
}
// Vertical grid lines (every 3 months)
for (let i = 0; i < props.data.length; i += 3) {
const x = padding + (i * chartWidth) / (props.data.length - 1)
ctx.beginPath()
ctx.moveTo(x, padding)
ctx.lineTo(x, padding + chartHeight)
ctx.stroke()
}
// Zero line if applicable
if (minValue < 0 && maxValue > 0) {
const zeroY = padding + chartHeight - ((0 - minValue) * scaleY)
ctx.strokeStyle = '#6b7280'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(padding, zeroY)
ctx.lineTo(padding + chartWidth, zeroY)
ctx.stroke()
}
}
function drawCashFlowData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
// Draw revenue line
ctx.strokeStyle = '#3b82f6'
ctx.lineWidth = 2
ctx.beginPath()
props.data.forEach((dataPoint, index) => {
const x = getX(index)
const y = getY(dataPoint.revenue)
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Draw expenses line
ctx.strokeStyle = '#ef4444'
ctx.lineWidth = 2
ctx.beginPath()
props.data.forEach((dataPoint, index) => {
const x = getX(index)
const y = getY(dataPoint.expenses)
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
}
function drawRunwayData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
// Draw balance line
ctx.strokeStyle = '#10b981'
ctx.lineWidth = 3
ctx.beginPath()
props.data.forEach((dataPoint, index) => {
const x = getX(index)
const y = getY(dataPoint.runningBalance)
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Mark zero crossing points
ctx.fillStyle = '#ef4444'
for (let i = 1; i < props.data.length; i++) {
const prev = props.data[i - 1].runningBalance
const current = props.data[i].runningBalance
if (prev >= 0 && current < 0) {
// Crossed from positive to negative
const x = getX(i)
const y = getY(0)
ctx.beginPath()
ctx.arc(x, y, 4, 0, 2 * Math.PI)
ctx.fill()
// Add warning label
ctx.fillStyle = '#ef4444'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Out of Money', x, y - 10)
ctx.fillStyle = '#ef4444' // Reset for next point
}
}
}
function drawOneOffEvents(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
props.data.forEach((dataPoint, index) => {
if (dataPoint.oneOffEvents && dataPoint.oneOffEvents.length > 0) {
const x = getX(index)
let yOffset = 0
dataPoint.oneOffEvents.forEach(event => {
const isIncome = event.type === 'income'
const baseY = getY(isIncome ? dataPoint.revenue : dataPoint.expenses)
const y = baseY + yOffset
// Draw event marker
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
ctx.beginPath()
ctx.arc(x, y, 3, 0, 2 * Math.PI)
ctx.fill()
// Add tooltip-style label
if (event.amount > 0) {
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
ctx.font = '10px sans-serif'
ctx.textAlign = 'center'
const shortName = event.name.length > 15 ? event.name.substring(0, 12) + '...' : event.name
ctx.fillText(shortName, x, y - 8)
}
yOffset += 15
})
}
})
}
function drawAxisLabels(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number) {
ctx.fillStyle = '#6b7280'
ctx.font = '12px sans-serif'
// X-axis labels (months)
ctx.textAlign = 'center'
props.data.forEach((dataPoint, index) => {
if (index % 2 === 0) { // Show every other month to avoid crowding
const x = padding + (index * chartWidth) / (props.data.length - 1)
ctx.fillText(monthNames[dataPoint.month], x, padding + chartHeight + 20)
}
})
// Y-axis labels (values)
ctx.textAlign = 'right'
const gridLines = 5
for (let i = 0; i <= gridLines; i++) {
const value = minValue + (valueRange / gridLines) * (gridLines - i)
const y = padding + (chartHeight / gridLines) * i + 4
ctx.fillText(formatShort(value), padding - 10, y)
}
}
function formatShort(value: number): string {
if (Math.abs(value) >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`
}
if (Math.abs(value) >= 1000) {
return `${(value / 1000).toFixed(0)}k`
}
return Math.round(value).toString()
}
// Watch for changes and redraw
watch(() => [props.data, props.viewMode], () => {
nextTick(() => drawChart())
}, { deep: true })
onMounted(() => {
nextTick(() => drawChart())
})
</script>

View file

@ -26,30 +26,25 @@
const route = useRoute();
const coopBuilderItems = [
{
id: "dashboard",
name: "Dashboard",
path: "/dashboard",
},
{
id: "coop-builder",
name: "Setup Wizard",
path: "/coop-builder",
},
{
id: "dashboard",
name: "Compensation",
path: "/dashboard",
},
{
id: "budget",
name: "Budget",
path: "/budget",
},
{
id: "mix",
name: "Revenue Mix",
path: "/mix",
},
{
id: "runway-lite",
name: "Runway Lite",
path: "/runway-lite",
id: "cash-flow",
name: "Cash Flow",
path: "/cash-flow",
},
];

File diff suppressed because it is too large Load diff

View file

@ -48,20 +48,43 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
const membersStore = useMembersStore()
const { members, teamStats } = storeToRefs(membersStore)
// Use coopBuilder store which has the actual data
const coopStore = useCoopBuilderStore()
const { members, equalHourlyWage } = storeToRefs(coopStore)
const membersWithCoverage = computed(() => {
return members.value.map(member => {
const coverage = membersStore.getMemberCoverage(member.id)
// Calculate coverage based on member hours vs pay
const hourlyWage = equalHourlyWage.value || 50
const monthlyHours = member.hoursPerMonth || 0
const expectedPay = monthlyHours * hourlyWage
const actualPay = member.monthlyPayPlanned || 0
const coverageMinPct = expectedPay > 0 ? Math.min(100, (actualPay / expectedPay) * 100) : 0
return {
...member,
coverageMinPct: coverage.minPct,
coverageTargetPct: coverage.targetPct
displayName: member.name,
coverageMinPct: coverageMinPct,
coverageTargetPct: coverageMinPct // Same for now since we don't have separate min/target
}
})
})
const teamStats = computed(() => {
const coverageValues = membersWithCoverage.value.map(m => m.coverageMinPct).filter(v => v !== undefined)
if (coverageValues.length === 0) {
return { under100: 0, median: 0 }
}
const sorted = [...coverageValues].sort((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)]
const under100 = coverageValues.filter(v => v < 100).length
return { under100, median }
})
function getBarColor(coverage: number | undefined) {
const pct = coverage || 0
if (pct >= 100) return 'bg-green-500'

View file

@ -0,0 +1,305 @@
<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
One-Off Transactions
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Add one-time income or expense transactions with expected dates.
</p>
</div>
<UButton @click="addEvent" size="sm" icon="i-heroicons-plus">
Add Transaction
</UButton>
</div>
</template>
<!-- Empty state -->
<div v-if="sortedEvents.length === 0" class="text-center py-8">
<UIcon name="i-heroicons-banknotes" class="w-12 h-12 mx-auto text-gray-400 mb-4" />
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
No transactions yet
</h4>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Add one-off income or expense transactions.
</p>
<UButton @click="addEvent" color="primary">
Add Your First Transaction
</UButton>
</div>
<!-- Events list -->
<div v-else class="space-y-6">
<!-- Month grouping -->
<div
v-for="monthGroup in eventsByMonth"
:key="monthGroup.month"
class="space-y-3"
>
<!-- Month header -->
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-gray-900 dark:text-white">
{{ monthGroup.monthName }}
</h4>
<div class="flex items-center gap-3">
<UBadge variant="subtle" color="gray">
{{ monthGroup.events.length }} transaction{{ monthGroup.events.length !== 1 ? 's' : '' }}
</UBadge>
<div class="text-sm font-medium" :class="monthGroup.netAmount >= 0 ? 'text-green-600' : 'text-red-600'">
{{ monthGroup.netAmount >= 0 ? '+' : '' }}{{ formatCurrency(monthGroup.netAmount) }}
</div>
</div>
</div>
<!-- Events in this month -->
<div class="space-y-3">
<UCard
v-for="event in monthGroup.events"
:key="event.id"
:ui="{
background: event.type === 'income' ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20',
ring: event.type === 'income' ? 'ring-green-200 dark:ring-green-800' : 'ring-red-200 dark:ring-red-800'
}"
>
<UForm :state="event" @submit="() => {}">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Category -->
<UFormField label="Category" name="category" required>
<USelect
v-model="event.category"
:options="categoryOptions"
@update:model-value="updateEvent(event.id, { category: $event })"
/>
</UFormField>
<!-- Name -->
<UFormField label="Name" name="name" required>
<UInput
v-model="event.name"
placeholder="e.g., Equipment purchase"
@update:model-value="updateEvent(event.id, { name: $event })"
/>
</UFormField>
<!-- Type -->
<UFormField label="Type" name="type" required>
<USelect
v-model="event.type"
:options="typeOptions"
@update:model-value="updateEvent(event.id, { type: $event })"
/>
</UFormField>
<!-- Amount -->
<UFormField label="Amount" name="amount" required>
<UInput
v-model="event.amount"
type="number"
placeholder="5000"
@update:model-value="updateEvent(event.id, { amount: Number($event) })"
>
<template #leading>
<span class="text-gray-500">$</span>
</template>
</UInput>
</UFormField>
</div>
<!-- Date Expected -->
<div class="mt-4">
<UFormField label="Date Expected" name="dateExpected" required>
<UInput
v-model="event.dateExpected"
type="date"
@update:model-value="updateEventWithDate(event.id, $event)"
/>
</UFormField>
</div>
</UForm>
<template #footer>
<div class="flex items-center justify-between">
<UButton
variant="ghost"
color="red"
size="sm"
icon="i-heroicons-trash"
@click="removeEvent(event.id)"
>
Delete
</UButton>
<UDropdown :items="getEventActions(event)">
<UButton variant="ghost" color="gray" size="sm" icon="i-heroicons-ellipsis-horizontal" />
</UDropdown>
</div>
</template>
</UCard>
</div>
</div>
<!-- Summary -->
<UCard>
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900 dark:text-white">
Total {{ sortedEvents.length }} transaction{{ sortedEvents.length !== 1 ? 's' : '' }}
</span>
<span class="text-lg font-bold" :class="totalAnnualImpact >= 0 ? 'text-green-600' : 'text-red-600'">
{{ totalAnnualImpact >= 0 ? '+' : '' }}{{ formatCurrency(totalAnnualImpact) }}
</span>
</div>
</UCard>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { OneOffEvent } from '~/types/cash'
const cashStore = useCashStore()
// Constants
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December']
const typeOptions = [
{ label: 'Income', value: 'income' },
{ label: 'Expense', value: 'expense' }
]
const categoryOptions = [
{ label: 'Equipment', value: 'Equipment' },
{ label: 'Marketing', value: 'Marketing' },
{ label: 'Legal', value: 'Legal' },
{ label: 'Contractors', value: 'Contractors' },
{ label: 'Office', value: 'Office' },
{ label: 'Development', value: 'Development' },
{ label: 'Other', value: 'Other' }
]
// Computed
const { oneOffEvents } = storeToRefs(cashStore)
const sortedEvents = computed(() => {
return oneOffEvents.value
.slice()
.sort((a, b) => a.month - b.month || a.name.localeCompare(b.name))
})
const eventsByMonth = computed(() => {
const groups: Record<number, OneOffEvent[]> = {}
sortedEvents.value.forEach(event => {
if (!groups[event.month]) {
groups[event.month] = []
}
groups[event.month].push(event)
})
return Object.entries(groups).map(([month, events]) => {
const monthNum = parseInt(month)
const netAmount = events.reduce((sum, event) => {
return sum + (event.type === 'income' ? event.amount : -event.amount)
}, 0)
return {
month: monthNum,
monthName: monthNames[monthNum],
events,
netAmount
}
}).sort((a, b) => a.month - b.month)
})
const totalIncome = computed(() => {
return oneOffEvents.value
.filter(e => e.type === 'income')
.reduce((sum, e) => sum + e.amount, 0)
})
const totalExpenses = computed(() => {
return oneOffEvents.value
.filter(e => e.type === 'expense')
.reduce((sum, e) => sum + e.amount, 0)
})
const totalAnnualImpact = computed(() => totalIncome.value - totalExpenses.value)
// Methods
function addEvent() {
const currentMonth = new Date().getMonth()
const today = new Date().toISOString().split('T')[0]
cashStore.addOneOffEvent({
name: '',
type: 'income',
amount: 0,
category: 'Other',
dateExpected: today
})
}
function updateEvent(eventId: string, updates: Partial<OneOffEvent>) {
cashStore.updateOneOffEvent(eventId, updates)
}
function updateEventWithDate(eventId: string, dateExpected: string) {
const eventDate = new Date(dateExpected)
const month = eventDate.getMonth()
cashStore.updateOneOffEvent(eventId, { dateExpected, month })
}
function removeEvent(eventId: string) {
cashStore.removeOneOffEvent(eventId)
}
function getEventActions(event: OneOffEvent) {
return [
[{
label: 'Move to Different Month',
icon: 'i-heroicons-arrow-right',
click: () => moveToMonth(event.id)
}],
[{
label: 'Duplicate Event',
icon: 'i-heroicons-document-duplicate',
click: () => duplicateEvent(event)
}]
]
}
function moveToMonth(eventId: string) {
// This could open a month selector modal
// For now, just move to next month
const event = oneOffEvents.value.find(e => e.id === eventId)
if (event) {
const newMonth = (event.month + 1) % 12
updateEvent(eventId, { month: newMonth })
}
}
function duplicateEvent(event: OneOffEvent) {
cashStore.addOneOffEvent({
name: `${event.name} (Copy)`,
type: event.type,
amount: event.amount,
category: event.category,
dateExpected: event.dateExpected
})
}
function getDateValue(dateExpected: string | undefined): string {
if (!dateExpected) {
return new Date().toISOString().split('T')[0]
}
return dateExpected
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(Math.abs(amount))
}
</script>

View file

@ -1,513 +0,0 @@
<template>
<div class="section-card">
<!-- Main Headline -->
<div class="text-center mb-6" v-if="hasData">
<div class="text-3xl font-mono font-bold text-black dark:text-white mb-2">
{{ mainHeadline }}
</div>
<div class="text-lg font-mono text-neutral-600 dark:text-neutral-400 mb-4">
{{ subHeadline }}
</div>
<!-- Coverage Text -->
<div class="text-base font-mono text-neutral-700 dark:text-neutral-300 mb-4" v-if="coverageText">
{{ coverageText }}
</div>
</div>
<!-- Toggles (Experiments) -->
<div class="mb-6 space-y-3" v-if="hasData">
<label class="flex items-center space-x-2 cursor-pointer">
<input
v-model="includePlannedRevenue"
type="checkbox"
class="bitmap-checkbox"
>
<span class="text-sm font-mono font-bold text-black dark:text-white">Count planned income</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input
v-model="imagineNoIncome"
type="checkbox"
class="bitmap-checkbox"
>
<span class="text-sm font-mono font-bold text-black dark:text-white">Imagine no income</span>
</label>
</div>
<!-- Chart Container -->
<div class="mb-6">
<div class="h-48 relative bg-white dark:bg-neutral-950 border border-black dark:border-white">
<canvas
ref="chartCanvas"
class="w-full h-full"
width="400"
height="192"
></canvas>
</div>
</div>
<!-- Chart Caption -->
<div class="text-center text-sm font-mono text-neutral-600 dark:text-neutral-400 mb-4" v-if="hasData">
This shows how your coop's money might hold up over a year.
</div>
<!-- Guidance Sentence -->
<div class="text-center mb-4" v-if="hasData">
<div class="text-base font-mono text-neutral-700 dark:text-neutral-300">
{{ guidanceText }}
</div>
</div>
<!-- Diversification Risk -->
<div v-if="diversificationGuidance" class="text-center text-sm font-mono text-neutral-600 dark:text-neutral-400 mb-4">
{{ diversificationGuidance }}
</div>
<!-- Empty State -->
<div v-else class="text-center py-8">
<div class="text-neutral-400 mb-4">
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
<p class="font-mono text-neutral-500 dark:text-neutral-500 text-sm">
Complete the Setup Wizard to see your runway projection
</p>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
startingCash?: number
revenuePlanned?: number[]
expensePlanned?: number[]
members?: Array<{ name: string, needs: number, targetPay: number, payRelationship: string }>
diversificationGuidance?: string
}
const props = withDefaults(defineProps<Props>(), {
startingCash: 0,
revenuePlanned: () => [],
expensePlanned: () => [],
members: () => [],
diversificationGuidance: ''
})
const includePlannedRevenue = ref(true)
const imagineNoIncome = ref(false)
const chartCanvas = ref<HTMLCanvasElement | null>(null)
const months = [...Array(12).keys()] // 0..11
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
const targetMonths = 6
const horizon = 6
const toNum = (v:any) => Number.isFinite(+v) ? +v : 0
const monthlyCosts = computed(() => {
if (!Array.isArray(props.expensePlanned)) return 0
const sum = props.expensePlanned.reduce((a, b) => toNum(a) + toNum(b), 0)
return sum / 12
})
// Keep the old name for compatibility
const monthlyBurn = monthlyCosts
const fmtCurrency = (v:number) => Number.isFinite(v) ? new Intl.NumberFormat(undefined,{style:'currency',currency:'USD',maximumFractionDigits:0}).format(v) : ''
const fmtShort = (v:number) => {
if (!Number.isFinite(v)) return ''
if (Math.abs(v) >= 1000) return `${(v/1000).toFixed(0)}k`
return `${Math.round(v)}`
}
function outOfCashMonth(balances: number[]): number {
return balances.findIndex(b => b < 0) // -1 if none
}
// Pay coverage calculations
const memberCoverage = computed(() => {
if (!Array.isArray(props.members) || props.members.length === 0) return []
return props.members.map(member => {
const coverage = member.needs > 0 ? ((member.targetPay || 0) / member.needs) * 100 : 0
return {
name: member.name,
coverage: Math.min(coverage, 200) // Cap at 200%
}
})
})
const coverageText = computed(() => {
if (memberCoverage.value.length === 0) return ''
const coverages = memberCoverage.value.map(m => m.coverage)
const min = Math.min(...coverages)
const max = Math.max(...coverages)
if (min >= 80) {
return "Most members' needs are nearly covered."
} else if (min < 50) {
return "Some members' needs are far from covered — consider adjusting pay relationships."
} else {
return `On this plan, coverage ranges from ${Math.round(min)}% to ${Math.round(max)}% of members' needs.`
}
})
function project(includeRevenue: boolean, forceNoIncome = false) {
const balances: number[] = []
let bal = toNum(props.startingCash)
// Pad arrays to 12 elements if shorter
const revenuePadded = Array.isArray(props.revenuePlanned) ?
[...props.revenuePlanned, ...Array(12 - props.revenuePlanned.length).fill(0)].slice(0, 12) :
Array(12).fill(0)
const expensesPadded = Array.isArray(props.expensePlanned) ?
[...props.expensePlanned, ...Array(12 - props.expensePlanned.length).fill(0)].slice(0, 12) :
Array(12).fill(0)
for (let i = 0; i < 12; i++) {
const inflow = (includeRevenue && !forceNoIncome) ? toNum(revenuePadded[i]) : 0
const outflow = toNum(expensesPadded[i])
bal = bal + inflow - outflow
balances.push(bal)
}
return balances
}
const withRev = computed(() => project(true, imagineNoIncome.value))
const noRev = computed(() => project(false, true))
function runwayFrom(balances: number[]) {
// find first month index where balance < 0
const i = balances.findIndex(b => b < 0)
if (i === -1) return 12 // survived 12+ months
// linear interpolation within month i
const prev = i === 0 ? toNum(props.startingCash) : toNum(balances[i-1])
const delta = toNum(balances[i]) - prev
const frac = delta === 0 ? 0 : (0 - prev) / delta // 0..1
return Math.max(0, (i-1) + frac + 1) // months from now
}
// Check if we have meaningful data
const hasData = computed(() => {
return props.startingCash > 0 ||
(Array.isArray(props.revenuePlanned) && props.revenuePlanned.some(v => v > 0)) ||
(Array.isArray(props.expensePlanned) && props.expensePlanned.some(v => v > 0))
})
const runwayMonths = computed(() => {
if (!hasData.value) return 0
if (monthlyCosts.value <= 0) return 12
if (imagineNoIncome.value) {
return runwayFrom(noRev.value)
} else {
return includePlannedRevenue.value ? runwayFrom(withRev.value) : runwayFrom(noRev.value)
}
})
const mainHeadline = computed(() => {
const months = runwayMonths.value >= 12 ? '12+' : runwayMonths.value.toFixed(1)
return `You could keep going for about ${months} months.`
})
const subHeadline = computed(() => {
return `That's with monthly costs of ${fmtCurrency(monthlyCosts.value)} and the income ideas you entered.`
})
const guidanceText = computed(() => {
const months = runwayMonths.value
if (months < 3) {
return "This sketch shows less than 3 months covered — that's risky."
} else if (months <= 6) {
return "This sketch shows about 36 months — that's a common minimum target."
} else {
return "This sketch shows more than 6 months — a safer position."
}
})
const bufferFlagText = computed(() => {
return guidanceText.value
})
// Out-of-money month computations
const oocWith = computed(() => outOfCashMonth(withRev.value))
const oocNo = computed(() => outOfCashMonth(noRev.value))
// Path to Safe Buffer calculation
// Break-even Month calculation
const projectionData = computed(() => {
const monthIndices = [...Array(13).keys()] // 0..12 for chart display
const withIncome = []
const withoutIncome = []
// Pad arrays to 12 elements if shorter
const revenuePadded = Array.isArray(props.revenuePlanned) ?
[...props.revenuePlanned, ...Array(12 - props.revenuePlanned.length).fill(0)].slice(0, 12) :
Array(12).fill(0)
const expensesPadded = Array.isArray(props.expensePlanned) ?
[...props.expensePlanned, ...Array(12 - props.expensePlanned.length).fill(0)].slice(0, 12) :
Array(12).fill(0)
// Start with initial balance
withIncome.push(toNum(props.startingCash))
withoutIncome.push(toNum(props.startingCash))
// Project forward month by month
for (let i = 0; i < 12; i++) {
const lastWithIncome = withIncome[withIncome.length - 1]
const lastWithoutIncome = withoutIncome[withoutIncome.length - 1]
// Safe access to array values using toNum helper
const revenueAmount = toNum(revenuePadded[i])
const expenseAmount = toNum(expensesPadded[i])
// Line A: with income ideas
const withIncomeBalance = lastWithIncome + revenueAmount - expenseAmount
withIncome.push(withIncomeBalance)
// Line B: no income
const withoutIncomeBalance = lastWithoutIncome - expenseAmount
withoutIncome.push(withoutIncomeBalance)
}
return { months: monthIndices, withIncome, withoutIncome }
})
const drawChart = () => {
if (!chartCanvas.value) return
const canvas = chartCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (!hasData.value) {
// Draw empty state in chart
ctx.fillStyle = '#6b7280'
ctx.font = '14px monospace'
ctx.textAlign = 'center'
ctx.fillText('No data available', canvas.width / 2, canvas.height / 2)
return
}
const { months, withIncome, withoutIncome } = projectionData.value
const padding = 40
const chartWidth = canvas.width - padding * 2
const chartHeight = canvas.height - padding * 2
// Calculate scale - ensure all values are finite numbers
const allValues = [...withIncome, ...withoutIncome].map(v => toNum(v))
const maxValue = Math.max(...allValues, toNum(props.startingCash))
const minValue = Math.min(...allValues, 0)
const valueRange = Math.max(maxValue - minValue, 1000) // ensure minimum range
const scaleX = chartWidth / 12
const scaleY = chartHeight / valueRange
// Helper function to get canvas coordinates
const getX = (month: number) => padding + (month * scaleX)
const getY = (value: number) => padding + chartHeight - ((value - minValue) * scaleY)
// Fill background red where balance < 0
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
const zeroY = getY(0)
ctx.fillRect(padding, zeroY, chartWidth, canvas.height - zeroY - padding)
// Draw grid lines
ctx.strokeStyle = '#e5e7eb'
ctx.lineWidth = 1
// Horizontal grid lines
for (let i = 0; i <= 4; i++) {
const y = padding + (chartHeight / 4) * i
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(padding + chartWidth, y)
ctx.stroke()
}
// Vertical grid lines
for (let i = 0; i <= 12; i += 3) {
const x = getX(i)
ctx.beginPath()
ctx.moveTo(x, padding)
ctx.lineTo(x, padding + chartHeight)
ctx.stroke()
}
// Draw zero line
ctx.strokeStyle = '#6b7280'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(padding, zeroY)
ctx.lineTo(padding + chartWidth, zeroY)
ctx.stroke()
// Draw vertical reference lines at out-of-cash points
ctx.strokeStyle = '#ef4444'
ctx.lineWidth = 1
ctx.setLineDash([5, 5])
if (oocWith.value !== -1) {
const x = getX(oocWith.value)
ctx.beginPath()
ctx.moveTo(x, padding)
ctx.lineTo(x, padding + chartHeight)
ctx.stroke()
// Label
ctx.fillStyle = '#ef4444'
ctx.font = '10px monospace'
ctx.textAlign = 'center'
ctx.fillText('OOC', x, padding - 5)
}
if (oocNo.value !== -1 && oocNo.value !== oocWith.value) {
const x = getX(oocNo.value)
ctx.beginPath()
ctx.moveTo(x, padding)
ctx.lineTo(x, padding + chartHeight)
ctx.stroke()
// Label
ctx.fillStyle = '#ef4444'
ctx.font = '10px monospace'
ctx.textAlign = 'center'
ctx.fillText('OOC', x, padding - 5)
}
ctx.setLineDash([])
// Draw Line A (with income) - always show, bold if selected
ctx.strokeStyle = '#22c55e'
ctx.lineWidth = (includePlannedRevenue.value && !imagineNoIncome.value) ? 3 : 2
ctx.globalAlpha = (includePlannedRevenue.value && !imagineNoIncome.value) ? 1 : 0.6
ctx.beginPath()
withIncome.forEach((value, index) => {
const x = getX(index)
const y = getY(toNum(value))
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Add point annotation where active scenario crosses y=0
if (includePlannedRevenue.value && !imagineNoIncome.value) {
const crossingIdx = withIncome.findIndex((value, idx) => {
if (idx === 0) return false
const prev = toNum(withIncome[idx - 1])
const curr = toNum(value)
return prev >= 0 && curr < 0
})
if (crossingIdx !== -1) {
const x = getX(crossingIdx)
const y = getY(0)
ctx.fillStyle = '#22c55e'
ctx.beginPath()
ctx.arc(x, y, 4, 0, 2 * Math.PI)
ctx.fill()
// Add label
ctx.fillStyle = '#22c55e'
ctx.font = '10px monospace'
ctx.textAlign = 'center'
ctx.fillText('Out of money', x, y - 10)
}
}
// Draw Line B (no income) - always show, bold if selected
ctx.strokeStyle = '#ef4444'
ctx.lineWidth = (!includePlannedRevenue.value || imagineNoIncome.value) ? 3 : 2
ctx.globalAlpha = (!includePlannedRevenue.value || imagineNoIncome.value) ? 1 : 0.6
ctx.beginPath()
withoutIncome.forEach((value, index) => {
const x = getX(index)
const y = getY(toNum(value))
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// Add point annotation where active scenario crosses y=0
if (!includePlannedRevenue.value || imagineNoIncome.value) {
const crossingIdx = withoutIncome.findIndex((value, idx) => {
if (idx === 0) return false
const prev = toNum(withoutIncome[idx - 1])
const curr = toNum(value)
return prev >= 0 && curr < 0
})
if (crossingIdx !== -1) {
const x = getX(crossingIdx)
const y = getY(0)
ctx.fillStyle = '#ef4444'
ctx.beginPath()
ctx.arc(x, y, 4, 0, 2 * Math.PI)
ctx.fill()
// Add label
ctx.fillStyle = '#ef4444'
ctx.font = '10px monospace'
ctx.textAlign = 'center'
ctx.fillText('Out of money', x, y - 10)
}
}
ctx.globalAlpha = 1
// Draw axis labels
ctx.fillStyle = '#6b7280'
ctx.font = '12px monospace'
ctx.textAlign = 'center'
// X-axis labels (months)
for (let i = 0; i <= 12; i += 3) {
const x = getX(i)
ctx.fillText(i.toString(), x, canvas.height - 10)
}
// Y-axis labels (balance) - guarded formatting
ctx.textAlign = 'right'
for (let i = 0; i <= 4; i++) {
const value = minValue + (valueRange / 4) * (4 - i)
const y = padding + (chartHeight / 4) * i + 4
const formattedValue = Number.isFinite(value) ? fmtShort(value) : '0'
ctx.fillText(formattedValue, padding - 10, y)
}
}
// Watch for changes that should trigger chart redraw
watch([includePlannedRevenue, imagineNoIncome, projectionData], () => {
nextTick(() => drawChart())
}, { deep: true })
onMounted(() => {
nextTick(() => drawChart())
})
</script>

View file

@ -0,0 +1,255 @@
<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>

View file

@ -1,17 +1,18 @@
<template>
<div class="hidden" data-ui="advanced_accordion_v1" />
<UAccordion :items="accordionItems" :multiple="false" class="shadow-sm rounded-xl">
<UAccordion
:items="accordionItems"
:multiple="false"
class="shadow-sm rounded-xl">
<template #advanced>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Scenarios Panel -->
<div class="space-y-6">
<h4 class="font-semibold">Scenarios</h4>
<USelect
v-model="scenario"
:options="scenarioOptions"
placeholder="Select scenario"
/>
placeholder="Select scenario" />
</div>
<!-- Stress Test Panel -->
@ -19,15 +20,18 @@
<h4 class="font-semibold">Stress Test</h4>
<div class="space-y-2">
<div>
<label class="text-xs text-gray-600">Revenue Delay (months)</label>
<label class="text-xs text-gray-600"
>Revenue Delay (months)</label
>
<URange
v-model="stress.revenueDelay"
:min="0"
:max="6"
:step="1"
class="mt-1"
/>
<div class="text-xs text-gray-500">{{ stress.revenueDelay }} months</div>
class="mt-1" />
<div class="text-xs text-gray-500">
{{ stress.revenueDelay }} months
</div>
</div>
<div>
<label class="text-xs text-gray-600">Cost Shock (%)</label>
@ -36,14 +40,12 @@
:min="0"
:max="30"
:step="1"
class="mt-1"
/>
<div class="text-xs text-gray-500">{{ stress.costShockPct }}%</div>
class="mt-1" />
<div class="text-xs text-gray-500">
{{ stress.costShockPct }}%
</div>
</div>
<UCheckbox
v-model="stress.grantLost"
label="Grant lost"
/>
<UCheckbox v-model="stress.grantLost" label="Grant lost" />
<div class="text-sm text-gray-600 pt-2 border-t">
Projected runway: {{ projectedRunway }}
</div>
@ -57,8 +59,7 @@
<UButton
size="xs"
variant="outline"
@click="showMilestoneModal = true"
>
@click="showMilestoneModal = true">
+ Add
</UButton>
</div>
@ -66,20 +67,22 @@
<div
v-for="milestone in milestoneStatus()"
:key="milestone.id"
class="flex items-center justify-between text-sm"
>
class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span>{{ milestone.willReach ? '✅' : '⚠️' }}</span>
<span>{{ milestone.willReach ? "✅" : "⚠️" }}</span>
<span>{{ milestone.label }}</span>
</div>
<span class="text-xs text-gray-600">{{ formatDate(milestone.date) }}</span>
<span class="text-xs text-gray-600">{{
formatDate(milestone.date)
}}</span>
</div>
<div v-if="milestones.length === 0" class="text-sm text-gray-600 italic">
<div
v-if="milestones.length === 0"
class="text-sm text-gray-600 italic">
No milestones yet
</div>
</div>
</div>
</div>
</template>
</UAccordion>
@ -90,14 +93,16 @@
<template #header>
<h3 class="text-lg font-medium">Add Milestone</h3>
</template>
<div class="space-y-4">
<UFormGroup label="Label">
<UInput v-model="newMilestone.label" placeholder="e.g. Product launch" />
</UFormGroup>
<UFormGroup label="Date">
<UFormField label="Label">
<UInput
v-model="newMilestone.label"
placeholder="e.g. Product launch" />
</UFormField>
<UFormField label="Date">
<UInput v-model="newMilestone.date" type="date" />
</UFormGroup>
</UFormField>
</div>
<template #footer>
@ -105,7 +110,9 @@
<UButton variant="ghost" @click="showMilestoneModal = false">
Cancel
</UButton>
<UButton @click="addNewMilestone" :disabled="!newMilestone.label || !newMilestone.date">
<UButton
@click="addNewMilestone"
:disabled="!newMilestone.label || !newMilestone.date">
Add Milestone
</UButton>
</div>
@ -115,72 +122,78 @@
</template>
<script setup lang="ts">
const {
scenario,
setScenario,
stress,
updateStress,
milestones,
milestoneStatus,
addMilestone,
runwayMonths
} = useCoopBuilder()
const {
scenario,
setScenario,
stress,
updateStress,
milestones,
milestoneStatus,
addMilestone,
runwayMonths,
} = useCoopBuilder();
// Accordion setup
const accordionItems = [{
label: 'Advanced Planning',
icon: 'i-heroicons-wrench-screwdriver',
slot: 'advanced',
defaultOpen: false
}]
const accordionItems = [
{
label: "Advanced Planning",
icon: "i-heroicons-wrench-screwdriver",
slot: "advanced",
defaultOpen: false,
},
];
// Scenarios
const scenarioOptions = [
{ label: 'Current', value: 'current' },
{ label: 'Quit Day Jobs', value: 'quit-jobs' },
{ label: 'Start Production', value: 'start-production' },
{ label: 'Custom', value: 'custom' }
]
{ label: "Current", value: "current" },
{ label: "Quit Day Jobs", value: "quit-jobs" },
{ label: "Start Production", value: "start-production" },
{ label: "Custom", value: "custom" },
];
// Stress test with live preview
const projectedRunway = computed(() => {
const months = runwayMonths(undefined, { useStress: true })
if (!isFinite(months)) return '∞'
if (months < 1) return '<1m'
return `${Math.round(months)}m`
})
const months = runwayMonths(undefined, { useStress: true });
if (!isFinite(months)) return "∞";
if (months < 1) return "<1m";
return `${Math.round(months)}m`;
});
// Milestones modal
const showMilestoneModal = ref(false)
const showMilestoneModal = ref(false);
const newMilestone = reactive({
label: '',
date: ''
})
label: "",
date: "",
});
function addNewMilestone() {
if (!newMilestone.label || !newMilestone.date) return
addMilestone(newMilestone.label, newMilestone.date)
newMilestone.label = ''
newMilestone.date = ''
showMilestoneModal.value = false
if (!newMilestone.label || !newMilestone.date) return;
addMilestone(newMilestone.label, newMilestone.date);
newMilestone.label = "";
newMilestone.date = "";
showMilestoneModal.value = false;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
// Watch scenario changes
watch(scenario, (newValue) => {
setScenario(newValue)
})
setScenario(newValue);
});
// Watch stress changes
watch(stress, (newValue) => {
updateStress(newValue)
}, { deep: true })
</script>
watch(
stress,
(newValue) => {
updateStress(newValue);
},
{ deep: true }
);
</script>