323 lines
9.4 KiB
Vue
323 lines
9.4 KiB
Vue
<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-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
|
|
<h4 class="font-medium text-neutral-900 dark:text-white mb-3">
|
|
Current Impact
|
|
</h4>
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<div class="text-neutral-600 dark:text-neutral-400">
|
|
Base Payroll
|
|
</div>
|
|
<div class="font-medium">
|
|
{{ formatCurrency(basePayroll) }}/month
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-neutral-600 dark:text-neutral-400">
|
|
Oncosts ({{ currentOncostPct }}%)
|
|
</div>
|
|
<div class="font-medium">
|
|
{{ formatCurrency(currentOncostAmount) }}/month
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
|
<div class="text-neutral-600 dark:text-neutral-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-neutral-700 dark:text-neutral-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-neutral-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-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 slider" />
|
|
<div class="flex justify-between text-xs text-neutral-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-neutral-700 dark:text-neutral-300">
|
|
Common Ranges
|
|
</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<UButton
|
|
v-for="preset in commonRanges"
|
|
:key="preset.value"
|
|
size="xs"
|
|
color="neutral"
|
|
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="neutral" 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>
|