app/components/dashboard/MemberCoveragePanel.vue

275 lines
9.2 KiB
Vue

<template>
<div class="hidden" data-ui="member_coverage_panel_v2" />
<UCard class="shadow-sm rounded-xl">
<template #header>
<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-neutral-400 hover:text-neutral-600 cursor-help" />
</UTooltip>
</div>
</template>
<div v-if="allocatedMembers.length > 0" class="space-y-4">
<div
v-for="member in allocatedMembers"
:key="member.id"
class="space-y-2">
<!-- Member name and coverage percentage -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-neutral-900">{{
member.displayName || member.name || "Unnamed Member"
}}</span>
<UBadge
:color="getCoverageColor(calculateCoverage(member))"
size="xs"
:ui="{ base: 'font-medium' }">
{{ Math.round(calculateCoverage(member)) }}% covered
</UBadge>
</div>
</div>
<!-- Financial breakdown -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="space-y-1">
<div class="text-neutral-600">Needs from co-op</div>
<div class="font-medium">
{{ formatCurrency(member.minMonthlyNeeds || 0) }}
</div>
</div>
<div class="space-y-1">
<div class="text-neutral-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-neutral-200 rounded-full h-3 relative overflow-hidden">
<div
class="h-3 rounded-full transition-all duration-300"
:class="getBarColor(calculateCoverage(member))"
:style="{
width: `${Math.min(100, calculateCoverage(member))}%`,
}" />
<!-- 100% marker line -->
<div
class="absolute top-0 h-3 w-0.5 bg-neutral-600 opacity-75"
style="left: 100%"
v-if="calculateCoverage(member) < 100">
<div
class="absolute -top-1 -left-1 w-2 h-2 bg-neutral-600 rounded-full opacity-75" />
</div>
</div>
<div class="flex justify-between text-xs text-neutral-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-neutral-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">
<!-- Summary Stats -->
<div
class="flex justify-between items-center text-sm text-neutral-600 pb-3 border-b border-neutral-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">
<UTooltip text="Based on available revenue after overhead costs">
<span class="cursor-help"
>Total sustainable payroll:
{{ formatCurrency(totalPayroll) }}</span
>
</UTooltip>
</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-neutral-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-neutral-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-neutral-600 pl-5">
Available payroll exactly matches member needs.
</p>
</div>
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder();
const allocatedMembers = computed(() => {
const members = allocatePayroll();
console.log("🔍 allocatedMembers computed:", members);
return members;
});
const stats = computed(() => teamCoverageStats());
// 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 || 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-neutral-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;
});
// Local coverage calculation for debugging
function calculateCoverage(member: any): number {
const coopPay = member.monthlyPayPlanned || 0;
const needs = member.minMonthlyNeeds || 0;
console.log(
`Coverage calc for ${member.name || member.displayName || "Unknown"}:`,
{
member: JSON.stringify(member, null, 2),
coopPay,
needs,
coverage: needs > 0 ? (coopPay / needs) * 100 : 100,
}
);
if (needs === 0) {
console.log(
`⚠️ Member ${member.name} has NO minMonthlyNeeds - defaulting to 100%`
);
return 100;
}
return Math.min(200, (coopPay / needs) * 100);
}
// Currency formatting
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
</script>