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 {
|
||||
--font-body: "Ubuntu", "Inter", sans-serif;
|
||||
--font-mono: "Ubuntu Mono", monospace;
|
||||
--font-display: "Inter", sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
|
|
@ -289,22 +290,9 @@ html.dark .item-selected::after {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<UFormField label="Duration in months" class="">
|
||||
<UInputNumber
|
||||
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>
|
||||
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
|
||||
<label for="price" class="block font-bold text-sm mb-1"
|
||||
>Price per copy:</label
|
||||
>
|
||||
<UInput
|
||||
id="price"
|
||||
v-model.number="price"
|
||||
v-model="price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="flex-1 ml-1 px-2 py-1 border-2 border-black font-mono"
|
||||
>
|
||||
</div>
|
||||
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>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
<label for="storeCut" class="block font-bold text-sm mb-1"
|
||||
>Store cut:</label
|
||||
>
|
||||
<UInput
|
||||
id="storeCut"
|
||||
v-model.number="storeCutInput"
|
||||
v-model="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>
|
||||
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>
|
||||
<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="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>
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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 gap-4">
|
||||
<h2 class="text-2xl font-bold">Budget Worksheet</h2>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold mb-4">Project Budget Estimate</h1>
|
||||
<p class="text-neutral-600 max-w-2xl mx-auto mb-4">
|
||||
Get a quick estimate of what it would cost to build your project with fair pay.
|
||||
This tool helps worker co-ops sketch project budgets and break-even scenarios.
|
||||
<div class="">
|
||||
<h1 class="font-bold text-2xl mb-4">Project Budget Estimate</h1>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mx-auto mb-4">
|
||||
This tool provides a rough estimate of what it would cost to build your
|
||||
project using the pay policy you've set in the setup.
|
||||
</p>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 max-w-2xl mx-auto">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-information-circle" class="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div class="text-sm text-blue-800">
|
||||
<p class="font-medium mb-1">About the calculations:</p>
|
||||
<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>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Sustainable payroll toggle hidden - defaulting to theoretical maximum -->
|
||||
<div class="hidden">
|
||||
<span class="text-sm font-medium">Sustainable Payroll</span>
|
||||
<USwitch v-model="useTheoreticalPayroll" size="md" />
|
||||
<span class="text-sm font-medium">Theoretical Maximum</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
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
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
|
@ -31,54 +31,100 @@
|
|||
v-else
|
||||
:members="membersWithPay"
|
||||
:oncost-rate="coopStore.payrollOncostPct / 100"
|
||||
/>
|
||||
:payroll-mode="useTheoreticalPayroll ? 'theoretical' : 'sustainable'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const coopStore = useCoopBuilderStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
import { allocatePayroll as allocatePayrollImpl } from "~/types/members";
|
||||
|
||||
// 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(() => {
|
||||
// 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)
|
||||
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
|
||||
const { allocatePayroll } = useCoopBuilder()
|
||||
const theoreticalMembers = allocatePayroll()
|
||||
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 allocatedMembers
|
||||
.map((member: any) => {
|
||||
const hours = getHoursForMember(member);
|
||||
|
||||
return {
|
||||
name: member.name || 'Unnamed',
|
||||
name: member.displayName || "Unnamed",
|
||||
hoursPerMonth: hours,
|
||||
monthlyPay: member.monthlyPayPlanned || 0
|
||||
}
|
||||
}).filter(m => m.hoursPerMonth > 0) // Only include members with hours
|
||||
})
|
||||
monthlyPay: member.monthlyPayPlanned || 0,
|
||||
};
|
||||
})
|
||||
.filter((m: any) => m.hoursPerMonth > 0); // Only include members with hours
|
||||
});
|
||||
|
||||
// Set page meta
|
||||
definePageMeta({
|
||||
title: 'Project Budget Estimate'
|
||||
})
|
||||
title: "Project Budget Estimate",
|
||||
});
|
||||
</script>
|
||||
|
|
@ -33,7 +33,8 @@
|
|||
</h3>
|
||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
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>
|
||||
<a
|
||||
href="https://miro.com/miroverse/goals-values-exercise/"
|
||||
|
|
@ -92,64 +93,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -24,12 +24,13 @@
|
|||
<!-- Purpose Section -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</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
|
||||
your group.
|
||||
you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -44,10 +45,11 @@
|
|||
<!-- Unified Principles & Importance Section -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</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
|
||||
excluded, 5 means critical.
|
||||
</p>
|
||||
|
|
@ -67,7 +69,7 @@
|
|||
:class="[
|
||||
'relative transition-all',
|
||||
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',
|
||||
]">
|
||||
<div class="p-6">
|
||||
|
|
@ -90,7 +92,7 @@
|
|||
? 'text-neutral-700'
|
||||
: 'text-neutral-600'
|
||||
"
|
||||
class="text-sm">
|
||||
class="text-sm dark:text-neutral-200">
|
||||
{{ principle.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -139,7 +141,7 @@
|
|||
<!-- Non-negotiable toggle (only shows for weights > 0) -->
|
||||
<div
|
||||
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
|
||||
:class="[
|
||||
'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1',
|
||||
|
|
@ -152,7 +154,7 @@
|
|||
:checked="nonNegotiables.includes(principle.id)"
|
||||
@change="toggleNonNegotiable(principle.id)"
|
||||
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
|
||||
</span>
|
||||
</label>
|
||||
|
|
@ -163,7 +165,7 @@
|
|||
v-if="principleWeights[principle.id] > 0"
|
||||
class="mt-4 p-3 item-label-bg selected border border-neutral-200">
|
||||
<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:
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
|
|
@ -180,15 +182,17 @@
|
|||
<div class="section-card">
|
||||
<div>
|
||||
<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">
|
||||
Technical Constraints
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
||||
<legend class="font-semibold text-lg">Authentication</legend>
|
||||
<fieldset class="bg-neutral-50 dark:bg-neutral-800 p-6">
|
||||
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||
Authentication
|
||||
</legend>
|
||||
<div
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
|
|
@ -209,7 +213,7 @@
|
|||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
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',
|
||||
]">
|
||||
{{ option.label }}
|
||||
|
|
@ -218,8 +222,10 @@
|
|||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
||||
<legend class="font-semibold text-lg">Hosting Model</legend>
|
||||
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||
Hosting Model
|
||||
</legend>
|
||||
<div
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
|
|
@ -240,7 +246,7 @@
|
|||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
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',
|
||||
]">
|
||||
{{ option.label }}
|
||||
|
|
@ -249,11 +255,12 @@
|
|||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
||||
<legend class="font-semibold text-lg">
|
||||
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||
Required Integrations
|
||||
</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
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3 constraint-buttons">
|
||||
|
|
@ -273,7 +280,7 @@
|
|||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
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',
|
||||
]">
|
||||
{{ integration }}
|
||||
|
|
@ -282,7 +289,7 @@
|
|||
</div>
|
||||
</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">
|
||||
Support Expectations
|
||||
</legend>
|
||||
|
|
@ -306,7 +313,7 @@
|
|||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
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',
|
||||
]">
|
||||
{{ option.label }}
|
||||
|
|
@ -315,8 +322,8 @@
|
|||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
||||
<legend class="font-semibold text-lg">
|
||||
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||
Migration Timeline
|
||||
</legend>
|
||||
<div
|
||||
|
|
@ -339,7 +346,7 @@
|
|||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
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',
|
||||
]">
|
||||
{{ option.label }}
|
||||
|
|
@ -406,10 +413,8 @@
|
|||
<section class="mb-8">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
|
||||
<p class="text-neutral-700 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -672,7 +677,7 @@ definePageMeta({
|
|||
|
||||
// State
|
||||
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 nonNegotiables = ref([]);
|
||||
|
|
@ -690,7 +695,7 @@ const constraints = ref({
|
|||
const principles = [
|
||||
{
|
||||
id: "privacy",
|
||||
name: "Privacy & Data Control",
|
||||
name: "Privacy and data control",
|
||||
description: "Data minimization, encryption, sovereignty, and user consent",
|
||||
rubricDescription:
|
||||
"Data collection practices, encryption standards, jurisdiction control",
|
||||
|
|
@ -698,14 +703,14 @@ const principles = [
|
|||
},
|
||||
{
|
||||
id: "accessibility",
|
||||
name: "Universal Access",
|
||||
name: "Universal access",
|
||||
description: "WCAG compliance, screen readers, keyboard navigation",
|
||||
rubricDescription: "WCAG 2.2 AA, keyboard nav, screen reader support",
|
||||
defaultWeight: 5,
|
||||
},
|
||||
{
|
||||
id: "portability",
|
||||
name: "Data Freedom",
|
||||
name: "Data freedom",
|
||||
description: "Easy export, no vendor lock-in, migration-friendly",
|
||||
rubricDescription:
|
||||
"Export capabilities, proprietary formats, switching costs",
|
||||
|
|
@ -713,7 +718,7 @@ const principles = [
|
|||
},
|
||||
{
|
||||
id: "opensource",
|
||||
name: "Open Source & Community",
|
||||
name: "Open source and community",
|
||||
description:
|
||||
"FOSS preference, transparent development, community governance",
|
||||
rubricDescription: "License type, community involvement, code transparency",
|
||||
|
|
@ -721,7 +726,7 @@ const principles = [
|
|||
},
|
||||
{
|
||||
id: "sustainability",
|
||||
name: "Sustainable Operations",
|
||||
name: "Sustainable operations",
|
||||
description: "Predictable costs, green hosting, efficient resource use",
|
||||
rubricDescription:
|
||||
"Total cost of ownership, carbon footprint, resource efficiency",
|
||||
|
|
@ -729,14 +734,14 @@ const principles = [
|
|||
},
|
||||
{
|
||||
id: "localization",
|
||||
name: "Local Support",
|
||||
name: "Local support",
|
||||
description: "Multi-language, timezone aware, cultural sensitivity",
|
||||
rubricDescription: "Language options, cultural awareness, regional support",
|
||||
defaultWeight: 2,
|
||||
},
|
||||
{
|
||||
id: "usability",
|
||||
name: "User Experience",
|
||||
name: "User experience",
|
||||
description:
|
||||
"Intuitive interface, minimal learning curve, daily efficiency",
|
||||
rubricDescription:
|
||||
|
|
@ -746,30 +751,30 @@ const principles = [
|
|||
];
|
||||
|
||||
const authOptions = [
|
||||
{ value: "required", label: "SSO Required" },
|
||||
{ value: "preferred", label: "SSO Preferred" },
|
||||
{ value: "optional", label: "SSO Optional" },
|
||||
{ value: "required", label: "SSO required" },
|
||||
{ value: "preferred", label: "SSO preferred" },
|
||||
{ value: "optional", label: "SSO optional" },
|
||||
];
|
||||
|
||||
const hostingOptions = [
|
||||
{ value: "self", label: "Self-Hosted Only" },
|
||||
{ value: "self", label: "Self-hosted only" },
|
||||
{ value: "either", label: "Either" },
|
||||
{ value: "managed", label: "Managed Only" },
|
||||
{ value: "managed", label: "Managed only" },
|
||||
];
|
||||
|
||||
const integrationOptions = ["Slack", "OIDC/OAuth", "Webhooks", "REST API"];
|
||||
|
||||
const supportOptions = [
|
||||
{ value: "community", label: "Community Only OK" },
|
||||
{ value: "business", label: "Business Hours" },
|
||||
{ value: "24-7", label: "24/7 Required" },
|
||||
{ value: "community", label: "Community only OK" },
|
||||
{ value: "business", label: "Business hours" },
|
||||
{ value: "24-7", label: "24/7 required" },
|
||||
];
|
||||
|
||||
const timelineOptions = [
|
||||
{ value: "immediate", label: "This Month" },
|
||||
{ value: "quarter", label: "This Quarter" },
|
||||
{ value: "year", label: "This Year" },
|
||||
{ value: "exploring", label: "Just Exploring" },
|
||||
{ value: "immediate", label: "This month" },
|
||||
{ value: "quarter", label: "This quarter" },
|
||||
{ value: "year", label: "This year" },
|
||||
{ value: "exploring", label: "Just exploring" },
|
||||
];
|
||||
|
||||
// Computed
|
||||
|
|
@ -853,7 +858,7 @@ const toggleIntegration = (integration) => {
|
|||
const resetForm = () => {
|
||||
if (confirm("Are you sure you want to clear all form data and start over?")) {
|
||||
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
|
||||
principles.forEach((p) => {
|
||||
principleWeights.value[p.id] = 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue