app/components/ProjectBudgetEstimate.vue

263 lines
No EOL
8.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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"
/>
-->