refactor: update app.vue and various components to enhance UI consistency, replace color classes for improved accessibility, and refine layout for better user experience
This commit is contained in:
parent
7b4fb6c2fd
commit
24e8b7a3a8
41 changed files with 2395 additions and 1603 deletions
|
|
@ -20,7 +20,7 @@
|
|||
<h4 class="font-semibold">Stress Test</h4>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-600"
|
||||
<label class="text-xs text-neutral-600"
|
||||
>Revenue Delay (months)</label
|
||||
>
|
||||
<URange
|
||||
|
|
@ -29,24 +29,24 @@
|
|||
:max="6"
|
||||
:step="1"
|
||||
class="mt-1" />
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-neutral-500">
|
||||
{{ stress.revenueDelay }} months
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Cost Shock (%)</label>
|
||||
<label class="text-xs text-neutral-600">Cost Shock (%)</label>
|
||||
<URange
|
||||
v-model="stress.costShockPct"
|
||||
:min="0"
|
||||
:max="30"
|
||||
:step="1"
|
||||
class="mt-1" />
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-neutral-500">
|
||||
{{ stress.costShockPct }}%
|
||||
</div>
|
||||
</div>
|
||||
<UCheckbox v-model="stress.grantLost" label="Grant lost" />
|
||||
<div class="text-sm text-gray-600 pt-2 border-t">
|
||||
<div class="text-sm text-neutral-600 pt-2 border-t">
|
||||
Projected runway: {{ projectedRunway }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -72,13 +72,13 @@
|
|||
<span>{{ milestone.willReach ? "✅" : "⚠️" }}</span>
|
||||
<span>{{ milestone.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600">{{
|
||||
<span class="text-xs text-neutral-600">{{
|
||||
formatDate(milestone.date)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="milestones.length === 0"
|
||||
class="text-sm text-gray-600 italic">
|
||||
class="text-sm text-neutral-600 italic">
|
||||
No milestones yet
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,27 +4,30 @@
|
|||
<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-gray-400 hover:text-gray-600 cursor-help" />
|
||||
<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"
|
||||
<div
|
||||
v-for="member in allocatedMembers"
|
||||
:key="member.id"
|
||||
class="space-y-2"
|
||||
>
|
||||
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-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
|
||||
<UBadge
|
||||
:color="getCoverageColor(calculateCoverage(member))"
|
||||
<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' }"
|
||||
>
|
||||
:ui="{ base: 'font-medium' }">
|
||||
{{ Math.round(calculateCoverage(member)) }}% covered
|
||||
</UBadge>
|
||||
</div>
|
||||
|
|
@ -33,12 +36,18 @@
|
|||
<!-- 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 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-gray-600">Co-op can pay</div>
|
||||
<div class="font-medium" :class="getAmountColor(member.monthlyPayPlanned, member.minMonthlyNeeds)">
|
||||
<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>
|
||||
|
|
@ -46,18 +55,24 @@
|
|||
|
||||
<!-- Visual progress bar -->
|
||||
<div class="space-y-1">
|
||||
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
|
||||
<div
|
||||
<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))}%` }"
|
||||
/>
|
||||
:style="{
|
||||
width: `${Math.min(100, calculateCoverage(member))}%`,
|
||||
}" />
|
||||
<!-- 100% marker line -->
|
||||
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="calculateCoverage(member) < 100">
|
||||
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" />
|
||||
<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-gray-500">
|
||||
<div class="flex justify-between text-xs text-neutral-500">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
<span>200%+</span>
|
||||
|
|
@ -65,21 +80,32 @@
|
|||
</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'"
|
||||
<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))) }}
|
||||
: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">
|
||||
<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>
|
||||
|
|
@ -87,16 +113,25 @@
|
|||
|
||||
<template #footer v-if="allocatedMembers.length > 0">
|
||||
<!-- Summary Stats -->
|
||||
<div class="flex justify-between items-center text-sm text-gray-600 pb-3 border-b border-gray-200">
|
||||
<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
|
||||
: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>
|
||||
<span class="cursor-help"
|
||||
>Total sustainable payroll:
|
||||
{{ formatCurrency(totalPayroll) }}</span
|
||||
>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -108,20 +143,22 @@
|
|||
<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>
|
||||
<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-gray-600 pl-5">
|
||||
You have <strong>{{ formatCurrency(totalSurplus) }}</strong> surplus after covering all member needs.
|
||||
Consider growth opportunities or building reserves.
|
||||
<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>
|
||||
|
||||
|
|
@ -130,7 +167,7 @@
|
|||
<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">
|
||||
<p class="mt-1 text-neutral-600 pl-5">
|
||||
Available payroll exactly matches member needs.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -140,88 +177,99 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
|
||||
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder();
|
||||
|
||||
const allocatedMembers = computed(() => {
|
||||
const members = allocatePayroll()
|
||||
console.log('🔍 allocatedMembers computed:', members)
|
||||
return members
|
||||
})
|
||||
const stats = computed(() => teamCoverageStats())
|
||||
const members = allocatePayroll();
|
||||
console.log("🔍 allocatedMembers computed:", members);
|
||||
return members;
|
||||
});
|
||||
const stats = computed(() => teamCoverageStats());
|
||||
|
||||
// Calculate total payroll
|
||||
const totalPayroll = computed(() =>
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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
|
||||
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 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
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
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
|
||||
console.log(
|
||||
`⚠️ Member ${member.name} has NO minMonthlyNeeds - defaulting to 100%`
|
||||
);
|
||||
return 100;
|
||||
}
|
||||
return Math.min(200, (coopPay / needs) * 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',
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}).format(amount);
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,93 +7,126 @@
|
|||
<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
|
||||
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-neutral-400 hover:text-neutral-600 cursor-help" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<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 class="text-sm text-neutral-600">members fully covered</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 class="text-neutral-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 class="font-medium" :class="underCoveredColor">
|
||||
{{ stats.under100 }}
|
||||
</div>
|
||||
<div class="text-neutral-600">Under 100%</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="font-medium">{{ formatCurrency(availablePayroll) }}</div>
|
||||
<div class="text-gray-600">Available</div>
|
||||
<div class="text-neutral-600">Available</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
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" />
|
||||
<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.
|
||||
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>
|
||||
<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.
|
||||
💡 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
|
||||
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" />
|
||||
<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.
|
||||
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>
|
||||
<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
|
||||
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" />
|
||||
<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.
|
||||
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.
|
||||
💡 Note: This reflects member-stated needs. Your Budget page may
|
||||
show different payroll amounts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -102,7 +135,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-6 text-gray-500">
|
||||
<div v-else class="text-center py-6 text-neutral-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>
|
||||
|
|
@ -110,56 +143,60 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { members, teamCoverageStats, allocatePayroll, streams } = useCoopBuilder()
|
||||
const coopStore = useCoopBuilderStore()
|
||||
const { members, teamCoverageStats, allocatePayroll, streams } =
|
||||
useCoopBuilder();
|
||||
const coopStore = useCoopBuilderStore();
|
||||
|
||||
const stats = computed(() => teamCoverageStats())
|
||||
const allocatedMembers = computed(() => allocatePayroll())
|
||||
const median = computed(() => Math.round(stats.value.median ?? 0))
|
||||
const stats = computed(() => teamCoverageStats());
|
||||
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)
|
||||
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(() =>
|
||||
const totalNeeds = computed(() =>
|
||||
allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const totalRevenue = computed(() =>
|
||||
const totalRevenue = computed(() =>
|
||||
streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const overheadCosts = computed(() =>
|
||||
const overheadCosts = computed(() =>
|
||||
coopStore.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const availablePayroll = computed(() =>
|
||||
const availablePayroll = computed(() =>
|
||||
Math.max(0, totalRevenue.value - overheadCosts.value)
|
||||
)
|
||||
);
|
||||
|
||||
// Status colors based on coverage
|
||||
const statusColor = computed(() => {
|
||||
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 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'
|
||||
})
|
||||
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',
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}).format(amount);
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@
|
|||
<h3 class="font-semibold">Revenue Mix</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="space-y-6">
|
||||
<div v-if="mix.length === 0" class="text-sm text-gray-600 text-center py-8">
|
||||
<div
|
||||
v-if="mix.length === 0"
|
||||
class="text-sm text-neutral-600 text-center py-8">
|
||||
Add revenue streams to see mix.
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else>
|
||||
<!-- Revenue bars -->
|
||||
<div v-for="s in mix.slice(0, 3)" :key="s.label" class="mb-2">
|
||||
|
|
@ -20,17 +22,16 @@
|
|||
<span class="truncate">{{ s.label }}</span>
|
||||
<span>{{ Math.round(s.pct * 100) }}%</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-200 rounded">
|
||||
<div
|
||||
class="h-2 rounded"
|
||||
<div class="h-2 bg-neutral-200 rounded">
|
||||
<div
|
||||
class="h-2 rounded"
|
||||
:class="getBarColor(mix.indexOf(s))"
|
||||
:style="{ width: (s.pct * 100) + '%' }"
|
||||
/>
|
||||
:style="{ width: s.pct * 100 + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtext with concentration warning -->
|
||||
<div class="text-sm text-gray-600 text-center">
|
||||
<div class="text-sm text-neutral-600 text-center">
|
||||
Top stream {{ Math.round(topPct * 100) }}%
|
||||
<span v-if="topPct > 0.5" class="text-amber-600">⚠</span>
|
||||
</div>
|
||||
|
|
@ -40,17 +41,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { revenueMix, concentrationPct } = useCoopBuilder()
|
||||
const { revenueMix, concentrationPct } = useCoopBuilder();
|
||||
|
||||
const mix = computed(() => revenueMix())
|
||||
const topPct = computed(() => concentrationPct())
|
||||
const mix = computed(() => revenueMix());
|
||||
const topPct = computed(() => concentrationPct());
|
||||
|
||||
function getBarColor(index: number): string {
|
||||
const colors = [
|
||||
'bg-blue-500',
|
||||
'bg-green-500',
|
||||
'bg-amber-500'
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
const colors = ["bg-blue-500", "bg-green-500", "bg-amber-500"];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,56 +7,53 @@
|
|||
<div class="w-2 h-2 rounded-full" :class="statusDotColor" />
|
||||
<h3 class="font-semibold">Runway</h3>
|
||||
</div>
|
||||
<UBadge
|
||||
:color="operatingMode === 'target' ? 'blue' : 'gray'"
|
||||
size="xs"
|
||||
>
|
||||
{{ operatingMode === 'target' ? 'Target Mode' : 'Min Mode' }}
|
||||
<UBadge
|
||||
:color="operatingMode === 'target' ? 'blue' : 'neutral'"
|
||||
size="xs">
|
||||
{{ operatingMode === "target" ? "Target Mode" : "Min Mode" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="text-center space-y-6">
|
||||
<div class="text-2xl font-semibold" :class="statusColor">
|
||||
{{ displayRunway }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
at current spending
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600">at current spending</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { runwayMonths, operatingMode } = useCoopBuilder()
|
||||
const { runwayMonths, operatingMode } = useCoopBuilder();
|
||||
|
||||
const runway = computed(() => runwayMonths())
|
||||
const runway = computed(() => runwayMonths());
|
||||
|
||||
const displayRunway = computed(() => {
|
||||
const months = runway.value
|
||||
if (!isFinite(months)) return '∞'
|
||||
if (months < 1) return '<1 month'
|
||||
return `${Math.round(months)} months`
|
||||
})
|
||||
const months = runway.value;
|
||||
if (!isFinite(months)) return "∞";
|
||||
if (months < 1) return "<1 month";
|
||||
return `${Math.round(months)} months`;
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const months = runway.value
|
||||
if (!isFinite(months) || months >= 6) return 'text-green-600'
|
||||
if (months >= 3) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
})
|
||||
const months = runway.value;
|
||||
if (!isFinite(months) || months >= 6) return "text-green-600";
|
||||
if (months >= 3) return "text-amber-600";
|
||||
return "text-red-600";
|
||||
});
|
||||
|
||||
const statusDotColor = computed(() => {
|
||||
const months = runway.value
|
||||
if (!isFinite(months) || months >= 6) return 'bg-green-500'
|
||||
if (months >= 3) return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
})
|
||||
const months = runway.value;
|
||||
if (!isFinite(months) || months >= 6) return "bg-green-500";
|
||||
if (months >= 3) return "bg-amber-500";
|
||||
return "bg-red-500";
|
||||
});
|
||||
|
||||
const borderColor = computed(() => {
|
||||
const months = runway.value
|
||||
if (!isFinite(months) || months >= 6) return 'ring-1 ring-green-200'
|
||||
if (months >= 3) return 'ring-1 ring-amber-200'
|
||||
return 'ring-1 ring-red-200'
|
||||
})
|
||||
</script>
|
||||
const months = runway.value;
|
||||
if (!isFinite(months) || months >= 6) return "ring-1 ring-green-200";
|
||||
if (months >= 3) return "ring-1 ring-amber-200";
|
||||
return "ring-1 ring-red-200";
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue