app/components/ProjectBudgetEstimate.vue

425 lines
14 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>
<!-- 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>{{ 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-base text-neutral-600 dark:text-neutral-400">
Assumes {{ percent(storeCutInput / 100) }} store fee. 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"
/>
-->