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
|
|
@ -1,295 +0,0 @@
|
|||
<template>
|
||||
<div class="w-full h-full">
|
||||
<canvas
|
||||
ref="chartCanvas"
|
||||
class="w-full h-full"
|
||||
:width="width"
|
||||
:height="height"
|
||||
></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ChartData {
|
||||
month: number
|
||||
monthName: string
|
||||
revenue: number
|
||||
expenses: number
|
||||
netCashFlow: number
|
||||
runningBalance: number
|
||||
oneOffEvents?: Array<{ type: string, amount: number, name: string }>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ChartData[]
|
||||
viewMode: 'combined' | 'runway' | 'cashflow'
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 800,
|
||||
height: 320
|
||||
})
|
||||
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
function drawChart() {
|
||||
if (!chartCanvas.value || !props.data.length) return
|
||||
|
||||
const canvas = chartCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const padding = 60
|
||||
const chartWidth = canvas.width - padding * 2
|
||||
const chartHeight = canvas.height - padding * 2
|
||||
|
||||
const dataLength = props.data.length
|
||||
const scaleX = chartWidth / (dataLength - 1)
|
||||
|
||||
// Calculate data ranges based on view mode
|
||||
let maxValue = 0
|
||||
let minValue = 0
|
||||
|
||||
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
|
||||
const balances = props.data.map(d => d.runningBalance)
|
||||
maxValue = Math.max(maxValue, ...balances)
|
||||
minValue = Math.min(minValue, ...balances)
|
||||
}
|
||||
|
||||
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
|
||||
const revenues = props.data.map(d => d.revenue)
|
||||
const expenses = props.data.map(d => d.expenses)
|
||||
maxValue = Math.max(maxValue, ...revenues, ...expenses)
|
||||
}
|
||||
|
||||
// Add some padding to the range
|
||||
const range = maxValue - minValue
|
||||
maxValue += range * 0.1
|
||||
minValue -= range * 0.1
|
||||
|
||||
// Ensure we show zero line
|
||||
if (minValue > 0) minValue = Math.min(0, minValue)
|
||||
if (maxValue < 0) maxValue = Math.max(0, maxValue)
|
||||
|
||||
const valueRange = maxValue - minValue
|
||||
const scaleY = valueRange > 0 ? chartHeight / valueRange : 1
|
||||
|
||||
// Helper functions
|
||||
const getX = (index: number) => padding + (index * scaleX)
|
||||
const getY = (value: number) => padding + chartHeight - ((value - minValue) * scaleY)
|
||||
|
||||
// Draw background for negative values
|
||||
if (minValue < 0) {
|
||||
const zeroY = getY(0)
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
|
||||
ctx.fillRect(padding, zeroY, chartWidth, canvas.height - zeroY - padding)
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
drawGrid(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange, scaleY)
|
||||
|
||||
// Draw data based on view mode
|
||||
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
|
||||
drawCashFlowData(ctx, getX, getY)
|
||||
}
|
||||
|
||||
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
|
||||
drawRunwayData(ctx, getX, getY)
|
||||
}
|
||||
|
||||
// Draw one-off events
|
||||
drawOneOffEvents(ctx, getX, getY)
|
||||
|
||||
// Draw axis labels
|
||||
drawAxisLabels(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange)
|
||||
}
|
||||
|
||||
function drawGrid(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number, scaleY: number) {
|
||||
ctx.strokeStyle = '#e5e7eb'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// Horizontal grid lines
|
||||
const gridLines = 5
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = padding + (chartHeight / gridLines) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, y)
|
||||
ctx.lineTo(padding + chartWidth, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Vertical grid lines (every 3 months)
|
||||
for (let i = 0; i < props.data.length; i += 3) {
|
||||
const x = padding + (i * chartWidth) / (props.data.length - 1)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Zero line if applicable
|
||||
if (minValue < 0 && maxValue > 0) {
|
||||
const zeroY = padding + chartHeight - ((0 - minValue) * scaleY)
|
||||
ctx.strokeStyle = '#6b7280'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, zeroY)
|
||||
ctx.lineTo(padding + chartWidth, zeroY)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function drawCashFlowData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
// Draw revenue line
|
||||
ctx.strokeStyle = '#3b82f6'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.revenue)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Draw expenses line
|
||||
ctx.strokeStyle = '#ef4444'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.expenses)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawRunwayData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
// Draw balance line
|
||||
ctx.strokeStyle = '#10b981'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.runningBalance)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Mark zero crossing points
|
||||
ctx.fillStyle = '#ef4444'
|
||||
for (let i = 1; i < props.data.length; i++) {
|
||||
const prev = props.data[i - 1].runningBalance
|
||||
const current = props.data[i].runningBalance
|
||||
|
||||
if (prev >= 0 && current < 0) {
|
||||
// Crossed from positive to negative
|
||||
const x = getX(i)
|
||||
const y = getY(0)
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add warning label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Out of Money', x, y - 10)
|
||||
ctx.fillStyle = '#ef4444' // Reset for next point
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawOneOffEvents(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
if (dataPoint.oneOffEvents && dataPoint.oneOffEvents.length > 0) {
|
||||
const x = getX(index)
|
||||
let yOffset = 0
|
||||
|
||||
dataPoint.oneOffEvents.forEach(event => {
|
||||
const isIncome = event.type === 'income'
|
||||
const baseY = getY(isIncome ? dataPoint.revenue : dataPoint.expenses)
|
||||
const y = baseY + yOffset
|
||||
|
||||
// Draw event marker
|
||||
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add tooltip-style label
|
||||
if (event.amount > 0) {
|
||||
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
const shortName = event.name.length > 15 ? event.name.substring(0, 12) + '...' : event.name
|
||||
ctx.fillText(shortName, x, y - 8)
|
||||
}
|
||||
|
||||
yOffset += 15
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawAxisLabels(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number) {
|
||||
ctx.fillStyle = '#6b7280'
|
||||
ctx.font = '12px sans-serif'
|
||||
|
||||
// X-axis labels (months)
|
||||
ctx.textAlign = 'center'
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
if (index % 2 === 0) { // Show every other month to avoid crowding
|
||||
const x = padding + (index * chartWidth) / (props.data.length - 1)
|
||||
ctx.fillText(monthNames[dataPoint.month], x, padding + chartHeight + 20)
|
||||
}
|
||||
})
|
||||
|
||||
// Y-axis labels (values)
|
||||
ctx.textAlign = 'right'
|
||||
const gridLines = 5
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const value = minValue + (valueRange / gridLines) * (gridLines - i)
|
||||
const y = padding + (chartHeight / gridLines) * i + 4
|
||||
ctx.fillText(formatShort(value), padding - 10, y)
|
||||
}
|
||||
}
|
||||
|
||||
function formatShort(value: number): string {
|
||||
if (Math.abs(value) >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (Math.abs(value) >= 1000) {
|
||||
return `${(value / 1000).toFixed(0)}k`
|
||||
}
|
||||
return Math.round(value).toString()
|
||||
}
|
||||
|
||||
// Watch for changes and redraw
|
||||
watch(() => [props.data, props.viewMode], () => {
|
||||
nextTick(() => drawChart())
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => drawChart())
|
||||
})
|
||||
</script>
|
||||
|
|
@ -31,20 +31,15 @@ const coopBuilderItems = [
|
|||
name: "Setup Wizard",
|
||||
path: "/coop-builder",
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
name: "Compensation",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
path: "/budget",
|
||||
},
|
||||
{
|
||||
id: "cash-flow",
|
||||
name: "Cash Flow",
|
||||
path: "/cash-flow",
|
||||
id: "project-budget",
|
||||
name: "Project Budget",
|
||||
path: "/project-budget",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
263
components/ProjectBudgetEstimate.vue
Normal file
263
components/ProjectBudgetEstimate.vue
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<template>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white border-4 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b-4 border-black bg-yellow-300">
|
||||
<h2 class="text-xl font-bold mb-2">
|
||||
If your team worked full-time for {{ durationMonths }} months, it would cost about {{ currency(projectBase) }}.
|
||||
</h2>
|
||||
<p class="text-sm">
|
||||
Based on sustainable payroll from available revenue after overhead costs.
|
||||
</p>
|
||||
<p v-if="bufferEnabled" class="text-sm mt-2 font-medium">
|
||||
Adding a 30% buffer for delays brings it to {{ currency(projectWithBuffer) }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="p-6 border-b-4 border-black bg-gray-100">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="duration" class="font-bold text-sm">Duration (months):</label>
|
||||
<input
|
||||
id="duration"
|
||||
v-model.number="durationMonths"
|
||||
type="number"
|
||||
min="6"
|
||||
max="36"
|
||||
class="w-20 px-2 py-1 border-2 border-black font-mono"
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="buffer"
|
||||
v-model="bufferEnabled"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 border-2 border-black"
|
||||
>
|
||||
<label for="buffer" class="font-bold text-sm">Add 30% buffer</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Summary -->
|
||||
<div class="p-6 border-b-4 border-black">
|
||||
<ul class="space-y-2">
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="font-bold">Monthly team cost:</span>
|
||||
<span class="font-mono">{{ currency(monthlyCost) }}</span>
|
||||
</li>
|
||||
<li class="text-xs text-gray-600 -mt-1">
|
||||
Sustainable payroll + {{ percent(props.oncostRate) }} benefits
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="font-bold">Project budget:</span>
|
||||
<span class="font-mono">{{ currency(projectBase) }}</span>
|
||||
</li>
|
||||
<li v-if="bufferEnabled" class="flex justify-between items-center border-t-2 border-black pt-2">
|
||||
<span class="font-bold">With buffer:</span>
|
||||
<span class="font-mono text-lg">{{ currency(projectWithBuffer) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Break-Even Sketch -->
|
||||
<details class="group">
|
||||
<summary class="p-6 border-b-4 border-black bg-blue-200 cursor-pointer font-bold hover:bg-blue-300 transition-colors">
|
||||
<span>Break-Even Sketch (optional)</span>
|
||||
</summary>
|
||||
<div class="p-6 border-b-4 border-black bg-blue-50">
|
||||
<!-- Inputs -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div>
|
||||
<label for="price" class="block font-bold text-sm mb-1">Price per copy:</label>
|
||||
<div class="flex items-center">
|
||||
<span class="font-mono">$</span>
|
||||
<input
|
||||
id="price"
|
||||
v-model.number="price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="flex-1 ml-1 px-2 py-1 border-2 border-black font-mono"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="storeCut" class="block font-bold text-sm mb-1">Store cut:</label>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="storeCut"
|
||||
v-model.number="storeCutInput"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
class="w-16 px-2 py-1 border-2 border-black font-mono"
|
||||
>
|
||||
<span class="ml-1 font-mono">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reviewToSales" class="block font-bold text-sm mb-1">Sales per review:</label>
|
||||
<input
|
||||
id="reviewToSales"
|
||||
v-model.number="reviewToSales"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-20 px-2 py-1 border-2 border-black font-mono"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outputs -->
|
||||
<ul class="space-y-2 mb-4">
|
||||
<li>
|
||||
At {{ currency(price) }} per copy after store fees, you'd need about
|
||||
<strong>{{ unitsToBreakEven.toLocaleString() }} sales</strong> to cover this budget.
|
||||
</li>
|
||||
<li>
|
||||
That's roughly <strong>{{ reviewsToBreakEven.toLocaleString() }} Steam reviews</strong>
|
||||
(≈ {{ reviewToSales }} sales per review).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-xs text-gray-600">
|
||||
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not included.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Viability Check -->
|
||||
<div class="p-6 border-b-4 border-black">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="checkbox" class="w-4 h-4 mt-0.5 border-2 border-black">
|
||||
<label class="text-sm">Does this plan pay everyone fairly if the project runs late?</label>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="checkbox" class="w-4 h-4 mt-0.5 border-2 border-black">
|
||||
<label class="text-sm">Could this project plausibly earn 2–4× its cost?</label>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="checkbox" class="w-4 h-4 mt-0.5 border-2 border-black">
|
||||
<label class="text-sm">Is this budget competitive for games of this size?</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guidance -->
|
||||
<div v-if="guidanceText" class="p-4 bg-gray-50 text-sm text-gray-600">
|
||||
{{ guidanceText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Member {
|
||||
name: string
|
||||
hoursPerMonth: number
|
||||
hourlyRate?: number
|
||||
monthlyPay?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
members: Member[]
|
||||
oncostRate?: number
|
||||
durationMonths?: number
|
||||
defaultPrice?: number
|
||||
storeCut?: number
|
||||
reviewToSales?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
oncostRate: 0.25,
|
||||
durationMonths: 12,
|
||||
defaultPrice: 20,
|
||||
storeCut: 0.30,
|
||||
reviewToSales: 57,
|
||||
})
|
||||
|
||||
// Local state
|
||||
const durationMonths = ref(props.durationMonths)
|
||||
const bufferEnabled = ref(false)
|
||||
const price = ref(props.defaultPrice)
|
||||
const storeCutInput = ref(props.storeCut * 100) // Convert to percentage for input
|
||||
const reviewToSales = ref(props.reviewToSales)
|
||||
|
||||
// Calculations
|
||||
const baseMonthlyPayroll = computed(() => {
|
||||
return props.members.reduce((sum, member) => {
|
||||
// Use monthlyPay if available, otherwise calculate from hourlyRate
|
||||
const memberCost = member.monthlyPay ?? (member.hoursPerMonth * (member.hourlyRate ?? 0))
|
||||
return sum + memberCost
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const monthlyCost = computed(() => {
|
||||
return baseMonthlyPayroll.value * (1 + props.oncostRate)
|
||||
})
|
||||
|
||||
const projectBase = computed(() => {
|
||||
return monthlyCost.value * durationMonths.value
|
||||
})
|
||||
|
||||
const projectWithBuffer = computed(() => {
|
||||
return projectBase.value * 1.30
|
||||
})
|
||||
|
||||
const projectBudget = computed(() => {
|
||||
return bufferEnabled.value ? projectWithBuffer.value : projectBase.value
|
||||
})
|
||||
|
||||
const netPerUnit = computed(() => {
|
||||
return price.value * (1 - (storeCutInput.value / 100))
|
||||
})
|
||||
|
||||
const unitsToBreakEven = computed(() => {
|
||||
return Math.ceil(projectBudget.value / Math.max(netPerUnit.value, 0.01))
|
||||
})
|
||||
|
||||
const reviewsToBreakEven = computed(() => {
|
||||
return Math.ceil(unitsToBreakEven.value / Math.max(reviewToSales.value, 1))
|
||||
})
|
||||
|
||||
const guidanceText = computed(() => {
|
||||
if (bufferEnabled.value) {
|
||||
return "This sketch includes a safety buffer."
|
||||
} else if (durationMonths.value * monthlyCost.value >= 1) {
|
||||
return "Consider adding a small buffer so the team isn't squeezed by delays."
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// Utility functions
|
||||
const currency = (n: number): string => {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
const percent = (n: number): string => {
|
||||
return `${Math.round(n * 100)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Test with sample props:
|
||||
|
||||
const sampleMembers = [
|
||||
{ name: 'Alice', hoursPerMonth: 160, hourlyRate: 25 },
|
||||
{ name: 'Bob', hoursPerMonth: 120, hourlyRate: 30 },
|
||||
{ name: 'Carol', hoursPerMonth: 80, hourlyRate: 35 }
|
||||
]
|
||||
|
||||
<ProjectBudgetEstimate
|
||||
:members="sampleMembers"
|
||||
:duration-months="18"
|
||||
:default-price="25"
|
||||
/>
|
||||
-->
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header with key metrics -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
13-Week Cash Flow
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Weekly cash flow analysis with one-off transactions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Key metrics cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold" :class="runwayWeeks >= 8 ? 'text-green-600' : runwayWeeks >= 4 ? 'text-yellow-600' : 'text-red-600'">
|
||||
{{ runwayWeeks.toFixed(1) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Weeks Runway</div>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
{{ getRunwayStatus(runwayWeeks) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatCurrency(weeklyBurn) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Weekly Burn</div>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
Average outflow
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold" :class="finalBalance >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ formatCurrency(finalBalance) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Week 13 Balance</div>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
End of quarter
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Alerts panel -->
|
||||
<div v-if="alerts.length > 0" class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="mr-2 text-yellow-500" />
|
||||
Cash Flow Alerts
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
class="p-4 rounded-lg border"
|
||||
:class="{
|
||||
'border-red-200 bg-red-50 dark:bg-red-900/20': alert.severity === 'high',
|
||||
'border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20': alert.severity === 'medium',
|
||||
'border-blue-200 bg-blue-50 dark:bg-blue-900/20': alert.severity === 'low'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<UBadge
|
||||
:color="getAlertColor(alert.severity)"
|
||||
variant="subtle"
|
||||
>
|
||||
{{ alert.severity.toUpperCase() }}
|
||||
</UBadge>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
{{ alert.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ alert.description }}
|
||||
</p>
|
||||
<p v-if="alert.suggestion" class="text-sm font-medium text-gray-900 dark:text-white mt-2">
|
||||
💡 {{ alert.suggestion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly breakdown table -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
13-Week Breakdown
|
||||
</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Week</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dates</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Inflow</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Outflow</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Net Flow</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Balance</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Transactions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="week in weeklyProjections" :key="week.number">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
Week {{ week.number }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ formatDate(week.weekStart) }} - {{ formatDate(week.weekEnd) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-green-600">
|
||||
{{ formatCurrency(week.inflow) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-red-600">
|
||||
{{ formatCurrency(week.outflow) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm" :class="week.net >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ week.net >= 0 ? '+' : '' }}{{ formatCurrency(week.net) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium" :class="week.balance >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ formatCurrency(week.balance) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div v-if="week.oneOffEvents && week.oneOffEvents.length > 0" class="space-y-1">
|
||||
<div v-for="event in week.oneOffEvents" :key="event.id" class="text-xs">
|
||||
{{ event.name }} ({{ formatCurrency(event.amount) }})
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
const cashStore = useCashStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
|
||||
// Computed
|
||||
const { weeklyProjections } = storeToRefs(cashStore)
|
||||
|
||||
const runwayWeeks = computed(() => {
|
||||
const projections = weeklyProjections.value
|
||||
|
||||
for (let i = 0; i < projections.length; i++) {
|
||||
if (projections[i].balance < 0) {
|
||||
// Linear interpolation for fractional week
|
||||
const prevBalance = i === 0 ? 0 : projections[i-1].balance
|
||||
const currentNet = projections[i].net
|
||||
|
||||
if (currentNet !== 0) {
|
||||
const fraction = prevBalance / Math.abs(currentNet)
|
||||
return Math.max(0, i + fraction)
|
||||
}
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
|
||||
return 13 // Survived all 13 weeks
|
||||
})
|
||||
|
||||
const weeklyBurn = computed(() => {
|
||||
const totalOutflow = weeklyProjections.value.reduce((sum, p) => sum + p.outflow, 0)
|
||||
return totalOutflow / 13
|
||||
})
|
||||
|
||||
|
||||
const finalBalance = computed(() => {
|
||||
const projections = weeklyProjections.value
|
||||
return projections.length > 0 ? projections[projections.length - 1].balance : 0
|
||||
})
|
||||
|
||||
|
||||
const alerts = computed(() => {
|
||||
const alertsList = []
|
||||
|
||||
// Check for negative cash flow periods
|
||||
const negativeWeeks = weeklyProjections.value.filter(p => p.balance < 0).length
|
||||
if (negativeWeeks > 0) {
|
||||
alertsList.push({
|
||||
id: 'negative-cashflow',
|
||||
severity: negativeWeeks > 6 ? 'high' : 'medium',
|
||||
title: 'Negative Cash Flow Detected',
|
||||
description: `Your cash flow goes negative in ${negativeWeeks} weeks of the quarter.`,
|
||||
suggestion: 'Consider increasing confirmed revenue sources or reducing fixed costs.'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for low runway
|
||||
if (runwayWeeks.value < 4) {
|
||||
alertsList.push({
|
||||
id: 'low-runway',
|
||||
severity: 'high',
|
||||
title: 'Critical: Very Low Runway',
|
||||
description: `You have less than 4 weeks of runway (${runwayWeeks.value.toFixed(1)} weeks).`,
|
||||
suggestion: 'Urgent action needed: secure immediate funding or dramatically reduce expenses.'
|
||||
})
|
||||
} else if (runwayWeeks.value < 8) {
|
||||
alertsList.push({
|
||||
id: 'medium-runway',
|
||||
severity: 'medium',
|
||||
title: 'Warning: Limited Runway',
|
||||
description: `You have ${runwayWeeks.value.toFixed(1)} weeks of runway.`,
|
||||
suggestion: 'Start fundraising or revenue diversification efforts soon.'
|
||||
})
|
||||
}
|
||||
|
||||
return alertsList
|
||||
})
|
||||
|
||||
// Methods
|
||||
|
||||
function getRunwayStatus(weeks: number): string {
|
||||
if (weeks < 4) return 'Critical'
|
||||
if (weeks < 8) return 'Warning'
|
||||
if (weeks < 13) return 'Healthy'
|
||||
return 'Strong'
|
||||
}
|
||||
|
||||
function getAlertColor(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'high': return 'red'
|
||||
case 'medium': return 'yellow'
|
||||
case 'low': return 'blue'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
|
@ -166,13 +166,7 @@ const overheadCosts = computed(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
// Operating mode toggle
|
||||
const useTargetMode = ref(coop.operatingMode.value === "target");
|
||||
|
||||
function updateOperatingMode(value: boolean) {
|
||||
coop.setOperatingMode(value ? "target" : "min");
|
||||
emit("save-status", "saved");
|
||||
}
|
||||
// Operating mode removed - always use target mode
|
||||
|
||||
// Category options
|
||||
const categoryOptions = [
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@
|
|||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
|
||||
<UBadge
|
||||
:color="getCoverageColor(coverage(member).coveragePct)"
|
||||
:color="getCoverageColor(calculateCoverage(member))"
|
||||
size="xs"
|
||||
:ui="{ base: 'font-medium' }"
|
||||
>
|
||||
{{ Math.round(coverage(member).coveragePct || 0) }}% covered
|
||||
{{ Math.round(calculateCoverage(member)) }}% covered
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,11 +49,11 @@
|
|||
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
|
||||
<div
|
||||
class="h-3 rounded-full transition-all duration-300"
|
||||
:class="getBarColor(coverage(member).coveragePct)"
|
||||
:style="{ width: `${Math.min(100, coverage(member).coveragePct || 0)}%` }"
|
||||
:class="getBarColor(calculateCoverage(member))"
|
||||
:style="{ width: `${Math.min(100, calculateCoverage(member))}%` }"
|
||||
/>
|
||||
<!-- 100% marker line -->
|
||||
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="(coverage(member).coveragePct || 0) < 100">
|
||||
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="calculateCoverage(member) < 100">
|
||||
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -95,7 +95,9 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
Total payroll: {{ formatCurrency(totalPayroll) }}
|
||||
<UTooltip text="Based on available revenue after overhead costs">
|
||||
<span class="cursor-help">Total sustainable payroll: {{ formatCurrency(totalPayroll) }}</span>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -140,7 +142,11 @@
|
|||
<script setup lang="ts">
|
||||
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
|
||||
|
||||
const allocatedMembers = computed(() => allocatePayroll())
|
||||
const allocatedMembers = computed(() => {
|
||||
const members = allocatePayroll()
|
||||
console.log('🔍 allocatedMembers computed:', members)
|
||||
return members
|
||||
})
|
||||
const stats = computed(() => teamCoverageStats())
|
||||
|
||||
// Calculate total payroll
|
||||
|
|
@ -190,6 +196,25 @@ const totalSurplus = computed(() => {
|
|||
return surplus > 0 ? surplus : 0
|
||||
})
|
||||
|
||||
// Local coverage calculation for debugging
|
||||
function calculateCoverage(member: any): number {
|
||||
const coopPay = member.monthlyPayPlanned || 0
|
||||
const needs = member.minMonthlyNeeds || 0
|
||||
|
||||
console.log(`Coverage calc for ${member.name || member.displayName || 'Unknown'}:`, {
|
||||
member: JSON.stringify(member, null, 2),
|
||||
coopPay,
|
||||
needs,
|
||||
coverage: needs > 0 ? (coopPay / needs) * 100 : 100
|
||||
})
|
||||
|
||||
if (needs === 0) {
|
||||
console.log(`⚠️ Member ${member.name} has NO minMonthlyNeeds - defaulting to 100%`)
|
||||
return 100
|
||||
}
|
||||
return Math.min(200, (coopPay / needs) * 100)
|
||||
}
|
||||
|
||||
// Currency formatting
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue