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:
parent
f073f91569
commit
b6e8d3b7ec
6 changed files with 502 additions and 358 deletions
|
|
@ -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 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-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"
|
||||
/>
|
||||
-->
|
||||
-->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue