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

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

View file

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