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:
Jennie Robinson Faber 2025-09-06 09:48:57 +01:00
parent 983aeca2dc
commit 09d8794d72
42 changed files with 2166 additions and 2974 deletions

View file

@ -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>

View file

@ -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>