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:
Jennie Robinson Faber 2025-09-10 11:02:54 +01:00
parent 7b4fb6c2fd
commit 24e8b7a3a8
41 changed files with 2395 additions and 1603 deletions

View file

@ -7,12 +7,12 @@
<div class="border border-black bg-white">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b-2 border-black bg-gray-100">
<tr class="border-b-2 border-black bg-neutral-100">
<th class="border-r-1 border-black px-4 py-3 text-left font-bold">
Category
</th>
<th
class="border-r border-gray-400 px-4 py-3 text-right font-bold">
class="border-r border-neutral-400 px-4 py-3 text-right font-bold">
Planned
</th>
<th class="px-4 py-3 text-right font-bold">%</th>
@ -28,21 +28,21 @@
<tr
v-for="(category, index) in revenueCategories"
:key="`rev-${index}`"
class="border-t border-gray-200"
class="border-t border-neutral-200"
v-show="category.planned > 0">
<td class="border-r-1 border-black px-4 py-2">
{{ category.name }}
</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
<td class="border-r border-neutral-400 px-4 py-2 text-right">
{{ formatCurrency(category.planned) }}
</td>
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
</tr>
<!-- Total Revenue -->
<tr class="border-t-2 border-black font-semibold bg-gray-50">
<tr class="border-t-2 border-black font-semibold bg-neutral-50">
<td class="border-r-1 border-black px-4 py-2">Total Revenue</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
<td class="border-r border-neutral-400 px-4 py-2 text-right">
{{ formatCurrency(totalRevenuePlanned) }}
</td>
<td class="px-4 py-2 text-right">100%</td>
@ -50,11 +50,11 @@
<!-- Revenue Diversification Guidance -->
<tr :class="guidanceBackgroundClass">
<td colspan="3" class="border-t border-gray-300 px-4 py-3">
<td colspan="3" class="border-t border-neutral-300 px-4 py-3">
<div class="text-sm">
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
<p
class="text-gray-600 mb-2"
class="text-neutral-600 mb-2"
v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(", ") }}
</p>
@ -78,21 +78,21 @@
<tr
v-for="(category, index) in expenseCategories"
:key="`exp-${index}`"
class="border-t border-gray-200"
class="border-t border-neutral-200"
v-show="category.planned > 0">
<td class="border-r-1 border-black px-4 py-2">
{{ category.name }}
</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
<td class="border-r border-neutral-400 px-4 py-2 text-right">
{{ formatCurrency(category.planned) }}
</td>
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
</tr>
<!-- Total Expenses -->
<tr class="border-t-2 border-black font-semibold bg-gray-50">
<tr class="border-t-2 border-black font-semibold bg-neutral-50">
<td class="border-r-1 border-black px-4 py-2">Total Expenses</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
<td class="border-r border-neutral-400 px-4 py-2 text-right">
{{ formatCurrency(totalExpensesPlanned) }}
</td>
<td class="px-4 py-2 text-right">100%</td>
@ -103,7 +103,7 @@
class="border-t-2 border-black font-bold text-lg"
:class="netTotalClass">
<td class="border-r-1 border-black px-4 py-3">NET TOTAL</td>
<td class="border-r border-gray-400 px-4 py-3 text-right">
<td class="border-r border-neutral-400 px-4 py-3 text-right">
{{ formatCurrency(netTotal) }}
</td>
<td class="px-4 py-3 text-right">-</td>
@ -244,7 +244,7 @@ const netTotal = computed(
const netTotalClass = computed(() => {
if (netTotal.value > 0) return "bg-green-50";
if (netTotal.value < 0) return "bg-red-50";
return "bg-gray-50";
return "bg-neutral-50";
});
// Diversification guidance
@ -397,7 +397,7 @@ function getPercentageClass(percentage: number): string {
if (percentage > 50) return "text-red-600 font-bold";
if (percentage > 35) return "text-yellow-600 font-semibold";
if (percentage > 20) return "text-black font-medium";
return "text-gray-500";
return "text-neutral-500";
}
// Initialize

View file

@ -2,7 +2,7 @@
<select
v-model="selectedCategory"
@change="handleSelection(selectedCategory)"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class="w-full px-3 py-2 border border-neutral-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option v-for="option in options" :key="option" :value="option">
{{ option }}

View file

@ -1,5 +1,5 @@
<template>
<UButton color="gray" variant="ghost" @click="toggle">
<UButton color="neutral" variant="ghost" @click="toggle">
<UIcon :name="icon" class="w-5 h-5" />
</UButton>
</template>

View file

@ -1,5 +1,5 @@
<template>
<div class="mb-12">
<div v-if="isSetupCompleted" class="mb-12">
<div class="w-full mx-auto">
<nav
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
@ -24,16 +24,17 @@
<script setup lang="ts">
const route = useRoute();
const coop = useCoopBuilder();
const coopBuilderItems = [
{
id: "coop-builder",
name: "Setup Wizard",
name: "Settings",
path: "/coop-builder",
},
{
id: "budget",
name: "Budget",
name: "Studio Budget",
path: "/budget",
},
{
@ -43,6 +44,31 @@ const coopBuilderItems = [
},
];
// Check if setup wizard is completed using the same validation logic as coop-builder page
const isSetupCompleted = computed(() => {
// Members validation: at least one member with name and positive hours
const membersValid = coop.members.value.some((m: any) => {
const hasName = typeof m.name === "string" && m.name.trim().length > 0;
const hours = Number((m as any).hoursPerMonth ?? 0);
return hasName && Number.isFinite(hours) && hours > 0;
});
// Streams validation: at least one stream with name and non-negative monthly amount
const streamsValid = coop.streams.value.length > 0 &&
coop.streams.value.every((s: any) => {
const monthly = Number((s as any).monthly ?? 0);
return (s.label || "").toString().trim().length > 0 && monthly >= 0;
});
// Policies validation: has members (same logic as coop-builder page)
const policiesValid = coop.members.value.length > 0;
// Costs are always valid (optional)
const costsValid = true;
return policiesValid && membersValid && costsValid && streamsValid;
});
function isActive(path: string): boolean {
return route.path === path;
}

View file

@ -69,7 +69,7 @@ const progressColor = computed(() => {
case "red":
return "red";
default:
return "gray";
return "neutral";
}
});
@ -82,7 +82,7 @@ const badgeColor = computed(() => {
case "red":
return "red";
default:
return "gray";
return "neutral";
}
});

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,16 @@
<template>
<div class="space-y-4">
<!-- Runway summary -->
<div class="grid grid-cols-2 gap-4 p-3 bg-gray-50 rounded-lg text-sm">
<div class="grid grid-cols-2 gap-4 p-3 bg-neutral-50 rounded-lg text-sm">
<div>
<span class="text-gray-600">Min mode runway:</span>
<span class="text-neutral-600">Min mode runway:</span>
<div class="font-bold text-lg">{{ minRunwayMonths }} months</div>
<div class="text-xs text-gray-500">Until {{ formatDate(minRunwayEndDate) }}</div>
<div class="text-xs text-neutral-500">Until {{ formatDate(minRunwayEndDate) }}</div>
</div>
<div>
<span class="text-gray-600">Target mode runway:</span>
<span class="text-neutral-600">Target mode runway:</span>
<div class="font-bold text-lg">{{ targetRunwayMonths }} months</div>
<div class="text-xs text-gray-500">Until {{ formatDate(targetRunwayEndDate) }}</div>
<div class="text-xs text-neutral-500">Until {{ formatDate(targetRunwayEndDate) }}</div>
</div>
</div>
@ -28,12 +28,12 @@
</UButton>
</div>
<div v-if="milestones.length === 0" class="text-xs text-gray-500 italic p-2">
<div v-if="milestones.length === 0" class="text-xs text-neutral-500 italic p-2">
No milestones set. Add key dates to track runway coverage.
</div>
<div v-for="milestone in milestonesWithStatus" :key="milestone.id"
class="flex items-center justify-between p-2 border border-gray-200 rounded text-sm">
class="flex items-center justify-between p-2 border border-neutral-200 rounded text-sm">
<div class="flex items-center gap-2">
<UIcon
:name="milestone.status === 'safe' ? 'i-heroicons-check-circle' :
@ -46,11 +46,11 @@
/>
<div>
<div class="font-medium">{{ milestone.label }}</div>
<div class="text-xs text-gray-500">{{ formatDate(milestone.date) }}</div>
<div class="text-xs text-neutral-500">{{ formatDate(milestone.date) }}</div>
</div>
</div>
<div class="text-right">
<div class="text-xs text-gray-600">
<div class="text-xs text-neutral-600">
{{ milestone.monthsFromNow > 0 ? `+${milestone.monthsFromNow}` : milestone.monthsFromNow }}mo
</div>
<UButton
@ -66,7 +66,7 @@
</div>
<!-- Add milestone form -->
<div v-if="showAddForm" class="p-3 border border-gray-200 rounded-lg space-y-2">
<div v-if="showAddForm" class="p-3 border border-neutral-200 rounded-lg space-y-2">
<UInput
v-model="newMilestone.label"
placeholder="Milestone name (e.g., 'Prototype release')"

View file

@ -1,12 +1,12 @@
<template>
<div class="space-y-3">
<div v-for="member in membersWithCoverage" :key="member.id" class="space-y-1">
<div class="flex justify-between text-xs font-medium text-gray-700">
<div class="flex justify-between text-xs font-medium text-neutral-700">
<span>{{ member.displayName || 'Unnamed' }}</span>
<span>{{ Math.round(member.coverageMinPct || 0) }}%</span>
</div>
<div class="relative h-6 bg-gray-100 rounded overflow-hidden">
<div class="relative h-6 bg-neutral-100 rounded overflow-hidden">
<!-- Min coverage bar -->
<div
class="absolute top-0 left-0 h-full transition-all duration-300"
@ -17,21 +17,21 @@
<!-- Target coverage tick/ghost -->
<div
v-if="member.coverageTargetPct"
class="absolute top-0 h-full w-0.5 bg-gray-400 opacity-50"
class="absolute top-0 h-full w-0.5 bg-neutral-400 opacity-50"
:style="{ left: `${Math.min(100, member.coverageTargetPct)}%` }"
>
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-400 rounded-full opacity-50" />
<div class="absolute -top-1 -left-1 w-2 h-2 bg-neutral-400 rounded-full opacity-50" />
</div>
<!-- 100% line -->
<div class="absolute top-0 left-0 h-full w-full pointer-events-none">
<div class="absolute top-0 h-full w-px bg-gray-600" style="left: 100%" />
<div class="absolute top-0 h-full w-px bg-neutral-600" style="left: 100%" />
</div>
</div>
</div>
<!-- Summary stats -->
<div class="pt-3 border-t border-gray-200 text-xs text-gray-600">
<div class="pt-3 border-t border-neutral-200 text-xs text-neutral-600">
<div class="flex justify-between">
<span>Team median: {{ Math.round(teamStats.median || 0) }}%</span>
<span v-if="teamStats.under100 > 0" class="text-yellow-600 font-medium">

View file

