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:
parent
864a81065c
commit
f1889b3a70
17 changed files with 922 additions and 1004 deletions
3
app.vue
3
app.vue
|
|
@ -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"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
263
components/ProjectBudgetEstimate.vue
Normal file
263
components/ProjectBudgetEstimate.vue
Normal 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 2–4× 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"
|
||||
/>
|
||||
-->
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -1,76 +1,94 @@
|
|||
/**
|
||||
* Store Synchronization Composable
|
||||
*
|
||||
*
|
||||
* Ensures that the legacy stores (streams, members, policies) always stay
|
||||
* synchronized with the new CoopBuilderStore. This makes the setup interface
|
||||
* the single source of truth while maintaining backward compatibility.
|
||||
*/
|
||||
|
||||
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, () => {
|
||||
if (!isSyncing) {
|
||||
syncToLegacyStores()
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => coopStore.streams,
|
||||
() => {
|
||||
if (!isSyncing) {
|
||||
syncToLegacyStores();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Watch members changes
|
||||
watch(() => coopStore.members, () => {
|
||||
if (!isSyncing) {
|
||||
syncToLegacyStores()
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => coopStore.members,
|
||||
() => {
|
||||
if (!isSyncing) {
|
||||
syncToLegacyStores();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Watch policy changes
|
||||
watch(() => [
|
||||
coopStore.equalHourlyWage,
|
||||
coopStore.payrollOncostPct,
|
||||
coopStore.savingsTargetMonths,
|
||||
coopStore.minCashCushion,
|
||||
coopStore.currency,
|
||||
coopStore.policy.relationship
|
||||
], () => {
|
||||
if (!isSyncing) {
|
||||
syncToLegacyStores()
|
||||
watch(
|
||||
() => [
|
||||
coopStore.equalHourlyWage,
|
||||
coopStore.payrollOncostPct,
|
||||
coopStore.savingsTargetMonths,
|
||||
coopStore.minCashCushion,
|
||||
coopStore.currency,
|
||||
coopStore.policy.relationship,
|
||||
],
|
||||
() => {
|
||||
if (!isSyncing) {
|
||||
syncToLegacyStores();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Watch for changes in legacy stores and sync to CoopBuilder
|
||||
const setupLegacyStoreWatchers = () => {
|
||||
// Watch streams store changes
|
||||
watch(() => streamsStore.streams, () => {
|
||||
if (!isSyncing) {
|
||||
syncFromLegacyStores()
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => streamsStore.streams,
|
||||
() => {
|
||||
if (!isSyncing) {
|
||||
syncFromLegacyStores();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Watch members store changes
|
||||
watch(() => membersStore.members, () => {
|
||||
if (!isSyncing) {
|
||||
syncFromLegacyStores()
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => membersStore.members,
|
||||
() => {
|
||||
if (!isSyncing) {
|
||||
syncFromLegacyStores();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Watch policies store changes
|
||||
watch(() => [
|
||||
policiesStore.equalHourlyWage,
|
||||
policiesStore.payrollOncostPct,
|
||||
policiesStore.savingsTargetMonths,
|
||||
policiesStore.minCashCushionAmount,
|
||||
policiesStore.payPolicy?.relationship
|
||||
], () => {
|
||||
if (!isSyncing) {
|
||||
syncFromLegacyStores()
|
||||
watch(
|
||||
() => [
|
||||
policiesStore.equalHourlyWage,
|
||||
policiesStore.payrollOncostPct,
|
||||
policiesStore.savingsTargetMonths,
|
||||
policiesStore.minCashCushionAmount,
|
||||
policiesStore.payPolicy?.relationship,
|
||||
membersStore.payPolicy?.relationship,
|
||||
],
|
||||
() => {
|
||||
if (!isSyncing) {
|
||||
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))
|
||||
|
||||
// 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
|
||||
|
||||
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)
|
||||
// Small delay to ensure localStorage is loaded
|
||||
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;
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
112
pages/budget.vue
112
pages/budget.vue
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
84
pages/project-budget.vue
Normal 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>
|
||||
293
stores/budget.ts
293
stores/budget.ts
|
|
@ -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,89 +359,163 @@ 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
|
||||
const payPolicy = {
|
||||
relationship: coopStore.policy.relationship,
|
||||
roleBands: coopStore.policy.roleBands
|
||||
};
|
||||
// Policy for allocation
|
||||
const payPolicy = {
|
||||
relationship: coopStore.policy.relationship,
|
||||
roleBands: coopStore.policy.roleBands
|
||||
};
|
||||
|
||||
const membersForAllocation = coopStore.members.map(m => ({
|
||||
...m,
|
||||
displayName: m.name,
|
||||
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||
hoursPerMonth: m.hoursPerMonth || 0
|
||||
}));
|
||||
|
||||
// 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
|
||||
|
||||
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")}`;
|
||||
|
||||
const membersForAllocation = coopStore.members.map(m => ({
|
||||
...m,
|
||||
displayName: m.name,
|
||||
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||
hoursPerMonth: m.hoursPerMonth || 0
|
||||
}));
|
||||
|
||||
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
||||
|
||||
// Sum the allocated payroll amounts
|
||||
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
||||
return sum + (m.monthlyPayPlanned || 0);
|
||||
// Get revenue for this specific month
|
||||
const monthRevenue = budgetWorksheet.value.revenue.reduce((sum, item) => {
|
||||
return sum + (item.monthlyValues?.[monthKey] || 0);
|
||||
}, 0);
|
||||
|
||||
// Update monthly values for base payroll
|
||||
// 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);
|
||||
|
||||
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 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);
|
||||
|
||||
let monthlyOncostAmount = monthlyAllocatedPayroll * (oncostPct / 100);
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
||||
monthlyAllocatedPayroll = maxSustainablePayroll;
|
||||
monthlyOncostAmount = monthlyAllocatedPayroll * (oncostPct / 100);
|
||||
}
|
||||
|
||||
// Update annual values for base payroll
|
||||
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 }
|
||||
};
|
||||
}
|
||||
|
||||
if (oncostIndex !== -1) {
|
||||
// Update oncost entry
|
||||
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
|
||||
// Update running balance with actual net income after payroll adjustments
|
||||
const actualNetIncome = monthRevenue - (nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount);
|
||||
runningBalance += actualNetIncome;
|
||||
|
||||
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 this specific month's payroll values
|
||||
if (basePayrollIndex !== -1) {
|
||||
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = monthlyAllocatedPayroll;
|
||||
}
|
||||
|
||||
// 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 }
|
||||
};
|
||||
}
|
||||
|
||||
// Handle legacy single payroll entry (update to combined amount for backwards compatibility)
|
||||
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;
|
||||
if (oncostIndex !== -1) {
|
||||
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = monthlyOncostAmount;
|
||||
}
|
||||
|
||||
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 }
|
||||
};
|
||||
// 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: 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 name with current percentage
|
||||
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
|
||||
|
||||
budgetWorksheet.value.expenses[oncostIndex].values = {
|
||||
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 annual values
|
||||
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
||||
const totalCombined = totalAnnualPayroll + totalAnnualOncosts;
|
||||
budgetWorksheet.value.expenses[legacyIndex].values = {
|
||||
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
|
||||
|
|
@ -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")}`;
|
||||
|
|
@ -530,17 +633,28 @@ 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;
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue