refactor: update app.vue and various components to enhance UI consistency, replace color classes for improved accessibility, and refine layout for better user experience
This commit is contained in:
parent
7b4fb6c2fd
commit
24e8b7a3a8
41 changed files with 2395 additions and 1603 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
<h4 class="font-semibold">Stress Test</h4>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-600"
|
||||
<label class="text-xs text-neutral-600"
|
||||
>Revenue Delay (months)</label
|
||||
>
|
||||
<URange
|
||||
|
|
@ -29,24 +29,24 @@
|
|||
:max="6"
|
||||
:step="1"
|
||||
class="mt-1" />
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-neutral-500">
|
||||
{{ stress.revenueDelay }} months
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Cost Shock (%)</label>
|
||||
<label class="text-xs text-neutral-600">Cost Shock (%)</label>
|
||||
<URange
|
||||
v-model="stress.costShockPct"
|
||||
:min="0"
|
||||
:max="30"
|
||||
:step="1"
|
||||
class="mt-1" />
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-neutral-500">
|
||||
{{ stress.costShockPct }}%
|
||||
</div>
|
||||
</div>
|
||||
<UCheckbox v-model="stress.grantLost" label="Grant lost" />
|
||||
<div class="text-sm text-gray-600 pt-2 border-t">
|
||||
<div class="text-sm text-neutral-600 pt-2 border-t">
|
||||
Projected runway: {{ projectedRunway }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -72,13 +72,13 @@
|
|||
<span>{{ milestone.willReach ? "✅" : "⚠️" }}</span>
|
||||
<span>{{ milestone.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600">{{
|
||||
<span class="text-xs text-neutral-600">{{
|
||||
formatDate(milestone.date)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="milestones.length === 0"
|
||||
class="text-sm text-gray-600 italic">
|
||||
class="text-sm text-neutral-600 italic">
|
||||
No milestones yet
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,27 +4,30 @@
|
|||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">Individual Member Coverage</h3>
|
||||
<UTooltip text="Shows what each member needs from the co-op vs. what we can actually pay them">
|
||||
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" />
|
||||
<UTooltip
|
||||
text="Shows what each member needs from the co-op vs. what we can actually pay them">
|
||||
<UIcon
|
||||
name="i-heroicons-information-circle"
|
||||
class="h-4 w-4 text-neutral-400 hover:text-neutral-600 cursor-help" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div v-if="allocatedMembers.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="member in allocatedMembers"
|
||||
<div
|
||||
v-for="member in allocatedMembers"
|
||||
:key="member.id"
|
||||
class="space-y-2"
|
||||
>
|
||||
class="space-y-2">
|
||||
<!-- Member name and coverage percentage -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
|
||||
<UBadge
|
||||
:color="getCoverageColor(calculateCoverage(member))"
|
||||
<span class="font-medium text-neutral-900">{{
|
||||
member.displayName || member.name || "Unnamed Member"
|
||||
}}</span>
|
||||
<UBadge
|
||||
:color="getCoverageColor(calculateCoverage(member))"
|
||||
size="xs"
|
||||
:ui="{ base: 'font-medium' }"
|
||||
>
|
||||
:ui="{ base: 'font-medium' }">
|
||||
{{ Math.round(calculateCoverage(member)) }}% covered
|
||||
</UBadge>
|
||||
</div>
|
||||
|
|
@ -33,12 +36,18 @@
|
|||
<!-- Financial breakdown -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-gray-600">Needs from co-op</div>
|
||||
<div class="font-medium">{{ formatCurrency(member.minMonthlyNeeds || 0) }}</div>
|
||||
<div class="text-neutral-600">Needs from co-op</div>
|
||||
<div class="font-medium">
|
||||
{{ formatCurrency(member.minMonthlyNeeds || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-gray-600">Co-op can pay</div>
|
||||
<div class="font-medium" :class="getAmountColor(member.monthlyPayPlanned, member.minMonthlyNeeds)">
|
||||
<div class="text-neutral-600">Co-op can pay</div>
|
||||
<div
|
||||
class="font-medium"
|
||||
:class="
|
||||
getAmountColor(member.monthlyPayPlanned, member.minMonthlyNeeds)
|
||||
">
|
||||
{{ formatCurrency(member.monthlyPayPlanned || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -46,18 +55,24 @@
|
|||
|
||||
<!-- Visual progress bar -->
|
||||
<div class="space-y-1">
|
||||
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
|
||||
<div
|
||||
<div
|
||||
class="w-full bg-neutral-200 rounded-full h-3 relative overflow-hidden">
|
||||
<div
|
||||
class="h-3 rounded-full transition-all duration-300"
|
||||
:class="getBarColor(calculateCoverage(member))"
|
||||
:style="{ width: `${Math.min(100, calculateCoverage(member))}%` }"
|
||||
/>
|
||||
:style="{
|
||||
width: `${Math.min(100, calculateCoverage(member))}%`,
|
||||
}" />
|
||||
<!-- 100% marker line -->
|
||||
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="calculateCoverage(member) < 100">
|
||||
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" />
|
||||
<div
|
||||
class="absolute top-0 h-3 w-0.5 bg-neutral-600 opacity-75"
|
||||
style="left: 100%"
|
||||
v-if="calculateCoverage(member) < 100">
|
||||
<div
|
||||
class="absolute -top-1 -left-1 w-2 h-2 bg-neutral-600 rounded-full opacity-75" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<div class="flex justify-between text-xs text-neutral-500">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
<span>200%+</span>
|
||||
|
|
@ -65,21 +80,32 @@
|
|||
</div>
|
||||
|
||||
<!-- Gap/surplus indicator -->
|
||||
<div v-if="getGapAmount(member) !== 0" class="flex items-center gap-1 text-xs">
|
||||
<UIcon
|
||||
:name="getGapAmount(member) > 0 ? 'i-heroicons-arrow-trending-down' : 'i-heroicons-arrow-trending-up'"
|
||||
<div
|
||||
v-if="getGapAmount(member) !== 0"
|
||||
class="flex items-center gap-1 text-xs">
|
||||
<UIcon
|
||||
:name="
|
||||
getGapAmount(member) > 0
|
||||
? 'i-heroicons-arrow-trending-down'
|
||||
: 'i-heroicons-arrow-trending-up'
|
||||
"
|
||||
class="h-3 w-3"
|
||||
:class="getGapAmount(member) > 0 ? 'text-red-500' : 'text-green-500'"
|
||||
/>
|
||||
<span :class="getGapAmount(member) > 0 ? 'text-red-600' : 'text-green-600'">
|
||||
{{ getGapAmount(member) > 0 ? 'Gap: ' : 'Surplus: ' }}{{ formatCurrency(Math.abs(getGapAmount(member))) }}
|
||||
:class="
|
||||
getGapAmount(member) > 0 ? 'text-red-500' : 'text-green-500'
|
||||
" />
|
||||
<span
|
||||
:class="
|
||||
getGapAmount(member) > 0 ? 'text-red-600' : 'text-green-600'
|
||||
">
|
||||
{{ getGapAmount(member) > 0 ? "Gap: " : "Surplus: "
|
||||
}}{{ formatCurrency(Math.abs(getGapAmount(member))) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<div v-else class="text-center py-8 text-neutral-500">
|
||||
<UIcon name="i-heroicons-users" class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm mb-2">No members added yet</p>
|
||||
<p class="text-xs">Complete setup wizard to add team members</p>
|
||||
|
|
@ -87,16 +113,25 @@
|
|||
|
||||
<template #footer v-if="allocatedMembers.length > 0">
|
||||
<!-- Summary Stats -->
|
||||
<div class="flex justify-between items-center text-sm text-gray-600 pb-3 border-b border-gray-200">
|
||||
<div
|
||||
class="flex justify-between items-center text-sm text-neutral-600 pb-3 border-b border-neutral-200">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>Median coverage: {{ Math.round(stats.median || 0) }}%</span>
|
||||
<span :class="stats.under100 === 0 ? 'text-green-600' : 'text-amber-600'">
|
||||
{{ stats.under100 === 0 ? 'All covered ✓' : `${stats.under100} need more` }}
|
||||
<span
|
||||
:class="stats.under100 === 0 ? 'text-green-600' : 'text-amber-600'">
|
||||
{{
|
||||
stats.under100 === 0
|
||||
? "All covered ✓"
|
||||
: `${stats.under100} need more`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<UTooltip text="Based on available revenue after overhead costs">
|
||||
<span class="cursor-help">Total sustainable payroll: {{ formatCurrency(totalPayroll) }}</span>
|
||||
<span class="cursor-help"
|
||||
>Total sustainable payroll:
|
||||
{{ formatCurrency(totalPayroll) }}</span
|
||||
>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -108,20 +143,22 @@
|
|||
<UIcon name="i-heroicons-light-bulb" class="h-3 w-3" />
|
||||
<span class="font-medium">To cover everyone:</span>
|
||||
</div>
|
||||
<p class="mt-1 text-gray-600 pl-5">
|
||||
Increase available payroll by <strong>{{ formatCurrency(totalGap) }}</strong>
|
||||
<p class="mt-1 text-neutral-600 pl-5">
|
||||
Increase available payroll by
|
||||
<strong>{{ formatCurrency(totalGap) }}</strong>
|
||||
through higher revenue or lower overhead costs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else-if="totalSurplus > 0" class="text-xs">
|
||||
<div class="flex items-center gap-2 text-green-700">
|
||||
<UIcon name="i-heroicons-check-circle" class="h-3 w-3" />
|
||||
<span class="font-medium">Healthy position:</span>
|
||||
</div>
|
||||
<p class="mt-1 text-gray-600 pl-5">
|
||||
You have <strong>{{ formatCurrency(totalSurplus) }}</strong> surplus after covering all member needs.
|
||||
Consider growth opportunities or building reserves.
|
||||
<p class="mt-1 text-neutral-600 pl-5">
|
||||
You have <strong>{{ formatCurrency(totalSurplus) }}</strong> surplus
|
||||
after covering all member needs. Consider growth opportunities or
|
||||
building reserves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -130,7 +167,7 @@
|
|||
<UIcon name="i-heroicons-scales" class="h-3 w-3" />
|
||||
<span class="font-medium">Perfect balance:</span>
|
||||
</div>
|
||||
<p class="mt-1 text-gray-600 pl-5">
|
||||
<p class="mt-1 text-neutral-600 pl-5">
|
||||
Available payroll exactly matches member needs.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -140,88 +177,99 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
|
||||
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder();
|
||||
|
||||
const allocatedMembers = computed(() => {
|
||||
const members = allocatePayroll()
|
||||
console.log('🔍 allocatedMembers computed:', members)
|
||||
return members
|
||||
})
|
||||
const stats = computed(() => teamCoverageStats())
|
||||
const members = allocatePayroll();
|
||||
console.log("🔍 allocatedMembers computed:", members);
|
||||
return members;
|
||||
});
|
||||
const stats = computed(() => teamCoverageStats());
|
||||
|
||||
// Calculate total payroll
|
||||
const totalPayroll = computed(() =>
|
||||
const totalPayroll = computed(() =>
|
||||
allocatedMembers.value.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
// Color functions for coverage display
|
||||
function getBarColor(pct: number): string {
|
||||
if (!pct || pct < 80) return 'bg-red-500'
|
||||
if (pct < 100) return 'bg-amber-500'
|
||||
return 'bg-green-500'
|
||||
if (!pct || pct < 80) return "bg-red-500";
|
||||
if (pct < 100) return "bg-amber-500";
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
function getCoverageColor(pct: number): string {
|
||||
if (!pct || pct < 80) return 'red'
|
||||
if (pct < 100) return 'amber'
|
||||
return 'green'
|
||||
if (!pct || pct < 80) return "red";
|
||||
if (pct < 100) return "amber";
|
||||
return "green";
|
||||
}
|
||||
|
||||
function getAmountColor(planned: number = 0, needed: number = 0): string {
|
||||
if (!needed) return 'text-gray-900'
|
||||
if (planned >= needed) return 'text-green-600'
|
||||
if (planned >= needed * 0.8) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
if (!needed) return "text-neutral-900";
|
||||
if (planned >= needed) return "text-green-600";
|
||||
if (planned >= needed * 0.8) return "text-amber-600";
|
||||
return "text-red-600";
|
||||
}
|
||||
|
||||
// Calculate gap between what's needed vs what can be paid
|
||||
function getGapAmount(member: any): number {
|
||||
const planned = member.monthlyPayPlanned || 0
|
||||
const needed = member.minMonthlyNeeds || 0
|
||||
return needed - planned // positive = gap, negative = surplus
|
||||
const planned = member.monthlyPayPlanned || 0;
|
||||
const needed = member.minMonthlyNeeds || 0;
|
||||
return needed - planned; // positive = gap, negative = surplus
|
||||
}
|
||||
|
||||
// Calculate total gap/surplus across all members
|
||||
const totalGap = computed(() => {
|
||||
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||
const totalPlanned = totalPayroll.value
|
||||
const gap = totalNeeded - totalPlanned
|
||||
return gap > 0 ? gap : 0
|
||||
})
|
||||
const totalNeeded = allocatedMembers.value.reduce(
|
||||
(sum, m) => sum + (m.minMonthlyNeeds || 0),
|
||||
0
|
||||
);
|
||||
const totalPlanned = totalPayroll.value;
|
||||
const gap = totalNeeded - totalPlanned;
|
||||
return gap > 0 ? gap : 0;
|
||||
});
|
||||
|
||||
const totalSurplus = computed(() => {
|
||||
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||
const totalPlanned = totalPayroll.value
|
||||
const surplus = totalPlanned - totalNeeded
|
||||
return surplus > 0 ? surplus : 0
|
||||
})
|
||||
const totalNeeded = allocatedMembers.value.reduce(
|
||||
(sum, m) => sum + (m.minMonthlyNeeds || 0),
|
||||
0
|
||||
);
|
||||
const totalPlanned = totalPayroll.value;
|
||||
const surplus = totalPlanned - totalNeeded;
|
||||
return surplus > 0 ? surplus : 0;
|
||||
});
|
||||
|
||||
// Local coverage calculation for debugging
|
||||
function calculateCoverage(member: any): number {
|
||||
const coopPay = member.monthlyPayPlanned || 0
|
||||
const needs = member.minMonthlyNeeds || 0
|
||||
|
||||
console.log(`Coverage calc for ${member.name || member.displayName || 'Unknown'}:`, {
|
||||
member: JSON.stringify(member, null, 2),
|
||||
coopPay,
|
||||
needs,
|
||||
coverage: needs > 0 ? (coopPay / needs) * 100 : 100
|
||||
})
|
||||
|
||||
const coopPay = member.monthlyPayPlanned || 0;
|
||||
const needs = member.minMonthlyNeeds || 0;
|
||||
|
||||
console.log(
|
||||
`Coverage calc for ${member.name || member.displayName || "Unknown"}:`,
|
||||
{
|
||||
member: JSON.stringify(member, null, 2),
|
||||
coopPay,
|
||||
needs,
|
||||
coverage: needs > 0 ? (coopPay / needs) * 100 : 100,
|
||||
}
|
||||
);
|
||||
|
||||
if (needs === 0) {
|
||||
console.log(`⚠️ Member ${member.name} has NO minMonthlyNeeds - defaulting to 100%`)
|
||||
return 100
|
||||
console.log(
|
||||
`⚠️ Member ${member.name} has NO minMonthlyNeeds - defaulting to 100%`
|
||||
);
|
||||
return 100;
|
||||
}
|
||||
return Math.min(200, (coopPay / needs) * 100)
|
||||
return Math.min(200, (coopPay / needs) * 100);
|
||||
}
|
||||
|
||||
// Currency formatting
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}).format(amount);
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,93 +7,126 @@
|
|||
<UIcon name="i-heroicons-user-group" class="h-5 w-5" />
|
||||
<h3 class="font-semibold">Member Needs Coverage</h3>
|
||||
</div>
|
||||
<UTooltip text="Shows how well the co-op can meet each member's stated financial needs">
|
||||
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" />
|
||||
<UTooltip
|
||||
text="Shows how well the co-op can meet each member's stated financial needs">
|
||||
<UIcon
|
||||
name="i-heroicons-information-circle"
|
||||
class="h-4 w-4 text-neutral-400 hover:text-neutral-600 cursor-help" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div v-if="hasMembers" class="space-y-4">
|
||||
<!-- Team Summary -->
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-semibold" :class="statusColor">
|
||||
{{ fullyCoveredCount }} of {{ totalMembers }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
members fully covered
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600">members fully covered</div>
|
||||
</div>
|
||||
|
||||
<!-- Coverage Stats -->
|
||||
<div class="flex justify-between text-sm">
|
||||
<div class="text-center">
|
||||
<div class="font-medium">{{ median }}%</div>
|
||||
<div class="text-gray-600">Median</div>
|
||||
<div class="text-neutral-600">Median</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="font-medium" :class="underCoveredColor">{{ stats.under100 }}</div>
|
||||
<div class="text-gray-600">Under 100%</div>
|
||||
<div class="font-medium" :class="underCoveredColor">
|
||||
{{ stats.under100 }}
|
||||
</div>
|
||||
<div class="text-neutral-600">Under 100%</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="font-medium">{{ formatCurrency(availablePayroll) }}</div>
|
||||
<div class="text-gray-600">Available</div>
|
||||
<div class="text-neutral-600">Available</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intelligent Financial Analysis -->
|
||||
<div v-if="hasMembers" class="space-y-2">
|
||||
<!-- Coverage gap analysis -->
|
||||
<div v-if="stats.under100 > 0" class="text-xs bg-amber-50 p-3 rounded border-l-4 border-amber-400">
|
||||
<div
|
||||
v-if="stats.under100 > 0"
|
||||
class="text-xs bg-amber-50 p-3 rounded border-l-4 border-amber-400">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-amber-800">Coverage Gap Analysis</p>
|
||||
<p class="text-amber-700">
|
||||
To meet member needs, you need <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements,
|
||||
but you have <strong>{{ formatCurrency(availablePayroll) }}</strong> available for payroll.
|
||||
To meet member needs, you need
|
||||
<strong>{{ formatCurrency(totalNeeds) }}</strong> based on their
|
||||
stated requirements, but you have
|
||||
<strong>{{ formatCurrency(availablePayroll) }}</strong>
|
||||
available for payroll.
|
||||
</p>
|
||||
<p class="text-amber-600">
|
||||
<strong>Shortfall: {{ formatCurrency(Math.max(0, totalNeeds - availablePayroll)) }}</strong>
|
||||
<strong
|
||||
>Shortfall:
|
||||
{{
|
||||
formatCurrency(Math.max(0, totalNeeds - availablePayroll))
|
||||
}}</strong
|
||||
>
|
||||
</p>
|
||||
<p class="text-xs text-amber-600 mt-2">
|
||||
💡 Note: This reflects member-stated needs. Check your Budget page for detailed payroll planning.
|
||||
💡 Note: This reflects member-stated needs. Check your Budget
|
||||
page for detailed payroll planning.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Surplus analysis -->
|
||||
<div v-else-if="availablePayroll > totalNeeds && totalNeeds > 0" class="text-xs bg-green-50 p-3 rounded border-l-4 border-green-400">
|
||||
<div
|
||||
v-else-if="availablePayroll > totalNeeds && totalNeeds > 0"
|
||||
class="text-xs bg-green-50 p-3 rounded border-l-4 border-green-400">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-check-circle" class="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
<UIcon
|
||||
name="i-heroicons-check-circle"
|
||||
class="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-green-800">Healthy Coverage</p>
|
||||
<p class="text-green-700">
|
||||
You have <strong>{{ formatCurrency(availablePayroll) }}</strong> available to cover
|
||||
<strong>{{ formatCurrency(totalNeeds) }}</strong> in member needs.
|
||||
You have
|
||||
<strong>{{ formatCurrency(availablePayroll) }}</strong>
|
||||
available to cover
|
||||
<strong>{{ formatCurrency(totalNeeds) }}</strong> in member
|
||||
needs.
|
||||
</p>
|
||||
<p class="text-green-600">
|
||||
<strong>Surplus: {{ formatCurrency(availablePayroll - totalNeeds) }}</strong>
|
||||
<strong
|
||||
>Surplus:
|
||||
{{ formatCurrency(availablePayroll - totalNeeds) }}</strong
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No payroll available -->
|
||||
<div v-else-if="availablePayroll === 0 && totalNeeds > 0" class="text-xs bg-red-50 p-3 rounded border-l-4 border-red-400">
|
||||
<div
|
||||
v-else-if="availablePayroll === 0 && totalNeeds > 0"
|
||||
class="text-xs bg-red-50 p-3 rounded border-l-4 border-red-400">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-x-circle" class="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<UIcon
|
||||
name="i-heroicons-x-circle"
|
||||
class="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-red-800">No Funds for Payroll</p>
|
||||
<p class="text-red-700">
|
||||
Member needs total <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements,
|
||||
but current revenue minus costs leaves $0 for payroll.
|
||||
Member needs total
|
||||
<strong>{{ formatCurrency(totalNeeds) }}</strong> based on their
|
||||
stated requirements, but current revenue minus costs leaves $0
|
||||
for payroll.
|
||||
</p>
|
||||
<p class="text-red-600">
|
||||
Consider increasing revenue or reducing overhead costs.
|
||||
</p>
|
||||
<p class="text-xs text-red-600 mt-2">
|
||||
💡 Note: This reflects member-stated needs. Your Budget page may show different payroll amounts.
|
||||
💡 Note: This reflects member-stated needs. Your Budget page may
|
||||
show different payroll amounts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -102,7 +135,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-6 text-gray-500">
|
||||
<div v-else class="text-center py-6 text-neutral-500">
|
||||
<UIcon name="i-heroicons-users" class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">Add members in setup to see coverage</p>
|
||||
</div>
|
||||
|
|
@ -110,56 +143,60 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { members, teamCoverageStats, allocatePayroll, streams } = useCoopBuilder()
|
||||
const coopStore = useCoopBuilderStore()
|
||||
const { members, teamCoverageStats, allocatePayroll, streams } =
|
||||
useCoopBuilder();
|
||||
const coopStore = useCoopBuilderStore();
|
||||
|
||||
const stats = computed(() => teamCoverageStats())
|
||||
const allocatedMembers = computed(() => allocatePayroll())
|
||||
const median = computed(() => Math.round(stats.value.median ?? 0))
|
||||
const stats = computed(() => teamCoverageStats());
|
||||
const allocatedMembers = computed(() => allocatePayroll());
|
||||
const median = computed(() => Math.round(stats.value.median ?? 0));
|
||||
|
||||
// Team-level calculations
|
||||
const hasMembers = computed(() => members.value.length > 0)
|
||||
const totalMembers = computed(() => members.value.length)
|
||||
const fullyCoveredCount = computed(() => totalMembers.value - stats.value.under100)
|
||||
const hasMembers = computed(() => members.value.length > 0);
|
||||
const totalMembers = computed(() => members.value.length);
|
||||
const fullyCoveredCount = computed(
|
||||
() => totalMembers.value - stats.value.under100
|
||||
);
|
||||
|
||||
// Financial calculations
|
||||
const totalNeeds = computed(() =>
|
||||
const totalNeeds = computed(() =>
|
||||
allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const totalRevenue = computed(() =>
|
||||
const totalRevenue = computed(() =>
|
||||
streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const overheadCosts = computed(() =>
|
||||
const overheadCosts = computed(() =>
|
||||
coopStore.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0)
|
||||
)
|
||||
);
|
||||
|
||||
const availablePayroll = computed(() =>
|
||||
const availablePayroll = computed(() =>
|
||||
Math.max(0, totalRevenue.value - overheadCosts.value)
|
||||
)
|
||||
);
|
||||
|
||||
// Status colors based on coverage
|
||||
const statusColor = computed(() => {
|
||||
const ratio = fullyCoveredCount.value / Math.max(1, totalMembers.value)
|
||||
if (ratio === 1) return 'text-green-600'
|
||||
if (ratio >= 0.8) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
})
|
||||
const ratio = fullyCoveredCount.value / Math.max(1, totalMembers.value);
|
||||
if (ratio === 1) return "text-green-600";
|
||||
if (ratio >= 0.8) return "text-amber-600";
|
||||
return "text-red-600";
|
||||
});
|
||||
|
||||
const underCoveredColor = computed(() => {
|
||||
if (stats.value.under100 === 0) return 'text-green-600'
|
||||
if (stats.value.under100 <= Math.ceil(totalMembers.value * 0.2)) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
})
|
||||
if (stats.value.under100 === 0) return "text-green-600";
|
||||
if (stats.value.under100 <= Math.ceil(totalMembers.value * 0.2))
|
||||
return "text-amber-600";
|
||||
return "text-red-600";
|
||||
});
|
||||
|
||||
// Currency formatting
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}).format(amount);
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@
|
|||
<h3 class="font-semibold">Revenue Mix</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="space-y-6">
|
||||
<div v-if="mix.length === 0" class="text-sm text-gray-600 text-center py-8">
|
||||
<div
|
||||
v-if="mix.length === 0"
|
||||
class="text-sm text-neutral-600 text-center py-8">
|
||||
Add revenue streams to see mix.
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else>
|
||||
<!-- Revenue bars -->
|
||||
<div v-for="s in mix.slice(0, 3)" :key="s.label" class="mb-2">
|
||||
|
|
@ -20,17 +22,16 @@
|
|||
<span class="truncate">{{ s.label }}</span>
|
||||
<span>{{ Math.round(s.pct * 100) }}%</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-200 rounded">
|
||||
<div
|
||||
class="h-2 rounded"
|
||||
<div class="h-2 bg-neutral-200 rounded">
|
||||
<div
|
||||
class="h-2 rounded"
|
||||
:class="getBarColor(mix.indexOf(s))"
|
||||
:style="{ width: (s.pct * 100) + '%' }"
|
||||
/>
|
||||
:style="{ width: s.pct * 100 + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtext with concentration warning -->
|
||||
<div class="text-sm text-gray-600 text-center">
|
||||
<div class="text-sm text-neutral-600 text-center">
|
||||
Top stream {{ Math.round(topPct * 100) }}%
|
||||
<span v-if="topPct > 0.5" class="text-amber-600">⚠</span>
|
||||
</div>
|
||||
|
|
@ -40,17 +41,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { revenueMix, concentrationPct } = useCoopBuilder()
|
||||
const { revenueMix, concentrationPct } = useCoopBuilder();
|
||||
|
||||
const mix = computed(() => revenueMix())
|
||||
const topPct = computed(() => concentrationPct())
|
||||
const mix = computed(() => revenueMix());
|
||||
const topPct = computed(() => concentrationPct());
|
||||
|
||||
function getBarColor(index: number): string {
|
||||
const colors = [
|
||||
'bg-blue-500',
|
||||
'bg-green-500',
|
||||
'bg-amber-500'
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
const colors = ["bg-blue-500", "bg-green-500", "bg-amber-500"];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,56 +7,53 @@
|
|||
<div class="w-2 h-2 rounded-full" :class="statusDotColor" />
|
||||
<h3 class="font-semibold">Runway</h3>
|
||||
</div>
|
||||
<UBadge
|
||||
:color="operatingMode === 'target' ? 'blue' : 'gray'"
|
||||
size="xs"
|
||||
>
|
||||
{{ operatingMode === 'target' ? 'Target Mode' : 'Min Mode' }}
|
||||
<UBadge
|
||||
:color="operatingMode === 'target' ? 'blue' : 'neutral'"
|
||||
size="xs">
|
||||
{{ operatingMode === "target" ? "Target Mode" : "Min Mode" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="text-center space-y-6">
|
||||
<div class="text-2xl font-semibold" :class="statusColor">
|
||||
{{ displayRunway }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
at current spending
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600">at current spending</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { runwayMonths, operatingMode } = useCoopBuilder()
|
||||
const { runwayMonths, operatingMode } = useCoopBuilder();
|
||||
|
||||
const runway = computed(() => runwayMonths())
|
||||
const runway = computed(() => runwayMonths());
|
||||
|
||||
const displayRunway = computed(() => {
|
||||
const months = runway.value
|
||||
if (!isFinite(months)) return '∞'
|
||||
if (months < 1) return '<1 month'
|
||||
return `${Math.round(months)} months`
|
||||
})
|
||||
const months = runway.value;
|
||||
if (!isFinite(months)) return "∞";
|
||||
if (months < 1) return "<1 month";
|
||||
return `${Math.round(months)} months`;
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const months = runway.value
|
||||
if (!isFinite(months) || months >= 6) return 'text-green-600'
|
||||
if (months >= 3) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
})
|
||||
const months = runway.value;
|
||||
if (!isFinite(months) || months >= 6) return "text-green-600";
|
||||
if (months >= 3) return "text-amber-600";
|
||||
return "text-red-600";
|
||||
});
|
||||
|
||||
const statusDotColor = computed(() => {
|
||||
const months = runway.value
|
||||
if (!isFinite(months) || months >= 6) return 'bg-green-500'
|
||||
if (months >= 3) return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
})
|
||||
const months = runway.value;
|
||||
if (!isFinite(months) || months >= 6) return "bg-green-500";
|
||||
if (months >= 3) return "bg-amber-500";
|
||||
return "bg-red-500";
|
||||
});
|
||||
|
||||
const borderColor = computed(() => {
|
||||
const months = runway.value
|
||||
if (!isFinite(months) || months >= 6) return 'ring-1 ring-green-200'
|
||||
if (months >= 3) return 'ring-1 ring-amber-200'
|
||||
return 'ring-1 ring-red-200'
|
||||
})
|
||||
</script>
|
||||
const months = runway.value;
|
||||
if (!isFinite(months) || months >= 6) return "ring-1 ring-green-200";
|
||||
if (months >= 3) return "ring-1 ring-amber-200";
|
||||
return "ring-1 ring-red-200";
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue