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-planner" ||
|
||||||
route.path === "/coop-builder" ||
|
route.path === "/coop-builder" ||
|
||||||
route.path === "/" ||
|
route.path === "/" ||
|
||||||
route.path === "/dashboard" ||
|
|
||||||
route.path === "/mix" ||
|
route.path === "/mix" ||
|
||||||
route.path === "/budget" ||
|
route.path === "/budget" ||
|
||||||
route.path === "/cash-flow" ||
|
route.path === "/project-budget" ||
|
||||||
route.path === "/settings" ||
|
route.path === "/settings" ||
|
||||||
route.path === "/glossary"
|
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",
|
name: "Setup Wizard",
|
||||||
path: "/coop-builder",
|
path: "/coop-builder",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "dashboard",
|
|
||||||
name: "Compensation",
|
|
||||||
path: "/dashboard",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "budget",
|
id: "budget",
|
||||||
name: "Budget",
|
name: "Budget",
|
||||||
path: "/budget",
|
path: "/budget",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cash-flow",
|
id: "project-budget",
|
||||||
name: "Cash Flow",
|
name: "Project Budget",
|
||||||
path: "/cash-flow",
|
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
|
// Operating mode removed - always use target mode
|
||||||
const useTargetMode = ref(coop.operatingMode.value === "target");
|
|
||||||
|
|
||||||
function updateOperatingMode(value: boolean) {
|
|
||||||
coop.setOperatingMode(value ? "target" : "min");
|
|
||||||
emit("save-status", "saved");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category options
|
// Category options
|
||||||
const categoryOptions = [
|
const categoryOptions = [
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,11 @@
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
|
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
|
||||||
<UBadge
|
<UBadge
|
||||||
:color="getCoverageColor(coverage(member).coveragePct)"
|
:color="getCoverageColor(calculateCoverage(member))"
|
||||||
size="xs"
|
size="xs"
|
||||||
:ui="{ base: 'font-medium' }"
|
:ui="{ base: 'font-medium' }"
|
||||||
>
|
>
|
||||||
{{ Math.round(coverage(member).coveragePct || 0) }}% covered
|
{{ Math.round(calculateCoverage(member)) }}% covered
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,11 +49,11 @@
|
||||||
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
|
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="h-3 rounded-full transition-all duration-300"
|
class="h-3 rounded-full transition-all duration-300"
|
||||||
:class="getBarColor(coverage(member).coveragePct)"
|
:class="getBarColor(calculateCoverage(member))"
|
||||||
:style="{ width: `${Math.min(100, coverage(member).coveragePct || 0)}%` }"
|
:style="{ width: `${Math.min(100, calculateCoverage(member))}%` }"
|
||||||
/>
|
/>
|
||||||
<!-- 100% marker line -->
|
<!-- 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 class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -95,7 +95,9 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -140,7 +142,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
|
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())
|
const stats = computed(() => teamCoverageStats())
|
||||||
|
|
||||||
// Calculate total payroll
|
// Calculate total payroll
|
||||||
|
|
@ -190,6 +196,25 @@ const totalSurplus = computed(() => {
|
||||||
return surplus > 0 ? surplus : 0
|
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
|
// Currency formatting
|
||||||
function formatCurrency(amount: number): string {
|
function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
|
|
||||||
|
|
@ -7,70 +7,88 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const useStoreSync = () => {
|
export const useStoreSync = () => {
|
||||||
const coopStore = useCoopBuilderStore()
|
const coopStore = useCoopBuilderStore();
|
||||||
const streamsStore = useStreamsStore()
|
const streamsStore = useStreamsStore();
|
||||||
const membersStore = useMembersStore()
|
const membersStore = useMembersStore();
|
||||||
const policiesStore = usePoliciesStore()
|
const policiesStore = usePoliciesStore();
|
||||||
|
|
||||||
// Flags to prevent recursive syncing and duplicate watchers
|
// Flags to prevent recursive syncing and duplicate watchers
|
||||||
let isSyncing = false
|
let isSyncing = false;
|
||||||
let watchersSetup = false
|
let watchersSetup = false;
|
||||||
|
|
||||||
// Sync CoopBuilder -> Legacy Stores
|
// Sync CoopBuilder -> Legacy Stores
|
||||||
const syncToLegacyStores = () => {
|
const syncToLegacyStores = () => {
|
||||||
if (isSyncing) return
|
if (isSyncing) return;
|
||||||
isSyncing = true
|
isSyncing = true;
|
||||||
// Sync streams
|
// Sync streams
|
||||||
streamsStore.resetStreams()
|
streamsStore.resetStreams();
|
||||||
coopStore.streams.forEach((stream: any) => {
|
coopStore.streams.forEach((stream: any) => {
|
||||||
streamsStore.upsertStream({
|
streamsStore.upsertStream({
|
||||||
id: stream.id,
|
id: stream.id,
|
||||||
name: stream.label,
|
name: stream.label,
|
||||||
category: stream.category || 'services',
|
category: stream.category || "services",
|
||||||
targetMonthlyAmount: stream.monthly,
|
targetMonthlyAmount: stream.monthly,
|
||||||
certainty: stream.certainty || 'Probable',
|
certainty: stream.certainty || "Probable",
|
||||||
payoutDelayDays: 30,
|
payoutDelayDays: 30,
|
||||||
terms: 'Net 30',
|
terms: "Net 30",
|
||||||
targetPct: 0,
|
targetPct: 0,
|
||||||
revenueSharePct: 0,
|
revenueSharePct: 0,
|
||||||
platformFeePct: 0,
|
platformFeePct: 0,
|
||||||
restrictions: 'General',
|
restrictions: "General",
|
||||||
seasonalityWeights: new Array(12).fill(1),
|
seasonalityWeights: new Array(12).fill(1),
|
||||||
effortHoursPerMonth: 0
|
effortHoursPerMonth: 0,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// Sync members
|
// Sync members
|
||||||
membersStore.resetMembers()
|
membersStore.resetMembers();
|
||||||
coopStore.members.forEach((member: any) => {
|
coopStore.members.forEach((member: any) => {
|
||||||
membersStore.upsertMember({
|
membersStore.upsertMember({
|
||||||
id: member.id,
|
id: member.id,
|
||||||
displayName: member.name,
|
displayName: member.name,
|
||||||
role: member.role || '',
|
role: member.role || "",
|
||||||
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33),
|
hoursPerWeek: Number(((member.hoursPerMonth || 0) / 4.33).toFixed(2)),
|
||||||
minMonthlyNeeds: member.minMonthlyNeeds || 0,
|
minMonthlyNeeds: member.minMonthlyNeeds || 0,
|
||||||
monthlyPayPlanned: member.monthlyPayPlanned || 0,
|
monthlyPayPlanned: member.monthlyPayPlanned || 0,
|
||||||
targetMonthlyPay: member.targetMonthlyPay || 0,
|
targetMonthlyPay: member.targetMonthlyPay || 0,
|
||||||
externalMonthlyIncome: member.externalMonthlyIncome || 0
|
externalMonthlyIncome: member.externalMonthlyIncome || 0,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// Sync policies - using individual update calls based on store structure
|
// Sync policies - using individual update calls based on store structure
|
||||||
policiesStore.updatePolicy('equalHourlyWage', coopStore.equalHourlyWage)
|
policiesStore.updatePolicy("equalHourlyWage", coopStore.equalHourlyWage);
|
||||||
policiesStore.updatePolicy('payrollOncostPct', coopStore.payrollOncostPct)
|
policiesStore.updatePolicy("payrollOncostPct", coopStore.payrollOncostPct);
|
||||||
policiesStore.updatePolicy('savingsTargetMonths', coopStore.savingsTargetMonths)
|
policiesStore.updatePolicy(
|
||||||
policiesStore.updatePolicy('minCashCushionAmount', coopStore.minCashCushion)
|
"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
|
// Reset flag after sync completes
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
isSyncing = false
|
isSyncing = false;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// Sync Legacy Stores -> CoopBuilder
|
// Sync Legacy Stores -> CoopBuilder
|
||||||
const syncFromLegacyStores = () => {
|
const syncFromLegacyStores = () => {
|
||||||
if (isSyncing) return
|
if (isSyncing) return;
|
||||||
isSyncing = true
|
isSyncing = true;
|
||||||
// Sync streams from legacy store
|
// Sync streams from legacy store
|
||||||
streamsStore.streams.forEach((stream: any) => {
|
streamsStore.streams.forEach((stream: any) => {
|
||||||
coopStore.upsertStream({
|
coopStore.upsertStream({
|
||||||
|
|
@ -78,9 +96,9 @@ export const useStoreSync = () => {
|
||||||
label: stream.name,
|
label: stream.name,
|
||||||
monthly: stream.targetMonthlyAmount,
|
monthly: stream.targetMonthlyAmount,
|
||||||
category: stream.category,
|
category: stream.category,
|
||||||
certainty: stream.certainty
|
certainty: stream.certainty,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// Sync members from legacy store
|
// Sync members from legacy store
|
||||||
membersStore.members.forEach((member: any) => {
|
membersStore.members.forEach((member: any) => {
|
||||||
|
|
@ -88,165 +106,222 @@ export const useStoreSync = () => {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
name: member.displayName,
|
name: member.displayName,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
hoursPerMonth: Math.round((member.hoursPerWeek || 0) * 4.33),
|
hoursPerMonth: Number(((member.hoursPerWeek || 0) * 4.33).toFixed(2)),
|
||||||
minMonthlyNeeds: member.minMonthlyNeeds,
|
minMonthlyNeeds: member.minMonthlyNeeds,
|
||||||
monthlyPayPlanned: member.monthlyPayPlanned,
|
monthlyPayPlanned: member.monthlyPayPlanned,
|
||||||
targetMonthlyPay: member.targetMonthlyPay,
|
targetMonthlyPay: member.targetMonthlyPay,
|
||||||
externalMonthlyIncome: member.externalMonthlyIncome
|
externalMonthlyIncome: member.externalMonthlyIncome,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// Sync policies from legacy store
|
// Sync policies from legacy store
|
||||||
if (policiesStore.isValid) {
|
if (policiesStore.isValid) {
|
||||||
coopStore.setEqualWage(policiesStore.equalHourlyWage)
|
coopStore.setEqualWage(policiesStore.equalHourlyWage);
|
||||||
coopStore.setOncostPct(policiesStore.payrollOncostPct)
|
coopStore.setOncostPct(policiesStore.payrollOncostPct);
|
||||||
coopStore.savingsTargetMonths = policiesStore.savingsTargetMonths
|
coopStore.savingsTargetMonths = policiesStore.savingsTargetMonths;
|
||||||
coopStore.minCashCushion = policiesStore.minCashCushionAmount
|
coopStore.minCashCushion = policiesStore.minCashCushionAmount;
|
||||||
if (policiesStore.payPolicy?.relationship) {
|
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
|
// Reset flag after sync completes
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
isSyncing = false
|
isSyncing = false;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// Watch for changes in CoopBuilder and sync to legacy stores
|
// Watch for changes in CoopBuilder and sync to legacy stores
|
||||||
const setupCoopBuilderWatchers = () => {
|
const setupCoopBuilderWatchers = () => {
|
||||||
// Watch streams changes
|
// Watch streams changes
|
||||||
watch(() => coopStore.streams, () => {
|
watch(
|
||||||
|
() => coopStore.streams,
|
||||||
|
() => {
|
||||||
if (!isSyncing) {
|
if (!isSyncing) {
|
||||||
syncToLegacyStores()
|
syncToLegacyStores();
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Watch members changes
|
// Watch members changes
|
||||||
watch(() => coopStore.members, () => {
|
watch(
|
||||||
|
() => coopStore.members,
|
||||||
|
() => {
|
||||||
if (!isSyncing) {
|
if (!isSyncing) {
|
||||||
syncToLegacyStores()
|
syncToLegacyStores();
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Watch policy changes
|
// Watch policy changes
|
||||||
watch(() => [
|
watch(
|
||||||
|
() => [
|
||||||
coopStore.equalHourlyWage,
|
coopStore.equalHourlyWage,
|
||||||
coopStore.payrollOncostPct,
|
coopStore.payrollOncostPct,
|
||||||
coopStore.savingsTargetMonths,
|
coopStore.savingsTargetMonths,
|
||||||
coopStore.minCashCushion,
|
coopStore.minCashCushion,
|
||||||
coopStore.currency,
|
coopStore.currency,
|
||||||
coopStore.policy.relationship
|
coopStore.policy.relationship,
|
||||||
], () => {
|
],
|
||||||
|
() => {
|
||||||
if (!isSyncing) {
|
if (!isSyncing) {
|
||||||
syncToLegacyStores()
|
syncToLegacyStores();
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Watch for changes in legacy stores and sync to CoopBuilder
|
// Watch for changes in legacy stores and sync to CoopBuilder
|
||||||
const setupLegacyStoreWatchers = () => {
|
const setupLegacyStoreWatchers = () => {
|
||||||
// Watch streams store changes
|
// Watch streams store changes
|
||||||
watch(() => streamsStore.streams, () => {
|
watch(
|
||||||
|
() => streamsStore.streams,
|
||||||
|
() => {
|
||||||
if (!isSyncing) {
|
if (!isSyncing) {
|
||||||
syncFromLegacyStores()
|
syncFromLegacyStores();
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Watch members store changes
|
// Watch members store changes
|
||||||
watch(() => membersStore.members, () => {
|
watch(
|
||||||
|
() => membersStore.members,
|
||||||
|
() => {
|
||||||
if (!isSyncing) {
|
if (!isSyncing) {
|
||||||
syncFromLegacyStores()
|
syncFromLegacyStores();
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Watch policies store changes
|
// Watch policies store changes
|
||||||
watch(() => [
|
watch(
|
||||||
|
() => [
|
||||||
policiesStore.equalHourlyWage,
|
policiesStore.equalHourlyWage,
|
||||||
policiesStore.payrollOncostPct,
|
policiesStore.payrollOncostPct,
|
||||||
policiesStore.savingsTargetMonths,
|
policiesStore.savingsTargetMonths,
|
||||||
policiesStore.minCashCushionAmount,
|
policiesStore.minCashCushionAmount,
|
||||||
policiesStore.payPolicy?.relationship
|
policiesStore.payPolicy?.relationship,
|
||||||
], () => {
|
membersStore.payPolicy?.relationship,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
if (!isSyncing) {
|
if (!isSyncing) {
|
||||||
syncFromLegacyStores()
|
syncFromLegacyStores();
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize synchronization
|
// Initialize synchronization
|
||||||
const initSync = async () => {
|
const initSync = async () => {
|
||||||
// Wait for next tick to ensure stores are mounted
|
// Wait for next tick to ensure stores are mounted
|
||||||
await nextTick()
|
await nextTick();
|
||||||
|
|
||||||
// Force store hydration by accessing $state
|
// Force store hydration by accessing $state
|
||||||
if (coopStore.$state) {
|
if (coopStore.$state) {
|
||||||
console.log('🔄 CoopBuilder store hydrated')
|
console.log("🔄 CoopBuilder store hydrated");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay to ensure localStorage is loaded
|
// Small delay to ensure localStorage is loaded
|
||||||
await new Promise(resolve => setTimeout(resolve, 10))
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
// Determine which store has data and sync accordingly
|
// Determine which store has data and sync accordingly
|
||||||
const coopHasData = coopStore.members.length > 0 || coopStore.streams.length > 0
|
const coopHasData =
|
||||||
const legacyHasData = streamsStore.streams.length > 0 || membersStore.members.length > 0
|
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(
|
||||||
console.log('🔄 CoopBuilder members:', coopStore.members.length, 'streams:', coopStore.streams.length)
|
"🔄 InitSync: CoopBuilder data:",
|
||||||
console.log('🔄 Legacy members:', membersStore.members.length, 'streams:', streamsStore.streams.length)
|
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) {
|
if (coopHasData && !legacyHasData) {
|
||||||
console.log('🔄 Syncing CoopBuilder → Legacy')
|
console.log("🔄 Syncing CoopBuilder → Legacy");
|
||||||
syncToLegacyStores()
|
syncToLegacyStores();
|
||||||
} else if (legacyHasData && !coopHasData) {
|
} else if (legacyHasData && !coopHasData) {
|
||||||
console.log('🔄 Syncing Legacy → CoopBuilder')
|
console.log("🔄 Syncing Legacy → CoopBuilder");
|
||||||
syncFromLegacyStores()
|
syncFromLegacyStores();
|
||||||
} else if (coopHasData && legacyHasData) {
|
} 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)
|
// Both have data, ensure consistency by syncing from CoopBuilder (primary source)
|
||||||
syncToLegacyStores()
|
syncToLegacyStores();
|
||||||
} else {
|
} else {
|
||||||
console.log('🔄 No data in either store')
|
console.log("🔄 No data in either store");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up watchers for ongoing sync (only once)
|
// Set up watchers for ongoing sync (only once)
|
||||||
if (!watchersSetup) {
|
if (!watchersSetup) {
|
||||||
setupCoopBuilderWatchers()
|
setupCoopBuilderWatchers();
|
||||||
setupLegacyStoreWatchers()
|
setupLegacyStoreWatchers();
|
||||||
watchersSetup = true
|
watchersSetup = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return promise to allow awaiting
|
// Return promise to allow awaiting
|
||||||
return Promise.resolve()
|
return Promise.resolve();
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get unified streams data (prioritize CoopBuilder) - make reactive
|
// Get unified streams data (prioritize CoopBuilder) - make reactive
|
||||||
const unifiedStreams = computed(() => {
|
const unifiedStreams = computed(() => {
|
||||||
if (coopStore.streams.length > 0) {
|
if (coopStore.streams.length > 0) {
|
||||||
return coopStore.streams.map(stream => ({
|
return coopStore.streams.map((stream) => ({
|
||||||
...stream,
|
...stream,
|
||||||
name: stream.label,
|
name: stream.label,
|
||||||
targetMonthlyAmount: stream.monthly
|
targetMonthlyAmount: stream.monthly,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
return streamsStore.streams
|
return streamsStore.streams;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Get unified members data (prioritize CoopBuilder) - make reactive
|
// Get unified members data (prioritize CoopBuilder) - make reactive
|
||||||
const unifiedMembers = computed(() => {
|
const unifiedMembers = computed(() => {
|
||||||
if (coopStore.members.length > 0) {
|
if (coopStore.members.length > 0) {
|
||||||
return coopStore.members.map(member => ({
|
return coopStore.members.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
displayName: member.name,
|
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
|
// Getter functions for backward compatibility
|
||||||
const getStreams = () => unifiedStreams.value
|
const getStreams = () => unifiedStreams.value;
|
||||||
const getMembers = () => unifiedMembers.value
|
const getMembers = () => unifiedMembers.value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
syncToLegacyStores,
|
syncToLegacyStores,
|
||||||
|
|
@ -255,6 +330,6 @@ export const useStoreSync = () => {
|
||||||
getStreams,
|
getStreams,
|
||||||
getMembers,
|
getMembers,
|
||||||
unifiedStreams,
|
unifiedStreams,
|
||||||
unifiedMembers
|
unifiedMembers,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
112
pages/budget.vue
112
pages/budget.vue
|
|
@ -28,6 +28,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<UButton @click="exportBudget" variant="ghost" size="sm">
|
||||||
Export
|
Export
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
@ -222,7 +225,7 @@
|
||||||
<div class="font-medium flex items-start gap-2">
|
<div class="font-medium flex items-start gap-2">
|
||||||
<UTooltip
|
<UTooltip
|
||||||
v-if="isPayrollItem(item.id)"
|
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' }">
|
:content="{ side: 'top', align: 'start' }">
|
||||||
<span class="cursor-help">{{ item.name }}</span>
|
<span class="cursor-help">{{ item.name }}</span>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
|
@ -315,6 +318,23 @@
|
||||||
{{ formatCurrency(monthlyTotals[month.key]?.net || 0) }}
|
{{ formatCurrency(monthlyTotals[month.key]?.net || 0) }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -622,6 +642,77 @@
|
||||||
<PayrollOncostModal
|
<PayrollOncostModal
|
||||||
v-model:open="showPayrollOncostModal"
|
v-model:open="showPayrollOncostModal"
|
||||||
@save="handlePayrollOncostUpdate" />
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -749,6 +840,7 @@ const activeView = ref("monthly");
|
||||||
const showAddRevenueModal = ref(false);
|
const showAddRevenueModal = ref(false);
|
||||||
const showAddExpenseModal = ref(false);
|
const showAddExpenseModal = ref(false);
|
||||||
const showPayrollOncostModal = ref(false);
|
const showPayrollOncostModal = ref(false);
|
||||||
|
const showCalculationModal = ref(false);
|
||||||
const activeTab = ref(0);
|
const activeTab = ref(0);
|
||||||
const highlightedItemId = ref<string | null>(null);
|
const highlightedItemId = ref<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -863,6 +955,7 @@ const budgetWorksheet = computed(
|
||||||
const groupedRevenue = computed(() => budgetStore.groupedRevenue);
|
const groupedRevenue = computed(() => budgetStore.groupedRevenue);
|
||||||
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
|
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
|
||||||
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
|
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
|
||||||
|
const cumulativeBalances = computed(() => budgetStore.cumulativeBalances);
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
// Removed duplicate onMounted - initialization is now handled above
|
// Removed duplicate onMounted - initialization is now handled above
|
||||||
|
|
@ -1141,6 +1234,23 @@ function getNetIncomeClass(amount: number): string {
|
||||||
return "text-gray-600";
|
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
|
// Payroll oncost handling
|
||||||
function handlePayrollOncostUpdate(newPercentage: number) {
|
function handlePayrollOncostUpdate(newPercentage: number) {
|
||||||
// Update the coop store
|
// 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()
|
const policiesStore = usePoliciesStore()
|
||||||
|
|
||||||
// Update reactive values
|
// Update reactive values
|
||||||
currentMode.value = policiesStore.operatingMode || 'minimum'
|
currentMode.value = 'target' // Simplified - always use target mode
|
||||||
memberCount.value = membersStore.members?.length || 0
|
memberCount.value = membersStore.members?.length || 0
|
||||||
streamCount.value = streamsStore.streams?.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>
|
<div>
|
||||||
<h2 class="text-2xl font-semibold">Compensation</h2>
|
<h2 class="text-2xl font-semibold">Compensation</h2>
|
||||||
<div class="flex items-center gap-2 mt-1">
|
<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">
|
<span class="text-xs font-mono">
|
||||||
Runway: {{ Math.round(metrics.runway) }}mo
|
Runway: {{ Math.round(metrics.runway) }}mo
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -224,7 +221,7 @@ const metrics = computed(() => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use integrated runway calculations that respect operating mode
|
// Use integrated runway calculations that respect operating mode
|
||||||
const currentMode = policiesStore.operatingMode || 'minimum';
|
const currentMode = 'target'; // Always target mode now
|
||||||
const monthlyBurn = getMonthlyBurn(currentMode);
|
const monthlyBurn = getMonthlyBurn(currentMode);
|
||||||
|
|
||||||
// Use actual cash store values with fallback
|
// 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>
|
||||||
247
stores/budget.ts
247
stores/budget.ts
|
|
@ -206,6 +206,28 @@ export const useBudgetStore = defineStore(
|
||||||
return totals;
|
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
|
// LEGACY: Keep for backward compatibility
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const currentYear = currentDate.getFullYear();
|
const currentYear = currentDate.getFullYear();
|
||||||
|
|
@ -242,6 +264,9 @@ export const useBudgetStore = defineStore(
|
||||||
budgetLines.value[period].revenueByStream[streamId] = {};
|
budgetLines.value[period].revenueByStream[streamId] = {};
|
||||||
}
|
}
|
||||||
budgetLines.value[period].revenueByStream[streamId][type] = amount;
|
budgetLines.value[period].revenueByStream[streamId][type] = amount;
|
||||||
|
|
||||||
|
// Refresh payroll to account for revenue changes
|
||||||
|
refreshPayrollInBudget();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wizard-required actions
|
// Wizard-required actions
|
||||||
|
|
@ -316,7 +341,11 @@ export const useBudgetStore = defineStore(
|
||||||
|
|
||||||
// Refresh payroll in budget when policy or operating mode changes
|
// Refresh payroll in budget when policy or operating mode changes
|
||||||
function refreshPayrollInBudget() {
|
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 coopStore = useCoopBuilderStore();
|
||||||
const basePayrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-base");
|
const basePayrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-base");
|
||||||
|
|
@ -330,13 +359,11 @@ export const useBudgetStore = defineStore(
|
||||||
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
||||||
const hourlyWage = coopStore.equalHourlyWage || 0;
|
const hourlyWage = coopStore.equalHourlyWage || 0;
|
||||||
const oncostPct = coopStore.payrollOncostPct || 0;
|
const oncostPct = coopStore.payrollOncostPct || 0;
|
||||||
const basePayrollBudget = totalHours * hourlyWage;
|
|
||||||
|
|
||||||
// Declare today once for the entire function
|
// Keep theoretical maximum for reference
|
||||||
const today = new Date();
|
const theoreticalMaxPayroll = totalHours * hourlyWage;
|
||||||
|
|
||||||
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
// Policy for allocation
|
||||||
// Use policy-driven allocation
|
|
||||||
const payPolicy = {
|
const payPolicy = {
|
||||||
relationship: coopStore.policy.relationship,
|
relationship: coopStore.policy.relationship,
|
||||||
roleBands: coopStore.policy.roleBands
|
roleBands: coopStore.policy.roleBands
|
||||||
|
|
@ -350,70 +377,146 @@ export const useBudgetStore = defineStore(
|
||||||
hoursPerMonth: m.hoursPerMonth || 0
|
hoursPerMonth: m.hoursPerMonth || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
// Calculate payroll for each month individually using cumulative balance approach
|
||||||
|
const refreshToday = new Date();
|
||||||
|
let totalAnnualPayroll = 0;
|
||||||
|
let totalAnnualOncosts = 0;
|
||||||
|
let runningBalance = 0; // Track cumulative cash balance
|
||||||
|
|
||||||
// Sum the allocated payroll amounts
|
for (let i = 0; i < 12; i++) {
|
||||||
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
const date = new Date(refreshToday.getFullYear(), refreshToday.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
// Get revenue for this specific month
|
||||||
|
const monthRevenue = budgetWorksheet.value.revenue.reduce((sum, item) => {
|
||||||
|
return sum + (item.monthlyValues?.[monthKey] || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Get non-payroll expenses for this specific month
|
||||||
|
const nonPayrollExpenses = budgetWorksheet.value.expenses.reduce((sum, item) => {
|
||||||
|
// Exclude payroll items from the calculation
|
||||||
|
if (item.id === "expense-payroll-base" || item.id === "expense-payroll-oncosts" || item.id === "expense-payroll") {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
return sum + (item.monthlyValues?.[monthKey] || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calculate normal payroll based on policy and wage (theoretical maximum)
|
||||||
|
const theoreticalPayrollBudget = totalHours * hourlyWage;
|
||||||
|
|
||||||
|
console.log(`Month ${monthKey}: Revenue=${monthRevenue}, NonPayrollExp=${nonPayrollExpenses}, TheoreticalPayroll=${theoreticalPayrollBudget}, RunningBalance=${runningBalance}`);
|
||||||
|
|
||||||
|
// Only allocate payroll if members exist
|
||||||
|
if (coopStore.members.length > 0) {
|
||||||
|
// First, calculate payroll using normal policy allocation
|
||||||
|
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, theoreticalPayrollBudget);
|
||||||
|
|
||||||
|
// Sum the allocated payroll amounts for this month
|
||||||
|
let monthlyAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
||||||
return sum + (m.monthlyPayPlanned || 0);
|
return sum + (m.monthlyPayPlanned || 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Update monthly values for base payroll
|
let monthlyOncostAmount = monthlyAllocatedPayroll * (oncostPct / 100);
|
||||||
|
|
||||||
if (basePayrollIndex !== -1) {
|
// Calculate projected balance after this month's expenses and payroll
|
||||||
// Update base payroll entry
|
const totalExpensesWithPayroll = nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount;
|
||||||
for (let i = 0; i < 12; i++) {
|
const monthlyNetIncome = monthRevenue - totalExpensesWithPayroll;
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
const projectedBalance = runningBalance + monthlyNetIncome;
|
||||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = totalAllocatedPayroll;
|
// Check if this payroll would push cumulative balance below minimum threshold
|
||||||
|
const minThreshold = coopStore.minCashThreshold || 0;
|
||||||
|
if (projectedBalance < minThreshold) {
|
||||||
|
// Calculate maximum sustainable payroll to maintain minimum cash threshold
|
||||||
|
const targetNetIncome = minThreshold - runningBalance;
|
||||||
|
const availableForExpenses = monthRevenue - targetNetIncome;
|
||||||
|
const maxSustainablePayroll = Math.max(0, (availableForExpenses - nonPayrollExpenses) / (1 + oncostPct / 100));
|
||||||
|
|
||||||
|
console.log(`Month ${monthKey}: Reducing payroll from ${monthlyAllocatedPayroll} to ${maxSustainablePayroll} to maintain minimum cash threshold of ${minThreshold}`);
|
||||||
|
|
||||||
|
// Proportionally reduce all member allocations
|
||||||
|
if (monthlyAllocatedPayroll > 0) {
|
||||||
|
const reductionRatio = maxSustainablePayroll / monthlyAllocatedPayroll;
|
||||||
|
allocatedMembers.forEach(m => {
|
||||||
|
m.monthlyPayPlanned = (m.monthlyPayPlanned || 0) * reductionRatio;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update annual values for base payroll
|
monthlyAllocatedPayroll = maxSustainablePayroll;
|
||||||
|
monthlyOncostAmount = monthlyAllocatedPayroll * (oncostPct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update running balance with actual net income after payroll adjustments
|
||||||
|
const actualNetIncome = monthRevenue - (nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount);
|
||||||
|
runningBalance += actualNetIncome;
|
||||||
|
|
||||||
|
// Update this specific month's payroll values
|
||||||
|
if (basePayrollIndex !== -1) {
|
||||||
|
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = monthlyAllocatedPayroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oncostIndex !== -1) {
|
||||||
|
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = monthlyOncostAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy single payroll entry
|
||||||
|
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
||||||
|
const combinedPayroll = monthlyAllocatedPayroll * (1 + oncostPct / 100);
|
||||||
|
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = combinedPayroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate for annual totals
|
||||||
|
totalAnnualPayroll += monthlyAllocatedPayroll;
|
||||||
|
totalAnnualOncosts += monthlyOncostAmount;
|
||||||
|
} else {
|
||||||
|
// No members or theoretical payroll is 0 - set payroll to 0
|
||||||
|
if (basePayrollIndex !== -1) {
|
||||||
|
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oncostIndex !== -1) {
|
||||||
|
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
||||||
|
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update running balance with net income (revenue - non-payroll expenses)
|
||||||
|
const actualNetIncome = monthRevenue - nonPayrollExpenses;
|
||||||
|
runningBalance += actualNetIncome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update annual values based on actual totals
|
||||||
|
if (basePayrollIndex !== -1) {
|
||||||
budgetWorksheet.value.expenses[basePayrollIndex].values = {
|
budgetWorksheet.value.expenses[basePayrollIndex].values = {
|
||||||
year1: { best: totalAllocatedPayroll * 12, worst: totalAllocatedPayroll * 8, mostLikely: totalAllocatedPayroll * 12 },
|
year1: { best: totalAnnualPayroll * 1.2, worst: totalAnnualPayroll * 0.8, mostLikely: totalAnnualPayroll },
|
||||||
year2: { best: totalAllocatedPayroll * 14, worst: totalAllocatedPayroll * 10, mostLikely: totalAllocatedPayroll * 13 },
|
year2: { best: totalAnnualPayroll * 1.3, worst: totalAnnualPayroll * 0.9, mostLikely: totalAnnualPayroll * 1.1 },
|
||||||
year3: { best: totalAllocatedPayroll * 16, worst: totalAllocatedPayroll * 12, mostLikely: totalAllocatedPayroll * 15 }
|
year3: { best: totalAnnualPayroll * 1.5, worst: totalAnnualPayroll, mostLikely: totalAnnualPayroll * 1.25 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oncostIndex !== -1) {
|
if (oncostIndex !== -1) {
|
||||||
// Update oncost entry
|
|
||||||
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
|
|
||||||
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
|
||||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = oncostAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update name with current percentage
|
// Update name with current percentage
|
||||||
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
|
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
|
||||||
|
|
||||||
// Update annual values for oncosts
|
|
||||||
budgetWorksheet.value.expenses[oncostIndex].values = {
|
budgetWorksheet.value.expenses[oncostIndex].values = {
|
||||||
year1: { best: oncostAmount * 12, worst: oncostAmount * 8, mostLikely: oncostAmount * 12 },
|
year1: { best: totalAnnualOncosts * 1.2, worst: totalAnnualOncosts * 0.8, mostLikely: totalAnnualOncosts },
|
||||||
year2: { best: oncostAmount * 14, worst: oncostAmount * 10, mostLikely: oncostAmount * 13 },
|
year2: { best: totalAnnualOncosts * 1.3, worst: totalAnnualOncosts * 0.9, mostLikely: totalAnnualOncosts * 1.1 },
|
||||||
year3: { best: oncostAmount * 16, worst: oncostAmount * 12, mostLikely: oncostAmount * 15 }
|
year3: { best: totalAnnualOncosts * 1.5, worst: totalAnnualOncosts, mostLikely: totalAnnualOncosts * 1.25 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle legacy single payroll entry (update to combined amount for backwards compatibility)
|
// Handle legacy single payroll entry annual values
|
||||||
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
||||||
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
|
const totalCombined = totalAnnualPayroll + totalAnnualOncosts;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetWorksheet.value.expenses[legacyIndex].values = {
|
budgetWorksheet.value.expenses[legacyIndex].values = {
|
||||||
year1: { best: monthlyPayroll * 12, worst: monthlyPayroll * 8, mostLikely: monthlyPayroll * 12 },
|
year1: { best: totalCombined * 1.2, worst: totalCombined * 0.8, mostLikely: totalCombined },
|
||||||
year2: { best: monthlyPayroll * 14, worst: monthlyPayroll * 10, mostLikely: monthlyPayroll * 13 },
|
year2: { best: totalCombined * 1.3, worst: totalCombined * 0.9, mostLikely: totalCombined * 1.1 },
|
||||||
year3: { best: monthlyPayroll * 16, worst: monthlyPayroll * 12, mostLikely: monthlyPayroll * 15 }
|
year3: { best: totalCombined * 1.5, worst: totalCombined, mostLikely: totalCombined * 1.25 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Force reinitialize - always reload from wizard data
|
// Force reinitialize - always reload from wizard data
|
||||||
async function forceInitializeFromWizardData() {
|
async function forceInitializeFromWizardData() {
|
||||||
|
|
@ -456,8 +559,8 @@ export const useBudgetStore = defineStore(
|
||||||
budgetWorksheet.value.revenue = [];
|
budgetWorksheet.value.revenue = [];
|
||||||
budgetWorksheet.value.expenses = [];
|
budgetWorksheet.value.expenses = [];
|
||||||
|
|
||||||
// Declare today once for the entire function
|
// Declare date once for the entire function
|
||||||
const today = new Date();
|
const initDate = new Date();
|
||||||
|
|
||||||
// Add revenue streams from wizard (but don't auto-load fixtures)
|
// 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
|
// 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
|
// Create monthly values - split the annual target evenly across 12 months
|
||||||
const monthlyValues: Record<string, number> = {};
|
const monthlyValues: Record<string, number> = {};
|
||||||
for (let i = 0; i < 12; i++) {
|
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(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
date.getMonth() + 1
|
date.getMonth() + 1
|
||||||
).padStart(2, "0")}`;
|
).padStart(2, "0")}`;
|
||||||
|
|
@ -531,16 +634,27 @@ export const useBudgetStore = defineStore(
|
||||||
const hourlyWage = coopStore.equalHourlyWage || 0;
|
const hourlyWage = coopStore.equalHourlyWage || 0;
|
||||||
const oncostPct = coopStore.payrollOncostPct || 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("=== PAYROLL CALCULATION DEBUG ===");
|
||||||
console.log("Total hours:", totalHours);
|
console.log("Total hours:", totalHours);
|
||||||
console.log("Hourly wage:", hourlyWage);
|
console.log("Hourly wage:", hourlyWage);
|
||||||
console.log("Oncost %:", oncostPct);
|
console.log("Oncost %:", oncostPct);
|
||||||
console.log("Operating mode:", coopStore.operatingMode);
|
|
||||||
console.log("Policy relationship:", coopStore.policy.relationship);
|
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
|
// Use revenue-constrained budget
|
||||||
const basePayrollBudget = totalHours * hourlyWage;
|
const basePayrollBudget = availableForPayroll;
|
||||||
console.log("Base payroll budget:", basePayrollBudget);
|
console.log("Using payroll budget:", basePayrollBudget);
|
||||||
|
|
||||||
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
||||||
// Use policy-driven allocation to get actual member pay amounts
|
// Use policy-driven allocation to get actual member pay amounts
|
||||||
|
|
@ -579,7 +693,7 @@ export const useBudgetStore = defineStore(
|
||||||
// Create monthly values for payroll
|
// Create monthly values for payroll
|
||||||
const monthlyValues: Record<string, number> = {};
|
const monthlyValues: Record<string, number> = {};
|
||||||
for (let i = 0; i < 12; i++) {
|
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(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
date.getMonth() + 1
|
date.getMonth() + 1
|
||||||
).padStart(2, "0")}`;
|
).padStart(2, "0")}`;
|
||||||
|
|
@ -591,9 +705,9 @@ export const useBudgetStore = defineStore(
|
||||||
// Create base payroll monthly values (without oncosts)
|
// Create base payroll monthly values (without oncosts)
|
||||||
const baseMonthlyValues: Record<string, number> = {};
|
const baseMonthlyValues: Record<string, number> = {};
|
||||||
const oncostMonthlyValues: 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++) {
|
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(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
date.getMonth() + 1
|
date.getMonth() + 1
|
||||||
).padStart(2, "0")}`;
|
).padStart(2, "0")}`;
|
||||||
|
|
@ -677,7 +791,7 @@ export const useBudgetStore = defineStore(
|
||||||
// Create monthly values for overhead costs
|
// Create monthly values for overhead costs
|
||||||
const monthlyValues: Record<string, number> = {};
|
const monthlyValues: Record<string, number> = {};
|
||||||
for (let i = 0; i < 12; i++) {
|
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(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
date.getMonth() + 1
|
date.getMonth() + 1
|
||||||
).padStart(2, "0")}`;
|
).padStart(2, "0")}`;
|
||||||
|
|
@ -809,7 +923,7 @@ export const useBudgetStore = defineStore(
|
||||||
console.log("Migrating item to monthly values:", item.name);
|
console.log("Migrating item to monthly values:", item.name);
|
||||||
item.monthlyValues = {};
|
item.monthlyValues = {};
|
||||||
for (let i = 0; i < 12; i++) {
|
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(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
date.getMonth() + 1
|
date.getMonth() + 1
|
||||||
).padStart(2, "0")}`;
|
).padStart(2, "0")}`;
|
||||||
|
|
@ -834,6 +948,10 @@ export const useBudgetStore = defineStore(
|
||||||
);
|
);
|
||||||
|
|
||||||
isInitialized.value = true;
|
isInitialized.value = true;
|
||||||
|
|
||||||
|
// Trigger payroll refresh after initialization
|
||||||
|
console.log("Triggering initial payroll refresh after initialization");
|
||||||
|
refreshPayrollInBudget();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing budget from wizard data:", 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('Updated item.monthlyValues:', item.monthlyValues);
|
||||||
console.log('Item updated:', item.name);
|
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 {
|
} else {
|
||||||
console.error('Item not found:', { category, itemId, availableItems: items.map(i => ({id: i.id, name: i.name})) });
|
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
|
// Create empty monthly values for next 12 months
|
||||||
const monthlyValues = {};
|
const monthlyValues = {};
|
||||||
const today = new Date();
|
const addDate = new Date();
|
||||||
for (let i = 0; i < 12; i++) {
|
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(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
date.getMonth() + 1
|
date.getMonth() + 1
|
||||||
).padStart(2, "0")}`;
|
).padStart(2, "0")}`;
|
||||||
|
|
@ -969,6 +1095,7 @@ export const useBudgetStore = defineStore(
|
||||||
budgetWorksheet,
|
budgetWorksheet,
|
||||||
budgetTotals,
|
budgetTotals,
|
||||||
monthlyTotals,
|
monthlyTotals,
|
||||||
|
cumulativeBalances,
|
||||||
revenueCategories,
|
revenueCategories,
|
||||||
expenseCategories,
|
expenseCategories,
|
||||||
revenueSubcategories,
|
revenueSubcategories,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import { defineStore } from "pinia";
|
||||||
|
|
||||||
export const useCoopBuilderStore = defineStore("coop", {
|
export const useCoopBuilderStore = defineStore("coop", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
operatingMode: "min" as "min" | "target",
|
|
||||||
|
|
||||||
// Currency preference
|
// Currency preference
|
||||||
currency: "EUR" as string,
|
currency: "EUR" as string,
|
||||||
|
|
||||||
|
|
@ -51,6 +49,9 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
},
|
},
|
||||||
equalHourlyWage: 50,
|
equalHourlyWage: 50,
|
||||||
payrollOncostPct: 25,
|
payrollOncostPct: 25,
|
||||||
|
|
||||||
|
// Cash flow management
|
||||||
|
minCashThreshold: 5000, // Minimum cash balance to maintain
|
||||||
savingsTargetMonths: 6,
|
savingsTargetMonths: 6,
|
||||||
minCashCushion: 10000,
|
minCashCushion: 10000,
|
||||||
|
|
||||||
|
|
@ -152,11 +153,6 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
this.milestones = this.milestones.filter((m) => m.id !== id);
|
this.milestones = this.milestones.filter((m) => m.id !== id);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Operating mode
|
|
||||||
setOperatingMode(mode: "min" | "target") {
|
|
||||||
this.operatingMode = mode;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Scenario
|
// Scenario
|
||||||
setScenario(
|
setScenario(
|
||||||
scenario: "current" | "start-production" | "custom"
|
scenario: "current" | "start-production" | "custom"
|
||||||
|
|
@ -182,6 +178,10 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
this.payrollOncostPct = pct;
|
this.payrollOncostPct = pct;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setMinCashThreshold(amount: number) {
|
||||||
|
this.minCashThreshold = amount;
|
||||||
|
},
|
||||||
|
|
||||||
setCurrency(currency: string) {
|
setCurrency(currency: string) {
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
},
|
},
|
||||||
|
|
@ -245,7 +245,6 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
clearAll() {
|
clearAll() {
|
||||||
// Reset ALL state to initial empty values
|
// Reset ALL state to initial empty values
|
||||||
this._wasCleared = true;
|
this._wasCleared = true;
|
||||||
this.operatingMode = "min";
|
|
||||||
this.currency = "EUR";
|
this.currency = "EUR";
|
||||||
this.members = [];
|
this.members = [];
|
||||||
this.streams = [];
|
this.streams = [];
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ export const usePoliciesStore = defineStore(
|
||||||
const payrollOncostPct = ref(0);
|
const payrollOncostPct = ref(0);
|
||||||
const savingsTargetMonths = ref(0);
|
const savingsTargetMonths = ref(0);
|
||||||
const minCashCushionAmount = ref(0);
|
const minCashCushionAmount = ref(0);
|
||||||
const operatingMode = ref<'minimum' | 'target'>('minimum');
|
|
||||||
|
|
||||||
// Pay policy for member needs coverage
|
// Pay policy for member needs coverage
|
||||||
const payPolicy = ref({
|
const payPolicy = ref({
|
||||||
|
|
@ -134,7 +133,6 @@ export const usePoliciesStore = defineStore(
|
||||||
payrollOncostPct.value = 0;
|
payrollOncostPct.value = 0;
|
||||||
savingsTargetMonths.value = 0;
|
savingsTargetMonths.value = 0;
|
||||||
minCashCushionAmount.value = 0;
|
minCashCushionAmount.value = 0;
|
||||||
operatingMode.value = 'minimum';
|
|
||||||
payPolicy.value = { relationship: 'equal-pay', roleBands: [] };
|
payPolicy.value = { relationship: 'equal-pay', roleBands: [] };
|
||||||
deferredCapHoursPerQtr.value = 0;
|
deferredCapHoursPerQtr.value = 0;
|
||||||
deferredSunsetMonths.value = 0;
|
deferredSunsetMonths.value = 0;
|
||||||
|
|
@ -155,7 +153,6 @@ export const usePoliciesStore = defineStore(
|
||||||
payrollOncostPct,
|
payrollOncostPct,
|
||||||
savingsTargetMonths,
|
savingsTargetMonths,
|
||||||
minCashCushionAmount,
|
minCashCushionAmount,
|
||||||
operatingMode,
|
|
||||||
payPolicy,
|
payPolicy,
|
||||||
deferredCapHoursPerQtr,
|
deferredCapHoursPerQtr,
|
||||||
deferredSunsetMonths,
|
deferredSunsetMonths,
|
||||||
|
|
@ -190,7 +187,6 @@ export const usePoliciesStore = defineStore(
|
||||||
"payrollOncostPct",
|
"payrollOncostPct",
|
||||||
"savingsTargetMonths",
|
"savingsTargetMonths",
|
||||||
"minCashCushionAmount",
|
"minCashCushionAmount",
|
||||||
"operatingMode",
|
|
||||||
"payPolicy",
|
"payPolicy",
|
||||||
"deferredCapHoursPerQtr",
|
"deferredCapHoursPerQtr",
|
||||||
"deferredSunsetMonths",
|
"deferredSunsetMonths",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue