refactor: enhance routing and state management in CoopBuilder, add migration checks on startup, and update Tailwind configuration for improved component styling

This commit is contained in:
Jennie Robinson Faber 2025-08-23 18:24:31 +01:00
parent 848386e3dd
commit 4cea1f71fe
55 changed files with 4053 additions and 1486 deletions

View file

@ -4,10 +4,10 @@
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-2xl font-black text-black mb-2">
What's your equal hourly wage?
Set your wage & pay policy
</h3>
<p class="text-neutral-600">
Set the hourly rate that all co-op members will earn for their work.
Choose how to allocate payroll among members and set the base hourly rate.
</p>
</div>
<div class="flex items-center gap-3">
@ -22,18 +22,68 @@
</div>
</div>
<div class="max-w-md">
<UInput
v-model="wageText"
type="text"
placeholder="0.00"
size="xl"
class="text-4xl font-black w-full h-20"
@update:model-value="validateAndSaveWage">
<template #leading>
<span class="text-neutral-500 text-3xl">$</span>
<!-- Pay Policy Selection -->
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<h4 class="font-bold mb-4">Pay Allocation Policy</h4>
<div class="space-y-3">
<label v-for="option in policyOptions" :key="option.value" class="flex items-start gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded-lg transition-colors">
<input
type="radio"
:value="option.value"
v-model="selectedPolicy"
@change="updatePolicy(option.value)"
class="mt-1 w-4 h-4 text-black border-2 border-gray-300 focus:ring-2 focus:ring-black"
/>
<span class="text-sm flex-1">{{ option.label }}</span>
</label>
</div>
<!-- Role bands editor if role-banded is selected -->
<div v-if="selectedPolicy === 'role-banded'" class="mt-4 p-4 bg-gray-50 rounded-lg">
<h5 class="text-sm font-medium mb-3">Role Bands (monthly or weight)</h5>
<div class="space-y-2">
<div v-for="member in uniqueRoles" :key="member.role" class="flex items-center gap-2">
<span class="text-sm w-32">{{ member.role || 'No role' }}</span>
<UInput
v-model="roleBands[member.role || '']"
type="text"
placeholder="3000"
size="sm"
class="w-24"
@update:model-value="updateRoleBands"
/>
</div>
</div>
</div>
<UAlert
class="mt-4"
color="primary"
variant="soft"
icon="i-heroicons-information-circle"
>
<template #description>
Policies affect payroll allocation and member coverage. You can iterate later.
</template>
</UInput>
</UAlert>
</div>
<!-- Hourly Wage Input -->
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<h4 class="font-bold mb-4">Base Hourly Wage</h4>
<div class="max-w-md">
<UInput
v-model="wageText"
type="text"
placeholder="0.00"
size="xl"
class="text-4xl font-black w-full h-20"
@update:model-value="validateAndSaveWage">
<template #leading>
<span class="text-neutral-500 text-3xl"></span>
</template>
</UInput>
</div>
</div>
</div>
</template>
@ -44,7 +94,13 @@ const emit = defineEmits<{
}>();
// Store
const policiesStore = usePoliciesStore();
const coop = useCoopBuilder();
const store = useCoopBuilderStore();
// Initialize from store
const selectedPolicy = ref(coop.policy.value?.relationship || 'equal-pay')
const roleBands = ref(coop.policy.value?.roleBands || {})
const wageText = ref(String(store.equalHourlyWage || ''))
function parseNumberInput(val: unknown): number {
if (typeof val === "number") return val;
@ -56,20 +112,49 @@ function parseNumberInput(val: unknown): number {
return 0;
}
// Text input for wage with validation
const wageText = ref(
policiesStore.equalHourlyWage > 0
? policiesStore.equalHourlyWage.toString()
: ""
);
// Pay policy options
const policyOptions = [
{ value: 'equal-pay', label: 'Equal pay - Everyone gets the same monthly amount' },
{ value: 'needs-weighted', label: 'Needs-weighted - Allocate based on minimum needs' },
{ value: 'hours-weighted', label: 'Hours-weighted - Allocate based on hours worked' },
{ value: 'role-banded', label: 'Role-banded - Different amounts per role' }
]
// Watch for store changes to update text field
watch(
() => policiesStore.equalHourlyWage,
(newWage) => {
wageText.value = newWage > 0 ? newWage.toString() : "";
// Already initialized above with store values
const uniqueRoles = computed(() => {
const roles = new Set(coop.members.value.map(m => m.role || ''))
return Array.from(roles).map(role => ({ role }))
})
function updatePolicy(value: string) {
selectedPolicy.value = value
coop.setPolicy(value as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded")
// Trigger payroll reallocation after policy change
const allocatedMembers = coop.allocatePayroll()
allocatedMembers.forEach(m => {
coop.upsertMember(m)
})
emit("save-status", "saved");
}
function updateRoleBands() {
coop.setRoleBands(roleBands.value)
// Trigger payroll reallocation after role bands change
if (selectedPolicy.value === 'role-banded') {
const allocatedMembers = coop.allocatePayroll()
allocatedMembers.forEach(m => {
coop.upsertMember(m)
})
}
);
emit("save-status", "saved");
}
// Text input for wage with validation (initialized above)
function validateAndSaveWage(value: string) {
const cleanValue = value.replace(/[^\d.]/g, "");
@ -78,56 +163,24 @@ function validateAndSaveWage(value: string) {
wageText.value = cleanValue;
if (!isNaN(numValue) && numValue >= 0) {
policiesStore.setEqualWage(numValue);
// Set sensible defaults when wage is set
if (numValue > 0) {
setDefaults();
emit("save-status", "saved");
}
coop.setEqualWage(numValue)
// Trigger payroll reallocation after wage change
const allocatedMembers = coop.allocatePayroll()
allocatedMembers.forEach(m => {
coop.upsertMember(m)
})
emit("save-status", "saved");
}
}
// Set reasonable defaults for hidden fields
function setDefaults() {
if (policiesStore.payrollOncostPct === 0) {
policiesStore.setOncostPct(25); // 25% on-costs
}
if (policiesStore.savingsTargetMonths === 0) {
policiesStore.setSavingsTargetMonths(3); // 3 months savings
}
if (policiesStore.minCashCushionAmount === 0) {
policiesStore.setMinCashCushion(3000); // 3k cushion
}
if (policiesStore.deferredCapHoursPerQtr === 0) {
policiesStore.setDeferredCap(240); // 240 hours quarterly cap
}
if (policiesStore.deferredSunsetMonths === 0) {
policiesStore.setDeferredSunset(12); // 12 month sunset
}
// Set default volunteer flows
if (policiesStore.volunteerScope.allowedFlows.length === 0) {
policiesStore.setVolunteerScope(["Care", "SharedLearning"]);
}
}
// Set defaults on mount if needed
onMounted(() => {
if (policiesStore.equalHourlyWage > 0) {
setDefaults();
}
});
function exportPolicies() {
const exportData = {
policies: {
equalHourlyWage: policiesStore.equalHourlyWage,
payrollOncostPct: policiesStore.payrollOncostPct,
savingsTargetMonths: policiesStore.savingsTargetMonths,
minCashCushionAmount: policiesStore.minCashCushionAmount,
deferredCapHoursPerQtr: policiesStore.deferredCapHoursPerQtr,
deferredSunsetMonths: policiesStore.deferredSunsetMonths,
volunteerScope: policiesStore.volunteerScope,
selectedPolicy: coop.policy.value?.relationship || selectedPolicy.value,
roleBands: coop.policy.value?.roleBands || roleBands.value,
equalHourlyWage: store.equalHourlyWage || parseFloat(wageText.value),
},
exportedAt: new Date().toISOString(),
section: "policies",