app/components/ProjectBudgetEstimate.vue

446 lines
16 KiB
Vue

<template>
<div class="mx-auto">
<div class="relative">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border-1 border-black dark:border-neutral-400">
<!-- Controls -->
<div
class="p-6 border-b-1 border-black dark:border-neutral-400 bg-neutral-100 dark:bg-neutral-950">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2">
<UFormField label="Duration in months" class="">
<UInputNumber
id="duration"
v-model="durationMonths"
:min="3"
:max="24"
size="lg"
class="w-full" />
</UFormField>
</div>
</div>
</div>
<!-- Cost Summary -->
<div
class="p-6 border-b-1 border-black bg-neutral-100 dark:bg-neutral-950">
<ul class="space-y-2">
<!-- Two Column Layout -->
<li class="pb-2">
<div class="grid grid-cols-2 gap-6">
<!-- Left Column: Detailed Breakdown -->
<div
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<div class="font-bold font-display">
Monthly payroll breakdown:
</div>
<div>
Base hourly rate: {{ currency(theoreticalHourlyRate) }}/hour
</div>
<div class="pl-2 space-y-0.5">
<div
v-for="member in props.members"
:key="member.name"
class="flex justify-between">
<span
>{{ member.name }} @ {{ member.hoursPerMonth }}h:</span
>
<span class="font-mono">{{
currency(member.hoursPerMonth * theoreticalHourlyRate)
}}</span>
</div>
</div>
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-medium">
<span>Total base pay:</span>
<span class="font-mono">{{
currency(baseMonthlyPayroll)
}}</span>
</div>
<div class="flex justify-between">
<span
>Payroll taxes & benefits ({{
percent(props.oncostRate)
}}):</span
>
<span class="font-mono">{{
currency(theoreticalOncosts)
}}</span>
</div>
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold">
<span>Total monthly payroll:</span>
<span class="font-mono">{{
currency(baseMonthlyPayroll + theoreticalOncosts)
}}</span>
</div>
</div>
<!-- Right Column: Complete Project Budget Estimate -->
<div
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<div class="font-bold font-display">
Complete project budget estimate:
</div>
<p
class="text-sm mb-2 italic text-neutral-500 dark:text-neutral-400">
This uses a 1.8x multiplier, based on industry standards.
</p>
<!-- Team Payroll -->
<div class="flex justify-between">
<span class="font-bold">Team Payroll:</span>
<span class="font-mono font-bold">{{
currency(projectBudget)
}}</span>
</div>
<!-- External Resources -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>External resources:</span>
<span class="font-mono">{{
currency(externalResources)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Freelancers, contractors, consultants, voice talent
</div>
</div>
<!-- Tools & Software -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Tools and software:</span>
<span class="font-mono">{{
currency(toolsSoftware)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Licenses, subscriptions, cloud services, development
tools/kits
</div>
</div>
<!-- Testing & QA -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Testing and QA:</span>
<span class="font-mono">{{ currency(testingQA) }}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
User testing sessions, focus groups, QA contractors,
playtesting, bug fixing
</div>
</div>
<!-- Marketing & Community -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Marketing and community:</span>
<span class="font-mono">{{
currency(marketingCommunity)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Community building, promotional materials, launch
preparation (minimum 10% for most funders)
</div>
</div>
<!-- Administration -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Administration:</span>
<span class="font-mono">{{
currency(administration)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Legal, accounting, insurance, project-specific business
costs
</div>
</div>
<!-- Subtotal -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold">
<span>Subtotal:</span>
<span class="font-mono">{{
currency(budgetSubtotal)
}}</span>
</div>
<!-- Contingency -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Contingency (10%):</span>
<span class="font-mono">{{ currency(contingency) }}</span>
</div>
</div>
<!-- Total -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold text-lg">
<span>TOTAL PROJECT:</span>
<span class="font-mono">{{
currency(totalProjectBudget)
}}</span>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- Break-Even Sketch -->
<div
class="text-black dark:text-white border-t border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-950">
<div class="p-6 text-black dark:text-white">
<h3 class="font-bold mb-4">Break-Even Sketch</h3>
<p class="mb-4">
There are so many ways to guess at the sales figures of published
games. One is to use a review-to-sales ratio, formalized in the
Boxleiter method and later discussed and refined by many others.
<a
href="https://app.sensortower.com/vgi/insights/article/how-to-estimate-steam-video-game-sales"
>VGInsights</a
>
and <a href="https://gamediscover.co/">GameDiscoverCo</a> are good
sources for more information on this method. Your chosen ratio
will vary, typically between 30 and 70 reviews per sale, and is
dependent on factors like genre, pricing, and market conditions at
the time of release.
</p>
<!-- Inputs -->
<div class="flex flex-wrap space-x-4 mb-6">
<div>
<label for="price" class="block font-bold text-sm mb-1"
>Price per copy:</label
>
<UInput
id="price"
v-model="price"
type="number"
placeholder="20.00"
size="lg"
class="w-32"
:ui="{ leading: 'pointer-events-none' }">
<template #leading>
<span class="text-sm font-mono">{{
formatCurrency(0, {
showSymbol: true,
precision: 0,
}).replace("0", "")
}}</span>
</template>
</UInput>
</div>
<div>
<label for="storeCut" class="block font-bold text-sm mb-1"
>Store cut:</label
>
<UInput
id="storeCut"
v-model="storeCutInput"
type="number"
placeholder="30"
size="lg"
class="w-24"
:ui="{ trailing: 'pointer-events-none' }">
<template #trailing>
<span class="text-sm font-mono">%</span>
</template>
</UInput>
</div>
<div>
<label for="reviewToSales" class="block font-bold text-sm mb-1"
>Sales per review:</label
>
<UInputNumber
id="reviewToSales"
v-model="reviewToSales"
:min="5"
:max="100"
size="lg" />
</div>
</div>
<!-- Outputs -->
<ul class="space-y-2 mb-4">
<li>
At {{ currency(price) }} per copy after store fees, you'd need
about
<strong class="bg-yellow-200 dark:text-black"
>{{ unitsToBreakEven.toLocaleString() }} sales</strong
>
to cover this budget.
</li>
<li>
That's roughly
<strong class="bg-yellow-200 dark:text-black"
>{{ reviewsToBreakEven.toLocaleString() }} Steam
reviews</strong
>
( {{ reviewToSales }} sales per review).
</li>
</ul>
<p class="text-base text-neutral-600 dark:text-neutral-400">
Taxes not included.
</p>
</div>
</div>
</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;
payrollMode?: "sustainable" | "theoretical";
}
const props = withDefaults(defineProps<Props>(), {
oncostRate: 0.25,
durationMonths: 6,
defaultPrice: 20,
storeCut: 0.3,
reviewToSales: 57,
payrollMode: "theoretical",
});
// Use the currency composable to get the stored currency
const { formatCurrency } = useCurrency();
const coopStore = useCoopBuilderStore();
// Local state
const durationMonths = ref(props.durationMonths);
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 projectBudget = computed(() => {
return projectBase.value;
});
// Budget estimation calculations using 1.8x multiplier
const externalResources = computed(
() => Math.round((projectBudget.value * 0.25) / 50) * 50
);
const toolsSoftware = computed(
() => Math.round((projectBudget.value * 0.11) / 50) * 50
);
const testingQA = computed(
() => Math.round((projectBudget.value * 0.13) / 50) * 50
);
const marketingCommunity = computed(
() => Math.round((projectBudget.value * 0.18) / 50) * 50
);
const administration = computed(
() => Math.round((projectBudget.value * 0.15) / 50) * 50
);
const budgetSubtotal = computed(() => {
return (
projectBudget.value +
externalResources.value +
toolsSoftware.value +
testingQA.value +
marketingCommunity.value +
administration.value
);
});
const contingency = computed(
() => Math.round((budgetSubtotal.value * 0.1) / 50) * 50
);
const totalProjectBudget = computed(
() => Math.round((budgetSubtotal.value + contingency.value) / 100) * 100
);
const netPerUnit = computed(() => {
return price.value * (1 - storeCutInput.value / 100);
});
const unitsToBreakEven = computed(() => {
return Math.ceil(totalProjectBudget.value / Math.max(netPerUnit.value, 0.01));
});
const reviewsToBreakEven = computed(() => {
return Math.ceil(unitsToBreakEven.value / Math.max(reviewToSales.value, 1));
});
// Theoretical maximum breakdown calculations
const theoreticalHourlyRate = computed(() => {
// Get the hourly rate from the coop store
// This should be the same rate used in the theoretical calculation
return coopStore.equalHourlyWage || 0;
});
const theoreticalOncosts = computed(() => {
// Calculate oncosts based on the base payroll and stored oncost rate
return baseMonthlyPayroll.value * props.oncostRate;
});
// Utility functions using stored currency
const currency = (n: number): string => {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: coopStore.currency,
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"
/>
-->