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
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"
|
||||
/>
|
||||
-->
|
||||
Loading…
Add table
Add a link
Reference in a new issue