263 lines
No EOL
8.6 KiB
Vue
263 lines
No EOL
8.6 KiB
Vue
<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"
|
||
/>
|
||
--> |