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
|
|
@ -239,7 +239,7 @@ const diversificationGuidance = computed(() => {
|
|||
if (grantsCategory && grantsCategory.percentage >= 20) {
|
||||
guidance += " You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
|
||||
} else if (servicesCategory && servicesCategory.percentage >= 20 && productsCategory && productsCategory.percentage >= 20) {
|
||||
guidance += " Strong foundation in both services and products — this balance helps smooth cash flow.";
|
||||
guidance += " Strong foundation in both services and products — this balance helps smooth revenue timing.";
|
||||
}
|
||||
|
||||
return guidance;
|
||||
|
|
|
|||
|
|
@ -51,21 +51,6 @@ const coopBuilderItems = [
|
|||
name: "Runway Lite",
|
||||
path: "/runway-lite",
|
||||
},
|
||||
{
|
||||
id: "scenarios",
|
||||
name: "Scenarios",
|
||||
path: "/scenarios",
|
||||
},
|
||||
{
|
||||
id: "cash",
|
||||
name: "Cash Flow",
|
||||
path: "/cash",
|
||||
},
|
||||
{
|
||||
id: "session",
|
||||
name: "Value Session",
|
||||
path: "/session",
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
<template>
|
||||
<UTooltip :text="tooltipText">
|
||||
<UBadge
|
||||
:color="badgeColor"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
class="font-medium"
|
||||
>
|
||||
<UBadge :color="badgeColor" variant="solid" size="sm" class="font-medium">
|
||||
<UIcon :name="iconName" class="w-3 h-3 mr-1" />
|
||||
{{ displayText }}
|
||||
</UBadge>
|
||||
|
|
@ -14,44 +9,46 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
coverageMinPct?: number
|
||||
coverageTargetPct?: number
|
||||
memberName?: string
|
||||
warnIfUnder?: number
|
||||
coveragePct?: number;
|
||||
memberName?: string;
|
||||
warnIfUnder?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
warnIfUnder: 100,
|
||||
memberName: 'member'
|
||||
})
|
||||
memberName: "member",
|
||||
});
|
||||
|
||||
const coverage = computed(() => props.coverageMinPct || 0)
|
||||
const coverage = computed(() => props.coveragePct || 0);
|
||||
|
||||
const badgeColor = computed(() => {
|
||||
if (coverage.value >= 100) return 'success'
|
||||
if (coverage.value >= 80) return 'warning'
|
||||
return 'error'
|
||||
})
|
||||
if (!props.coveragePct) return "neutral";
|
||||
if (coverage.value >= 100) return "success";
|
||||
if (coverage.value >= 80) return "warning";
|
||||
return "error";
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (coverage.value >= 100) return 'i-heroicons-check-circle'
|
||||
if (coverage.value >= 80) return 'i-heroicons-exclamation-triangle'
|
||||
return 'i-heroicons-x-circle'
|
||||
})
|
||||
if (!props.coveragePct) return "i-heroicons-cog-6-tooth";
|
||||
if (coverage.value >= 100) return "i-heroicons-check-circle";
|
||||
if (coverage.value >= 80) return "i-heroicons-exclamation-triangle";
|
||||
return "i-heroicons-x-circle";
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!props.coverageMinPct) return 'No needs set'
|
||||
return `${Math.round(coverage.value)}% coverage`
|
||||
})
|
||||
if (!props.coveragePct) return "Set needs";
|
||||
if (coverage.value === 0) return "No coverage";
|
||||
return `${Math.round(coverage.value)}% covered`;
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (!props.coverageMinPct) {
|
||||
return `${props.memberName} hasn't set their minimum needs yet`
|
||||
if (!props.coveragePct) {
|
||||
return `Click 'Set minimum needs' to enable coverage tracking for ${props.memberName}`;
|
||||
}
|
||||
|
||||
const percent = Math.round(coverage.value)
|
||||
const status = coverage.value >= 100 ? 'meets' : 'covers'
|
||||
|
||||
return `${status} ${percent}% of ${props.memberName}'s minimum needs (incl. external income)`
|
||||
})
|
||||
</script>
|
||||
|
||||
const percent = Math.round(coverage.value);
|
||||
const status = coverage.value >= 100 ? "meets" : "covers";
|
||||
|
||||
return `Co-op pay ${status} ${percent}% of ${props.memberName}'s minimum needs`;
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -1,42 +1,14 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header with Export Controls -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black mb-2">
|
||||
Where does your money go?
|
||||
</h3>
|
||||
<p class="text-neutral-600">
|
||||
Add costs like rent, tools, insurance, or other recurring expenses.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UButton variant="outline" color="gray" size="sm" @click="exportCosts">
|
||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
||||
Export
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operating Mode Toggle -->
|
||||
<div class="p-4 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-bold text-sm">Operating Mode</h4>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
Choose between minimum needs or target pay for payroll calculations
|
||||
</p>
|
||||
</div>
|
||||
<UToggle
|
||||
v-model="useTargetMode"
|
||||
@update:model-value="updateOperatingMode"
|
||||
:ui="{ active: 'bg-success-500' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 text-xs font-medium">
|
||||
{{ useTargetMode ? '🎯 Target Mode' : '⚡ Minimum Mode' }}:
|
||||
{{ useTargetMode ? 'Uses target pay allocations' : 'Uses minimum needs allocations' }}
|
||||
</div>
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black mb-2">
|
||||
Where does your money go?
|
||||
</h3>
|
||||
<p class="text-neutral-600">
|
||||
Add costs like rent + utilities, software licenses, insurance, lawyer
|
||||
fees, accountant fees, and other recurring expenses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Overhead Costs -->
|
||||
|
|
@ -44,24 +16,12 @@
|
|||
<div
|
||||
v-if="overheadCosts.length > 0"
|
||||
class="flex items-center justify-between">
|
||||
<h4 class="text-lg font-bold text-black">Monthly Overhead</h4>
|
||||
<UButton
|
||||
size="sm"
|
||||
@click="addOverheadCost"
|
||||
variant="solid"
|
||||
color="success"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||
}">
|
||||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
||||
Add Cost
|
||||
</UButton>
|
||||
<h4 class="text-lg font-bold text-black">Overhead</h4>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="overheadCosts.length === 0"
|
||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
||||
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
||||
<h4 class="font-medium text-neutral-900 mb-2">No overhead costs yet</h4>
|
||||
<p class="text-sm text-neutral-500 mb-4">
|
||||
Get started by adding your first overhead cost.
|
||||
|
|
@ -79,48 +39,23 @@
|
|||
<div
|
||||
v-for="cost in overheadCosts"
|
||||
:key="cost.id"
|
||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<UFormField label="Cost Name" required>
|
||||
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
<!-- Header row with name and delete button -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<UInput
|
||||
v-model="cost.name"
|
||||
placeholder="Office rent"
|
||||
size="xl"
|
||||
class="text-lg font-medium w-full"
|
||||
class="text-xl w-full font-bold flex-1"
|
||||
@update:model-value="saveCost(cost)"
|
||||
@blur="saveCost(cost)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Monthly Amount" required>
|
||||
<UInput
|
||||
v-model="cost.amount"
|
||||
type="text"
|
||||
placeholder="800.00"
|
||||
size="xl"
|
||||
class="text-lg font-bold w-full"
|
||||
@update:model-value="validateAndSaveAmount($event, cost)"
|
||||
@blur="saveCost(cost)">
|
||||
<template #leading>
|
||||
<span class="text-neutral-500">€</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Category">
|
||||
<USelect
|
||||
v-model="cost.category"
|
||||
:items="categoryOptions"
|
||||
size="xl"
|
||||
class="text-lg font-medium w-full"
|
||||
@update:model-value="saveCost(cost)" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
|
||||
</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="solid"
|
||||
color="error"
|
||||
class="ml-4"
|
||||
@click="removeCost(cost.id)"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||
|
|
@ -128,6 +63,66 @@
|
|||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Fields grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<UFormField label="Category">
|
||||
<USelect
|
||||
v-model="cost.category"
|
||||
:items="categoryOptions"
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="saveCost(cost)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
:label="
|
||||
cost.amountType === 'annual' ? 'Annual Amount' : 'Monthly Amount'
|
||||
"
|
||||
required>
|
||||
<div class="flex gap-2">
|
||||
<UInput
|
||||
:value="
|
||||
cost.amountType === 'annual' ? cost.annualAmount : cost.amount
|
||||
"
|
||||
type="text"
|
||||
:placeholder="cost.amountType === 'annual' ? '9600' : '800'"
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="validateAndSaveAmount($event, cost)"
|
||||
@blur="saveCost(cost)">
|
||||
<template #leading>
|
||||
<span class="text-neutral-500">{{ currencySymbol }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<UButtonGroup size="md">
|
||||
<UButton
|
||||
:variant="cost.amountType === 'monthly' ? 'solid' : 'outline'"
|
||||
color="primary"
|
||||
@click="switchAmountType(cost, 'monthly')"
|
||||
class="text-xs">
|
||||
Monthly
|
||||
</UButton>
|
||||
<UButton
|
||||
:variant="cost.amountType === 'annual' ? 'solid' : 'outline'"
|
||||
color="primary"
|
||||
@click="switchAmountType(cost, 'annual')"
|
||||
class="text-xs">
|
||||
Annual
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
<template v-if="cost.amountType === 'annual'">
|
||||
{{ currencySymbol
|
||||
}}{{ Math.round((cost.annualAmount || 0) / 12) }} per month
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ currencySymbol }}{{ (cost.amount || 0) * 12 }} per year
|
||||
</template>
|
||||
</p>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Cost Button (when items exist) -->
|
||||
|
|
@ -155,20 +150,27 @@ const emit = defineEmits<{
|
|||
"save-status": [status: "saving" | "saved" | "error"];
|
||||
}>();
|
||||
|
||||
// Store
|
||||
// Store and Currency
|
||||
const coop = useCoopBuilder();
|
||||
const { currencySymbol } = useCurrency();
|
||||
|
||||
// Get the store directly for overhead costs
|
||||
const store = useCoopBuilderStore();
|
||||
|
||||
// Computed for overhead costs (from store)
|
||||
const overheadCosts = computed(() => store.overheadCosts || []);
|
||||
// Computed for overhead costs (from store) with amountType defaults
|
||||
const overheadCosts = computed(() =>
|
||||
(store.overheadCosts || []).map((cost) => ({
|
||||
...cost,
|
||||
amountType: cost.amountType || "monthly",
|
||||
annualAmount: cost.annualAmount || (cost.amount || 0) * 12,
|
||||
}))
|
||||
);
|
||||
|
||||
// Operating mode toggle
|
||||
const useTargetMode = ref(coop.operatingMode.value === 'target');
|
||||
const useTargetMode = ref(coop.operatingMode.value === "target");
|
||||
|
||||
function updateOperatingMode(value: boolean) {
|
||||
coop.setOperatingMode(value ? 'target' : 'min');
|
||||
coop.setOperatingMode(value ? "target" : "min");
|
||||
emit("save-status", "saved");
|
||||
}
|
||||
|
||||
|
|
@ -211,23 +213,66 @@ const debouncedSave = useDebounceFn((cost: any) => {
|
|||
}, 300);
|
||||
|
||||
function saveCost(cost: any) {
|
||||
if (cost.name && cost.amount >= 0) {
|
||||
const hasValidAmount =
|
||||
cost.amountType === "annual" ? cost.annualAmount >= 0 : cost.amount >= 0;
|
||||
|
||||
if (cost.name && hasValidAmount) {
|
||||
debouncedSave(cost);
|
||||
}
|
||||
}
|
||||
|
||||
// Immediate save without debounce for UI responsiveness
|
||||
function saveCostImmediate(cost: any) {
|
||||
try {
|
||||
// Use store's upsert method directly
|
||||
store.upsertOverheadCost(cost);
|
||||
} catch (error) {
|
||||
console.error("Failed to save cost:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Validation function for amount
|
||||
function validateAndSaveAmount(value: string, cost: any) {
|
||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||
cost.amount = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||
|
||||
if (cost.amountType === "annual") {
|
||||
cost.annualAmount = validValue;
|
||||
cost.amount = Math.round(validValue / 12);
|
||||
} else {
|
||||
cost.amount = validValue;
|
||||
cost.annualAmount = validValue * 12;
|
||||
}
|
||||
|
||||
saveCost(cost);
|
||||
}
|
||||
|
||||
// Function to switch between annual and monthly
|
||||
function switchAmountType(cost: any, type: "annual" | "monthly") {
|
||||
cost.amountType = type;
|
||||
|
||||
// Recalculate values based on new type
|
||||
if (type === "annual") {
|
||||
if (!cost.annualAmount) {
|
||||
cost.annualAmount = (cost.amount || 0) * 12;
|
||||
}
|
||||
} else {
|
||||
if (!cost.amount) {
|
||||
cost.amount = Math.round((cost.annualAmount || 0) / 12);
|
||||
}
|
||||
}
|
||||
|
||||
// Save immediately without debounce for instant UI update
|
||||
saveCostImmediate(cost);
|
||||
}
|
||||
|
||||
function addOverheadCost() {
|
||||
const newCost = {
|
||||
id: Date.now().toString(),
|
||||
name: "",
|
||||
amount: 0,
|
||||
annualAmount: 0,
|
||||
amountType: "monthly",
|
||||
category: "Operations",
|
||||
recurring: true,
|
||||
};
|
||||
|
|
@ -240,24 +285,4 @@ function removeCost(id: string) {
|
|||
store.removeOverheadCost(id);
|
||||
emit("save-status", "saved");
|
||||
}
|
||||
|
||||
function exportCosts() {
|
||||
const exportData = {
|
||||
overheadCosts: overheadCosts.value,
|
||||
exportedAt: new Date().toISOString(),
|
||||
section: "costs",
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `coop-costs-${new Date().toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,16 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header with Export Controls -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black mb-2">Who's on your team?</h3>
|
||||
<p class="text-neutral-600">
|
||||
Add everyone who'll be working in the co-op, even if they're not ready
|
||||
to be paid yet.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="gray"
|
||||
size="sm"
|
||||
@click="exportMembers">
|
||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
||||
Export
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="members.length > 0"
|
||||
@click="addMember"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="success"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||
}">
|
||||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
||||
Add member
|
||||
</UButton>
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black mb-2">Who's on your team?</h3>
|
||||
<p class="text-neutral-600">
|
||||
Add everyone who'll be working in the co-op. Based on your pay approach,
|
||||
we'll collect the right information for each person.
|
||||
</p>
|
||||
<!-- Debug info -->
|
||||
<div class="mt-2 p-2 bg-gray-100 rounded text-xs">
|
||||
Debug: Policy = {{ currentPolicy }}, Needs field shown =
|
||||
{{ isNeedsWeighted }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -38,7 +18,7 @@
|
|||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="members.length === 0"
|
||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
||||
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
||||
<h4 class="font-medium text-neutral-900 mb-2">No team members yet</h4>
|
||||
<p class="text-sm text-neutral-500 mb-4">
|
||||
Get started by adding your first team member.
|
||||
|
|
@ -52,27 +32,23 @@
|
|||
<div
|
||||
v-for="(member, index) in members"
|
||||
:key="member.id"
|
||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<!-- Header row with name and coverage chip -->
|
||||
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
<!-- Header row with name and optional coverage chip -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<UInput
|
||||
v-model="member.displayName"
|
||||
placeholder="Member name"
|
||||
size="lg"
|
||||
class="text-lg font-bold w-48"
|
||||
size="xl"
|
||||
class="text-xl w-full font-bold flex-1"
|
||||
@update:model-value="saveMember(member)"
|
||||
@blur="saveMember(member)" />
|
||||
<CoverageChip
|
||||
:coverage-min-pct="memberCoverage(member).minPct"
|
||||
:coverage-target-pct="memberCoverage(member).targetPct"
|
||||
:member-name="member.displayName || 'This member'"
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="solid"
|
||||
color="error"
|
||||
class="ml-4"
|
||||
@click="removeMember(member.id)"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||
|
|
@ -80,77 +56,36 @@
|
|||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Compact grid for pay and hours -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||
<UFormField label="Pay relationship" required>
|
||||
<USelect
|
||||
v-model="member.payRelationship"
|
||||
:items="payRelationshipOptions"
|
||||
|
||||
<!-- Essential fields based on policy -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<UFormField label="Hours per month" required>
|
||||
<UInputNumber
|
||||
v-model="member.capacity.targetHours"
|
||||
:min="0"
|
||||
:max="500"
|
||||
:step="1"
|
||||
placeholder="160"
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="saveMember(member)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Hours/month" required>
|
||||
<UInput
|
||||
v-model="member.capacity.targetHours"
|
||||
type="text"
|
||||
placeholder="120"
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="validateAndSaveHours($event, member)"
|
||||
@blur="saveMember(member)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Role (optional)">
|
||||
<UInput
|
||||
v-model="member.role"
|
||||
placeholder="Developer"
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="saveMember(member)"
|
||||
@blur="saveMember(member)" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Compact needs section -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-600 mb-1 block">Minimum needs (€/mo)</label>
|
||||
<UInput
|
||||
<!-- Show minimum needs field when needs-weighted policy is selected -->
|
||||
<UFormField
|
||||
v-if="isNeedsWeighted"
|
||||
:label="`Minimum needs (${getCurrencySymbol(coop.currency.value)}/month)`"
|
||||
required>
|
||||
<UInputNumber
|
||||
v-model="member.minMonthlyNeeds"
|
||||
type="text"
|
||||
placeholder="2000"
|
||||
size="sm"
|
||||
:min="0"
|
||||
:max="50000"
|
||||
:step="10"
|
||||
placeholder="2500"
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="validateAndSaveAmount($event, member, 'minMonthlyNeeds')"
|
||||
@blur="saveMember(member)" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-600 mb-1 block">Target pay (€/mo)</label>
|
||||
<UInput
|
||||
v-model="member.targetMonthlyPay"
|
||||
type="text"
|
||||
placeholder="3500"
|
||||
size="sm"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="validateAndSaveAmount($event, member, 'targetMonthlyPay')"
|
||||
@blur="saveMember(member)" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-600 mb-1 block">External income (€/mo)</label>
|
||||
<UInput
|
||||
v-model="member.externalMonthlyIncome"
|
||||
type="text"
|
||||
placeholder="1500"
|
||||
size="sm"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="validateAndSaveAmount($event, member, 'externalMonthlyIncome')"
|
||||
@blur="saveMember(member)" />
|
||||
</div>
|
||||
@update:model-value="saveMember(member)" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -176,6 +111,7 @@
|
|||
<script setup lang="ts">
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { coverage } from "~/types/members";
|
||||
import { getCurrencySymbol } from "~/utils/currency";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"save-status": [status: "saving" | "saved" | "error"];
|
||||
|
|
@ -183,24 +119,30 @@ const emit = defineEmits<{
|
|||
|
||||
// Store
|
||||
const coop = useCoopBuilder();
|
||||
const members = computed(() =>
|
||||
coop.members.value.map(m => ({
|
||||
const members = computed(() =>
|
||||
coop.members.value.map((m) => ({
|
||||
// Map store fields to component expectations
|
||||
id: m.id,
|
||||
displayName: m.name,
|
||||
role: m.role || '',
|
||||
capacity: {
|
||||
targetHours: m.hoursPerMonth || 0
|
||||
targetHours: Number(m.hoursPerMonth) || 0,
|
||||
},
|
||||
payRelationship: 'FullyPaid', // Default since not in store yet
|
||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||
targetMonthlyPay: m.targetMonthlyPay || 0,
|
||||
externalMonthlyIncome: m.externalMonthlyIncome || 0,
|
||||
monthlyPayPlanned: m.monthlyPayPlanned || 0
|
||||
payRelationship: "FullyPaid", // Default since not in store yet
|
||||
minMonthlyNeeds: Number(m.minMonthlyNeeds) || 0,
|
||||
monthlyPayPlanned: Number(m.monthlyPayPlanned) || 0,
|
||||
}))
|
||||
);
|
||||
|
||||
// Options
|
||||
// Get current policy to determine which fields to show
|
||||
const isNeedsWeighted = computed(() => {
|
||||
const policy = coop.policy.value?.relationship;
|
||||
return policy === "needs-weighted";
|
||||
});
|
||||
|
||||
// Also expose policy for debugging in template
|
||||
const currentPolicy = computed(() => coop.policy.value?.relationship || "none");
|
||||
|
||||
// Simplified options - removed pay relationship as it's now in the policies step
|
||||
const payRelationshipOptions = [
|
||||
{ label: "Fully Paid", value: "FullyPaid" },
|
||||
{ label: "Hybrid", value: "Hybrid" },
|
||||
|
|
@ -236,15 +178,12 @@ const debouncedSave = useDebounceFn((member: any) => {
|
|||
// Convert component format back to store format
|
||||
const memberData = {
|
||||
id: member.id,
|
||||
name: member.displayName || '',
|
||||
role: member.role || '',
|
||||
hoursPerMonth: member.capacity?.targetHours || 0,
|
||||
minMonthlyNeeds: member.minMonthlyNeeds || 0,
|
||||
targetMonthlyPay: member.targetMonthlyPay || 0,
|
||||
externalMonthlyIncome: member.externalMonthlyIncome || 0,
|
||||
monthlyPayPlanned: member.monthlyPayPlanned || 0,
|
||||
name: member.displayName || "",
|
||||
hoursPerMonth: Number(member.capacity?.targetHours) || 0,
|
||||
minMonthlyNeeds: Number(member.minMonthlyNeeds) || 0,
|
||||
monthlyPayPlanned: Number(member.monthlyPayPlanned) || 0,
|
||||
};
|
||||
|
||||
|
||||
coop.upsertMember(memberData);
|
||||
emit("save-status", "saved");
|
||||
} catch (error) {
|
||||
|
|
@ -257,13 +196,7 @@ function saveMember(member: any) {
|
|||
debouncedSave(member);
|
||||
}
|
||||
|
||||
// Validation functions
|
||||
function validateAndSaveHours(value: string, member: any) {
|
||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||
member.capacity.targetHours = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||
saveMember(member);
|
||||
}
|
||||
|
||||
// Validation functions (simplified since UInputNumber handles numeric validation)
|
||||
function validateAndSavePercentage(value: string, member: any) {
|
||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||
member.externalCoveragePct = isNaN(numValue)
|
||||
|
|
@ -272,30 +205,16 @@ function validateAndSavePercentage(value: string, member: any) {
|
|||
saveMember(member);
|
||||
}
|
||||
|
||||
function validateAndSaveAmount(value: string, member: any, field: string) {
|
||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||
member[field] = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||
saveMember(member);
|
||||
}
|
||||
|
||||
function memberCoverage(member: any) {
|
||||
return coverage(
|
||||
member.minMonthlyNeeds || 0,
|
||||
member.targetMonthlyPay || 0,
|
||||
member.monthlyPayPlanned || 0,
|
||||
member.externalMonthlyIncome || 0
|
||||
);
|
||||
return coverage(member.minMonthlyNeeds || 0, member.monthlyPayPlanned || 0);
|
||||
}
|
||||
|
||||
function addMember() {
|
||||
const newMember = {
|
||||
id: Date.now().toString(),
|
||||
name: "",
|
||||
role: "",
|
||||
hoursPerMonth: 0,
|
||||
minMonthlyNeeds: 0,
|
||||
targetMonthlyPay: 0,
|
||||
externalMonthlyIncome: 0,
|
||||
monthlyPayPlanned: 0,
|
||||
};
|
||||
|
||||
|
|
@ -305,24 +224,4 @@ function addMember() {
|
|||
function removeMember(id: string) {
|
||||
coop.removeMember(id);
|
||||
}
|
||||
|
||||
function exportMembers() {
|
||||
const exportData = {
|
||||
members: members.value,
|
||||
exportedAt: new Date().toISOString(),
|
||||
section: "members",
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `coop-members-${new Date().toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,97 +1,78 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header with Export Controls -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black mb-2">Set your wage & pay policy</h3>
|
||||
<p class="text-neutral-600">
|
||||
Choose how to allocate payroll among members and set the base hourly rate.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UButton variant="outline" color="neutral" size="sm" @click="exportPolicies">
|
||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
||||
Export
|
||||
</UButton>
|
||||
</div>
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black mb-2">
|
||||
How will you share money?
|
||||
</h3>
|
||||
<p class="text-neutral-600">
|
||||
This is the foundation of your co-op's finances. Choose a pay approach
|
||||
and set your hourly rate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</UAlert>
|
||||
<div class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
<h4 class="font-bold mb-2">Step 1: Choose your pay approach</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
How should available money be shared among members?
|
||||
</p>
|
||||
<URadioGroup
|
||||
v-model="selectedPolicy"
|
||||
:items="policyOptions"
|
||||
@update:model-value="updatePolicy"
|
||||
variant="list"
|
||||
size="xl"
|
||||
class="flex flex-col gap-2 w-full" />
|
||||
</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 class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
<h4 class="font-bold mb-2">Step 2: Set your base wage</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
This hourly rate applies to all paid work in your co-op
|
||||
</p>
|
||||
<div class="flex gap-4 items-start">
|
||||
<!-- Currency Selection -->
|
||||
<UFormField label="Currency" class="w-1/2">
|
||||
<USelect
|
||||
v-model="selectedCurrency"
|
||||
:items="currencySelectOptions"
|
||||
placeholder="Select currency"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
@update:model-value="updateCurrency">
|
||||
<template #leading>
|
||||
<span class="text-lg">{{
|
||||
getCurrencySymbol(selectedCurrency)
|
||||
}}</span>
|
||||
</template>
|
||||
</USelect>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Hourly Rate" class="w-1/2">
|
||||
<UInput
|
||||
v-model="wageText"
|
||||
type="text"
|
||||
placeholder="0.00"
|
||||
size="xl"
|
||||
class="text-2xl font-bold w-full"
|
||||
@update:model-value="validateAndSaveWage">
|
||||
<template #leading>
|
||||
<span class="text-neutral-500 text-xl">{{
|
||||
getCurrencySymbol(selectedCurrency)
|
||||
}}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { currencyOptions, getCurrencySymbol } from "~/utils/currency";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"save-status": [status: "saving" | "saved" | "error"];
|
||||
}>();
|
||||
|
|
@ -104,6 +85,7 @@ const store = useCoopBuilderStore();
|
|||
const selectedPolicy = ref(coop.policy.value?.relationship || "equal-pay");
|
||||
const roleBands = ref(coop.policy.value?.roleBands || {});
|
||||
const wageText = ref(String(store.equalHourlyWage || ""));
|
||||
const selectedCurrency = ref(coop.currency.value || "EUR");
|
||||
|
||||
function parseNumberInput(val: unknown): number {
|
||||
if (typeof val === "number") return val;
|
||||
|
|
@ -115,35 +97,42 @@ function parseNumberInput(val: unknown): number {
|
|||
return 0;
|
||||
}
|
||||
|
||||
// Pay policy options
|
||||
// Simplified 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",
|
||||
label: "Equal pay - Everyone gets the same amount",
|
||||
},
|
||||
{
|
||||
value: "hours-weighted",
|
||||
label: "Hours-weighted - Allocate based on hours worked",
|
||||
label: "Hours-based - Pay proportional to hours worked",
|
||||
},
|
||||
{
|
||||
value: "needs-weighted",
|
||||
label: "Needs-based - Pay proportional to individual needs",
|
||||
},
|
||||
{ value: "role-banded", label: "Role-banded - Different amounts per role" },
|
||||
];
|
||||
|
||||
// Currency options for USelect (simplified format)
|
||||
const currencySelectOptions = computed(() =>
|
||||
currencyOptions.map((currency) => ({
|
||||
label: `${currency.name} (${currency.code})`,
|
||||
value: currency.code,
|
||||
}))
|
||||
);
|
||||
|
||||
// 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 }));
|
||||
});
|
||||
// Removed uniqueRoles computed - no longer needed with simplified policies
|
||||
|
||||
function updateCurrency(value: string) {
|
||||
selectedCurrency.value = value;
|
||||
coop.setCurrency(value);
|
||||
emit("save-status", "saved");
|
||||
}
|
||||
|
||||
function updatePolicy(value: string) {
|
||||
selectedPolicy.value = value;
|
||||
coop.setPolicy(
|
||||
value as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded"
|
||||
);
|
||||
coop.setPolicy(value as "equal-pay" | "needs-weighted" | "hours-weighted");
|
||||
|
||||
// Trigger payroll reallocation after policy change
|
||||
const allocatedMembers = coop.allocatePayroll();
|
||||
|
|
@ -154,19 +143,7 @@ function updatePolicy(value: string) {
|
|||
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");
|
||||
}
|
||||
// Removed updateRoleBands - no longer needed with simplified policies
|
||||
|
||||
// Text input for wage with validation (initialized above)
|
||||
|
||||
|
|
@ -188,28 +165,4 @@ function validateAndSaveWage(value: string) {
|
|||
emit("save-status", "saved");
|
||||
}
|
||||
}
|
||||
|
||||
function exportPolicies() {
|
||||
const exportData = {
|
||||
policies: {
|
||||
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",
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `coop-policies-${new Date().toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -12,22 +12,11 @@
|
|||
|
||||
<!-- Removed Tab Navigation - showing streams directly -->
|
||||
<div class="space-y-6">
|
||||
<!-- Export Controls -->
|
||||
<div class="flex justify-end">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="gray"
|
||||
size="sm"
|
||||
@click="exportStreams">
|
||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
||||
Export
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="streams.length === 0"
|
||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
||||
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
||||
<h4 class="font-medium text-neutral-900 mb-2">
|
||||
No revenue streams yet
|
||||
</h4>
|
||||
|
|
@ -47,56 +36,85 @@
|
|||
<div
|
||||
v-for="stream in streams"
|
||||
:key="stream.id"
|
||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<UFormField label="Category" required>
|
||||
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
<!-- First row: Category and Name with delete button -->
|
||||
<div class="flex gap-4 mb-4">
|
||||
<UFormField label="Category" required class="flex-1">
|
||||
<USelect
|
||||
v-model="stream.category"
|
||||
:items="categoryOptions"
|
||||
size="xl"
|
||||
class="text-xl font-bold w-full"
|
||||
@update:model-value="saveStream(stream)" />
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="saveCategoryChange(stream)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Revenue source name" required>
|
||||
<USelectMenu
|
||||
v-model="stream.name"
|
||||
:items="nameOptionsByCategory[stream.category] || []"
|
||||
placeholder="Select or type a source name"
|
||||
creatable
|
||||
searchable
|
||||
size="xl"
|
||||
class="text-xl font-bold w-full"
|
||||
@update:model-value="saveStream(stream)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Monthly amount" required>
|
||||
<UInput
|
||||
v-model="stream.targetMonthlyAmount"
|
||||
type="text"
|
||||
placeholder="5000"
|
||||
size="xl"
|
||||
class="text-xl font-black w-full"
|
||||
@update:model-value="validateAndSaveAmount($event, stream)"
|
||||
@blur="saveStream(stream)">
|
||||
<template #leading>
|
||||
<span class="text-neutral-500 text-xl">$</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<UFormField label="Name" required class="flex-1">
|
||||
<div class="flex gap-2">
|
||||
<USelectMenu
|
||||
v-model="stream.name"
|
||||
:items="nameOptionsByCategory[stream.category] || []"
|
||||
placeholder="Select or type a source name"
|
||||
creatable
|
||||
searchable
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="saveStream(stream)" />
|
||||
<UButton
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="error"
|
||||
@click="removeStream(stream.id)"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||
}">
|
||||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||
</UButton>
|
||||
</div>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="solid"
|
||||
color="error"
|
||||
@click="removeStream(stream.id)"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||
}">
|
||||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||
</UButton>
|
||||
<!-- Second row: Amount with toggle -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<UFormField :label="stream.amountType === 'annual' ? 'Annual amount' : 'Monthly amount'" required>
|
||||
<div class="flex gap-2">
|
||||
<UInput
|
||||
:value="stream.amountType === 'annual' ? stream.targetAnnualAmount : stream.targetMonthlyAmount"
|
||||
type="text"
|
||||
:placeholder="stream.amountType === 'annual' ? '60000' : '5000'"
|
||||
size="md"
|
||||
class="text-sm font-medium w-full"
|
||||
@update:model-value="validateAndSaveAmount($event, stream)"
|
||||
@blur="saveStream(stream)">
|
||||
<template #leading>
|
||||
<span class="text-neutral-500">{{ currencySymbol }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<UButtonGroup size="md">
|
||||
<UButton
|
||||
:variant="stream.amountType === 'monthly' ? 'solid' : 'outline'"
|
||||
color="primary"
|
||||
@click="switchAmountType(stream, 'monthly')"
|
||||
class="text-xs">
|
||||
Monthly
|
||||
</UButton>
|
||||
<UButton
|
||||
:variant="stream.amountType === 'annual' ? 'solid' : 'outline'"
|
||||
color="primary"
|
||||
@click="switchAmountType(stream, 'annual')"
|
||||
class="text-xs">
|
||||
Annual
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
<template v-if="stream.amountType === 'annual'">
|
||||
{{ currencySymbol }}{{ Math.round((stream.targetAnnualAmount || 0) / 12) }} per month
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ currencySymbol }}{{ (stream.targetMonthlyAmount || 0) * 12 }} per year
|
||||
</template>
|
||||
</p>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -115,21 +133,6 @@
|
|||
Add another stream
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-if="streams.length > 0" class="flex items-center gap-3 justify-end">
|
||||
<UButton
|
||||
@click="addRevenueStream"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="success"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||
}">
|
||||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
||||
Add stream
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -142,8 +145,9 @@ const emit = defineEmits<{
|
|||
"save-status": [status: "saving" | "saved" | "error"];
|
||||
}>();
|
||||
|
||||
// Store
|
||||
// Store and Currency
|
||||
const coop = useCoopBuilder();
|
||||
const { currencySymbol } = useCurrency();
|
||||
const streams = computed(() =>
|
||||
coop.streams.value.map(s => ({
|
||||
// Map store fields to component expectations
|
||||
|
|
@ -151,6 +155,8 @@ const streams = computed(() =>
|
|||
name: s.label,
|
||||
category: s.category || 'games',
|
||||
targetMonthlyAmount: s.monthly || 0,
|
||||
targetAnnualAmount: (s.annual || (s.monthly || 0) * 12),
|
||||
amountType: s.amountType || 'monthly',
|
||||
subcategory: '',
|
||||
targetPct: 0,
|
||||
certainty: s.certainty || 'Aspirational',
|
||||
|
|
@ -219,7 +225,12 @@ const nameOptionsByCategory: Record<string, string[]> = {
|
|||
|
||||
// Computed
|
||||
const totalMonthlyAmount = computed(() =>
|
||||
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
|
||||
streams.value.reduce((sum, s) => {
|
||||
const monthly = s.amountType === 'annual'
|
||||
? Math.round((s.targetAnnualAmount || 0) / 12)
|
||||
: (s.targetMonthlyAmount || 0);
|
||||
return sum + monthly;
|
||||
}, 0)
|
||||
);
|
||||
|
||||
// Live-write with debounce
|
||||
|
|
@ -228,10 +239,16 @@ const debouncedSave = useDebounceFn((stream: any) => {
|
|||
|
||||
try {
|
||||
// Convert component format back to store format
|
||||
const monthly = stream.amountType === 'annual'
|
||||
? Math.round((stream.targetAnnualAmount || 0) / 12)
|
||||
: (stream.targetMonthlyAmount || 0);
|
||||
|
||||
const streamData = {
|
||||
id: stream.id,
|
||||
label: stream.name || '',
|
||||
monthly: stream.targetMonthlyAmount || 0,
|
||||
monthly: monthly,
|
||||
annual: stream.targetAnnualAmount || monthly * 12,
|
||||
amountType: stream.amountType || 'monthly',
|
||||
category: stream.category || 'games',
|
||||
certainty: stream.certainty || 'Aspirational'
|
||||
};
|
||||
|
|
@ -245,23 +262,87 @@ const debouncedSave = useDebounceFn((stream: any) => {
|
|||
}, 300);
|
||||
|
||||
function saveStream(stream: any) {
|
||||
if (stream.name && stream.category && stream.targetMonthlyAmount >= 0) {
|
||||
const hasValidAmount = stream.amountType === 'annual'
|
||||
? stream.targetAnnualAmount >= 0
|
||||
: stream.targetMonthlyAmount >= 0;
|
||||
|
||||
if (stream.name && stream.category && hasValidAmount) {
|
||||
debouncedSave(stream);
|
||||
}
|
||||
}
|
||||
|
||||
// Save category changes immediately even without a name
|
||||
function saveCategoryChange(stream: any) {
|
||||
// Always save category changes immediately
|
||||
saveStreamImmediate(stream);
|
||||
}
|
||||
|
||||
// Immediate save without debounce for UI responsiveness
|
||||
function saveStreamImmediate(stream: any) {
|
||||
try {
|
||||
// Convert component format back to store format
|
||||
const monthly = stream.amountType === 'annual'
|
||||
? Math.round((stream.targetAnnualAmount || 0) / 12)
|
||||
: (stream.targetMonthlyAmount || 0);
|
||||
|
||||
const streamData = {
|
||||
id: stream.id,
|
||||
label: stream.name || '',
|
||||
monthly: monthly,
|
||||
annual: stream.targetAnnualAmount || monthly * 12,
|
||||
amountType: stream.amountType || 'monthly',
|
||||
category: stream.category || 'games',
|
||||
certainty: stream.certainty || 'Aspirational'
|
||||
};
|
||||
|
||||
coop.upsertStream(streamData);
|
||||
} catch (error) {
|
||||
console.error("Failed to save stream:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Validation function for amount
|
||||
function validateAndSaveAmount(value: string, stream: any) {
|
||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||
stream.targetMonthlyAmount = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||
|
||||
if (stream.amountType === 'annual') {
|
||||
stream.targetAnnualAmount = validValue;
|
||||
stream.targetMonthlyAmount = Math.round(validValue / 12);
|
||||
} else {
|
||||
stream.targetMonthlyAmount = validValue;
|
||||
stream.targetAnnualAmount = validValue * 12;
|
||||
}
|
||||
|
||||
saveStream(stream);
|
||||
}
|
||||
|
||||
// Function to switch between annual and monthly
|
||||
function switchAmountType(stream: any, type: 'annual' | 'monthly') {
|
||||
stream.amountType = type;
|
||||
|
||||
// Recalculate values based on new type
|
||||
if (type === 'annual') {
|
||||
if (!stream.targetAnnualAmount) {
|
||||
stream.targetAnnualAmount = (stream.targetMonthlyAmount || 0) * 12;
|
||||
}
|
||||
} else {
|
||||
if (!stream.targetMonthlyAmount) {
|
||||
stream.targetMonthlyAmount = Math.round((stream.targetAnnualAmount || 0) / 12);
|
||||
}
|
||||
}
|
||||
|
||||
// Save immediately without debounce for instant UI update
|
||||
saveStreamImmediate(stream);
|
||||
}
|
||||
|
||||
function addRevenueStream() {
|
||||
const newStream = {
|
||||
id: Date.now().toString(),
|
||||
label: "",
|
||||
monthly: 0,
|
||||
annual: 0,
|
||||
amountType: "monthly",
|
||||
category: "games",
|
||||
certainty: "Aspirational"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,395 +0,0 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black dark:text-white mb-2">Review & Complete</h3>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
Review your setup and complete the wizard to start using your co-op
|
||||
tool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Members Summary -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium">Members ({{ members.length }})</h4>
|
||||
<UBadge :color="membersValid ? 'green' : 'red'" variant="subtle">
|
||||
{{ membersValid ? "Valid" : "Incomplete" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span class="font-medium">{{
|
||||
member.displayName || "Unnamed Member"
|
||||
}}</span>
|
||||
<span v-if="member.roleFocus" class="text-neutral-500 ml-1"
|
||||
>({{ member.roleFocus }})</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-right text-xs text-neutral-500">
|
||||
<div>{{ member.payRelationship || "No relationship set" }}</div>
|
||||
<div>{{ member.capacity?.targetHours || 0 }}h/month</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-neutral-100">
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<span class="text-neutral-600">Total capacity:</span>
|
||||
<span class="font-medium ml-1">{{ totalCapacity }}h</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-neutral-600">Avg external:</span>
|
||||
<span class="font-medium ml-1">{{ avgExternal }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Policies Summary -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium">Policies</h4>
|
||||
<UBadge :color="policiesValid ? 'green' : 'red'" variant="subtle">
|
||||
{{ policiesValid ? "Valid" : "Incomplete" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-600">Equal hourly wage:</span>
|
||||
<span class="font-medium"
|
||||
>€{{ policies.equalHourlyWage || 0 }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-600">Payroll on-costs:</span>
|
||||
<span class="font-medium"
|
||||
>{{ policies.payrollOncostPct || 0 }}%</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-600">Savings target:</span>
|
||||
<span class="font-medium"
|
||||
>{{ policies.savingsTargetMonths || 0 }} months</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-600">Cash cushion:</span>
|
||||
<span class="font-medium"
|
||||
>€{{ policies.minCashCushionAmount || 0 }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-600">Deferred cap:</span>
|
||||
<span class="font-medium"
|
||||
>{{ policies.deferredCapHoursPerQtr || 0 }}h/qtr</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-600">Volunteer flows:</span>
|
||||
<span class="font-medium"
|
||||
>{{ policies.volunteerScope.allowedFlows.length }} types</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Costs Summary -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium">
|
||||
Overhead Costs ({{ overheadCosts.length }})
|
||||
</h4>
|
||||
<UBadge color="blue" variant="subtle">Optional</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="overheadCosts.length === 0" class="text-center py-8">
|
||||
<h4 class="font-medium text-neutral-900 mb-1">
|
||||
No overhead costs yet
|
||||
</h4>
|
||||
<p class="text-sm text-neutral-500">Optional - add costs in step 3</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="cost in overheadCosts.slice(0, 3)"
|
||||
:key="cost.id"
|
||||
class="flex justify-between text-sm">
|
||||
<span class="text-neutral-700">{{ cost.name }}</span>
|
||||
<span class="font-medium">€{{ cost.amount || 0 }}</span>
|
||||
</div>
|
||||
<div v-if="overheadCosts.length > 3" class="text-xs text-neutral-500">
|
||||
+{{ overheadCosts.length - 3 }} more items
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-t border-neutral-100">
|
||||
<div class="flex justify-between text-sm font-medium">
|
||||
<span>Monthly total:</span>
|
||||
<span>€{{ totalMonthlyCosts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Revenue Summary -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium">Revenue Streams ({{ streams.length }})</h4>
|
||||
<UBadge :color="streamsValid ? 'green' : 'red'" variant="subtle">
|
||||
{{ streamsValid ? "Valid" : "Incomplete" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="streams.length === 0" class="text-center py-8">
|
||||
<h4 class="font-medium text-neutral-900 mb-1">
|
||||
No revenue streams yet
|
||||
</h4>
|
||||
<p class="text-sm text-neutral-500">
|
||||
Required - add streams in step 4
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="stream in streams.slice(0, 3)"
|
||||
:key="stream.id"
|
||||
class="space-y-1">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-medium">{{
|
||||
stream.name || "Unnamed Stream"
|
||||
}}</span>
|
||||
<span class="text-neutral-600">{{ stream.targetPct || 0 }}%</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-neutral-500">
|
||||
<span>{{ stream.category }} • {{ stream.certainty }}</span>
|
||||
<span>€{{ stream.targetMonthlyAmount || 0 }}/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="streams.length > 3" class="text-xs text-neutral-500">
|
||||
+{{ streams.length - 3 }} more streams
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-neutral-100">
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<span class="text-neutral-600">Target % total:</span>
|
||||
<span
|
||||
class="font-medium ml-1"
|
||||
:class="
|
||||
totalTargetPct === 100 ? 'text-green-600' : 'text-red-600'
|
||||
">
|
||||
{{ totalTargetPct }}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-neutral-600">Monthly target:</span>
|
||||
<span class="font-medium ml-1">€{{ totalMonthlyTarget }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Team Coverage Summary -->
|
||||
<div class="bg-white border-2 border-black rounded-lg p-4 mb-4">
|
||||
<h4 class="font-medium text-sm mb-3">Team Coverage (min needs)</h4>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
:name="teamStats.under100 === 0 ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
|
||||
:class="teamStats.under100 === 0 ? 'text-green-500' : 'text-yellow-500'"
|
||||
class="w-4 h-4" />
|
||||
<span>
|
||||
<strong>{{ teamStats.under100 }}</strong> under 100%
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="teamStats.median" class="flex items-center gap-1">
|
||||
<span class="text-neutral-600">Median:</span>
|
||||
<strong>{{ Math.round(teamStats.median) }}%</strong>
|
||||
</div>
|
||||
<div v-if="teamStats.gini !== undefined" class="flex items-center gap-1">
|
||||
<span class="text-neutral-600">Gini:</span>
|
||||
<strong>{{ teamStats.gini.toFixed(2) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="teamStats.under100 > 0" class="mt-3 p-2 bg-yellow-50 rounded text-xs text-yellow-800">
|
||||
Consider more needs-weighting or a smaller headcount to ensure everyone's minimum needs are met.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status -->
|
||||
<div class="bg-neutral-50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-sm mb-3">Setup Status</h4>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
:name="
|
||||
membersValid ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'
|
||||
"
|
||||
:class="membersValid ? 'text-green-500' : 'text-red-500'"
|
||||
class="w-4 h-4" />
|
||||
<span class="text-sm">Members</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
:name="
|
||||
policiesValid
|
||||
? 'i-heroicons-check-circle'
|
||||
: 'i-heroicons-x-circle'
|
||||
"
|
||||
:class="policiesValid ? 'text-green-500' : 'text-red-500'"
|
||||
class="w-4 h-4" />
|
||||
<span class="text-sm">Policies</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
name="i-heroicons-check-circle"
|
||||
class="text-blue-500 w-4 h-4" />
|
||||
<span class="text-sm">Costs (Optional)</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
:name="
|
||||
streamsValid ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'
|
||||
"
|
||||
:class="streamsValid ? 'text-green-500' : 'text-red-500'"
|
||||
class="w-4 h-4" />
|
||||
<span class="text-sm">Revenue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!canComplete"
|
||||
class="bg-yellow-100 border border-yellow-200 rounded-md p-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="text-yellow-600 w-4 h-4" />
|
||||
<span class="text-sm font-medium text-yellow-800"
|
||||
>Complete required sections to finish setup</span
|
||||
>
|
||||
</div>
|
||||
<ul class="list-disc list-inside text-xs text-yellow-700 mt-2">
|
||||
<li v-if="!membersValid">
|
||||
Add at least one member with valid details
|
||||
</li>
|
||||
<li v-if="!policiesValid">
|
||||
Set a valid hourly wage and complete policy fields
|
||||
</li>
|
||||
<li v-if="!streamsValid">
|
||||
Add at least one revenue stream with valid details
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center pt-6 border-t">
|
||||
<UButton variant="ghost" color="red" @click="$emit('reset')">
|
||||
Reset All Data
|
||||
</UButton>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<UButton
|
||||
@click="completeSetup"
|
||||
:disabled="!canComplete"
|
||||
size="lg"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
Complete Setup
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
complete: [];
|
||||
reset: [];
|
||||
}>();
|
||||
|
||||
// Store
|
||||
const coop = useCoopBuilder();
|
||||
|
||||
// Computed data
|
||||
const members = computed(() => coop.members.value);
|
||||
const teamStats = computed(() => coop.teamCoverageStats());
|
||||
const policies = computed(() => ({
|
||||
// TODO: Get actual policy data from centralized store
|
||||
equalHourlyWage: 0,
|
||||
payrollOncostPct: 0,
|
||||
savingsTargetMonths: 0,
|
||||
minCashCushionAmount: 0,
|
||||
deferredCapHoursPerQtr: 0,
|
||||
volunteerScope: { allowedFlows: [] },
|
||||
}));
|
||||
const overheadCosts = computed(() => []);
|
||||
const streams = computed(() => coop.streams.value);
|
||||
|
||||
// Validation
|
||||
const membersValid = computed(() => coop.members.value.length > 0);
|
||||
const policiesValid = computed(() => true); // TODO: Add validation
|
||||
const streamsValid = computed(() => coop.streams.value.length > 0);
|
||||
const canComplete = computed(
|
||||
() => membersValid.value && policiesValid.value && streamsValid.value
|
||||
);
|
||||
|
||||
// Summary calculations
|
||||
const totalCapacity = computed(() =>
|
||||
members.value.reduce((sum, m) => sum + (m.capacity?.targetHours || 0), 0)
|
||||
);
|
||||
|
||||
const avgExternal = computed(() => {
|
||||
if (members.value.length === 0) return 0;
|
||||
const total = members.value.reduce(
|
||||
(sum, m) => sum + (m.externalCoveragePct || 0),
|
||||
0
|
||||
);
|
||||
return Math.round(total / members.value.length);
|
||||
});
|
||||
|
||||
const totalMonthlyCosts = computed(() =>
|
||||
overheadCosts.value.reduce((sum, c) => sum + (c.amount || 0), 0)
|
||||
);
|
||||
|
||||
const totalTargetPct = computed(() =>
|
||||
coop.streams.value.reduce((sum, s) => sum + (s.targetPct || 0), 0)
|
||||
);
|
||||
const totalMonthlyTarget = computed(() =>
|
||||
Math.round(
|
||||
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
function completeSetup() {
|
||||
if (canComplete.value) {
|
||||
// Mark setup as complete in some way (could be a store flag)
|
||||
emit("complete");
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h4 class="font-medium">Scenarios</h4>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<USelect
|
||||
:model-value="scenario"
|
||||
:options="scenarioOptions"
|
||||
@update:model-value="setScenario"
|
||||
/>
|
||||
|
||||
<div v-if="scenario !== 'current'" class="p-3 bg-blue-50 border border-blue-200 rounded text-sm">
|
||||
<div class="flex items-center gap-2 text-blue-800">
|
||||
<UIcon name="i-heroicons-information-circle" class="w-4 h-4" />
|
||||
<span class="font-medium">Scenario Active</span>
|
||||
</div>
|
||||
<p class="text-blue-700 mt-1">
|
||||
{{ getScenarioDescription(scenario) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { scenario, setScenario } = useCoopBuilder()
|
||||
|
||||
const scenarioOptions = [
|
||||
{ label: 'Current', value: 'current' },
|
||||
{ label: 'Quit Day Jobs', value: 'quit-jobs' },
|
||||
{ label: 'Start Production', value: 'start-production' },
|
||||
{ label: 'Custom', value: 'custom', disabled: true }
|
||||
]
|
||||
|
||||
function getScenarioDescription(scenario: string): string {
|
||||
switch (scenario) {
|
||||
case 'quit-jobs':
|
||||
return 'All external income removed. Shows runway if everyone works full-time for the co-op.'
|
||||
case 'start-production':
|
||||
return 'Service revenue reduced by 30%. Models transition from services to product development.'
|
||||
case 'custom':
|
||||
return 'Custom scenario configuration coming soon.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,41 +1,137 @@
|
|||
<template>
|
||||
<div class="hidden" data-ui="member_coverage_panel_v1" />
|
||||
<div class="hidden" data-ui="member_coverage_panel_v2" />
|
||||
<UCard class="shadow-sm rounded-xl">
|
||||
<template #header>
|
||||
<h3 class="font-semibold">Member needs coverage</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">Individual Member Coverage</h3>
|
||||
<UTooltip text="Shows what each member needs from the co-op vs. what we can actually pay them">
|
||||
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div v-if="allocatedMembers.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="member in allocatedMembers"
|
||||
:key="member.id"
|
||||
class="flex items-center gap-4"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="w-20 text-sm font-medium text-gray-700 truncate">
|
||||
{{ member.name }}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getBarColor(coverage(member).minPct)"
|
||||
:style="{ width: `${Math.min(100, (coverage(member).minPct / 200) * 100)}%` }"
|
||||
/>
|
||||
<!-- Member name and coverage percentage -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
|
||||
<UBadge
|
||||
:color="getCoverageColor(coverage(member).coveragePct)"
|
||||
size="xs"
|
||||
:ui="{ base: 'font-medium' }"
|
||||
>
|
||||
{{ Math.round(coverage(member).coveragePct || 0) }}% covered
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 text-sm font-medium text-right">
|
||||
{{ Math.round(coverage(member).minPct) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="allocatedMembers.length === 0" class="text-sm text-gray-600 text-center py-8">
|
||||
Add members in Setup → Members to see coverage.
|
||||
<!-- Financial breakdown -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-gray-600">Needs from co-op</div>
|
||||
<div class="font-medium">{{ formatCurrency(member.minMonthlyNeeds || 0) }}</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-gray-600">Co-op can pay</div>
|
||||
<div class="font-medium" :class="getAmountColor(member.monthlyPayPlanned, member.minMonthlyNeeds)">
|
||||
{{ formatCurrency(member.monthlyPayPlanned || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual progress bar -->
|
||||
<div class="space-y-1">
|
||||
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
|
||||
<div
|
||||
class="h-3 rounded-full transition-all duration-300"
|
||||
:class="getBarColor(coverage(member).coveragePct)"
|
||||
:style="{ width: `${Math.min(100, coverage(member).coveragePct || 0)}%` }"
|
||||
/>
|
||||
<!-- 100% marker line -->
|
||||
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="(coverage(member).coveragePct || 0) < 100">
|
||||
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
<span>200%+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gap/surplus indicator -->
|
||||
<div v-if="getGapAmount(member) !== 0" class="flex items-center gap-1 text-xs">
|
||||
<UIcon
|
||||
:name="getGapAmount(member) > 0 ? 'i-heroicons-arrow-trending-down' : 'i-heroicons-arrow-trending-up'"
|
||||
class="h-3 w-3"
|
||||
:class="getGapAmount(member) > 0 ? 'text-red-500' : 'text-green-500'"
|
||||
/>
|
||||
<span :class="getGapAmount(member) > 0 ? 'text-red-600' : 'text-green-600'">
|
||||
{{ getGapAmount(member) > 0 ? 'Gap: ' : 'Surplus: ' }}{{ formatCurrency(Math.abs(getGapAmount(member))) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<UIcon name="i-heroicons-users" class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm mb-2">No members added yet</p>
|
||||
<p class="text-xs">Complete setup wizard to add team members</p>
|
||||
</div>
|
||||
|
||||
<template #footer v-if="allocatedMembers.length > 0">
|
||||
<div class="text-sm text-gray-600 text-center">
|
||||
Team median {{ Math.round(stats.median) }}% • {{ stats.under100 }} under 100%{{ allCovered ? ' • All covered ✓' : '' }}
|
||||
<!-- Summary Stats -->
|
||||
<div class="flex justify-between items-center text-sm text-gray-600 pb-3 border-b border-gray-200">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>Median coverage: {{ Math.round(stats.median || 0) }}%</span>
|
||||
<span :class="stats.under100 === 0 ? 'text-green-600' : 'text-amber-600'">
|
||||
{{ stats.under100 === 0 ? 'All covered ✓' : `${stats.under100} need more` }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
Total payroll: {{ formatCurrency(totalPayroll) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actionable Insights -->
|
||||
<div class="pt-3">
|
||||
<div v-if="totalGap > 0" class="text-xs">
|
||||
<div class="flex items-center gap-2 text-amber-700">
|
||||
<UIcon name="i-heroicons-light-bulb" class="h-3 w-3" />
|
||||
<span class="font-medium">To cover everyone:</span>
|
||||
</div>
|
||||
<p class="mt-1 text-gray-600 pl-5">
|
||||
Increase available payroll by <strong>{{ formatCurrency(totalGap) }}</strong>
|
||||
through higher revenue or lower overhead costs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="totalSurplus > 0" class="text-xs">
|
||||
<div class="flex items-center gap-2 text-green-700">
|
||||
<UIcon name="i-heroicons-check-circle" class="h-3 w-3" />
|
||||
<span class="font-medium">Healthy position:</span>
|
||||
</div>
|
||||
<p class="mt-1 text-gray-600 pl-5">
|
||||
You have <strong>{{ formatCurrency(totalSurplus) }}</strong> surplus after covering all member needs.
|
||||
Consider growth opportunities or building reserves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-xs">
|
||||
<div class="flex items-center gap-2 text-green-700">
|
||||
<UIcon name="i-heroicons-scales" class="h-3 w-3" />
|
||||
<span class="font-medium">Perfect balance:</span>
|
||||
</div>
|
||||
<p class="mt-1 text-gray-600 pl-5">
|
||||
Available payroll exactly matches member needs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
|
@ -46,11 +142,61 @@ const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
|
|||
|
||||
const allocatedMembers = computed(() => allocatePayroll())
|
||||
const stats = computed(() => teamCoverageStats())
|
||||
const allCovered = computed(() => stats.value.under100 === 0)
|
||||
|
||||
// Calculate total payroll
|
||||
const totalPayroll = computed(() =>
|
||||
allocatedMembers.value.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
|
||||
)
|
||||
|
||||
// Color functions for coverage display
|
||||
function getBarColor(pct: number): string {
|
||||
if (pct >= 100) return 'bg-green-500'
|
||||
if (pct >= 80) return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
if (!pct || pct < 80) return 'bg-red-500'
|
||||
if (pct < 100) return 'bg-amber-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
function getCoverageColor(pct: number): string {
|
||||
if (!pct || pct < 80) return 'red'
|
||||
if (pct < 100) return 'amber'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
function getAmountColor(planned: number = 0, needed: number = 0): string {
|
||||
if (!needed) return 'text-gray-900'
|
||||
if (planned >= needed) return 'text-green-600'
|
||||
if (planned >= needed * 0.8) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
// Calculate gap between what's needed vs what can be paid
|
||||
function getGapAmount(member: any): number {
|
||||
const planned = member.monthlyPayPlanned || 0
|
||||
const needed = member.minMonthlyNeeds || 0
|
||||
return needed - planned // positive = gap, negative = surplus
|
||||
}
|
||||
|
||||
// Calculate total gap/surplus across all members
|
||||
const totalGap = computed(() => {
|
||||
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||
const totalPlanned = totalPayroll.value
|
||||
const gap = totalNeeded - totalPlanned
|
||||
return gap > 0 ? gap : 0
|
||||
})
|
||||
|
||||
const totalSurplus = computed(() => {
|
||||
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||
const totalPlanned = totalPayroll.value
|
||||
const surplus = totalPlanned - totalNeeded
|
||||
return surplus > 0 ? surplus : 0
|
||||
})
|
||||
|
||||
// Currency formatting
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,37 +1,165 @@
|
|||
<template>
|
||||
<div class="hidden" data-ui="needs_coverage_card_v1" />
|
||||
<div class="hidden" data-ui="needs_coverage_card_v2" />
|
||||
<UCard class="min-h-[140px] shadow-sm rounded-xl">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user-group" class="h-5 w-5" />
|
||||
<h3 class="font-semibold">Members covered</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user-group" class="h-5 w-5" />
|
||||
<h3 class="font-semibold">Member Needs Coverage</h3>
|
||||
</div>
|
||||
<UTooltip text="Shows how well the co-op can meet each member's stated financial needs">
|
||||
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="text-center space-y-6">
|
||||
<div class="text-2xl font-semibold" :class="statusColor">
|
||||
{{ pctCovered }}%
|
||||
<div v-if="hasMembers" class="space-y-4">
|
||||
<!-- Team Summary -->
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-semibold" :class="statusColor">
|
||||
{{ fullyCoveredCount }} of {{ totalMembers }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
members fully covered
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
Median {{ median }}%
|
||||
|
||||
<!-- Coverage Stats -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<div class="text-center">
|
||||
<div class="font-medium">{{ median }}%</div>
|
||||
<div class="text-gray-600">Median</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="font-medium" :class="underCoveredColor">{{ stats.under100 }}</div>
|
||||
<div class="text-gray-600">Under 100%</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="font-medium">{{ formatCurrency(availablePayroll) }}</div>
|
||||
<div class="text-gray-600">Available</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="stats.under100 > 0" class="flex items-center justify-center gap-1 text-xs text-amber-600 mt-3">
|
||||
<span>⚠</span>
|
||||
<span>{{ stats.under100 }} under 100%</span>
|
||||
|
||||
<!-- Intelligent Financial Analysis -->
|
||||
<div v-if="hasMembers" class="space-y-2">
|
||||
<!-- Coverage gap analysis -->
|
||||
<div v-if="stats.under100 > 0" class="text-xs bg-amber-50 p-3 rounded border-l-4 border-amber-400">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-amber-800">Coverage Gap Analysis</p>
|
||||
<p class="text-amber-700">
|
||||
To meet member needs, you need <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements,
|
||||
but you have <strong>{{ formatCurrency(availablePayroll) }}</strong> available for payroll.
|
||||
</p>
|
||||
<p class="text-amber-600">
|
||||
<strong>Shortfall: {{ formatCurrency(Math.max(0, totalNeeds - availablePayroll)) }}</strong>
|
||||
</p>
|
||||
<p class="text-xs text-amber-600 mt-2">
|
||||
💡 Note: This reflects member-stated needs. Check your Budget page for detailed payroll planning.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Surplus analysis -->
|
||||
<div v-else-if="availablePayroll > totalNeeds && totalNeeds > 0" class="text-xs bg-green-50 p-3 rounded border-l-4 border-green-400">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-check-circle" class="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-green-800">Healthy Coverage</p>
|
||||
<p class="text-green-700">
|
||||
You have <strong>{{ formatCurrency(availablePayroll) }}</strong> available to cover
|
||||
<strong>{{ formatCurrency(totalNeeds) }}</strong> in member needs.
|
||||
</p>
|
||||
<p class="text-green-600">
|
||||
<strong>Surplus: {{ formatCurrency(availablePayroll - totalNeeds) }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No payroll available -->
|
||||
<div v-else-if="availablePayroll === 0 && totalNeeds > 0" class="text-xs bg-red-50 p-3 rounded border-l-4 border-red-400">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-x-circle" class="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-red-800">No Funds for Payroll</p>
|
||||
<p class="text-red-700">
|
||||
Member needs total <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements,
|
||||
but current revenue minus costs leaves $0 for payroll.
|
||||
</p>
|
||||
<p class="text-red-600">
|
||||
Consider increasing revenue or reducing overhead costs.
|
||||
</p>
|
||||
<p class="text-xs text-red-600 mt-2">
|
||||
💡 Note: This reflects member-stated needs. Your Budget page may show different payroll amounts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-6 text-gray-500">
|
||||
<UIcon name="i-heroicons-users" class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">Add members in setup to see coverage</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { members, teamCoverageStats } = useCoopBuilder()
|
||||
const { members, teamCoverageStats, allocatePayroll, streams } = useCoopBuilder()
|
||||
const coopStore = useCoopBuilderStore()
|
||||
|
||||
const stats = computed(() => teamCoverageStats())
|
||||
const pctCovered = computed(() => Math.round(stats.value.over100Pct || 0))
|
||||
const allocatedMembers = computed(() => allocatePayroll())
|
||||
const median = computed(() => Math.round(stats.value.median ?? 0))
|
||||
|
||||
// Team-level calculations
|
||||
const hasMembers = computed(() => members.value.length > 0)
|
||||
const totalMembers = computed(() => members.value.length)
|
||||
const fullyCoveredCount = computed(() => totalMembers.value - stats.value.under100)
|
||||
|
||||
// Financial calculations
|
||||
const totalNeeds = computed(() =>
|
||||
allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||
)
|
||||
|
||||
const totalRevenue = computed(() =>
|
||||
streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0)
|
||||
)
|
||||
|
||||
const overheadCosts = computed(() =>
|
||||
coopStore.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0)
|
||||
)
|
||||
|
||||
const availablePayroll = computed(() =>
|
||||
Math.max(0, totalRevenue.value - overheadCosts.value)
|
||||
)
|
||||
|
||||
// Status colors based on coverage
|
||||
const statusColor = computed(() => {
|
||||
if (pctCovered.value >= 100) return 'text-green-600'
|
||||
if (pctCovered.value >= 80) return 'text-amber-600'
|
||||
const ratio = fullyCoveredCount.value / Math.max(1, totalMembers.value)
|
||||
if (ratio === 1) return 'text-green-600'
|
||||
if (ratio >= 0.8) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
})
|
||||
|
||||
const underCoveredColor = computed(() => {
|
||||
if (stats.value.under100 === 0) return 'text-green-600'
|
||||
if (stats.value.under100 <= Math.ceil(totalMembers.value * 0.2)) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
})
|
||||
|
||||
// Currency formatting
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue