refactor: remove deprecated components and streamline member coverage calculations, enhance budget management with improved payroll handling, and update UI elements for better clarity
This commit is contained in:
parent
983aeca2dc
commit
09d8794d72
42 changed files with 2166 additions and 2974 deletions
278
components/PayrollOncostModal.vue
Normal file
278
components/PayrollOncostModal.vue
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<template>
|
||||
<UModal
|
||||
v-model:open="isOpen"
|
||||
title="Payroll Oncost Settings"
|
||||
description="Configure payroll taxes and benefits percentage"
|
||||
:dismissible="true"
|
||||
>
|
||||
|
||||
<template #body>
|
||||
<div class="space-y-6">
|
||||
<!-- Explanation -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<UIcon name="i-heroicons-information-circle" class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div class="text-sm">
|
||||
<p class="text-blue-800 dark:text-blue-200 font-medium mb-2">What are payroll oncosts?</p>
|
||||
<p class="text-blue-700 dark:text-blue-300">
|
||||
Payroll oncosts cover taxes, benefits, and other employee-related expenses beyond base wages.
|
||||
This typically includes employer payroll taxes, worker's compensation, benefits contributions, and other statutory requirements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Settings Display -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-3">Current Impact</h4>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-gray-600 dark:text-gray-400">Base Payroll</div>
|
||||
<div class="font-medium">{{ formatCurrency(basePayroll) }}/month</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600 dark:text-gray-400">Oncosts ({{ currentOncostPct }}%)</div>
|
||||
<div class="font-medium">{{ formatCurrency(currentOncostAmount) }}/month</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">Total Payroll Cost</div>
|
||||
<div class="font-semibold text-lg">{{ formatCurrency(totalPayrollCost) }}/month</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Percentage Input -->
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Oncost Percentage
|
||||
</label>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-1">
|
||||
<UInput
|
||||
v-model.number="newOncostPct"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
placeholder="25"
|
||||
class="text-center"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">%</span>
|
||||
</div>
|
||||
|
||||
<!-- Slider for easier adjustment -->
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
v-model.number="newOncostPct"
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
step="1"
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 slider"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>0%</span>
|
||||
<span>25%</span>
|
||||
<span>50%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview of New Settings -->
|
||||
<div v-if="newOncostPct !== currentOncostPct" class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
|
||||
<h4 class="font-medium text-green-800 dark:text-green-200 mb-3">Preview Changes</h4>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-green-700 dark:text-green-300">New Oncosts ({{ newOncostPct }}%)</div>
|
||||
<div class="font-medium">{{ formatCurrency(newOncostAmount) }}/month</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-green-700 dark:text-green-300">New Total Cost</div>
|
||||
<div class="font-medium">{{ formatCurrency(newTotalCost) }}/month</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs">
|
||||
<span class="text-green-700 dark:text-green-300">
|
||||
{{ newTotalCost > totalPayrollCost ? 'Increase' : 'Decrease' }} of
|
||||
{{ formatCurrency(Math.abs(newTotalCost - totalPayrollCost)) }}/month
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common Oncost Ranges -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Common Ranges
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
v-for="preset in commonRanges"
|
||||
:key="preset.value"
|
||||
size="xs"
|
||||
color="gray"
|
||||
variant="outline"
|
||||
@click="newOncostPct = preset.value"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton color="gray" variant="ghost" @click="handleCancel">
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
@click="handleSave"
|
||||
:disabled="!isValidPercentage"
|
||||
>
|
||||
Update Oncost Percentage
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { allocatePayroll as allocatePayrollImpl } from '~/types/members'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'save', percentage: number): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Modal state
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
})
|
||||
|
||||
// Get current payroll data
|
||||
const coopStore = useCoopBuilderStore()
|
||||
const currentOncostPct = computed(() => coopStore.payrollOncostPct || 0)
|
||||
|
||||
// Calculate current payroll values using the same logic as the budget store
|
||||
const { allocatePayroll } = useCoopBuilder()
|
||||
|
||||
const basePayroll = computed(() => {
|
||||
// Calculate base payroll the same way the budget store does
|
||||
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0)), 0)
|
||||
const hourlyWage = coopStore.equalHourlyWage || 0
|
||||
const basePayrollBudget = totalHours * hourlyWage
|
||||
|
||||
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
||||
// Use policy-driven allocation to get actual member pay amounts
|
||||
const payPolicy = {
|
||||
relationship: coopStore.policy.relationship,
|
||||
roleBands: coopStore.policy.roleBands
|
||||
}
|
||||
|
||||
// Convert members to the format expected by allocatePayroll
|
||||
const membersForAllocation = coopStore.members.map(m => ({
|
||||
...m,
|
||||
displayName: m.name,
|
||||
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||
hoursPerMonth: m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0)
|
||||
}))
|
||||
|
||||
// Use the imported allocatePayroll function
|
||||
const allocatedMembers = allocatePayrollImpl(membersForAllocation, payPolicy, basePayrollBudget)
|
||||
|
||||
// Sum the allocated amounts for total payroll
|
||||
return allocatedMembers.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
const currentOncostAmount = computed(() =>
|
||||
basePayroll.value * (currentOncostPct.value / 100)
|
||||
)
|
||||
|
||||
const totalPayrollCost = computed(() =>
|
||||
basePayroll.value + currentOncostAmount.value
|
||||
)
|
||||
|
||||
// New percentage input
|
||||
const newOncostPct = ref(currentOncostPct.value)
|
||||
|
||||
// Computed values for preview
|
||||
const newOncostAmount = computed(() => basePayroll.value * (newOncostPct.value / 100))
|
||||
const newTotalCost = computed(() => basePayroll.value + newOncostAmount.value)
|
||||
|
||||
const isValidPercentage = computed(() =>
|
||||
newOncostPct.value >= 0 && newOncostPct.value <= 100
|
||||
)
|
||||
|
||||
// Common oncost ranges
|
||||
const commonRanges = [
|
||||
{ label: '0% (No oncosts)', value: 0 },
|
||||
{ label: '15% (Basic)', value: 15 },
|
||||
{ label: '25% (Standard)', value: 25 },
|
||||
{ label: '35% (Comprehensive)', value: 35 }
|
||||
]
|
||||
|
||||
// Reset to current value when modal opens
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
newOncostPct.value = currentOncostPct.value
|
||||
}
|
||||
})
|
||||
|
||||
// Handlers
|
||||
function handleCancel() {
|
||||
newOncostPct.value = currentOncostPct.value
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (isValidPercentage.value) {
|
||||
emit('save', newOncostPct.value)
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Currency formatting
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue