refactor: remove CashFlowChart and UnifiedCashFlowDashboard components, update routing paths in app.vue, and enhance budget page with cumulative balance calculations and payroll explanation modal for improved user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-10 07:42:56 +01:00
parent 864a81065c
commit f1889b3a70
17 changed files with 922 additions and 1004 deletions

View file

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

@ -31,20 +31,15 @@ const coopBuilderItems = [
name: "Setup Wizard",
path: "/coop-builder",
},
{
id: "dashboard",
name: "Compensation",
path: "/dashboard",
},
{
id: "budget",
name: "Budget",
path: "/budget",
},
{
id: "cash-flow",
name: "Cash Flow",
path: "/cash-flow",
id: "project-budget",
name: "Project Budget",
path: "/project-budget",
},
];

View file

@ -0,0 +1,263 @@
<template>
<div class="max-w-3xl mx-auto">
<div class="bg-white border-4 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<!-- Header -->
<div class="p-6 border-b-4 border-black bg-yellow-300">
<h2 class="text-xl font-bold mb-2">
If your team worked full-time for {{ durationMonths }} months, it would cost about {{ currency(projectBase) }}.
</h2>
<p class="text-sm">
Based on sustainable payroll from available revenue after overhead costs.
</p>
<p v-if="bufferEnabled" class="text-sm mt-2 font-medium">
Adding a 30% buffer for delays brings it to {{ currency(projectWithBuffer) }}.
</p>
</div>
<!-- Controls -->
<div class="p-6 border-b-4 border-black bg-gray-100">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2">
<label for="duration" class="font-bold text-sm">Duration (months):</label>
<input
id="duration"
v-model.number="durationMonths"
type="number"
min="6"
max="36"
class="w-20 px-2 py-1 border-2 border-black font-mono"
>
</div>
<div class="flex items-center gap-2">
<input
id="buffer"
v-model="bufferEnabled"
type="checkbox"
class="w-4 h-4 border-2 border-black"
>
<label for="buffer" class="font-bold text-sm">Add 30% buffer</label>
</div>
</div>
</div>
<!-- Cost Summary -->
<div class="p-6 border-b-4 border-black">
<ul class="space-y-2">
<li class="flex justify-between items-center">
<span class="font-bold">Monthly team cost:</span>
<span class="font-mono">{{ currency(monthlyCost) }}</span>
</li>
<li class="text-xs text-gray-600 -mt-1">
Sustainable payroll + {{ percent(props.oncostRate) }} benefits
</li>
<li class="flex justify-between items-center">
<span class="font-bold">Project budget:</span>
<span class="font-mono">{{ currency(projectBase) }}</span>
</li>
<li v-if="bufferEnabled" class="flex justify-between items-center border-t-2 border-black pt-2">
<span class="font-bold">With buffer:</span>
<span class="font-mono text-lg">{{ currency(projectWithBuffer) }}</span>
</li>
</ul>
</div>
<!-- Break-Even Sketch -->
<details class="group">
<summary class="p-6 border-b-4 border-black bg-blue-200 cursor-pointer font-bold hover:bg-blue-300 transition-colors">
<span>Break-Even Sketch (optional)</span>
</summary>
<div class="p-6 border-b-4 border-black bg-blue-50">
<!-- Inputs -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div>
<label for="price" class="block font-bold text-sm mb-1">Price per copy:</label>
<div class="flex items-center">
<span class="font-mono">$</span>
<input
id="price"
v-model.number="price"
type="number"
min="0"
step="0.01"
class="flex-1 ml-1 px-2 py-1 border-2 border-black font-mono"
>
</div>
</div>
<div>
<label for="storeCut" class="block font-bold text-sm mb-1">Store cut:</label>
<div class="flex items-center">
<input
id="storeCut"
v-model.number="storeCutInput"
type="number"
min="0"
max="100"
step="1"
class="w-16 px-2 py-1 border-2 border-black font-mono"
>
<span class="ml-1 font-mono">%</span>
</div>
</div>
<div>
<label for="reviewToSales" class="block font-bold text-sm mb-1">Sales per review:</label>
<input
id="reviewToSales"
v-model.number="reviewToSales"
type="number"
min="1"
class="w-20 px-2 py-1 border-2 border-black font-mono"
>
</div>
</div>
<!-- Outputs -->
<ul class="space-y-2 mb-4">
<li>
At {{ currency(price) }} per copy after store fees, you'd need about
<strong>{{ unitsToBreakEven.toLocaleString() }} sales</strong> to cover this budget.
</li>
<li>
That's roughly <strong>{{ reviewsToBreakEven.toLocaleString() }} Steam reviews</strong>
( {{ reviewToSales }} sales per review).
</li>
</ul>
<p class="text-xs text-gray-600">
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not included.
</p>
</div>
</details>
<!-- Viability Check -->
<div class="p-6 border-b-4 border-black">
<div class="space-y-3">
<div class="flex items-start gap-2">
<input type="checkbox" class="w-4 h-4 mt-0.5 border-2 border-black">
<label class="text-sm">Does this plan pay everyone fairly if the project runs late?</label>
</div>
<div class="flex items-start gap-2">
<input type="checkbox" class="w-4 h-4 mt-0.5 border-2 border-black">
<label class="text-sm">Could this project plausibly earn 24× its cost?</label>
</div>
<div class="flex items-start gap-2">
<input type="checkbox" class="w-4 h-4 mt-0.5 border-2 border-black">
<label class="text-sm">Is this budget competitive for games of this size?</label>
</div>
</div>
</div>
<!-- Guidance -->
<div v-if="guidanceText" class="p-4 bg-gray-50 text-sm text-gray-600">
{{ guidanceText }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Member {
name: string
hoursPerMonth: number
hourlyRate?: number
monthlyPay?: number
}
interface Props {
members: Member[]
oncostRate?: number
durationMonths?: number
defaultPrice?: number
storeCut?: number
reviewToSales?: number
}
const props = withDefaults(defineProps<Props>(), {
oncostRate: 0.25,
durationMonths: 12,
defaultPrice: 20,
storeCut: 0.30,
reviewToSales: 57,
})
// Local state
const durationMonths = ref(props.durationMonths)
const bufferEnabled = ref(false)
const price = ref(props.defaultPrice)
const storeCutInput = ref(props.storeCut * 100) // Convert to percentage for input
const reviewToSales = ref(props.reviewToSales)
// Calculations
const baseMonthlyPayroll = computed(() => {
return props.members.reduce((sum, member) => {
// Use monthlyPay if available, otherwise calculate from hourlyRate
const memberCost = member.monthlyPay ?? (member.hoursPerMonth * (member.hourlyRate ?? 0))
return sum + memberCost
}, 0)
})
const monthlyCost = computed(() => {
return baseMonthlyPayroll.value * (1 + props.oncostRate)
})
const projectBase = computed(() => {
return monthlyCost.value * durationMonths.value
})
const projectWithBuffer = computed(() => {
return projectBase.value * 1.30
})
const projectBudget = computed(() => {
return bufferEnabled.value ? projectWithBuffer.value : projectBase.value
})
const netPerUnit = computed(() => {
return price.value * (1 - (storeCutInput.value / 100))
})
const unitsToBreakEven = computed(() => {
return Math.ceil(projectBudget.value / Math.max(netPerUnit.value, 0.01))
})
const reviewsToBreakEven = computed(() => {
return Math.ceil(unitsToBreakEven.value / Math.max(reviewToSales.value, 1))
})
const guidanceText = computed(() => {
if (bufferEnabled.value) {
return "This sketch includes a safety buffer."
} else if (durationMonths.value * monthlyCost.value >= 1) {
return "Consider adding a small buffer so the team isn't squeezed by delays."
}
return ""
})
// Utility functions
const currency = (n: number): string => {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(n)
}
const percent = (n: number): string => {
return `${Math.round(n * 100)}%`
}
</script>
<!--
Test with sample props:
const sampleMembers = [
{ name: 'Alice', hoursPerMonth: 160, hourlyRate: 25 },
{ name: 'Bob', hoursPerMonth: 120, hourlyRate: 30 },
{ name: 'Carol', hoursPerMonth: 80, hourlyRate: 35 }
]
<ProjectBudgetEstimate
:members="sampleMembers"
:duration-months="18"
:default-price="25"
/>
-->

View file

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

@ -166,13 +166,7 @@ const overheadCosts = computed(() =>
}))
);
// Operating mode toggle
const useTargetMode = ref(coop.operatingMode.value === "target");
function updateOperatingMode(value: boolean) {
coop.setOperatingMode(value ? "target" : "min");
emit("save-status", "saved");
}
// Operating mode removed - always use target mode
// Category options
const categoryOptions = [

View file

@ -21,11 +21,11 @@
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
<UBadge
:color="getCoverageColor(coverage(member).coveragePct)"
:color="getCoverageColor(calculateCoverage(member))"
size="xs"
:ui="{ base: 'font-medium' }"
>
{{ Math.round(coverage(member).coveragePct || 0) }}% covered
{{ Math.round(calculateCoverage(member)) }}% covered
</UBadge>
</div>
</div>
@ -49,11 +49,11 @@
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
<div
class="h-3 rounded-full transition-all duration-300"
:class="getBarColor(coverage(member).coveragePct)"
:style="{ width: `${Math.min(100, coverage(member).coveragePct || 0)}%` }"
:class="getBarColor(calculateCoverage(member))"
:style="{ width: `${Math.min(100, calculateCoverage(member))}%` }"
/>
<!-- 100% marker line -->
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="(coverage(member).coveragePct || 0) < 100">
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="calculateCoverage(member) < 100">
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" />
</div>
</div>
@ -95,7 +95,9 @@
</span>
</div>
<div class="text-xs">
Total payroll: {{ formatCurrency(totalPayroll) }}
<UTooltip text="Based on available revenue after overhead costs">
<span class="cursor-help">Total sustainable payroll: {{ formatCurrency(totalPayroll) }}</span>
</UTooltip>
</div>
</div>
@ -140,7 +142,11 @@
<script setup lang="ts">
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
const allocatedMembers = computed(() => allocatePayroll())
const allocatedMembers = computed(() => {
const members = allocatePayroll()
console.log('🔍 allocatedMembers computed:', members)
return members
})
const stats = computed(() => teamCoverageStats())
// Calculate total payroll
@ -190,6 +196,25 @@ const totalSurplus = computed(() => {
return surplus > 0 ? surplus : 0
})
// Local coverage calculation for debugging
function calculateCoverage(member: any): number {
const coopPay = member.monthlyPayPlanned || 0
const needs = member.minMonthlyNeeds || 0
console.log(`Coverage calc for ${member.name || member.displayName || 'Unknown'}:`, {
member: JSON.stringify(member, null, 2),
coopPay,
needs,
coverage: needs > 0 ? (coopPay / needs) * 100 : 100
})
if (needs === 0) {
console.log(`⚠️ Member ${member.name} has NO minMonthlyNeeds - defaulting to 100%`)
return 100
}
return Math.min(200, (coopPay / needs) * 100)
}
// Currency formatting
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {