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
|
|
@ -6,6 +6,7 @@
|
||||||
@theme static {
|
@theme static {
|
||||||
--font-body: "Ubuntu", "Inter", sans-serif;
|
--font-body: "Ubuntu", "Inter", sans-serif;
|
||||||
--font-mono: "Ubuntu Mono", monospace;
|
--font-mono: "Ubuntu Mono", monospace;
|
||||||
|
--font-display: "Inter", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|
@ -289,22 +290,9 @@ html.dark .item-selected::after {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text background for better readability on selected items */
|
|
||||||
.item-label-bg {
|
|
||||||
@apply bg-white/85 dark:bg-neutral-950/85 rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-label-bg.selected {
|
|
||||||
@apply bg-white/95 dark:bg-neutral-950/95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-text-bg {
|
|
||||||
@apply bg-white/90 dark:bg-neutral-950/90;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-text-bg.selected {
|
|
||||||
@apply bg-white/95 dark:bg-neutral-950/95;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
BUTTON STYLING
|
BUTTON STYLING
|
||||||
|
|
|
||||||
|
|
@ -1,154 +1,276 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="mx-auto">
|
||||||
<div class="bg-white border-4 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
<div class="relative">
|
||||||
<!-- Header -->
|
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||||
<div class="p-6 border-b-4 border-black bg-yellow-300">
|
<div class="relative bg-white dark:bg-neutral-950 border-1 border-black dark:border-neutral-400">
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- 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 flex-wrap gap-4 items-center">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label for="duration" class="font-bold text-sm">Duration (months):</label>
|
<UFormField label="Duration in months" class="">
|
||||||
<input
|
<UInputNumber
|
||||||
id="duration"
|
id="duration"
|
||||||
v-model.number="durationMonths"
|
v-model="durationMonths"
|
||||||
type="number"
|
:min="3"
|
||||||
min="6"
|
:max="24"
|
||||||
max="36"
|
size="lg"
|
||||||
class="w-20 px-2 py-1 border-2 border-black font-mono"
|
class="w-full" />
|
||||||
>
|
</UFormField>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cost Summary -->
|
<!-- 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">
|
<ul class="space-y-2">
|
||||||
<li class="flex justify-between items-center">
|
<!-- Two Column Layout -->
|
||||||
<span class="font-bold">Monthly team cost:</span>
|
<li class="pb-2">
|
||||||
<span class="font-mono">{{ currency(monthlyCost) }}</span>
|
<div class="grid grid-cols-2 gap-6">
|
||||||
</li>
|
<!-- Left Column: Detailed Breakdown -->
|
||||||
<li class="text-xs text-neutral-600 -mt-1">
|
<div
|
||||||
Sustainable payroll + {{ percent(props.oncostRate) }} benefits
|
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
|
||||||
</li>
|
<div class="font-bold font-display">
|
||||||
<li class="flex justify-between items-center">
|
Monthly payroll breakdown:
|
||||||
<span class="font-bold">Project budget:</span>
|
</div>
|
||||||
<span class="font-mono">{{ currency(projectBase) }}</span>
|
<div>
|
||||||
</li>
|
Base hourly rate: {{ currency(theoreticalHourlyRate) }}/hour
|
||||||
<li v-if="bufferEnabled" class="flex justify-between items-center border-t-2 border-black pt-2">
|
</div>
|
||||||
<span class="font-bold">With buffer:</span>
|
<div class="pl-2 space-y-0.5">
|
||||||
<span class="font-mono text-lg">{{ currency(projectWithBuffer) }}</span>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Break-Even Sketch -->
|
<!-- Break-Even Sketch -->
|
||||||
<details class="group">
|
<div
|
||||||
<summary class="p-6 border-b-4 border-black bg-blue-200 cursor-pointer font-bold hover:bg-blue-300 transition-colors">
|
class="text-black dark:text-white border-t border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-950">
|
||||||
<span>Break-Even Sketch (optional)</span>
|
<div class="p-6 text-black dark:text-white">
|
||||||
</summary>
|
<h3 class="font-bold mb-4">Break-Even Sketch</h3>
|
||||||
<div class="p-6 border-b-4 border-black bg-blue-50">
|
|
||||||
<!-- Inputs -->
|
<!-- 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>
|
<div>
|
||||||
<label for="price" class="block font-bold text-sm mb-1">Price per copy:</label>
|
<label for="price" class="block font-bold text-sm mb-1"
|
||||||
<div class="flex items-center">
|
>Price per copy:</label
|
||||||
<span class="font-mono">$</span>
|
>
|
||||||
<input
|
<UInput
|
||||||
id="price"
|
id="price"
|
||||||
v-model.number="price"
|
v-model="price"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
placeholder="20.00"
|
||||||
step="0.01"
|
size="lg"
|
||||||
class="flex-1 ml-1 px-2 py-1 border-2 border-black font-mono"
|
class="w-32"
|
||||||
>
|
:ui="{ leading: 'pointer-events-none' }">
|
||||||
</div>
|
<template #leading>
|
||||||
|
<span class="text-sm font-mono">{{
|
||||||
|
formatCurrency(0, {
|
||||||
|
showSymbol: true,
|
||||||
|
precision: 0,
|
||||||
|
}).replace("0", "")
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="storeCut" class="block font-bold text-sm mb-1">Store cut:</label>
|
<label for="storeCut" class="block font-bold text-sm mb-1"
|
||||||
<div class="flex items-center">
|
>Store cut:</label
|
||||||
<input
|
>
|
||||||
|
<UInput
|
||||||
id="storeCut"
|
id="storeCut"
|
||||||
v-model.number="storeCutInput"
|
v-model="storeCutInput"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
placeholder="30"
|
||||||
max="100"
|
size="lg"
|
||||||
step="1"
|
class="w-24"
|
||||||
class="w-16 px-2 py-1 border-2 border-black font-mono"
|
:ui="{ trailing: 'pointer-events-none' }">
|
||||||
>
|
<template #trailing>
|
||||||
<span class="ml-1 font-mono">%</span>
|
<span class="text-sm font-mono">%</span>
|
||||||
</div>
|
</template>
|
||||||
|
</UInput>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="reviewToSales" class="block font-bold text-sm mb-1">Sales per review:</label>
|
<label for="reviewToSales" class="block font-bold text-sm mb-1"
|
||||||
<input
|
>Sales per review:</label
|
||||||
id="reviewToSales"
|
|
||||||
v-model.number="reviewToSales"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
class="w-20 px-2 py-1 border-2 border-black font-mono"
|
|
||||||
>
|
>
|
||||||
|
<UInputNumber
|
||||||
|
id="reviewToSales"
|
||||||
|
v-model="reviewToSales"
|
||||||
|
:min="5"
|
||||||
|
:max="100"
|
||||||
|
size="lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Outputs -->
|
<!-- Outputs -->
|
||||||
<ul class="space-y-2 mb-4">
|
<ul class="space-y-2 mb-4">
|
||||||
<li>
|
<li>
|
||||||
At {{ currency(price) }} per copy after store fees, you'd need about
|
At {{ currency(price) }} per copy after store fees, you'd need
|
||||||
<strong>{{ unitsToBreakEven.toLocaleString() }} sales</strong> to cover this budget.
|
about
|
||||||
|
<strong>{{ unitsToBreakEven.toLocaleString() }} sales</strong> to
|
||||||
|
cover this budget.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
That's roughly <strong>{{ reviewsToBreakEven.toLocaleString() }} Steam reviews</strong>
|
That's roughly
|
||||||
|
<strong
|
||||||
|
>{{ reviewsToBreakEven.toLocaleString() }} Steam reviews</strong
|
||||||
|
>
|
||||||
(≈ {{ reviewToSales }} sales per review).
|
(≈ {{ reviewToSales }} sales per review).
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p class="text-xs text-neutral-600">
|
<p class="text-base text-neutral-600 dark:text-neutral-400">
|
||||||
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not included.
|
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not
|
||||||
|
included.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,94 +278,134 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Member {
|
interface Member {
|
||||||
name: string
|
name: string;
|
||||||
hoursPerMonth: number
|
hoursPerMonth: number;
|
||||||
hourlyRate?: number
|
hourlyRate?: number;
|
||||||
monthlyPay?: number
|
monthlyPay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
members: Member[]
|
members: Member[];
|
||||||
oncostRate?: number
|
oncostRate?: number;
|
||||||
durationMonths?: number
|
durationMonths?: number;
|
||||||
defaultPrice?: number
|
defaultPrice?: number;
|
||||||
storeCut?: number
|
storeCut?: number;
|
||||||
reviewToSales?: number
|
reviewToSales?: number;
|
||||||
|
payrollMode?: "sustainable" | "theoretical";
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
oncostRate: 0.25,
|
oncostRate: 0.25,
|
||||||
durationMonths: 12,
|
durationMonths: 6,
|
||||||
defaultPrice: 20,
|
defaultPrice: 20,
|
||||||
storeCut: 0.30,
|
storeCut: 0.3,
|
||||||
reviewToSales: 57,
|
reviewToSales: 57,
|
||||||
})
|
payrollMode: "theoretical",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the currency composable to get the stored currency
|
||||||
|
const { formatCurrency } = useCurrency();
|
||||||
|
const coopStore = useCoopBuilderStore();
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const durationMonths = ref(props.durationMonths)
|
const durationMonths = ref(props.durationMonths);
|
||||||
const bufferEnabled = ref(false)
|
const price = ref(props.defaultPrice);
|
||||||
const price = ref(props.defaultPrice)
|
const storeCutInput = ref(props.storeCut * 100); // Convert to percentage for input
|
||||||
const storeCutInput = ref(props.storeCut * 100) // Convert to percentage for input
|
const reviewToSales = ref(props.reviewToSales);
|
||||||
const reviewToSales = ref(props.reviewToSales)
|
|
||||||
|
|
||||||
// Calculations
|
// Calculations
|
||||||
const baseMonthlyPayroll = computed(() => {
|
const baseMonthlyPayroll = computed(() => {
|
||||||
return props.members.reduce((sum, member) => {
|
return props.members.reduce((sum, member) => {
|
||||||
// Use monthlyPay if available, otherwise calculate from hourlyRate
|
// Use monthlyPay if available, otherwise calculate from hourlyRate
|
||||||
const memberCost = member.monthlyPay ?? (member.hoursPerMonth * (member.hourlyRate ?? 0))
|
const memberCost =
|
||||||
return sum + memberCost
|
member.monthlyPay ?? member.hoursPerMonth * (member.hourlyRate ?? 0);
|
||||||
}, 0)
|
return sum + memberCost;
|
||||||
})
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
const monthlyCost = computed(() => {
|
const monthlyCost = computed(() => {
|
||||||
return baseMonthlyPayroll.value * (1 + props.oncostRate)
|
return baseMonthlyPayroll.value * (1 + props.oncostRate);
|
||||||
})
|
});
|
||||||
|
|
||||||
const projectBase = computed(() => {
|
const projectBase = computed(() => {
|
||||||
return monthlyCost.value * durationMonths.value
|
return monthlyCost.value * durationMonths.value;
|
||||||
})
|
});
|
||||||
|
|
||||||
const projectWithBuffer = computed(() => {
|
|
||||||
return projectBase.value * 1.30
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectBudget = computed(() => {
|
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(() => {
|
const netPerUnit = computed(() => {
|
||||||
return price.value * (1 - (storeCutInput.value / 100))
|
return price.value * (1 - storeCutInput.value / 100);
|
||||||
})
|
});
|
||||||
|
|
||||||
const unitsToBreakEven = computed(() => {
|
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(() => {
|
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(() => {
|
// Theoretical maximum breakdown calculations
|
||||||
if (bufferEnabled.value) {
|
const theoreticalHourlyRate = computed(() => {
|
||||||
return "This sketch includes a safety buffer."
|
// Get the hourly rate from the coop store
|
||||||
} else if (durationMonths.value * monthlyCost.value >= 1) {
|
// This should be the same rate used in the theoretical calculation
|
||||||
return "Consider adding a small buffer so the team isn't squeezed by delays."
|
return coopStore.equalHourlyWage || 0;
|
||||||
}
|
});
|
||||||
return ""
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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 => {
|
const currency = (n: number): string => {
|
||||||
return new Intl.NumberFormat(undefined, {
|
return new Intl.NumberFormat(undefined, {
|
||||||
style: 'currency',
|
style: "currency",
|
||||||
currency: 'USD',
|
currency: coopStore.currency,
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0,
|
||||||
}).format(n)
|
}).format(n);
|
||||||
}
|
};
|
||||||
|
|
||||||
const percent = (n: number): string => {
|
const percent = (n: number): string => {
|
||||||
return `${Math.round(n * 100)}%`
|
return `${Math.round(n * 100)}%`;
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<section class="py-8 space-y-6">
|
<section class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<h2 class="text-2xl font-bold">Budget Worksheet</h2>
|
<h2 class="text-2xl font-bold">Budget Worksheet</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<div class="text-center">
|
<div class="">
|
||||||
<h1 class="text-3xl font-bold mb-4">Project Budget Estimate</h1>
|
<h1 class="font-bold text-2xl mb-4">Project Budget Estimate</h1>
|
||||||
<p class="text-neutral-600 max-w-2xl mx-auto mb-4">
|
<p class="text-neutral-600 dark:text-neutral-400 mx-auto mb-4">
|
||||||
Get a quick estimate of what it would cost to build your project with fair pay.
|
This tool provides a rough estimate of what it would cost to build your
|
||||||
This tool helps worker co-ops sketch project budgets and break-even scenarios.
|
project using the pay policy you've set in the setup.
|
||||||
</p>
|
</p>
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 max-w-2xl mx-auto">
|
<div class="space-y-4">
|
||||||
<div class="flex items-start gap-2">
|
<!-- Sustainable payroll toggle hidden - defaulting to theoretical maximum -->
|
||||||
<UIcon name="i-heroicons-information-circle" class="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
<div class="hidden">
|
||||||
<div class="text-sm text-blue-800">
|
<span class="text-sm font-medium">Sustainable Payroll</span>
|
||||||
<p class="font-medium mb-1">About the calculations:</p>
|
<USwitch v-model="useTheoreticalPayroll" size="md" />
|
||||||
<p>These estimates are based on <strong>sustainable payroll</strong> — what you can actually afford to pay based on your revenue minus overhead costs. This may be different from theoretical maximum wages if revenue is limited.</p>
|
<span class="text-sm font-medium">Theoretical Maximum</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="membersWithPay.length === 0" class="text-center py-8">
|
<div v-if="membersWithPay.length === 0" class="text-center py-8">
|
||||||
<p class="text-neutral-600 mb-4">No team members set up yet.</p>
|
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
No team members set up yet.
|
||||||
|
</p>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/coop-builder"
|
to="/coop-builder"
|
||||||
class="px-4 py-2 border-2 border-black bg-white font-bold hover:bg-neutral-100"
|
class="px-4 py-2 border-2 border-black dark:border-white bg-white dark:bg-black text-black dark:text-white font-bold hover:bg-neutral-100 dark:hover:bg-neutral-900">
|
||||||
>
|
|
||||||
Set up your team in Setup Wizard
|
Set up your team in Setup Wizard
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -31,54 +31,100 @@
|
||||||
v-else
|
v-else
|
||||||
:members="membersWithPay"
|
:members="membersWithPay"
|
||||||
:oncost-rate="coopStore.payrollOncostPct / 100"
|
:oncost-rate="coopStore.payrollOncostPct / 100"
|
||||||
/>
|
:payroll-mode="useTheoreticalPayroll ? 'theoretical' : 'sustainable'" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const coopStore = useCoopBuilderStore()
|
import { allocatePayroll as allocatePayrollImpl } from "~/types/members";
|
||||||
const budgetStore = useBudgetStore()
|
|
||||||
|
|
||||||
// Calculate member pay using the same allocation logic as the budget system
|
const coopStore = useCoopBuilderStore();
|
||||||
|
const budgetStore = useBudgetStore();
|
||||||
|
|
||||||
|
// Toggle between sustainable and theoretical payroll modes - defaulting to theoretical maximum
|
||||||
|
const useTheoreticalPayroll = ref(true);
|
||||||
|
|
||||||
|
// Calculate member pay using different logic based on payroll mode
|
||||||
const membersWithPay = computed(() => {
|
const membersWithPay = computed(() => {
|
||||||
// Get current month's payroll from budget store (matches budget page)
|
|
||||||
const today = new Date()
|
|
||||||
const currentMonthKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`
|
|
||||||
|
|
||||||
const payrollExpense = budgetStore.budgetWorksheet.expenses.find(item =>
|
|
||||||
item.id === "expense-payroll-base" || item.id === "expense-payroll"
|
|
||||||
)
|
|
||||||
const actualPayrollBudget = payrollExpense?.monthlyValues?.[currentMonthKey] || 0
|
|
||||||
|
|
||||||
// Use the member's desired hours (targetHours if available, otherwise hoursPerMonth)
|
// Use the member's desired hours (targetHours if available, otherwise hoursPerMonth)
|
||||||
const getHoursForMember = (member: any) => {
|
const getHoursForMember = (member: any) => {
|
||||||
return member.capacity?.targetHours || member.hoursPerMonth || 0
|
return member.capacity?.targetHours || member.hoursPerMonth || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let allocatedMembers;
|
||||||
|
|
||||||
|
if (useTheoreticalPayroll.value) {
|
||||||
|
// Theoretical mode: Calculate true theoretical maximum without revenue constraints
|
||||||
|
const allMembers = coopStore.members.map((m: any) => ({
|
||||||
|
...m,
|
||||||
|
displayName: m.name,
|
||||||
|
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
||||||
|
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||||
|
hoursPerMonth: m.hoursPerMonth || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const payPolicy = {
|
||||||
|
relationship: coopStore.policy.relationship || ("equal-pay" as const),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate theoretical maximum budget: total hours × hourly wage
|
||||||
|
const totalHours = allMembers.reduce(
|
||||||
|
(sum, m) => sum + (m.hoursPerMonth || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const hourlyWage = coopStore.equalHourlyWage || 0;
|
||||||
|
const theoreticalMaxBudget = totalHours * hourlyWage;
|
||||||
|
|
||||||
|
allocatedMembers = allocatePayrollImpl(
|
||||||
|
allMembers,
|
||||||
|
payPolicy,
|
||||||
|
theoreticalMaxBudget
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Sustainable mode: Use revenue-constrained allocation (current behavior)
|
||||||
|
const { allocatePayroll } = useCoopBuilder();
|
||||||
|
const sustainableMembers = allocatePayroll();
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const currentMonthKey = `${today.getFullYear()}-${String(
|
||||||
|
today.getMonth() + 1
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const payrollExpense = budgetStore.budgetWorksheet.expenses.find(
|
||||||
|
(item) =>
|
||||||
|
item.id === "expense-payroll-base" || item.id === "expense-payroll"
|
||||||
|
);
|
||||||
|
const actualPayrollBudget =
|
||||||
|
payrollExpense?.monthlyValues?.[currentMonthKey] || 0;
|
||||||
|
|
||||||
|
const theoreticalTotal = sustainableMembers.reduce(
|
||||||
|
(sum, m) => sum + (m.monthlyPayPlanned || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const scaleFactor =
|
||||||
|
theoreticalTotal > 0 ? actualPayrollBudget / theoreticalTotal : 0;
|
||||||
|
|
||||||
|
allocatedMembers = sustainableMembers.map((member) => ({
|
||||||
|
...member,
|
||||||
|
monthlyPayPlanned: (member.monthlyPayPlanned || 0) * scaleFactor,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get theoretical allocation then scale to actual budget
|
return allocatedMembers
|
||||||
const { allocatePayroll } = useCoopBuilder()
|
.map((member: any) => {
|
||||||
const theoreticalMembers = allocatePayroll()
|
const hours = getHoursForMember(member);
|
||||||
const theoreticalTotal = theoreticalMembers.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
|
|
||||||
const scaleFactor = theoreticalTotal > 0 ? actualPayrollBudget / theoreticalTotal : 0
|
|
||||||
|
|
||||||
const allocatedMembers = theoreticalMembers.map(member => ({
|
|
||||||
...member,
|
|
||||||
monthlyPayPlanned: (member.monthlyPayPlanned || 0) * scaleFactor
|
|
||||||
}))
|
|
||||||
|
|
||||||
return allocatedMembers.map(member => {
|
|
||||||
const hours = getHoursForMember(member)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: member.name || 'Unnamed',
|
name: member.displayName || "Unnamed",
|
||||||
hoursPerMonth: hours,
|
hoursPerMonth: hours,
|
||||||
monthlyPay: member.monthlyPayPlanned || 0
|
monthlyPay: member.monthlyPayPlanned || 0,
|
||||||
}
|
};
|
||||||
}).filter(m => m.hoursPerMonth > 0) // Only include members with hours
|
|
||||||
})
|
})
|
||||||
|
.filter((m: any) => m.hoursPerMonth > 0); // Only include members with hours
|
||||||
|
});
|
||||||
|
|
||||||
// Set page meta
|
// Set page meta
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
title: 'Project Budget Estimate'
|
title: "Project Budget Estimate",
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -33,7 +33,8 @@
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
||||||
A Miro template to help your team align on shared goals and
|
A Miro template to help your team align on shared goals and
|
||||||
values through collaborative exercises.
|
values through collaborative exercises. Make sure to do this
|
||||||
|
WITH your full team!
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="https://miro.com/miroverse/goals-values-exercise/"
|
href="https://miro.com/miroverse/goals-values-exercise/"
|
||||||
|
|
@ -92,64 +93,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- PDF Downloads Section -->
|
|
||||||
<section>
|
|
||||||
<h2
|
|
||||||
class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
|
|
||||||
Wizard PDF Downloads
|
|
||||||
</h2>
|
|
||||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
|
||||||
Download PDF versions of our wizards for offline use or printing.
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<div
|
|
||||||
v-for="pdf in pdfDownloads"
|
|
||||||
:key="pdf.id"
|
|
||||||
class="template-card">
|
|
||||||
<div
|
|
||||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
|
||||||
<div
|
|
||||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<h3
|
|
||||||
class="text-lg font-semibold text-neutral-900 dark:text-white">
|
|
||||||
{{ pdf.name }}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
class="text-xs text-neutral-600 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-2 py-1 rounded">
|
|
||||||
PDF
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
|
|
||||||
{{ pdf.description }}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
:disabled="!pdf.available"
|
|
||||||
:class="[
|
|
||||||
'inline-flex items-center px-4 py-2 font-medium transition-opacity',
|
|
||||||
pdf.available
|
|
||||||
? 'bg-black dark:bg-white text-white dark:text-black hover:opacity-90'
|
|
||||||
: 'bg-neutral-200 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-500 cursor-not-allowed',
|
|
||||||
]">
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
{{ pdf.available ? "Download" : "Coming Soon" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,13 @@
|
||||||
<!-- Purpose Section -->
|
<!-- Purpose Section -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-neutral-800 mb-4">
|
<h2
|
||||||
|
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-4">
|
||||||
Charter Purpose
|
Charter Purpose
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-neutral-600 mb-4">
|
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
Describe what this charter will guide and why it matters to
|
Describe what this charter will guide and why it matters to
|
||||||
your group.
|
you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -44,10 +45,11 @@
|
||||||
<!-- Unified Principles & Importance Section -->
|
<!-- Unified Principles & Importance Section -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-neutral-800 mb-4">
|
<h2
|
||||||
|
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-4">
|
||||||
Define Your Principles & Importance
|
Define Your Principles & Importance
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-neutral-600 mb-6">
|
<p class="text-neutral-600 dark:text-neutral-400 mb-6">
|
||||||
Select principles and set their importance. Zero means
|
Select principles and set their importance. Zero means
|
||||||
excluded, 5 means critical.
|
excluded, 5 means critical.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -67,7 +69,7 @@
|
||||||
:class="[
|
:class="[
|
||||||
'relative transition-all',
|
'relative transition-all',
|
||||||
principleWeights[principle.id] > 0
|
principleWeights[principle.id] > 0
|
||||||
? 'item-selected border-2 border-black dark:border-white bg-white dark:bg-neutral-950'
|
? 'item-selected border-2 border-black dark:border-neutral-400 bg-white dark:bg-neutral-950'
|
||||||
: 'border border-black dark:border-white bg-transparent',
|
: 'border border-black dark:border-white bg-transparent',
|
||||||
]">
|
]">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
|
|
@ -90,7 +92,7 @@
|
||||||
? 'text-neutral-700'
|
? 'text-neutral-700'
|
||||||
: 'text-neutral-600'
|
: 'text-neutral-600'
|
||||||
"
|
"
|
||||||
class="text-sm">
|
class="text-sm dark:text-neutral-200">
|
||||||
{{ principle.description }}
|
{{ principle.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,7 +141,7 @@
|
||||||
<!-- Non-negotiable toggle (only shows for weights > 0) -->
|
<!-- Non-negotiable toggle (only shows for weights > 0) -->
|
||||||
<div
|
<div
|
||||||
v-if="principleWeights[principle.id] > 0"
|
v-if="principleWeights[principle.id] > 0"
|
||||||
class="mt-4 pt-4 border-t border-neutral-200">
|
class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
|
||||||
<label
|
<label
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1',
|
'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1',
|
||||||
|
|
@ -152,7 +154,7 @@
|
||||||
:checked="nonNegotiables.includes(principle.id)"
|
:checked="nonNegotiables.includes(principle.id)"
|
||||||
@change="toggleNonNegotiable(principle.id)"
|
@change="toggleNonNegotiable(principle.id)"
|
||||||
class="w-4 h-4" />
|
class="w-4 h-4" />
|
||||||
<span class="text-sm font-medium text-red-600">
|
<span class="text-sm font-medium text-white">
|
||||||
Make this non-negotiable
|
Make this non-negotiable
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -163,7 +165,7 @@
|
||||||
v-if="principleWeights[principle.id] > 0"
|
v-if="principleWeights[principle.id] > 0"
|
||||||
class="mt-4 p-3 item-label-bg selected border border-neutral-200">
|
class="mt-4 p-3 item-label-bg selected border border-neutral-200">
|
||||||
<div
|
<div
|
||||||
class="text-xs font-bold uppercase text-neutral-500 mb-1">
|
class="text-xs font-bold uppercase text-neutral-300 mb-1">
|
||||||
Evaluation Criteria:
|
Evaluation Criteria:
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
|
|
@ -180,15 +182,17 @@
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
class="text-2xl font-bold text-neutral-800 mb-2"
|
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-2"
|
||||||
id="constraints-heading">
|
id="constraints-heading">
|
||||||
Technical Constraints
|
Technical Constraints
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
<fieldset class="bg-neutral-50 dark:bg-neutral-800 p-6">
|
||||||
<legend class="font-semibold text-lg">Authentication</legend>
|
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||||
|
Authentication
|
||||||
|
</legend>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-3 constraint-buttons"
|
class="flex flex-wrap gap-3 constraint-buttons"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
|
|
@ -209,7 +213,7 @@
|
||||||
:class="[
|
:class="[
|
||||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||||
constraints.sso === option.value
|
constraints.sso === option.value
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]">
|
]">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
|
|
@ -218,8 +222,10 @@
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||||
<legend class="font-semibold text-lg">Hosting Model</legend>
|
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||||
|
Hosting Model
|
||||||
|
</legend>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-3 constraint-buttons"
|
class="flex flex-wrap gap-3 constraint-buttons"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
|
|
@ -240,7 +246,7 @@
|
||||||
:class="[
|
:class="[
|
||||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||||
constraints.hosting === option.value
|
constraints.hosting === option.value
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer '
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]">
|
]">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
|
|
@ -249,11 +255,12 @@
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||||
<legend class="font-semibold text-lg">
|
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||||
Required Integrations
|
Required Integrations
|
||||||
</legend>
|
</legend>
|
||||||
<p class="text-sm text-neutral-600 mb-4">
|
<p
|
||||||
|
class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
Select all that apply
|
Select all that apply
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap gap-3 constraint-buttons">
|
<div class="flex flex-wrap gap-3 constraint-buttons">
|
||||||
|
|
@ -273,7 +280,7 @@
|
||||||
:class="[
|
:class="[
|
||||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||||
constraints.integrations.includes(integration)
|
constraints.integrations.includes(integration)
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]">
|
]">
|
||||||
{{ integration }}
|
{{ integration }}
|
||||||
|
|
@ -282,7 +289,7 @@
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||||
<legend class="font-semibold text-lg">
|
<legend class="font-semibold text-lg">
|
||||||
Support Expectations
|
Support Expectations
|
||||||
</legend>
|
</legend>
|
||||||
|
|
@ -306,7 +313,7 @@
|
||||||
:class="[
|
:class="[
|
||||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||||
constraints.support === option.value
|
constraints.support === option.value
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]">
|
]">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
|
|
@ -315,8 +322,8 @@
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||||
<legend class="font-semibold text-lg">
|
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||||
Migration Timeline
|
Migration Timeline
|
||||||
</legend>
|
</legend>
|
||||||
<div
|
<div
|
||||||
|
|
@ -339,7 +346,7 @@
|
||||||
:class="[
|
:class="[
|
||||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||||
constraints.timeline === option.value
|
constraints.timeline === option.value
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]">
|
]">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
|
|
@ -406,10 +413,8 @@
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
|
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
|
||||||
<p class="text-neutral-700 leading-relaxed">
|
<p class="text-neutral-700 leading-relaxed">
|
||||||
This charter guides our cooperative's technology decisions
|
This charter guides our cooperative's technology decisions, so
|
||||||
based on our shared values and operational needs. It ensures
|
that we can choose tools that don't contradict our values.
|
||||||
we choose tools that support our mission while respecting our
|
|
||||||
principles of autonomy, sustainability, and mutual aid.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -672,7 +677,7 @@ definePageMeta({
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const charterPurpose = ref(
|
const charterPurpose = ref(
|
||||||
"This charter guides our cooperative's technology decisions based on our shared values and operational needs. It ensures we choose tools that support our mission while respecting our principles of autonomy, sustainability, and mutual aid."
|
"This charter guides our cooperative's technology decisions, so that we can choose tools that don't contradict our values."
|
||||||
);
|
);
|
||||||
const principleWeights = ref({});
|
const principleWeights = ref({});
|
||||||
const nonNegotiables = ref([]);
|
const nonNegotiables = ref([]);
|
||||||
|
|
@ -690,7 +695,7 @@ const constraints = ref({
|
||||||
const principles = [
|
const principles = [
|
||||||
{
|
{
|
||||||
id: "privacy",
|
id: "privacy",
|
||||||
name: "Privacy & Data Control",
|
name: "Privacy and data control",
|
||||||
description: "Data minimization, encryption, sovereignty, and user consent",
|
description: "Data minimization, encryption, sovereignty, and user consent",
|
||||||
rubricDescription:
|
rubricDescription:
|
||||||
"Data collection practices, encryption standards, jurisdiction control",
|
"Data collection practices, encryption standards, jurisdiction control",
|
||||||
|
|
@ -698,14 +703,14 @@ const principles = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "accessibility",
|
id: "accessibility",
|
||||||
name: "Universal Access",
|
name: "Universal access",
|
||||||
description: "WCAG compliance, screen readers, keyboard navigation",
|
description: "WCAG compliance, screen readers, keyboard navigation",
|
||||||
rubricDescription: "WCAG 2.2 AA, keyboard nav, screen reader support",
|
rubricDescription: "WCAG 2.2 AA, keyboard nav, screen reader support",
|
||||||
defaultWeight: 5,
|
defaultWeight: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "portability",
|
id: "portability",
|
||||||
name: "Data Freedom",
|
name: "Data freedom",
|
||||||
description: "Easy export, no vendor lock-in, migration-friendly",
|
description: "Easy export, no vendor lock-in, migration-friendly",
|
||||||
rubricDescription:
|
rubricDescription:
|
||||||
"Export capabilities, proprietary formats, switching costs",
|
"Export capabilities, proprietary formats, switching costs",
|
||||||
|
|
@ -713,7 +718,7 @@ const principles = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "opensource",
|
id: "opensource",
|
||||||
name: "Open Source & Community",
|
name: "Open source and community",
|
||||||
description:
|
description:
|
||||||
"FOSS preference, transparent development, community governance",
|
"FOSS preference, transparent development, community governance",
|
||||||
rubricDescription: "License type, community involvement, code transparency",
|
rubricDescription: "License type, community involvement, code transparency",
|
||||||
|
|
@ -721,7 +726,7 @@ const principles = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sustainability",
|
id: "sustainability",
|
||||||
name: "Sustainable Operations",
|
name: "Sustainable operations",
|
||||||
description: "Predictable costs, green hosting, efficient resource use",
|
description: "Predictable costs, green hosting, efficient resource use",
|
||||||
rubricDescription:
|
rubricDescription:
|
||||||
"Total cost of ownership, carbon footprint, resource efficiency",
|
"Total cost of ownership, carbon footprint, resource efficiency",
|
||||||
|
|
@ -729,14 +734,14 @@ const principles = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "localization",
|
id: "localization",
|
||||||
name: "Local Support",
|
name: "Local support",
|
||||||
description: "Multi-language, timezone aware, cultural sensitivity",
|
description: "Multi-language, timezone aware, cultural sensitivity",
|
||||||
rubricDescription: "Language options, cultural awareness, regional support",
|
rubricDescription: "Language options, cultural awareness, regional support",
|
||||||
defaultWeight: 2,
|
defaultWeight: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "usability",
|
id: "usability",
|
||||||
name: "User Experience",
|
name: "User experience",
|
||||||
description:
|
description:
|
||||||
"Intuitive interface, minimal learning curve, daily efficiency",
|
"Intuitive interface, minimal learning curve, daily efficiency",
|
||||||
rubricDescription:
|
rubricDescription:
|
||||||
|
|
@ -746,30 +751,30 @@ const principles = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const authOptions = [
|
const authOptions = [
|
||||||
{ value: "required", label: "SSO Required" },
|
{ value: "required", label: "SSO required" },
|
||||||
{ value: "preferred", label: "SSO Preferred" },
|
{ value: "preferred", label: "SSO preferred" },
|
||||||
{ value: "optional", label: "SSO Optional" },
|
{ value: "optional", label: "SSO optional" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hostingOptions = [
|
const hostingOptions = [
|
||||||
{ value: "self", label: "Self-Hosted Only" },
|
{ value: "self", label: "Self-hosted only" },
|
||||||
{ value: "either", label: "Either" },
|
{ value: "either", label: "Either" },
|
||||||
{ value: "managed", label: "Managed Only" },
|
{ value: "managed", label: "Managed only" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const integrationOptions = ["Slack", "OIDC/OAuth", "Webhooks", "REST API"];
|
const integrationOptions = ["Slack", "OIDC/OAuth", "Webhooks", "REST API"];
|
||||||
|
|
||||||
const supportOptions = [
|
const supportOptions = [
|
||||||
{ value: "community", label: "Community Only OK" },
|
{ value: "community", label: "Community only OK" },
|
||||||
{ value: "business", label: "Business Hours" },
|
{ value: "business", label: "Business hours" },
|
||||||
{ value: "24-7", label: "24/7 Required" },
|
{ value: "24-7", label: "24/7 required" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const timelineOptions = [
|
const timelineOptions = [
|
||||||
{ value: "immediate", label: "This Month" },
|
{ value: "immediate", label: "This month" },
|
||||||
{ value: "quarter", label: "This Quarter" },
|
{ value: "quarter", label: "This quarter" },
|
||||||
{ value: "year", label: "This Year" },
|
{ value: "year", label: "This year" },
|
||||||
{ value: "exploring", label: "Just Exploring" },
|
{ value: "exploring", label: "Just exploring" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
|
|
@ -853,7 +858,7 @@ const toggleIntegration = (integration) => {
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
if (confirm("Are you sure you want to clear all form data and start over?")) {
|
if (confirm("Are you sure you want to clear all form data and start over?")) {
|
||||||
charterPurpose.value =
|
charterPurpose.value =
|
||||||
"This charter guides our cooperative's technology decisions based on our shared values and operational needs. It ensures we choose tools that support our mission while respecting our principles of autonomy, sustainability, and mutual aid.";
|
"This charter guides our cooperative's technology decisions, so that we can choose tools that don't contradict our values.";
|
||||||
// Reset all principle weights to 0
|
// Reset all principle weights to 0
|
||||||
principles.forEach((p) => {
|
principles.forEach((p) => {
|
||||||
principleWeights.value[p.id] = 0;
|
principleWeights.value[p.id] = 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue