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

@ -88,10 +88,9 @@ const isCoopBuilderSection = computed(
route.path === "/coop-planner" ||
route.path === "/coop-builder" ||
route.path === "/" ||
route.path === "/dashboard" ||
route.path === "/mix" ||
route.path === "/budget" ||
route.path === "/cash-flow" ||
route.path === "/project-budget" ||
route.path === "/settings" ||
route.path === "/glossary"
);

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', {

View file

@ -7,70 +7,88 @@
*/
export const useStoreSync = () => {
const coopStore = useCoopBuilderStore()
const streamsStore = useStreamsStore()
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const coopStore = useCoopBuilderStore();
const streamsStore = useStreamsStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
// Flags to prevent recursive syncing and duplicate watchers
let isSyncing = false
let watchersSetup = false
let isSyncing = false;
let watchersSetup = false;
// Sync CoopBuilder -> Legacy Stores
const syncToLegacyStores = () => {
if (isSyncing) return
isSyncing = true
if (isSyncing) return;
isSyncing = true;
// Sync streams
streamsStore.resetStreams()
streamsStore.resetStreams();
coopStore.streams.forEach((stream: any) => {
streamsStore.upsertStream({
id: stream.id,
name: stream.label,
category: stream.category || 'services',
category: stream.category || "services",
targetMonthlyAmount: stream.monthly,
certainty: stream.certainty || 'Probable',
certainty: stream.certainty || "Probable",
payoutDelayDays: 30,
terms: 'Net 30',
terms: "Net 30",
targetPct: 0,
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
restrictions: "General",
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0
})
})
effortHoursPerMonth: 0,
});
});
// Sync members
membersStore.resetMembers()
membersStore.resetMembers();
coopStore.members.forEach((member: any) => {
membersStore.upsertMember({
id: member.id,
displayName: member.name,
role: member.role || '',
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33),
role: member.role || "",
hoursPerWeek: Number(((member.hoursPerMonth || 0) / 4.33).toFixed(2)),
minMonthlyNeeds: member.minMonthlyNeeds || 0,
monthlyPayPlanned: member.monthlyPayPlanned || 0,
targetMonthlyPay: member.targetMonthlyPay || 0,
externalMonthlyIncome: member.externalMonthlyIncome || 0
})
})
externalMonthlyIncome: member.externalMonthlyIncome || 0,
});
});
// Sync policies - using individual update calls based on store structure
policiesStore.updatePolicy('equalHourlyWage', coopStore.equalHourlyWage)
policiesStore.updatePolicy('payrollOncostPct', coopStore.payrollOncostPct)
policiesStore.updatePolicy('savingsTargetMonths', coopStore.savingsTargetMonths)
policiesStore.updatePolicy('minCashCushionAmount', coopStore.minCashCushion)
policiesStore.updatePolicy("equalHourlyWage", coopStore.equalHourlyWage);
policiesStore.updatePolicy("payrollOncostPct", coopStore.payrollOncostPct);
policiesStore.updatePolicy(
"savingsTargetMonths",
coopStore.savingsTargetMonths
);
policiesStore.updatePolicy(
"minCashCushionAmount",
coopStore.minCashCushion
);
// Ensure pay policy relationship is kept in sync across stores
if (coopStore.policy?.relationship) {
if (typeof (policiesStore as any).setPayPolicy === "function") {
(policiesStore as any).setPayPolicy(coopStore.policy.relationship);
} else if (policiesStore.payPolicy) {
policiesStore.payPolicy.relationship = coopStore.policy.relationship;
}
if (membersStore.payPolicy) {
membersStore.payPolicy.relationship = coopStore.policy
.relationship as any;
}
}
// Reset flag after sync completes
nextTick(() => {
isSyncing = false
})
}
isSyncing = false;
});
};
// Sync Legacy Stores -> CoopBuilder
const syncFromLegacyStores = () => {
if (isSyncing) return
isSyncing = true
if (isSyncing) return;
isSyncing = true;
// Sync streams from legacy store
streamsStore.streams.forEach((stream: any) => {
coopStore.upsertStream({
@ -78,9 +96,9 @@ export const useStoreSync = () => {
label: stream.name,
monthly: stream.targetMonthlyAmount,
category: stream.category,
certainty: stream.certainty
})
})
certainty: stream.certainty,
});
});
// Sync members from legacy store
membersStore.members.forEach((member: any) => {
@ -88,165 +106,222 @@ export const useStoreSync = () => {
id: member.id,
name: member.displayName,
role: member.role,
hoursPerMonth: Math.round((member.hoursPerWeek || 0) * 4.33),
hoursPerMonth: Number(((member.hoursPerWeek || 0) * 4.33).toFixed(2)),
minMonthlyNeeds: member.minMonthlyNeeds,
monthlyPayPlanned: member.monthlyPayPlanned,
targetMonthlyPay: member.targetMonthlyPay,
externalMonthlyIncome: member.externalMonthlyIncome
})
})
externalMonthlyIncome: member.externalMonthlyIncome,
});
});
// Sync policies from legacy store
if (policiesStore.isValid) {
coopStore.setEqualWage(policiesStore.equalHourlyWage)
coopStore.setOncostPct(policiesStore.payrollOncostPct)
coopStore.savingsTargetMonths = policiesStore.savingsTargetMonths
coopStore.minCashCushion = policiesStore.minCashCushionAmount
coopStore.setEqualWage(policiesStore.equalHourlyWage);
coopStore.setOncostPct(policiesStore.payrollOncostPct);
coopStore.savingsTargetMonths = policiesStore.savingsTargetMonths;
coopStore.minCashCushion = policiesStore.minCashCushionAmount;
if (policiesStore.payPolicy?.relationship) {
coopStore.setPolicy(policiesStore.payPolicy.relationship as any)
coopStore.setPolicy(policiesStore.payPolicy.relationship as any);
// Keep members store aligned with legacy policy
if (membersStore.payPolicy) {
membersStore.payPolicy.relationship = policiesStore.payPolicy
.relationship as any;
}
}
}
// Also consider members store policy as a source of truth if set
if (membersStore.payPolicy?.relationship) {
coopStore.setPolicy(membersStore.payPolicy.relationship as any);
if (typeof (policiesStore as any).setPayPolicy === "function") {
(policiesStore as any).setPayPolicy(
membersStore.payPolicy.relationship as any
);
} else if (policiesStore.payPolicy) {
policiesStore.payPolicy.relationship = membersStore.payPolicy
.relationship as any;
}
}
// Reset flag after sync completes
nextTick(() => {
isSyncing = false
})
}
isSyncing = false;
});
};
// Watch for changes in CoopBuilder and sync to legacy stores
const setupCoopBuilderWatchers = () => {
// Watch streams changes
watch(() => coopStore.streams, () => {
watch(
() => coopStore.streams,
() => {
if (!isSyncing) {
syncToLegacyStores()
syncToLegacyStores();
}
}, { deep: true })
},
{ deep: true }
);
// Watch members changes
watch(() => coopStore.members, () => {
watch(
() => coopStore.members,
() => {
if (!isSyncing) {
syncToLegacyStores()
syncToLegacyStores();
}
}, { deep: true })
},
{ deep: true }
);
// Watch policy changes
watch(() => [
watch(
() => [
coopStore.equalHourlyWage,
coopStore.payrollOncostPct,
coopStore.savingsTargetMonths,
coopStore.minCashCushion,
coopStore.currency,
coopStore.policy.relationship
], () => {
coopStore.policy.relationship,
],
() => {
if (!isSyncing) {
syncToLegacyStores()
syncToLegacyStores();
}
})
}
);
};
// Watch for changes in legacy stores and sync to CoopBuilder
const setupLegacyStoreWatchers = () => {
// Watch streams store changes
watch(() => streamsStore.streams, () => {
watch(
() => streamsStore.streams,
() => {
if (!isSyncing) {
syncFromLegacyStores()
syncFromLegacyStores();
}
}, { deep: true })
},
{ deep: true }
);
// Watch members store changes
watch(() => membersStore.members, () => {
watch(
() => membersStore.members,
() => {
if (!isSyncing) {
syncFromLegacyStores()
syncFromLegacyStores();
}
}, { deep: true })
},
{ deep: true }
);
// Watch policies store changes
watch(() => [
watch(
() => [
policiesStore.equalHourlyWage,
policiesStore.payrollOncostPct,
policiesStore.savingsTargetMonths,
policiesStore.minCashCushionAmount,
policiesStore.payPolicy?.relationship
], () => {
policiesStore.payPolicy?.relationship,
membersStore.payPolicy?.relationship,
],
() => {
if (!isSyncing) {
syncFromLegacyStores()
syncFromLegacyStores();
}
})
}
);
};
// Initialize synchronization
const initSync = async () => {
// Wait for next tick to ensure stores are mounted
await nextTick()
await nextTick();
// Force store hydration by accessing $state
if (coopStore.$state) {
console.log('🔄 CoopBuilder store hydrated')
console.log("🔄 CoopBuilder store hydrated");
}
// Small delay to ensure localStorage is loaded
await new Promise(resolve => setTimeout(resolve, 10))
await new Promise((resolve) => setTimeout(resolve, 10));
// Determine which store has data and sync accordingly
const coopHasData = coopStore.members.length > 0 || coopStore.streams.length > 0
const legacyHasData = streamsStore.streams.length > 0 || membersStore.members.length > 0
const coopHasData =
coopStore.members.length > 0 || coopStore.streams.length > 0;
const legacyHasData =
streamsStore.streams.length > 0 || membersStore.members.length > 0;
console.log('🔄 InitSync: CoopBuilder data:', coopHasData, 'Legacy data:', legacyHasData)
console.log('🔄 CoopBuilder members:', coopStore.members.length, 'streams:', coopStore.streams.length)
console.log('🔄 Legacy members:', membersStore.members.length, 'streams:', streamsStore.streams.length)
console.log(
"🔄 InitSync: CoopBuilder data:",
coopHasData,
"Legacy data:",
legacyHasData
);
console.log(
"🔄 CoopBuilder members:",
coopStore.members.length,
"streams:",
coopStore.streams.length
);
console.log(
"🔄 Legacy members:",
membersStore.members.length,
"streams:",
streamsStore.streams.length
);
if (coopHasData && !legacyHasData) {
console.log('🔄 Syncing CoopBuilder → Legacy')
syncToLegacyStores()
console.log("🔄 Syncing CoopBuilder → Legacy");
syncToLegacyStores();
} else if (legacyHasData && !coopHasData) {
console.log('🔄 Syncing Legacy → CoopBuilder')
syncFromLegacyStores()
console.log("🔄 Syncing Legacy → CoopBuilder");
syncFromLegacyStores();
} else if (coopHasData && legacyHasData) {
console.log('🔄 Both have data, keeping in sync')
console.log("🔄 Both have data, keeping in sync");
// Both have data, ensure consistency by syncing from CoopBuilder (primary source)
syncToLegacyStores()
syncToLegacyStores();
} else {
console.log('🔄 No data in either store')
console.log("🔄 No data in either store");
}
// Set up watchers for ongoing sync (only once)
if (!watchersSetup) {
setupCoopBuilderWatchers()
setupLegacyStoreWatchers()
watchersSetup = true
setupCoopBuilderWatchers();
setupLegacyStoreWatchers();
watchersSetup = true;
}
// Return promise to allow awaiting
return Promise.resolve()
}
return Promise.resolve();
};
// Get unified streams data (prioritize CoopBuilder) - make reactive
const unifiedStreams = computed(() => {
if (coopStore.streams.length > 0) {
return coopStore.streams.map(stream => ({
return coopStore.streams.map((stream) => ({
...stream,
name: stream.label,
targetMonthlyAmount: stream.monthly
}))
targetMonthlyAmount: stream.monthly,
}));
}
return streamsStore.streams
})
return streamsStore.streams;
});
// Get unified members data (prioritize CoopBuilder) - make reactive
const unifiedMembers = computed(() => {
if (coopStore.members.length > 0) {
return coopStore.members.map(member => ({
return coopStore.members.map((member) => ({
...member,
displayName: member.name,
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33)
}))
hoursPerWeek: Number(((member.hoursPerMonth || 0) / 4.33).toFixed(2)),
}));
}
return membersStore.members
})
return membersStore.members;
});
// Getter functions for backward compatibility
const getStreams = () => unifiedStreams.value
const getMembers = () => unifiedMembers.value
const getStreams = () => unifiedStreams.value;
const getMembers = () => unifiedMembers.value;
return {
syncToLegacyStores,
@ -255,6 +330,6 @@ export const useStoreSync = () => {
getStreams,
getMembers,
unifiedStreams,
unifiedMembers
}
}
unifiedMembers,
};
};

View file

@ -28,6 +28,9 @@
</div>
</div>
<div class="flex items-center gap-2">
<UButton @click="showCalculationModal = true" variant="ghost" size="sm">
How are these calculated?
</UButton>
<UButton @click="exportBudget" variant="ghost" size="sm">
Export
</UButton>
@ -222,7 +225,7 @@
<div class="font-medium flex items-start gap-2">
<UTooltip
v-if="isPayrollItem(item.id)"
text="Calculated from compensation settings"
text="Calculated based on available revenue after overhead costs. This represents realistic, sustainable payroll."
:content="{ side: 'top', align: 'start' }">
<span class="cursor-help">{{ item.name }}</span>
</UTooltip>
@ -315,6 +318,23 @@
{{ formatCurrency(monthlyTotals[month.key]?.net || 0) }}
</td>
</tr>
<!-- Cumulative Balance Row -->
<tr class="border-t-1 border-gray-400 font-bold text-lg bg-blue-50">
<td
class="border-r-1 border-black px-4 py-3 sticky left-0 bg-blue-50 z-10">
CUMULATIVE BALANCE
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-gray-400 px-2 py-3 text-right last:border-r-0"
:class="
getCumulativeBalanceClass(cumulativeBalances[month.key] || 0)
">
{{ formatCurrency(cumulativeBalances[month.key] || 0) }}
</td>
</tr>
</tbody>
</table>
</div>
@ -622,6 +642,77 @@
<PayrollOncostModal
v-model:open="showPayrollOncostModal"
@save="handlePayrollOncostUpdate" />
<!-- Calculation Explanation Modal -->
<UModal v-model:open="showCalculationModal" title="How Budget Calculations Work">
<template #content>
<div class="space-y-6 max-w-2xl p-6">
<!-- Revenue Section -->
<div>
<h4 class="font-semibold text-green-600 mb-2">📈 Revenue Calculation</h4>
<p class="text-sm text-gray-600 mb-2">Revenue comes from your setup wizard streams and any manual additions:</p>
<ul class="text-sm text-gray-600 space-y-1 ml-4">
<li> Monthly amounts you entered for each revenue stream</li>
<li> Varies by month based on your specific projections</li>
</ul>
</div>
<!-- Payroll Section -->
<div>
<h4 class="font-semibold text-blue-600 mb-2">👥 Smart Payroll Calculation</h4>
<p class="text-sm text-gray-600 mb-2">Payroll uses a <strong>cumulative balance approach</strong> to ensure sustainability:</p>
<div class="bg-blue-50 border border-blue-200 rounded p-3 text-sm">
<p class="font-medium mb-2">Step-by-step process:</p>
<ol class="space-y-1 ml-4">
<li>1. Calculate available funds: Revenue - Other Expenses</li>
<li>2. Check if this maintains minimum cash threshold (${{ $format.currency(coopBuilderStore.minCashThreshold || 0) }})</li>
<li>3. Allocate using your chosen policy ({{ getPolicyName() }})</li>
<li>4. Account for payroll taxes ({{ coopBuilderStore.payrollOncostPct || 0 }}%)</li>
<li>5. Ensure cumulative balance doesn't fall below threshold</li>
</ol>
</div>
<p class="text-sm text-gray-600 mt-2">
This means payroll varies by month - higher in good cash flow months, lower when cash is tight.
</p>
</div>
<!-- Cumulative Balance Section -->
<div>
<h4 class="font-semibold text-purple-600 mb-2">💰 Cumulative Balance</h4>
<p class="text-sm text-gray-600 mb-2">Shows your running cash position over time:</p>
<ul class="text-sm text-gray-600 space-y-1 ml-4">
<li> Starts at $0 (current cash position)</li>
<li> Adds each month's net income (Revenue - All Expenses)</li>
<li> Helps you see when cash might run low</li>
<li> Payroll is reduced to prevent going below minimum threshold</li>
</ul>
</div>
<!-- Policy Explanation -->
<div>
<h4 class="font-semibold text-orange-600 mb-2"> Pay Policy: {{ getPolicyName() }}</h4>
<div class="text-sm text-gray-600">
<p v-if="coopBuilderStore.policy?.relationship === 'equal-pay'">
Everyone gets equal hourly wage (${{ coopBuilderStore.equalHourlyWage || 0 }}/hour) based on their monthly hours.
</p>
<p v-else-if="coopBuilderStore.policy?.relationship === 'needs-weighted'">
Pay is allocated proportionally based on each member's minimum monthly needs, ensuring fair coverage.
</p>
<p v-else-if="coopBuilderStore.policy?.relationship === 'hours-weighted'">
Pay is allocated proportionally based on hours worked, with higher hours getting more pay.
</p>
</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded p-3">
<p class="text-sm text-gray-700">
<strong>Key insight:</strong> This system prioritizes sustainability over theoretical maximums.
You might not always get full theoretical wages, but you'll never run out of cash.
</p>
</div>
</div>
</template>
</UModal>
</div>
</template>
@ -749,6 +840,7 @@ const activeView = ref("monthly");
const showAddRevenueModal = ref(false);
const showAddExpenseModal = ref(false);
const showPayrollOncostModal = ref(false);
const showCalculationModal = ref(false);
const activeTab = ref(0);
const highlightedItemId = ref<string | null>(null);
@ -863,6 +955,7 @@ const budgetWorksheet = computed(
const groupedRevenue = computed(() => budgetStore.groupedRevenue);
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
const cumulativeBalances = computed(() => budgetStore.cumulativeBalances);
// Initialize on mount
// Removed duplicate onMounted - initialization is now handled above
@ -1141,6 +1234,23 @@ function getNetIncomeClass(amount: number): string {
return "text-gray-600";
}
function getCumulativeBalanceClass(amount: number): string {
if (amount > 50000) return "text-green-700 font-bold"; // Healthy cash position
if (amount > 10000) return "text-green-600 font-bold"; // Good cash position
if (amount > 0) return "text-blue-600 font-bold"; // Positive but low
if (amount > -10000) return "text-orange-600 font-bold"; // Concerning
return "text-red-700 font-bold"; // Critical cash position
}
function getPolicyName(): string {
const policyType = coopBuilderStore.policy?.relationship || 'equal-pay';
if (policyType === 'equal-pay') return 'Equal Pay';
if (policyType === 'hours-weighted') return 'Hours Based';
if (policyType === 'needs-weighted') return 'Needs Weighted';
return 'Equal Pay';
}
// Payroll oncost handling
function handlePayrollOncostUpdate(newPercentage: number) {
// Update the coop store

View file

@ -1,30 +0,0 @@
<template>
<div class="max-w-6xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Cash Flow Analysis
</h1>
<p class="text-gray-600 dark:text-gray-400">
Detailed cash flow projections with one-time events and scenario planning.
</p>
</div>
<div class="space-y-8">
<!-- Unified Cash Flow Dashboard -->
<UnifiedCashFlowDashboard />
<!-- One-Off Events Editor -->
<OneOffEventEditor />
</div>
</div>
</template>
<script setup lang="ts">
// Component auto-imported
// SEO
useSeoMeta({
title: 'Cash Flow Analysis - Plan Your Cooperative Finances',
description: 'Detailed cash flow analysis with runway projections, one-time events, and scenario planning for your cooperative.'
})
</script>

View file

@ -64,7 +64,7 @@ onMounted(async () => {
const policiesStore = usePoliciesStore()
// Update reactive values
currentMode.value = policiesStore.operatingMode || 'minimum'
currentMode.value = 'target' // Simplified - always use target mode
memberCount.value = membersStore.members?.length || 0
streamCount.value = streamsStore.streams?.length || 0

View file

@ -1,166 +0,0 @@
<template>
<section class="py-8 space-y-6 max-w-4xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Compensation</h2>
<div class="flex items-center gap-2">
<span class="text-sm">Mode:</span>
<button
@click="setOperatingMode('min')"
class="px-3 py-1 text-sm font-bold border-2 border-black"
:class="coopStore.operatingMode === 'min' ? 'bg-black text-white' : 'bg-white'">
MIN
</button>
<button
@click="setOperatingMode('target')"
class="px-3 py-1 text-sm font-bold border-2 border-black"
:class="coopStore.operatingMode === 'target' ? 'bg-black text-white' : 'bg-white'">
TARGET
</button>
</div>
</div>
<!-- Simple Policy Display -->
<div class="border-2 border-black bg-white p-4">
<div class="text-lg font-bold mb-2">
{{ getPolicyName() }} Policy
</div>
<div class="text-2xl font-mono">
{{ getPolicyFormula() }}
</div>
</div>
<!-- Member List -->
<div class="border-2 border-black bg-white">
<div class="border-b-2 border-black p-4">
<h3 class="font-bold">Members ({{ coopStore.members.length }})</h3>
</div>
<div class="divide-y divide-gray-300">
<div v-if="coopStore.members.length === 0" class="p-4 text-gray-500 text-center">
No members yet. Add members in Setup Wizard.
</div>
<div v-for="member in membersWithPay" :key="member.id" class="p-4 flex justify-between items-center">
<div>
<div class="font-bold">{{ member.name || 'Unnamed' }}</div>
<div class="text-sm text-gray-600">
<span v-if="coopStore.policy?.relationship === 'needs-weighted'">
Needs: {{ $format.currency(member.minMonthlyNeeds || 0) }}/month
</span>
<span v-else>
{{ member.hoursPerMonth || 0 }} hrs/month
</span>
</div>
</div>
<div class="text-right">
<div class="font-mono font-bold">{{ $format.currency(member.expectedPay) }}</div>
<div class="text-xs" :class="member.coverage >= 100 ? 'text-green-600' : 'text-red-600'">
{{ member.coverage }}% covered
</div>
</div>
</div>
</div>
</div>
<!-- Total -->
<div class="border-2 border-black bg-gray-100 p-4">
<div class="flex justify-between items-center">
<span class="font-bold">Total Monthly Payroll</span>
<span class="text-xl font-mono font-bold">{{ $format.currency(totalPayroll) }}</span>
</div>
<div class="flex justify-between items-center mt-2 text-sm text-gray-600">
<span>+ Oncosts ({{ coopStore.payrollOncostPct }}%)</span>
<span class="font-mono">{{ $format.currency(totalPayroll * coopStore.payrollOncostPct / 100) }}</span>
</div>
<div class="flex justify-between items-center mt-2 pt-2 border-t border-gray-400">
<span class="font-bold">Total Cost</span>
<span class="text-xl font-mono font-bold">{{ $format.currency(totalPayroll * (1 + coopStore.payrollOncostPct / 100)) }}</span>
</div>
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
@click="navigateTo('/coop-builder')"
class="px-4 py-2 border-2 border-black bg-white font-bold hover:bg-gray-100">
Edit in Setup Wizard
</button>
</div>
</section>
</template>
<script setup lang="ts">
const { $format } = useNuxtApp();
const coopStore = useCoopBuilderStore();
// Calculate member pay based on policy
const membersWithPay = computed(() => {
const policyType = coopStore.policy?.relationship || 'equal-pay';
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
const totalNeeds = coopStore.members.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0);
return coopStore.members.map(member => {
let expectedPay = 0;
const hours = member.hoursPerMonth || 0;
if (policyType === 'equal-pay') {
// Equal pay: hours × wage
expectedPay = hours * coopStore.equalHourlyWage;
} else if (policyType === 'hours-weighted') {
// Hours weighted: proportion of total hours
expectedPay = totalHours > 0 ? (hours / totalHours) * (totalHours * coopStore.equalHourlyWage) : 0;
} else if (policyType === 'needs-weighted') {
// Needs weighted: based on individual needs
const needs = member.minMonthlyNeeds || 0;
expectedPay = totalNeeds > 0 ? (needs / totalNeeds) * (totalHours * coopStore.equalHourlyWage) : 0;
}
const actualPay = member.monthlyPayPlanned || expectedPay;
const coverage = expectedPay > 0 ? Math.round((actualPay / expectedPay) * 100) : 100;
return {
...member,
expectedPay,
actualPay,
coverage
};
});
});
// Total payroll
const totalPayroll = computed(() => {
return membersWithPay.value.reduce((sum, m) => sum + m.expectedPay, 0);
});
// Operating mode toggle
function setOperatingMode(mode: 'min' | 'target') {
coopStore.setOperatingMode(mode);
}
// Get current policy name
function getPolicyName() {
// Check both coopStore.policy and the root level policy.relationship
const policyType = coopStore.policy?.relationship || coopStore.policy || 'equal-pay';
if (policyType === 'equal-pay') return 'Equal Pay';
if (policyType === 'hours-weighted') return 'Hours Based';
if (policyType === 'needs-weighted') return 'Needs Based';
return 'Equal Pay'; // fallback
}
// Get policy formula display
function getPolicyFormula() {
const policyType = coopStore.policy?.relationship || coopStore.policy || 'equal-pay';
const mode = coopStore.operatingMode === 'target' ? 'Target' : 'Min';
if (policyType === 'equal-pay') {
return `${$format.currency(coopStore.equalHourlyWage)}/hour × ${mode} Hours`;
}
if (policyType === 'hours-weighted') {
return `Based on ${mode} Hours Proportion`;
}
if (policyType === 'needs-weighted') {
return `Based on Individual Needs`;
}
return `${$format.currency(coopStore.equalHourlyWage)}/hour × ${mode} Hours`;
}
</script>

View file

@ -4,9 +4,6 @@
<div>
<h2 class="text-2xl font-semibold">Compensation</h2>
<div class="flex items-center gap-2 mt-1">
<span class="px-2 py-1 border border-black bg-white text-xs font-bold uppercase">
{{ policiesStore.operatingMode === 'target' ? 'Target Mode' : 'Min Mode' }}
</span>
<span class="text-xs font-mono">
Runway: {{ Math.round(metrics.runway) }}mo
</span>
@ -224,7 +221,7 @@ const metrics = computed(() => {
);
// Use integrated runway calculations that respect operating mode
const currentMode = policiesStore.operatingMode || 'minimum';
const currentMode = 'target'; // Always target mode now
const monthlyBurn = getMonthlyBurn(currentMode);
// Use actual cash store values with fallback

84
pages/project-budget.vue Normal file
View file

@ -0,0 +1,84 @@
<template>
<div class="space-y-8">
<div class="text-center">
<h1 class="text-3xl font-bold mb-4">Project Budget Estimate</h1>
<p class="text-gray-600 max-w-2xl mx-auto mb-4">
Get a quick estimate of what it would cost to build your project with fair pay.
This tool helps worker co-ops sketch project budgets and break-even scenarios.
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 max-w-2xl mx-auto">
<div class="flex items-start gap-2">
<UIcon name="i-heroicons-information-circle" class="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div class="text-sm text-blue-800">
<p class="font-medium mb-1">About the calculations:</p>
<p>These estimates are based on <strong>sustainable payroll</strong> what you can actually afford to pay based on your revenue minus overhead costs. This may be different from theoretical maximum wages if revenue is limited.</p>
</div>
</div>
</div>
</div>
<div v-if="membersWithPay.length === 0" class="text-center py-8">
<p class="text-gray-600 mb-4">No team members set up yet.</p>
<NuxtLink
to="/coop-builder"
class="px-4 py-2 border-2 border-black bg-white font-bold hover:bg-gray-100"
>
Set up your team in Setup Wizard
</NuxtLink>
</div>
<ProjectBudgetEstimate
v-else
:members="membersWithPay"
:oncost-rate="coopStore.payrollOncostPct / 100"
/>
</div>
</template>
<script setup lang="ts">
const coopStore = useCoopBuilderStore()
const budgetStore = useBudgetStore()
// Calculate member pay using the same allocation logic as the budget system
const membersWithPay = computed(() => {
// Get current month's payroll from budget store (matches budget page)
const today = new Date()
const currentMonthKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`
const payrollExpense = budgetStore.budgetWorksheet.expenses.find(item =>
item.id === "expense-payroll-base" || item.id === "expense-payroll"
)
const actualPayrollBudget = payrollExpense?.monthlyValues?.[currentMonthKey] || 0
// Use the member's desired hours (targetHours if available, otherwise hoursPerMonth)
const getHoursForMember = (member: any) => {
return member.capacity?.targetHours || member.hoursPerMonth || 0
}
// Get theoretical allocation then scale to actual budget
const { allocatePayroll } = useCoopBuilder()
const theoreticalMembers = allocatePayroll()
const theoreticalTotal = theoreticalMembers.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
const scaleFactor = theoreticalTotal > 0 ? actualPayrollBudget / theoreticalTotal : 0
const allocatedMembers = theoreticalMembers.map(member => ({
...member,
monthlyPayPlanned: (member.monthlyPayPlanned || 0) * scaleFactor
}))
return allocatedMembers.map(member => {
const hours = getHoursForMember(member)
return {
name: member.name || 'Unnamed',
hoursPerMonth: hours,
monthlyPay: member.monthlyPayPlanned || 0
}
}).filter(m => m.hoursPerMonth > 0) // Only include members with hours
})
// Set page meta
definePageMeta({
title: 'Project Budget Estimate'
})
</script>

View file

@ -206,6 +206,28 @@ export const useBudgetStore = defineStore(
return totals;
});
// Cumulative balance computation (running cash balance)
const cumulativeBalances = computed(() => {
const balances: Record<string, number> = {};
let runningBalance = 0; // Assuming starting balance of 0 - could be configurable
// Generate month keys for next 12 months in order
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
// Add this month's net income to running balance
const monthlyNet = monthlyTotals.value[monthKey]?.net || 0;
runningBalance += monthlyNet;
balances[monthKey] = runningBalance;
}
return balances;
});
// LEGACY: Keep for backward compatibility
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
@ -242,6 +264,9 @@ export const useBudgetStore = defineStore(
budgetLines.value[period].revenueByStream[streamId] = {};
}
budgetLines.value[period].revenueByStream[streamId][type] = amount;
// Refresh payroll to account for revenue changes
refreshPayrollInBudget();
}
// Wizard-required actions
@ -316,7 +341,11 @@ export const useBudgetStore = defineStore(
// Refresh payroll in budget when policy or operating mode changes
function refreshPayrollInBudget() {
if (!isInitialized.value) return;
console.log("=== REFRESH PAYROLL CALLED ===");
if (!isInitialized.value) {
console.log("Not initialized, skipping payroll refresh");
return;
}
const coopStore = useCoopBuilderStore();
const basePayrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-base");
@ -330,13 +359,11 @@ export const useBudgetStore = defineStore(
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
const hourlyWage = coopStore.equalHourlyWage || 0;
const oncostPct = coopStore.payrollOncostPct || 0;
const basePayrollBudget = totalHours * hourlyWage;
// Declare today once for the entire function
const today = new Date();
// Keep theoretical maximum for reference
const theoreticalMaxPayroll = totalHours * hourlyWage;
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Use policy-driven allocation
// Policy for allocation
const payPolicy = {
relationship: coopStore.policy.relationship,
roleBands: coopStore.policy.roleBands
@ -350,70 +377,146 @@ export const useBudgetStore = defineStore(
hoursPerMonth: m.hoursPerMonth || 0
}));
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
// Calculate payroll for each month individually using cumulative balance approach
const refreshToday = new Date();
let totalAnnualPayroll = 0;
let totalAnnualOncosts = 0;
let runningBalance = 0; // Track cumulative cash balance
// Sum the allocated payroll amounts
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
for (let i = 0; i < 12; i++) {
const date = new Date(refreshToday.getFullYear(), refreshToday.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
// Get revenue for this specific month
const monthRevenue = budgetWorksheet.value.revenue.reduce((sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Get non-payroll expenses for this specific month
const nonPayrollExpenses = budgetWorksheet.value.expenses.reduce((sum, item) => {
// Exclude payroll items from the calculation
if (item.id === "expense-payroll-base" || item.id === "expense-payroll-oncosts" || item.id === "expense-payroll") {
return sum;
}
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Calculate normal payroll based on policy and wage (theoretical maximum)
const theoreticalPayrollBudget = totalHours * hourlyWage;
console.log(`Month ${monthKey}: Revenue=${monthRevenue}, NonPayrollExp=${nonPayrollExpenses}, TheoreticalPayroll=${theoreticalPayrollBudget}, RunningBalance=${runningBalance}`);
// Only allocate payroll if members exist
if (coopStore.members.length > 0) {
// First, calculate payroll using normal policy allocation
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, theoreticalPayrollBudget);
// Sum the allocated payroll amounts for this month
let monthlyAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
return sum + (m.monthlyPayPlanned || 0);
}, 0);
// Update monthly values for base payroll
let monthlyOncostAmount = monthlyAllocatedPayroll * (oncostPct / 100);
if (basePayrollIndex !== -1) {
// Update base payroll entry
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = totalAllocatedPayroll;
// Calculate projected balance after this month's expenses and payroll
const totalExpensesWithPayroll = nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount;
const monthlyNetIncome = monthRevenue - totalExpensesWithPayroll;
const projectedBalance = runningBalance + monthlyNetIncome;
// Check if this payroll would push cumulative balance below minimum threshold
const minThreshold = coopStore.minCashThreshold || 0;
if (projectedBalance < minThreshold) {
// Calculate maximum sustainable payroll to maintain minimum cash threshold
const targetNetIncome = minThreshold - runningBalance;
const availableForExpenses = monthRevenue - targetNetIncome;
const maxSustainablePayroll = Math.max(0, (availableForExpenses - nonPayrollExpenses) / (1 + oncostPct / 100));
console.log(`Month ${monthKey}: Reducing payroll from ${monthlyAllocatedPayroll} to ${maxSustainablePayroll} to maintain minimum cash threshold of ${minThreshold}`);
// Proportionally reduce all member allocations
if (monthlyAllocatedPayroll > 0) {
const reductionRatio = maxSustainablePayroll / monthlyAllocatedPayroll;
allocatedMembers.forEach(m => {
m.monthlyPayPlanned = (m.monthlyPayPlanned || 0) * reductionRatio;
});
}
// Update annual values for base payroll
monthlyAllocatedPayroll = maxSustainablePayroll;
monthlyOncostAmount = monthlyAllocatedPayroll * (oncostPct / 100);
}
// Update running balance with actual net income after payroll adjustments
const actualNetIncome = monthRevenue - (nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount);
runningBalance += actualNetIncome;
// Update this specific month's payroll values
if (basePayrollIndex !== -1) {
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = monthlyAllocatedPayroll;
}
if (oncostIndex !== -1) {
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = monthlyOncostAmount;
}
// Handle legacy single payroll entry
if (legacyIndex !== -1 && basePayrollIndex === -1) {
const combinedPayroll = monthlyAllocatedPayroll * (1 + oncostPct / 100);
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = combinedPayroll;
}
// Accumulate for annual totals
totalAnnualPayroll += monthlyAllocatedPayroll;
totalAnnualOncosts += monthlyOncostAmount;
} else {
// No members or theoretical payroll is 0 - set payroll to 0
if (basePayrollIndex !== -1) {
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = 0;
}
if (oncostIndex !== -1) {
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = 0;
}
if (legacyIndex !== -1 && basePayrollIndex === -1) {
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = 0;
}
// Update running balance with net income (revenue - non-payroll expenses)
const actualNetIncome = monthRevenue - nonPayrollExpenses;
runningBalance += actualNetIncome;
}
}
// Update annual values based on actual totals
if (basePayrollIndex !== -1) {
budgetWorksheet.value.expenses[basePayrollIndex].values = {
year1: { best: totalAllocatedPayroll * 12, worst: totalAllocatedPayroll * 8, mostLikely: totalAllocatedPayroll * 12 },
year2: { best: totalAllocatedPayroll * 14, worst: totalAllocatedPayroll * 10, mostLikely: totalAllocatedPayroll * 13 },
year3: { best: totalAllocatedPayroll * 16, worst: totalAllocatedPayroll * 12, mostLikely: totalAllocatedPayroll * 15 }
year1: { best: totalAnnualPayroll * 1.2, worst: totalAnnualPayroll * 0.8, mostLikely: totalAnnualPayroll },
year2: { best: totalAnnualPayroll * 1.3, worst: totalAnnualPayroll * 0.9, mostLikely: totalAnnualPayroll * 1.1 },
year3: { best: totalAnnualPayroll * 1.5, worst: totalAnnualPayroll, mostLikely: totalAnnualPayroll * 1.25 }
};
}
if (oncostIndex !== -1) {
// Update oncost entry
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = oncostAmount;
}
// Update name with current percentage
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
// Update annual values for oncosts
budgetWorksheet.value.expenses[oncostIndex].values = {
year1: { best: oncostAmount * 12, worst: oncostAmount * 8, mostLikely: oncostAmount * 12 },
year2: { best: oncostAmount * 14, worst: oncostAmount * 10, mostLikely: oncostAmount * 13 },
year3: { best: oncostAmount * 16, worst: oncostAmount * 12, mostLikely: oncostAmount * 15 }
year1: { best: totalAnnualOncosts * 1.2, worst: totalAnnualOncosts * 0.8, mostLikely: totalAnnualOncosts },
year2: { best: totalAnnualOncosts * 1.3, worst: totalAnnualOncosts * 0.9, mostLikely: totalAnnualOncosts * 1.1 },
year3: { best: totalAnnualOncosts * 1.5, worst: totalAnnualOncosts, mostLikely: totalAnnualOncosts * 1.25 }
};
}
// Handle legacy single payroll entry (update to combined amount for backwards compatibility)
// Handle legacy single payroll entry annual values
if (legacyIndex !== -1 && basePayrollIndex === -1) {
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = monthlyPayroll;
}
const totalCombined = totalAnnualPayroll + totalAnnualOncosts;
budgetWorksheet.value.expenses[legacyIndex].values = {
year1: { best: monthlyPayroll * 12, worst: monthlyPayroll * 8, mostLikely: monthlyPayroll * 12 },
year2: { best: monthlyPayroll * 14, worst: monthlyPayroll * 10, mostLikely: monthlyPayroll * 13 },
year3: { best: monthlyPayroll * 16, worst: monthlyPayroll * 12, mostLikely: monthlyPayroll * 15 }
year1: { best: totalCombined * 1.2, worst: totalCombined * 0.8, mostLikely: totalCombined },
year2: { best: totalCombined * 1.3, worst: totalCombined * 0.9, mostLikely: totalCombined * 1.1 },
year3: { best: totalCombined * 1.5, worst: totalCombined, mostLikely: totalCombined * 1.25 }
};
}
}
}
// Force reinitialize - always reload from wizard data
async function forceInitializeFromWizardData() {
@ -456,8 +559,8 @@ export const useBudgetStore = defineStore(
budgetWorksheet.value.revenue = [];
budgetWorksheet.value.expenses = [];
// Declare today once for the entire function
const today = new Date();
// Declare date once for the entire function
const initDate = new Date();
// Add revenue streams from wizard (but don't auto-load fixtures)
// Note: We don't auto-load fixtures anymore, but wizard data should still work
@ -486,7 +589,7 @@ export const useBudgetStore = defineStore(
// Create monthly values - split the annual target evenly across 12 months
const monthlyValues: Record<string, number> = {};
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -531,16 +634,27 @@ export const useBudgetStore = defineStore(
const hourlyWage = coopStore.equalHourlyWage || 0;
const oncostPct = coopStore.payrollOncostPct || 0;
// Use revenue-constrained payroll budget (same logic as useCoopBuilder)
const totalRevenue = coopStore.streams.reduce((sum, s) => sum + (s.monthly || 0), 0);
const overheadCosts = coopStore.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0);
const availableForPayroll = Math.max(0, totalRevenue - overheadCosts);
// Keep theoretical maximum for reference
const theoreticalMaxPayroll = totalHours * hourlyWage;
console.log("=== PAYROLL CALCULATION DEBUG ===");
console.log("Total hours:", totalHours);
console.log("Hourly wage:", hourlyWage);
console.log("Oncost %:", oncostPct);
console.log("Operating mode:", coopStore.operatingMode);
console.log("Policy relationship:", coopStore.policy.relationship);
console.log("Total revenue:", totalRevenue);
console.log("Overhead costs:", overheadCosts);
console.log("Available for payroll:", availableForPayroll);
console.log("Theoretical max payroll:", theoreticalMaxPayroll);
// Calculate total payroll budget using policy allocation
const basePayrollBudget = totalHours * hourlyWage;
console.log("Base payroll budget:", basePayrollBudget);
// Use revenue-constrained budget
const basePayrollBudget = availableForPayroll;
console.log("Using payroll budget:", basePayrollBudget);
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Use policy-driven allocation to get actual member pay amounts
@ -579,7 +693,7 @@ export const useBudgetStore = defineStore(
// Create monthly values for payroll
const monthlyValues: Record<string, number> = {};
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -591,9 +705,9 @@ export const useBudgetStore = defineStore(
// Create base payroll monthly values (without oncosts)
const baseMonthlyValues: Record<string, number> = {};
const oncostMonthlyValues: Record<string, number> = {};
// Reuse the today variable from above
// Reuse the initDate variable from above
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -677,7 +791,7 @@ export const useBudgetStore = defineStore(
// Create monthly values for overhead costs
const monthlyValues: Record<string, number> = {};
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -809,7 +923,7 @@ export const useBudgetStore = defineStore(
console.log("Migrating item to monthly values:", item.name);
item.monthlyValues = {};
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -834,6 +948,10 @@ export const useBudgetStore = defineStore(
);
isInitialized.value = true;
// Trigger payroll refresh after initialization
console.log("Triggering initial payroll refresh after initialization");
refreshPayrollInBudget();
} catch (error) {
console.error("Error initializing budget from wizard data:", error);
@ -868,6 +986,14 @@ export const useBudgetStore = defineStore(
console.log('Updated item.monthlyValues:', item.monthlyValues);
console.log('Item updated:', item.name);
// Refresh payroll when any budget item changes (except payroll items themselves)
if (!itemId.includes('payroll')) {
console.log('Triggering payroll refresh for non-payroll item:', itemId);
refreshPayrollInBudget();
} else {
console.log('Skipping payroll refresh for payroll item:', itemId);
}
} else {
console.error('Item not found:', { category, itemId, availableItems: items.map(i => ({id: i.id, name: i.name})) });
}
@ -878,9 +1004,9 @@ export const useBudgetStore = defineStore(
// Create empty monthly values for next 12 months
const monthlyValues = {};
const today = new Date();
const addDate = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(addDate.getFullYear(), addDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -969,6 +1095,7 @@ export const useBudgetStore = defineStore(
budgetWorksheet,
budgetTotals,
monthlyTotals,
cumulativeBalances,
revenueCategories,
expenseCategories,
revenueSubcategories,

View file

@ -2,8 +2,6 @@ import { defineStore } from "pinia";
export const useCoopBuilderStore = defineStore("coop", {
state: () => ({
operatingMode: "min" as "min" | "target",
// Currency preference
currency: "EUR" as string,
@ -51,6 +49,9 @@ export const useCoopBuilderStore = defineStore("coop", {
},
equalHourlyWage: 50,
payrollOncostPct: 25,
// Cash flow management
minCashThreshold: 5000, // Minimum cash balance to maintain
savingsTargetMonths: 6,
minCashCushion: 10000,
@ -152,11 +153,6 @@ export const useCoopBuilderStore = defineStore("coop", {
this.milestones = this.milestones.filter((m) => m.id !== id);
},
// Operating mode
setOperatingMode(mode: "min" | "target") {
this.operatingMode = mode;
},
// Scenario
setScenario(
scenario: "current" | "start-production" | "custom"
@ -182,6 +178,10 @@ export const useCoopBuilderStore = defineStore("coop", {
this.payrollOncostPct = pct;
},
setMinCashThreshold(amount: number) {
this.minCashThreshold = amount;
},
setCurrency(currency: string) {
this.currency = currency;
},
@ -245,7 +245,6 @@ export const useCoopBuilderStore = defineStore("coop", {
clearAll() {
// Reset ALL state to initial empty values
this._wasCleared = true;
this.operatingMode = "min";
this.currency = "EUR";
this.members = [];
this.streams = [];

View file

@ -11,7 +11,6 @@ export const usePoliciesStore = defineStore(
const payrollOncostPct = ref(0);
const savingsTargetMonths = ref(0);
const minCashCushionAmount = ref(0);
const operatingMode = ref<'minimum' | 'target'>('minimum');
// Pay policy for member needs coverage
const payPolicy = ref({
@ -134,7 +133,6 @@ export const usePoliciesStore = defineStore(
payrollOncostPct.value = 0;
savingsTargetMonths.value = 0;
minCashCushionAmount.value = 0;
operatingMode.value = 'minimum';
payPolicy.value = { relationship: 'equal-pay', roleBands: [] };
deferredCapHoursPerQtr.value = 0;
deferredSunsetMonths.value = 0;
@ -155,7 +153,6 @@ export const usePoliciesStore = defineStore(
payrollOncostPct,
savingsTargetMonths,
minCashCushionAmount,
operatingMode,
payPolicy,
deferredCapHoursPerQtr,
deferredSunsetMonths,
@ -190,7 +187,6 @@ export const usePoliciesStore = defineStore(
"payrollOncostPct",
"savingsTargetMonths",
"minCashCushionAmount",
"operatingMode",
"payPolicy",
"deferredCapHoursPerQtr",
"deferredSunsetMonths",