425 lines
14 KiB
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"
|
|
/>
|
|
-->
|