refactor: enhance ProjectBudgetEstimate component layout, improve budget estimation calculations, and update CSS for better visual consistency and dark mode support
This commit is contained in:
parent
f073f91569
commit
b6e8d3b7ec
6 changed files with 502 additions and 358 deletions
|
|
@ -1,84 +1,130 @@
|
|||
<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>
|
||||
<NuxtLink
|
||||
to="/coop-builder"
|
||||
class="px-4 py-2 border-2 border-black bg-white font-bold hover:bg-neutral-100"
|
||||
>
|
||||
<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 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>
|
||||
|
||||
<ProjectBudgetEstimate
|
||||
|
||||
<ProjectBudgetEstimate
|
||||
v-else
|
||||
:members="membersWithPay"
|
||||
: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 {
|
||||
name: member.name || 'Unnamed',
|
||||
hoursPerMonth: hours,
|
||||
monthlyPay: member.monthlyPayPlanned || 0
|
||||
}
|
||||
}).filter(m => m.hoursPerMonth > 0) // Only include members with hours
|
||||
})
|
||||
|
||||
return allocatedMembers
|
||||
.map((member: any) => {
|
||||
const hours = getHoursForMember(member);
|
||||
|
||||
return {
|
||||
name: member.displayName || "Unnamed",
|
||||
hoursPerMonth: hours,
|
||||
monthlyPay: member.monthlyPayPlanned || 0,
|
||||
};
|
||||
})
|
||||
.filter((m: any) => m.hoursPerMonth > 0); // Only include members with hours
|
||||
});
|
||||
|
||||
// Set page meta
|
||||
definePageMeta({
|
||||
title: 'Project Budget Estimate'
|
||||
})
|
||||
</script>
|
||||
title: "Project Budget Estimate",
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue