refactor: enhance ProjectBudgetEstimate component layout, improve budget estimation calculations, and update CSS for better visual consistency and dark mode support

This commit is contained in:
Jennie Robinson Faber 2025-09-10 21:54:28 +01:00
parent f073f91569
commit b6e8d3b7ec
6 changed files with 502 additions and 358 deletions

View file

@ -1,154 +1,276 @@
<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>
<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-4 border-black bg-neutral-100">
<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">
<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>
<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-4 border-black">
<div
class="p-6 border-b-1 border-black bg-neutral-100 dark:bg-neutral-950">
<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-neutral-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>
<!-- 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 -->
<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">
<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="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<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>
<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"
<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.
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>
That's roughly
<strong
>{{ reviewsToBreakEven.toLocaleString() }} Steam reviews</strong
>
( {{ reviewToSales }} sales per review).
</li>
</ul>
<p class="text-xs text-neutral-600">
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not included.
<p class="text-base text-neutral-600 dark:text-neutral-400">
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-neutral-50 text-sm text-neutral-600">
{{ guidanceText }}
</div>
</div>
</div>
@ -156,94 +278,134 @@
<script setup lang="ts">
interface Member {
name: string
hoursPerMonth: number
hourlyRate?: number
monthlyPay?: number
name: string;
hoursPerMonth: number;
hourlyRate?: number;
monthlyPay?: number;
}
interface Props {
members: Member[]
oncostRate?: number
durationMonths?: number
defaultPrice?: number
storeCut?: number
reviewToSales?: number
members: Member[];
oncostRate?: number;
durationMonths?: number;
defaultPrice?: number;
storeCut?: number;
reviewToSales?: number;
payrollMode?: "sustainable" | "theoretical";
}
const props = withDefaults(defineProps<Props>(), {
oncostRate: 0.25,
durationMonths: 12,
durationMonths: 6,
defaultPrice: 20,
storeCut: 0.30,
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 bufferEnabled = ref(false)
const price = ref(props.defaultPrice)
const storeCutInput = ref(props.storeCut * 100) // Convert to percentage for input
const reviewToSales = ref(props.reviewToSales)
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 memberCost =
member.monthlyPay ?? member.hoursPerMonth * (member.hourlyRate ?? 0);
return sum + memberCost;
}, 0);
});
const monthlyCost = computed(() => {
return baseMonthlyPayroll.value * (1 + props.oncostRate)
})
return baseMonthlyPayroll.value * (1 + props.oncostRate);
});
const projectBase = computed(() => {
return monthlyCost.value * durationMonths.value
})
const projectWithBuffer = computed(() => {
return projectBase.value * 1.30
})
return monthlyCost.value * durationMonths.value;
});
const projectBudget = computed(() => {
return bufferEnabled.value ? projectWithBuffer.value : projectBase.value
})
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))
})
return price.value * (1 - storeCutInput.value / 100);
});
const unitsToBreakEven = computed(() => {
return Math.ceil(projectBudget.value / Math.max(netPerUnit.value, 0.01))
})
return Math.ceil(totalProjectBudget.value / Math.max(netPerUnit.value, 0.01));
});
const reviewsToBreakEven = computed(() => {
return Math.ceil(unitsToBreakEven.value / Math.max(reviewToSales.value, 1))
})
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 ""
})
// 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;
});
// Utility functions
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: 'USD',
maximumFractionDigits: 0
}).format(n)
}
style: "currency",
currency: coopStore.currency,
maximumFractionDigits: 0,
}).format(n);
};
const percent = (n: number): string => {
return `${Math.round(n * 100)}%`
}
return `${Math.round(n * 100)}%`;
};
</script>
<!--
@ -260,4 +422,4 @@ const sampleMembers = [
:duration-months="18"
:default-price="25"
/>
-->
-->