@ -3,10 +3,10 @@
<template #header>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<h3 class="text-lg font-semibold text-neutral-900 dark:text-white">
One-Off Transactions
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
<p class="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
Add one-time income or expense transactions with expected dates.
</p>
</div>
@ -18,11 +18,13 @@
<!-- Empty state -->
<div v-if="sortedEvents.length === 0" class="text-center py-8">
<UIcon name="i-heroicons-banknotes" class="w-12 h-12 mx-auto text-gray-400 mb-4" />
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
<UIcon
name="i-heroicons-banknotes"
class="w-12 h-12 mx-auto text-neutral-400 mb-4" />
<h4 class="text-lg font-medium text-neutral-900 dark:text-white mb-2">
No transactions yet
</h4>
<p class="text-gray-600 dark:text-gray-400 mb-4">
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
Add one-off income or expense transactions.
</p>
<UButton @click="addEvent" color="primary">
@ -36,19 +38,26 @@
<div
v-for="monthGroup in eventsByMonth"
:key="monthGroup.month"
class="space-y-3"
>
class="space-y-3">
<!-- Month header -->
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-gray-900 dark:text-white">
<div
class="flex items-center justify-between py-2 border-b border-neutral-200 dark:border-neutral-700">
<h4 class="font-medium text-neutral-900 dark:text-white">
{{ monthGroup.monthName }}
</h4>
<div class="flex items-center gap-3">
<UBadge variant="subtle" color="gray">
{{ monthGroup.events.length }} transaction{{ monthGroup.events.length !== 1 ? 's' : '' }}
<UBadge variant="subtle" color="neutral">
{{ monthGroup.events.length }} transaction{{
monthGroup.events.length !== 1 ? "s" : ""
}}
</UBadge>
<div class="text-sm font-medium" :class="monthGroup.netAmount >= 0 ? 'text-green-600' : 'text-red-600'">
{{ monthGroup.netAmount >= 0 ? '+' : '' }}{{ formatCurrency(monthGroup.netAmount) }}
<div
class="text-sm font-medium"
:class="
monthGroup.netAmount >= 0 ? 'text-green-600' : 'text-red-600'
">
{{ monthGroup.netAmount >= 0 ? "+" : ""
}}{{ formatCurrency(monthGroup.netAmount) }}
</div>
</div>
</div>
@ -59,10 +68,15 @@
v-for="event in monthGroup.events"
:key="event.id"
:ui="{
background: event.type === 'income' ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20',
ring: event.type === 'income' ? 'ring-green-200 dark:ring-green-800' : 'ring-red-200 dark:ring-red-800'
}"
>
background:
event.type === 'income'
? 'bg-green-50 dark:bg-green-900/20'
: 'bg-red-50 dark:bg-red-900/20',
ring:
event.type === 'income'
? 'ring-green-200 dark:ring-green-800'
: 'ring-red-200 dark:ring-red-800',
}">
<UForm :state="event" @submit="() => {}">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Category -->
@ -70,8 +84,9 @@
<USelect
v-model="event.category"
:options="categoryOptions"
@update:model-value="updateEvent(event.id, { category: $event })"
/>
@update:model-value="
updateEvent(event.id, { category: $event })
" />
</UFormField>
<!-- Name -->
@ -79,8 +94,9 @@
<UInput
v-model="event.name"
placeholder="e.g., Equipment purchase"
@update:model-value="updateEvent(event.id, { name: $event })"
/>
@update:model-value="
updateEvent(event.id, { name: $event })
" />
</UFormField>
<!-- Type -->
@ -88,8 +104,9 @@
<USelect
v-model="event.type"
:options="typeOptions"
@update:model-value="updateEvent(event.id, { type: $event })"
/>
@update:model-value="
updateEvent(event.id, { type: $event })
" />
</UFormField>
<!-- Amount -->
@ -98,10 +115,11 @@
v-model="event.amount"
type="number"
placeholder="5000"
@update:model-value="updateEvent(event.id, { amount: Number($event) })"
>
@update:model-value="
updateEvent(event.id, { amount: Number($event) })
">
<template #leading>
<span class="text-gray-500">$</span>
<span class="text-neutral-500">$</span>
</template>
</UInput>
</UFormField>
@ -113,8 +131,9 @@
<UInput
v-model="event.dateExpected"
type="date"
@update:model-value="updateEventWithDate(event.id, $event)"
/>
@update:model-value="
updateEventWithDate(event.id, $event)
" />
</UFormField>
</div>
</UForm>
@ -126,12 +145,15 @@
color="red"
size="sm"
icon="i-heroicons-trash"
@click="removeEvent(event.id)"
>
@click="removeEvent(event.id)">
Delete
</UButton>
<UDropdown :items="getEventActions(event)">
<UButton variant="ghost" color="gray" size="sm" icon="i-heroicons-ellipsis-horizontal" />
<UButton
variant="ghost"
color="neutral"
size="sm"
icon="i-heroicons-ellipsis-horizontal" />
</UDropdown>
</div>
</template>
@ -142,11 +164,16 @@
<!-- Summary -->
<UCard>
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900 dark:text-white">
Total {{ sortedEvents.length }} transaction{{ sortedEvents.length !== 1 ? 's' : '' }}
<span class="font-medium text-neutral-900 dark:text-white">
Total {{ sortedEvents.length }} transaction{{
sortedEvents.length !== 1 ? "s" : ""
}}
</span>
<span class="text-lg font-bold" :class="totalAnnualImpact >= 0 ? 'text-green-600' : 'text-red-600'">
{{ totalAnnualImpact >= 0 ? '+' : '' }}{{ formatCurrency(totalAnnualImpact) }}
<span
class="text-lg font-bold"
:class="totalAnnualImpact >= 0 ? 'text-green-600' : 'text-red-600'">
{{ totalAnnualImpact >= 0 ? "+" : ""
}}{{ formatCurrency(totalAnnualImpact) }}
</span>
</div>
</UCard>
@ -155,126 +182,146 @@
</template>
<script setup lang="ts">
import type { OneOffEvent } from '~/types/cash'
import type { OneOffEvent } from "~/types/cash";
const cashStore = useCashStore()
const cashStore = useCashStore();
// Constants
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December']
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const typeOptions = [
{ label: 'Income', value: 'income' },
{ label: 'Expense', value: 'expense' }
]
{ label: "Income", value: "income" },
{ label: "Expense", value: "expense" },
];
const categoryOptions = [
{ label: 'Equipment', value: 'Equipment' },
{ label: 'Marketing', value: 'Marketing' },
{ label: 'Legal', value: 'Legal' },
{ label: 'Contractors', value: 'Contractors' },
{ label: 'Office', value: 'Office' },
{ label: 'Development', value: 'Development' },
{ label: 'Other', value: 'Other' }
]
{ label: "Equipment", value: "Equipment" },
{ label: "Marketing", value: "Marketing" },
{ label: "Legal", value: "Legal" },
{ label: "Contractors", value: "Contractors" },
{ label: "Office", value: "Office" },
{ label: "Development", value: "Development" },
{ label: "Other", value: "Other" },
];
// Computed
const { oneOffEvents } = storeToRefs(cashStore)
const { oneOffEvents } = storeToRefs(cashStore);
const sortedEvents = computed(() => {
return oneOffEvents.value
.slice()
.sort((a, b) => a.month - b.month || a.name.localeCompare(b.name))
})
.sort((a, b) => a.month - b.month || a.name.localeCompare(b.name));
});
const eventsByMonth = computed(() => {
const groups: Record<number, OneOffEvent[]> = {}
sortedEvents.value.forEach(event => {
const groups: Record<number, OneOffEvent[]> = {};
sortedEvents.value.forEach((event) => {
if (!groups[event.month]) {
groups[event.month] = []
groups[event.month] = [];
}
groups[event.month].push(event)
})
return Object.entries(groups).map(([month, events]) => {
const monthNum = parseInt(month)
const netAmount = events.reduce((sum, event) => {
return sum + (event.type === 'income' ? event.amount : -event.amount)
}, 0)
return {
month: monthNum,
monthName: monthNames[monthNum],
events,
netAmount
}
}).sort((a, b) => a.month - b.month)
})
groups[event.month].push(event);
});
return Object.entries(groups)
.map(([month, events]) => {
const monthNum = parseInt(month);
const netAmount = events.reduce((sum, event) => {
return sum + (event.type === "income" ? event.amount : -event.amount);
}, 0);
return {
month: monthNum,
monthName: monthNames[monthNum],
events,
netAmount,
};
})
.sort((a, b) => a.month - b.month);
});
const totalIncome = computed(() => {
return oneOffEvents.value
.filter(e => e.type === 'income')
.reduce((sum, e) => sum + e.amount, 0)
})
.filter((e) => e.type === "income")
.reduce((sum, e) => sum + e.amount, 0);
});
const totalExpenses = computed(() => {
return oneOffEvents.value
.filter(e => e.type === 'expense')
.reduce((sum, e) => sum + e.amount, 0)
})
.filter((e) => e.type === "expense")
.reduce((sum, e) => sum + e.amount, 0);
});
const totalAnnualImpact = computed(() => totalIncome.value - totalExpenses.value)
const totalAnnualImpact = computed(
() => totalIncome.value - totalExpenses.value
);
// Methods
function addEvent() {
const currentMonth = new Date().getMonth()
const today = new Date().toISOString().split('T')[0]
const currentMonth = new Date().getMonth();
const today = new Date().toISOString().split("T")[0];
cashStore.addOneOffEvent({
name: '',
type: 'income',
name: "",
type: "income",
amount: 0,
category: 'Other',
dateExpected: today
})
category: "Other",
dateExpected: today,
});
}
function updateEvent(eventId: string, updates: Partial<OneOffEvent>) {
cashStore.updateOneOffEvent(eventId, updates)
cashStore.updateOneOffEvent(eventId, updates);
}
function updateEventWithDate(eventId: string, dateExpected: string) {
const eventDate = new Date(dateExpected)
const month = eventDate.getMonth()
cashStore.updateOneOffEvent(eventId, { dateExpected, month })
const eventDate = new Date(dateExpected);
const month = eventDate.getMonth();
cashStore.updateOneOffEvent(eventId, { dateExpected, month });
}
function removeEvent(eventId: string) {
cashStore.removeOneOffEvent(eventId)
cashStore.removeOneOffEvent(eventId);
}
function getEventActions(event: OneOffEvent) {
return [
[{
label: 'Move to Different Month',
icon: 'i-heroicons-arrow-right',
click: () => moveToMonth(event.id)
}],
[{
label: 'Duplicate Event',
icon: 'i-heroicons-document-duplicate',
click: () => duplicateEvent(event)
}]
]
[
{
label: "Move to Different Month",
icon: "i-heroicons-arrow-right",
click: () => moveToMonth(event.id),
},
],
[
{
label: "Duplicate Event",
icon: "i-heroicons-document-duplicate",
click: () => duplicateEvent(event),
},
],
];
}
function moveToMonth(eventId: string) {
// This could open a month selector modal
// For now, just move to next month
const event = oneOffEvents.value.find(e => e.id === eventId)
const event = oneOffEvents.value.find((e) => e.id === eventId);
if (event) {
const newMonth = (event.month + 1) % 12
updateEvent(eventId, { month: newMonth })
const newMonth = (event.month + 1) % 12;
updateEvent(eventId, { month: newMonth });
}
}
@ -284,22 +331,22 @@ function duplicateEvent(event: OneOffEvent) {
type: event.type,
amount: event.amount,
category: event.category,
dateExpected: event.dateExpected
})
dateExpected: event.dateExpected,
});
}
function getDateValue(dateExpected: string | undefined): string {
if (!dateExpected) {
return new Date().toISOString().split('T')[0]
return new Date().toISOString().split("T")[0];
}
return dateExpected
return dateExpected;
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(Math.abs(amount))
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(Math.abs(amount));
}
</script>
</script>

View file

@ -1,54 +1,54 @@
<template>
<UBadge
:color="badgeColor"
:variant="variant"
:size="size"
>
<UBadge :color="badgeColor" :variant="variant" :size="size">
{{ displayText }}
</UBadge>
</template>
<script setup lang="ts">
type PayRelationship = 'FullyPaid' | 'Hybrid' | 'Supplemental' | 'VolunteerOrDeferred'
type PayRelationship =
| "FullyPaid"
| "Hybrid"
| "Supplemental"
| "VolunteerOrDeferred";
interface Props {
relationship: PayRelationship
variant?: 'solid' | 'outline' | 'soft' | 'subtle'
size?: 'xs' | 'sm' | 'md' | 'lg'
relationship: PayRelationship;
variant?: "solid" | "outline" | "soft" | "subtle";
size?: "xs" | "sm" | "md" | "lg";
}
const props = withDefaults(defineProps<Props>(), {
variant: 'subtle',
size: 'sm'
})
variant: "subtle",
size: "sm",
});
const badgeColor = computed(() => {
switch (props.relationship) {
case 'FullyPaid':
return 'green'
case 'Hybrid':
return 'blue'
case 'Supplemental':
return 'yellow'
case 'VolunteerOrDeferred':
return 'gray'
case "FullyPaid":
return "green";
case "Hybrid":
return "blue";
case "Supplemental":
return "yellow";
case "VolunteerOrDeferred":
return "neutral";
default:
return 'gray'
return "neutral";
}
})
});
const displayText = computed(() => {
switch (props.relationship) {
case 'FullyPaid':
return 'Fully Paid'
case 'Hybrid':
return 'Hybrid'
case 'Supplemental':
return 'Supplemental'
case 'VolunteerOrDeferred':
return 'Volunteer'
case "FullyPaid":
return "Fully Paid";
case "Hybrid":
return "Hybrid";
case "Supplemental":
return "Supplemental";
case "VolunteerOrDeferred":
return "Volunteer";
default:
return 'Unknown'
return "Unknown";
}
})
});
</script>

View file

@ -1,52 +1,72 @@
<template>
<UModal
v-model:open="isOpen"
<UModal
v-model:open="isOpen"
title="Payroll Oncost Settings"
description="Configure payroll taxes and benefits percentage"
:dismissible="true"
>
: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" />
<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-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.
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="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
<h4 class="font-medium text-neutral-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 class="text-neutral-600 dark:text-neutral-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 class="text-neutral-600 dark:text-neutral-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
class="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="text-neutral-600 dark:text-neutral-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">
<label
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Oncost Percentage
</label>
<div class="flex items-center space-x-3">
<div class="flex-1">
<UInput
@ -56,10 +76,9 @@
max="100"
step="1"
placeholder="25"
class="text-center"
/>
class="text-center" />
</div>
<span class="text-sm text-gray-500">%</span>
<span class="text-sm text-neutral-500">%</span>
</div>
<!-- Slider for easier adjustment -->
@ -70,9 +89,8 @@
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">
class="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 slider" />
<div class="flex justify-between text-xs text-neutral-500">
<span>0%</span>
<span>25%</span>
<span>50%</span>
@ -81,29 +99,44 @@
</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
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 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 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
{{ 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">
<label
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Common Ranges
</label>
<div class="flex flex-wrap gap-2">
@ -111,10 +144,9 @@
v-for="preset in commonRanges"
:key="preset.value"
size="xs"
color="gray"
color="neutral"
variant="outline"
@click="newOncostPct = preset.value"
>
@click="newOncostPct = preset.value">
{{ preset.label }}
</UButton>
</div>
@ -124,14 +156,13 @@
<template #footer="{ close }">
<div class="flex justify-end gap-3">
<UButton color="gray" variant="ghost" @click="handleCancel">
<UButton color="neutral" variant="ghost" @click="handleCancel">
Cancel
</UButton>
<UButton
color="primary"
<UButton
color="primary"
@click="handleSave"
:disabled="!isValidPercentage"
>
:disabled="!isValidPercentage">
Update Oncost Percentage
</UButton>
</div>
@ -140,120 +171,134 @@
</template>
<script setup lang="ts">
import { allocatePayroll as allocatePayrollImpl } from '~/types/members'
import { allocatePayroll as allocatePayrollImpl } from "~/types/members";
interface Props {
open: boolean
open: boolean;
}
interface Emits {
(e: 'update:open', value: boolean): void
(e: 'save', percentage: number): void
(e: "update:open", value: boolean): void;
(e: "save", percentage: number): void;
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Modal state
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
set: (value) => emit("update:open", value),
});
// Get current payroll data
const coopStore = useCoopBuilderStore()
const currentOncostPct = computed(() => coopStore.payrollOncostPct || 0)
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 { 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
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
}
roleBands: coopStore.policy.roleBands,
};
// Convert members to the format expected by allocatePayroll
const membersForAllocation = coopStore.members.map(m => ({
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)
}))
hoursPerMonth:
m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0),
}));
// Use the imported allocatePayroll function
const allocatedMembers = allocatePayrollImpl(membersForAllocation, payPolicy, basePayrollBudget)
const allocatedMembers = allocatePayrollImpl(
membersForAllocation,
payPolicy,
basePayrollBudget
);
// Sum the allocated amounts for total payroll
return allocatedMembers.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
return allocatedMembers.reduce(
(sum, m) => sum + (m.monthlyPayPlanned || 0),
0
);
}
return 0
})
const currentOncostAmount = computed(() =>
basePayroll.value * (currentOncostPct.value / 100)
)
return 0;
});
const totalPayrollCost = computed(() =>
basePayroll.value + currentOncostAmount.value
)
const currentOncostAmount = computed(
() => basePayroll.value * (currentOncostPct.value / 100)
);
const totalPayrollCost = computed(
() => basePayroll.value + currentOncostAmount.value
);
// New percentage input
const newOncostPct = ref(currentOncostPct.value)
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 newOncostAmount = computed(
() => basePayroll.value * (newOncostPct.value / 100)
);
const newTotalCost = computed(() => basePayroll.value + newOncostAmount.value);
const isValidPercentage = computed(() =>
newOncostPct.value >= 0 && newOncostPct.value <= 100
)
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 }
]
{ 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
newOncostPct.value = currentOncostPct.value;
}
})
});
// Handlers
function handleCancel() {
newOncostPct.value = currentOncostPct.value
isOpen.value = false
newOncostPct.value = currentOncostPct.value;
isOpen.value = false;
}
function handleSave() {
if (isValidPercentage.value) {
emit('save', newOncostPct.value)
isOpen.value = false
emit("save", newOncostPct.value);
isOpen.value = false;
}
}
// 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>
@ -275,4 +320,4 @@ function formatCurrency(amount: number): string {
cursor: pointer;
border: none;
}
</style>
</style>

View file

@ -15,7 +15,7 @@
</div>
<!-- Controls -->
<div class="p-6 border-b-4 border-black bg-gray-100">
<div class="p-6 border-b-4 border-black bg-neutral-100">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2">
<label for="duration" class="font-bold text-sm">Duration (months):</label>
@ -47,7 +47,7 @@
<span class="font-bold">Monthly team cost:</span>
<span class="font-mono">{{ currency(monthlyCost) }}</span>
</li>
<li class="text-xs text-gray-600 -mt-1">
<li class="text-xs text-neutral-600 -mt-1">
Sustainable payroll + {{ percent(props.oncostRate) }} benefits
</li>
<li class="flex justify-between items-center">
@ -122,7 +122,7 @@
</li>
</ul>
<p class="text-xs text-gray-600">
<p class="text-xs text-neutral-600">
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not included.
</p>
</div>
@ -147,7 +147,7 @@
</div>
<!-- Guidance -->
<div v-if="guidanceText" class="p-4 bg-gray-50 text-sm text-gray-600">
<div v-if="guidanceText" class="p-4 bg-neutral-50 text-sm text-neutral-600">
{{ guidanceText }}
</div>
</div>

View file

@ -72,7 +72,7 @@ const progressColor = computed(() => {
case "red":
return "red";
default:
return "gray";
return "neutral";
}
});
@ -85,7 +85,7 @@ const badgeColor = computed(() => {
case "red":
return "red";
default:
return "gray";
return "neutral";
}
});

View file

@ -1,39 +1,35 @@
<template>
<UBadge
:color="badgeColor"
:variant="variant"
:size="size"
>
<UBadge :color="badgeColor" :variant="variant" :size="size">
{{ displayText }}
</UBadge>
</template>
<script setup lang="ts">
type Restriction = 'Restricted' | 'General'
type Restriction = "Restricted" | "General";
interface Props {
restriction: Restriction
variant?: 'solid' | 'outline' | 'soft' | 'subtle'
size?: 'xs' | 'sm' | 'md' | 'lg'
restriction: Restriction;
variant?: "solid" | "outline" | "soft" | "subtle";
size?: "xs" | "sm" | "md" | "lg";
}
const props = withDefaults(defineProps<Props>(), {
variant: 'subtle',
size: 'sm'
})
variant: "subtle",
size: "sm",
});
const badgeColor = computed(() => {
switch (props.restriction) {
case 'Restricted':
return 'orange'
case 'General':
return 'green'
case "Restricted":
return "orange";
case "General":
return "green";
default:
return 'gray'
return "neutral";
}
})
});
const displayText = computed(() => {
return props.restriction
})
return props.restriction;
});
</script>

View file

@ -12,7 +12,7 @@
<span class="font-medium">{{ stream.name }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-gray-600">{{ formatCurrency(stream.targetMonthlyAmount || 0) }}</span>
<span class="text-neutral-600">{{ formatCurrency(stream.targetMonthlyAmount || 0) }}</span>
<span class="font-medium min-w-[40px] text-right">{{ stream.percentage }}%</span>
</div>
</div>
@ -31,7 +31,7 @@
</div>
<!-- Totals -->
<div class="pt-2 border-t border-gray-200 text-xs text-gray-600">
<div class="pt-2 border-t border-neutral-200 text-xs text-neutral-600">
<div class="flex justify-between">
<span>Total monthly target:</span>
<span class="font-medium">{{ formatCurrency(totalMonthly) }}</span>

View file

@ -1,41 +1,37 @@
<template>
<UBadge
:color="badgeColor"
:variant="variant"
:size="size"
>
<UBadge :color="badgeColor" :variant="variant" :size="size">
{{ displayText }}
</UBadge>
</template>
<script setup lang="ts">
type RiskBand = 'Low' | 'Medium' | 'High'
type RiskBand = "Low" | "Medium" | "High";
interface Props {
risk: RiskBand
variant?: 'solid' | 'outline' | 'soft' | 'subtle'
size?: 'xs' | 'sm' | 'md' | 'lg'
risk: RiskBand;
variant?: "solid" | "outline" | "soft" | "subtle";
size?: "xs" | "sm" | "md" | "lg";
}
const props = withDefaults(defineProps<Props>(), {
variant: 'subtle',
size: 'sm'
})
variant: "subtle",
size: "sm",
});
const badgeColor = computed(() => {
switch (props.risk) {
case 'Low':
return 'green'
case 'Medium':
return 'yellow'
case 'High':
return 'red'
case "Low":
return "green";
case "Medium":
return "yellow";
case "High":
return "red";
default:
return 'gray'
return "neutral";
}
})
});
const displayText = computed(() => {
return `${props.risk} Risk`
})
return `${props.risk} Risk`;
});
</script>

View file

@ -57,7 +57,7 @@ const progressColor = computed(() => {
case "red":
return "red";
default:
return "gray";
return "neutral";
}
});
@ -70,7 +70,7 @@ const badgeColor = computed(() => {
case "red":
return "red";
default:
return "gray";
return "neutral";
}
});

View file

@ -2,10 +2,10 @@
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">
<h3 class="text-2xl font-black text-black dark:text-white mb-2">
Where does your money go?
</h3>
<p class="text-neutral-600">
<p class="text-neutral-600 dark:text-neutral-200">
Add costs like rent + utilities, software licenses, insurance, lawyer
fees, accountant fees, and other recurring expenses.
</p>
@ -16,14 +16,16 @@
<div
v-if="overheadCosts.length > 0"
class="flex items-center justify-between">
<h4 class="text-lg font-bold text-black">Overhead</h4>
<h4 class="text-lg font-bold text-black dark:text-white">Overhead</h4>
</div>
<div
v-if="overheadCosts.length === 0"
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">
class="text-center py-12 border-2 border-dashed border-neutral-300 dark:border-neutral-600 rounded-xl bg-white dark:bg-neutral-950 shadow-sm">
<h4 class="font-medium text-neutral-900 dark:text-neutral-100 mb-2">
No overhead costs yet
</h4>
<p class="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Get started by adding your first overhead cost.
</p>
<UButton
@ -39,7 +41,7 @@
<div
v-for="cost in overheadCosts"
:key="cost.id"
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
class="p-6 border-2 border-black dark:border-neutral-400 rounded-xl bg-white dark:bg-neutral-950 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">
@ -112,7 +114,7 @@
</UButton>
</UButtonGroup>
</div>
<p class="text-xs text-neutral-500 mt-1">
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
<template v-if="cost.amountType === 'annual'">
{{ currencySymbol
}}{{ Math.round((cost.annualAmount || 0) / 12) }} per month
@ -131,7 +133,6 @@
@click="addOverheadCost"
size="lg"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',

View file

@ -2,13 +2,15 @@
<div class="max-w-4xl mx-auto space-y-6">
<!-- 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">
<h3 class="text-2xl font-black text-black dark:text-white mb-2">
Who's on your team?
</h3>
<p class="text-neutral-600 dark:text-neutral-200">
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">
<div class="mt-2 p-2 bg-neutral-100 dark:bg-neutral-800 rounded text-xs">
Debug: Policy = {{ currentPolicy }}, Needs field shown =
{{ isNeedsWeighted }}
</div>
@ -18,9 +20,11 @@
<div class="space-y-3">
<div
v-if="members.length === 0"
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">
class="text-center py-12 border-2 border-dashed border-neutral-300 dark:border-neutral-600 rounded-xl bg-white dark:bg-neutral-950 shadow-sm">
<h4 class="font-medium text-neutral-900 dark:text-neutral-100 mb-2">
No team members yet
</h4>
<p class="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Get started by adding your first team member.
</p>
<UButton @click="addMember" size="lg" variant="solid" color="primary">
@ -32,7 +36,7 @@
<div
v-for="(member, index) in members"
:key="member.id"
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
class="p-6 border-2 border-black dark:border-neutral-400 rounded-xl bg-white dark:bg-neutral-950 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-4 flex-1">
@ -74,7 +78,9 @@
<!-- Show minimum needs field when needs-weighted policy is selected -->
<UFormField
v-if="isNeedsWeighted"
:label="`Minimum needs (${getCurrencySymbol(coop.currency.value)}/month)`"
:label="`Minimum needs (${getCurrencySymbol(
coop.currency.value
)}/month)`"
required>
<UInputNumber
v-model="member.minMonthlyNeeds"
@ -95,7 +101,6 @@
@click="addMember"
size="lg"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',

View file

@ -2,19 +2,20 @@
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">
<h3 class="text-2xl font-black text-black dark:text-white mb-2">
How will you share money?
</h3>
<p class="text-neutral-600">
<p class="text-neutral-600 dark:text-neutral-200">
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-2 border-black rounded-xl bg-white shadow-md">
<div
class="p-6 border-2 border-black dark:border-netural-400 bg-white dark:bg-neutral-950 shadow-md">
<h4 class="font-bold mb-2">Step 1: Choose your pay approach</h4>
<p class="text-sm text-gray-600 mb-4">
<p class="text-sm text-neutral-600 dark:text-neutral-200 mb-4">
How should available money be shared among members?
</p>
<URadioGroup
@ -27,9 +28,10 @@
</div>
<!-- Hourly Wage Input -->
<div class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
<div
class="p-6 border-2 border-black dark:border-neutral-950 bg-white dark:bg-neutral-950 shadow-md">
<h4 class="font-bold mb-2">Step 2: Set your base wage</h4>
<p class="text-sm text-gray-600 mb-4">
<p class="text-sm text-neutral-600 dark:text-neutral-200 mb-4">
This hourly rate applies to all paid work in your co-op
</p>
<div class="flex gap-4 items-start">

View file

@ -2,139 +2,156 @@
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">
<h3 class="text-2xl font-black text-black dark:text-white mb-2">
Where will your money come from?
</h3>
<p class="text-neutral-600">
<p class="text-neutral-600 dark:text-neutral-200">
Add sources like client work, grants, product sales, or donations.
</p>
</div>
<!-- Removed Tab Navigation - showing streams directly -->
<div class="space-y-6">
<div class="space-y-6">
<div class="space-y-3">
<div
v-if="streams.length === 0"
class="text-center py-12 border-2 border-dashed border-neutral-300 dark:border-neutral-600 rounded-xl bg-white dark:bg-neutral-950 shadow-sm">
<h4 class="font-medium text-neutral-900 dark:text-neutral-100 mb-2">
No revenue streams yet
</h4>
<p class="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Get started by adding your first revenue source.
</p>
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
color="primary">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add your first revenue stream
</UButton>
</div>
<div class="space-y-3">
<div
v-if="streams.length === 0"
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>
<p class="text-sm text-neutral-500 mb-4">
Get started by adding your first revenue source.
<div
v-for="stream in streams"
:key="stream.id"
class="p-6 border-2 border-black dark:border-neutral-400 rounded-xl bg-white dark:bg-neutral-950 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="md"
class="text-sm font-medium w-full"
@update:model-value="saveCategoryChange(stream)" />
</UFormField>
<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>
<!-- 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 dark:text-neutral-400 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>
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
color="primary">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add your first revenue stream
</UButton>
</div>
<div
v-for="stream in streams"
:key="stream.id"
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="md"
class="text-sm font-medium w-full"
@update:model-value="saveCategoryChange(stream)" />
</UFormField>
<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>
<!-- 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>
<!-- Add Stream Button (when items exist) -->
<div v-if="streams.length > 0" class="flex justify-center">
<UButton
@click="addRevenueStream"
size="lg"
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-2" />
Add another stream
</UButton>
</div>
</UFormField>
</div>
</div>
<!-- Add Stream Button (when items exist) -->
<div v-if="streams.length > 0" class="flex justify-center">
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add another stream
</UButton>
</div>
</div>
</div>
</div>
</template>
@ -148,23 +165,23 @@ const emit = defineEmits<{
// Store and Currency
const coop = useCoopBuilder();
const { currencySymbol } = useCurrency();
const streams = computed(() =>
coop.streams.value.map(s => ({
const streams = computed(() =>
coop.streams.value.map((s) => ({
// Map store fields to component expectations
id: s.id,
name: s.label,
category: s.category || 'games',
category: s.category || "games",
targetMonthlyAmount: s.monthly || 0,
targetAnnualAmount: (s.annual || (s.monthly || 0) * 12),
amountType: s.amountType || 'monthly',
subcategory: '',
targetAnnualAmount: s.annual || (s.monthly || 0) * 12,
amountType: s.amountType || "monthly",
subcategory: "",
targetPct: 0,
certainty: s.certainty || 'Aspirational',
certainty: s.certainty || "Aspirational",
payoutDelayDays: 30,
terms: 'Net 30',
terms: "Net 30",
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
restrictions: "General",
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0,
}))
@ -226,9 +243,10 @@ const nameOptionsByCategory: Record<string, string[]> = {
// Computed
const totalMonthlyAmount = computed(() =>
streams.value.reduce((sum, s) => {
const monthly = s.amountType === 'annual'
? Math.round((s.targetAnnualAmount || 0) / 12)
: (s.targetMonthlyAmount || 0);
const monthly =
s.amountType === "annual"
? Math.round((s.targetAnnualAmount || 0) / 12)
: s.targetMonthlyAmount || 0;
return sum + monthly;
}, 0)
);
@ -239,18 +257,19 @@ 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 monthly =
stream.amountType === "annual"
? Math.round((stream.targetAnnualAmount || 0) / 12)
: stream.targetMonthlyAmount || 0;
const streamData = {
id: stream.id,
label: stream.name || '',
label: stream.name || "",
monthly: monthly,
annual: stream.targetAnnualAmount || monthly * 12,
amountType: stream.amountType || 'monthly',
category: stream.category || 'games',
certainty: stream.certainty || 'Aspirational'
amountType: stream.amountType || "monthly",
category: stream.category || "games",
certainty: stream.certainty || "Aspirational",
};
coop.upsertStream(streamData);
@ -262,10 +281,11 @@ const debouncedSave = useDebounceFn((stream: any) => {
}, 300);
function saveStream(stream: any) {
const hasValidAmount = stream.amountType === 'annual'
? stream.targetAnnualAmount >= 0
: stream.targetMonthlyAmount >= 0;
const hasValidAmount =
stream.amountType === "annual"
? stream.targetAnnualAmount >= 0
: stream.targetMonthlyAmount >= 0;
if (stream.name && stream.category && hasValidAmount) {
debouncedSave(stream);
}
@ -281,18 +301,19 @@ function saveCategoryChange(stream: any) {
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 monthly =
stream.amountType === "annual"
? Math.round((stream.targetAnnualAmount || 0) / 12)
: stream.targetMonthlyAmount || 0;
const streamData = {
id: stream.id,
label: stream.name || '',
label: stream.name || "",
monthly: monthly,
annual: stream.targetAnnualAmount || monthly * 12,
amountType: stream.amountType || 'monthly',
category: stream.category || 'games',
certainty: stream.certainty || 'Aspirational'
amountType: stream.amountType || "monthly",
category: stream.category || "games",
certainty: stream.certainty || "Aspirational",
};
coop.upsertStream(streamData);
@ -305,33 +326,35 @@ function saveStreamImmediate(stream: any) {
function validateAndSaveAmount(value: string, stream: any) {
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
if (stream.amountType === 'annual') {
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') {
function switchAmountType(stream: any, type: "annual" | "monthly") {
stream.amountType = type;
// Recalculate values based on new type
if (type === 'annual') {
if (type === "annual") {
if (!stream.targetAnnualAmount) {
stream.targetAnnualAmount = (stream.targetMonthlyAmount || 0) * 12;
}
} else {
if (!stream.targetMonthlyAmount) {
stream.targetMonthlyAmount = Math.round((stream.targetAnnualAmount || 0) / 12);
stream.targetMonthlyAmount = Math.round(
(stream.targetAnnualAmount || 0) / 12
);
}
}
// Save immediately without debounce for instant UI update
saveStreamImmediate(stream);
}
@ -344,7 +367,7 @@ function addRevenueStream() {
annual: 0,
amountType: "monthly",
category: "games",
certainty: "Aspirational"
certainty: "Aspirational",
};
coop.upsertStream(newStream);

View file

@ -3,36 +3,39 @@
<template #header>
<div class="flex items-center justify-between">
<h4 class="font-medium">Milestones</h4>
<UButton
size="xs"
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-plus"
@click="showAddForm = true"
>
@click="showAddForm = true">
Add
</UButton>
</div>
</template>
<div class="space-y-3">
<div v-if="milestoneStatuses.length === 0" class="text-sm text-gray-500 italic py-2">
<div
v-if="milestoneStatuses.length === 0"
class="text-sm text-neutral-500 italic py-2">
No milestones set. Add key dates to track progress.
</div>
<div
v-for="milestone in milestoneStatuses"
:key="milestone.id"
class="flex items-center justify-between p-2 border border-gray-200 rounded"
>
class="flex items-center justify-between p-2 border border-neutral-200 rounded">
<div class="flex items-center gap-2">
<UIcon
:name="milestone.willReach ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
:name="
milestone.willReach
? 'i-heroicons-check-circle'
: 'i-heroicons-exclamation-triangle'
"
:class="milestone.willReach ? 'text-green-500' : 'text-amber-500'"
class="w-4 h-4"
/>
class="w-4 h-4" />
<div>
<div class="text-sm font-medium">{{ milestone.label }}</div>
<div class="text-xs text-gray-500">
<div class="text-xs text-neutral-500">
{{ formatDate(milestone.date) }}
</div>
</div>
@ -42,8 +45,7 @@
variant="ghost"
color="red"
icon="i-heroicons-trash"
@click="removeMilestone(milestone.id)"
/>
@click="removeMilestone(milestone.id)" />
</div>
</div>
@ -54,12 +56,8 @@
<div class="space-y-3">
<UInput
v-model="newMilestone.label"
placeholder="Milestone name (e.g., 'Product launch')"
/>
<UInput
v-model="newMilestone.date"
type="date"
/>
placeholder="Milestone name (e.g., 'Product launch')" />
<UInput v-model="newMilestone.date" type="date" />
<div class="flex gap-2">
<UButton @click="saveMilestone">Save</UButton>
<UButton variant="ghost" @click="cancelAdd">Cancel</UButton>
@ -71,28 +69,28 @@
</template>
<script setup lang="ts">
const { milestoneStatus, addMilestone, removeMilestone } = useCoopBuilder()
const { milestoneStatus, addMilestone, removeMilestone } = useCoopBuilder();
const showAddForm = ref(false)
const newMilestone = ref({ label: '', date: '' })
const showAddForm = ref(false);
const newMilestone = ref({ label: "", date: "" });
const milestoneStatuses = computed(() => milestoneStatus())
const milestoneStatuses = computed(() => milestoneStatus());
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
const date = new Date(dateString);
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
}
function saveMilestone() {
if (newMilestone.value.label && newMilestone.value.date) {
addMilestone(newMilestone.value.label, newMilestone.value.date)
newMilestone.value = { label: '', date: '' }
showAddForm.value = false
addMilestone(newMilestone.value.label, newMilestone.value.date);
newMilestone.value = { label: "", date: "" };
showAddForm.value = false;
}
}
function cancelAdd() {
newMilestone.value = { label: '', date: '' }
showAddForm.value = false
newMilestone.value = { label: "", date: "" };
showAddForm.value = false;
}
</script>
</script>

View file

@ -3,10 +3,10 @@
<template #header>
<h4 class="font-medium">Stress Test</h4>
</template>
<div class="space-y-4">
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">
<label class="text-sm font-medium text-neutral-700 mb-2 block">
Revenue Delay: {{ stress.revenueDelay }} months
</label>
<input
@ -15,13 +15,14 @@
min="0"
max="6"
step="1"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
@input="(e) => updateStress({ revenueDelay: Number(e.target.value) })"
/>
class="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer"
@input="
(e) => updateStress({ revenueDelay: Number(e.target.value) })
" />
</div>
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">
<label class="text-sm font-medium text-neutral-700 mb-2 block">
Cost Shock: +{{ stress.costShockPct }}%
</label>
<input
@ -30,29 +31,32 @@
min="0"
max="30"
step="5"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
@input="(e) => updateStress({ costShockPct: Number(e.target.value) })"
/>
class="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer"
@input="
(e) => updateStress({ costShockPct: Number(e.target.value) })
" />
</div>
<div>
<UCheckbox
:model-value="stress.grantLost"
label="Major Grant Lost"
@update:model-value="(val) => updateStress({ grantLost: val })"
/>
@update:model-value="(val) => updateStress({ grantLost: val })" />
</div>
<div v-if="isStressActive" class="p-3 bg-orange-50 border border-orange-200 rounded">
<div
v-if="isStressActive"
class="p-3 bg-orange-50 border border-orange-200 rounded">
<div class="text-sm">
<div class="flex items-center gap-2 text-orange-800 mb-1">
<UIcon name="i-heroicons-exclamation-triangle" class="w-4 h-4" />
<span class="font-medium">Stress Test Active</span>
</div>
<div class="text-orange-700">
Projected runway: <span class="font-semibold">{{ displayStressedRunway }}</span>
Projected runway:
<span class="font-semibold">{{ displayStressedRunway }}</span>
<span v-if="runwayChange !== 0" class="ml-2">
({{ runwayChange > 0 ? '+' : '' }}{{ runwayChange }} months)
({{ runwayChange > 0 ? "+" : "" }}{{ runwayChange }} months)
</span>
</div>
</div>
@ -62,32 +66,35 @@
</template>
<script setup lang="ts">
const { stress, updateStress, runwayMonths } = useCoopBuilder()
const { stress, updateStress, runwayMonths } = useCoopBuilder();
const isStressActive = computed(() =>
stress.value.revenueDelay > 0 ||
stress.value.costShockPct > 0 ||
stress.value.grantLost
)
const isStressActive = computed(
() =>
stress.value.revenueDelay > 0 ||
stress.value.costShockPct > 0 ||
stress.value.grantLost
);
const stressedRunway = computed(() => runwayMonths(undefined, { useStress: true }))
const normalRunway = computed(() => runwayMonths())
const stressedRunway = computed(() =>
runwayMonths(undefined, { useStress: true })
);
const normalRunway = computed(() => runwayMonths());
const displayStressedRunway = computed(() => {
const months = stressedRunway.value
if (!isFinite(months)) return '∞'
if (months < 1) return '<1 month'
return `${Math.round(months)} months`
})
const months = stressedRunway.value;
if (!isFinite(months)) return "∞";
if (months < 1) return "<1 month";
return `${Math.round(months)} months`;
});
const runwayChange = computed(() => {
const normal = normalRunway.value
const stressed = stressedRunway.value
if (!isFinite(normal) && !isFinite(stressed)) return 0
if (!isFinite(normal)) return -99 // Very large negative change
if (!isFinite(stressed)) return 0
return Math.round(stressed - normal)
})
</script>
const normal = normalRunway.value;
const stressed = stressedRunway.value;
if (!isFinite(normal) && !isFinite(stressed)) return 0;
if (!isFinite(normal)) return -99; // Very large negative change
if (!isFinite(stressed)) return 0;
return Math.round(stressed - normal);
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<template>
<div class="flex items-center gap-2">
<div class="flex-1 h-3 bg-gray-200 rounded-full overflow-hidden">
<div class="flex-1 h-3 bg-neutral-200 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="barColor"

View file

@ -1,21 +1,19 @@
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Mode:</span>
<span class="text-sm text-neutral-600">Mode:</span>
<UButtonGroup>
<UButton
:variant="modelValue === 'minimum' ? 'solid' : 'ghost'"
color="gray"
color="neutral"
size="xs"
@click="$emit('update:modelValue', 'minimum')"
>
@click="$emit('update:modelValue', 'minimum')">
Min Mode
</UButton>
<UButton
:variant="modelValue === 'target' ? 'solid' : 'ghost'"
:variant="modelValue === 'target' ? 'solid' : 'ghost'"
color="primary"
size="xs"
@click="$emit('update:modelValue', 'target')"
>
@click="$emit('update:modelValue', 'target')">
Target Mode
</UButton>
</UButtonGroup>
@ -24,13 +22,13 @@
<script setup lang="ts">
interface Props {
modelValue: 'minimum' | 'target'
modelValue: "minimum" | "target";
}
interface Emits {
(e: 'update:modelValue', value: 'minimum' | 'target'): void
(e: "update:modelValue", value: "minimum" | "target"): void;
}
defineProps<Props>()
defineEmits<Emits>()
</script>
defineProps<Props>();
defineEmits<Emits>();
</script>