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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -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"
};

View file

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

View file

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

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>