Compare commits

...

2 commits

44 changed files with 2874 additions and 2275 deletions

42
app.config.ts Normal file
View file

@ -0,0 +1,42 @@
export default defineAppConfig({
ui: {
colors: {
primary: "fuchsia",
neutral: "stone",
},
global: {
body: "bg-white dark:bg-neutral-950",
},
container: {
base: "mx-auto",
padding: "px-4 sm:px-6 lg:px-8",
constrained: "max-w-7xl",
background: "",
},
// Spacious card styling
card: {
base: "overflow-hidden",
background: "bg-white dark:bg-neutral-950",
divide: "divide-y divide-neutral-200 dark:divide-neutral-800",
ring: "ring-1 ring-neutral-200 dark:ring-neutral-800",
rounded: "rounded-lg",
shadow: "shadow",
body: {
base: "",
background: "",
padding: "px-6 py-5 sm:p-6",
},
header: {
base: "",
background: "",
padding: "px-6 py-4 sm:px-6",
},
footer: {
base: "",
background: "",
padding: "px-6 py-4 sm:px-6",
},
},
},
});

26
app.vue
View file

@ -9,11 +9,9 @@
<div class="relative flex items-center justify-center"> <div class="relative flex items-center justify-center">
<NuxtLink <NuxtLink
to="/" to="/"
class="flex items-center gap-2 hover:opacity-80 transition-opacity" class="flex items-center gap-2 hover:opacity-80 transition-opacity">
>
<h1 <h1
class="text-black dark:text-white text-center text-2xl font-mono uppercase font-bold" class="text-black dark:text-white text-center text-2xl font-mono uppercase font-bold">
>
Urgent Tools Urgent Tools
</h1> </h1>
</NuxtLink> </NuxtLink>
@ -24,16 +22,14 @@
<nav <nav
class="mt-4 flex items-center justify-center gap-1" class="mt-4 flex items-center justify-center gap-1"
role="navigation" role="navigation"
aria-label="Main navigation" aria-label="Main navigation">
>
<NuxtLink <NuxtLink
to="/coop-planner" to="/coop-planner"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors" class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{ :class="{
'bg-neutral-100 dark:bg-neutral-800': isCoopBuilderSection, 'bg-neutral-100 dark:bg-neutral-800': isCoopBuilderSection,
}" }">
> Budget Builder
Co-Op Builder
</NuxtLink> </NuxtLink>
<!-- Coach feature - hidden for now --> <!-- Coach feature - hidden for now -->
<!-- <NuxtLink <!-- <NuxtLink
@ -49,18 +45,18 @@
to="/wizards" to="/wizards"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors" class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{ :class="{
'bg-neutral-100 dark:bg-neutral-800': $route.path === '/wizards', 'bg-neutral-100 dark:bg-neutral-800':
}" $route.path === '/wizards',
> }">
Wizards Wizards
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/resources" to="/resources"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors" class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{ :class="{
'bg-neutral-100 dark:bg-neutral-800': $route.path === '/resources', 'bg-neutral-100 dark:bg-neutral-800':
}" $route.path === '/resources',
> }">
More Resources & Templates More Resources & Templates
</NuxtLink> </NuxtLink>
</nav> </nav>

View file

@ -3,7 +3,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@nuxt/ui"; @import "@nuxt/ui";
@theme { @theme static {
--font-body: "Ubuntu", "Inter", sans-serif; --font-body: "Ubuntu", "Inter", sans-serif;
--font-mono: "Ubuntu Mono", monospace; --font-mono: "Ubuntu Mono", monospace;
} }
@ -237,7 +237,7 @@ html.dark .section-card::before {
========================= */ ========================= */
.dither-shadow { .dither-shadow {
@apply bg-black dark:bg-white; @apply bg-black dark:bg-neutral-600;
background-image: radial-gradient(white 1px, transparent 1px); background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px; background-size: 2px 2px;
} }

View file

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

View file

@ -2,7 +2,7 @@
<select <select
v-model="selectedCategory" v-model="selectedCategory"
@change="handleSelection(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 v-for="option in options" :key="option" :value="option">
{{ option }} {{ option }}

View file

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

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="mb-12"> <div v-if="isSetupCompleted" class="mb-12">
<div class="w-full mx-auto"> <div class="w-full mx-auto">
<nav <nav
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center" class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
@ -24,16 +24,17 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute(); const route = useRoute();
const coop = useCoopBuilder();
const coopBuilderItems = [ const coopBuilderItems = [
{ {
id: "coop-builder", id: "coop-builder",
name: "Setup Wizard", name: "Settings",
path: "/coop-builder", path: "/coop-builder",
}, },
{ {
id: "budget", id: "budget",
name: "Budget", name: "Studio Budget",
path: "/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 { function isActive(path: string): boolean {
return route.path === path; return route.path === path;
} }

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,16 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<!-- Runway summary --> <!-- 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> <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="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>
<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="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>
</div> </div>
@ -28,12 +28,12 @@
</UButton> </UButton>
</div> </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. No milestones set. Add key dates to track runway coverage.
</div> </div>
<div v-for="milestone in milestonesWithStatus" :key="milestone.id" <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"> <div class="flex items-center gap-2">
<UIcon <UIcon
:name="milestone.status === 'safe' ? 'i-heroicons-check-circle' : :name="milestone.status === 'safe' ? 'i-heroicons-check-circle' :
@ -46,11 +46,11 @@
/> />
<div> <div>
<div class="font-medium">{{ milestone.label }}</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> </div>
<div class="text-right"> <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 {{ milestone.monthsFromNow > 0 ? `+${milestone.monthsFromNow}` : milestone.monthsFromNow }}mo
</div> </div>
<UButton <UButton
@ -66,7 +66,7 @@
</div> </div>
<!-- Add milestone form --> <!-- 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 <UInput
v-model="newMilestone.label" v-model="newMilestone.label"
placeholder="Milestone name (e.g., 'Prototype release')" placeholder="Milestone name (e.g., 'Prototype release')"

View file

@ -1,12 +1,12 @@
<template> <template>
<div class="space-y-3"> <div class="space-y-3">
<div v-for="member in membersWithCoverage" :key="member.id" class="space-y-1"> <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>{{ member.displayName || 'Unnamed' }}</span>
<span>{{ Math.round(member.coverageMinPct || 0) }}%</span> <span>{{ Math.round(member.coverageMinPct || 0) }}%</span>
</div> </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 --> <!-- Min coverage bar -->
<div <div
class="absolute top-0 left-0 h-full transition-all duration-300" class="absolute top-0 left-0 h-full transition-all duration-300"
@ -17,21 +17,21 @@
<!-- Target coverage tick/ghost --> <!-- Target coverage tick/ghost -->
<div <div
v-if="member.coverageTargetPct" 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)}%` }" :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> </div>
<!-- 100% line --> <!-- 100% line -->
<div class="absolute top-0 left-0 h-full w-full pointer-events-none"> <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> </div>
</div> </div>
<!-- Summary stats --> <!-- 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"> <div class="flex justify-between">
<span>Team median: {{ Math.round(teamStats.median || 0) }}%</span> <span>Team median: {{ Math.round(teamStats.median || 0) }}%</span>
<span v-if="teamStats.under100 > 0" class="text-yellow-600 font-medium"> <span v-if="teamStats.under100 > 0" class="text-yellow-600 font-medium">

View file

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

View file

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

View file

@ -1,52 +1,72 @@
<template> <template>
<UModal <UModal
v-model:open="isOpen" v-model:open="isOpen"
title="Payroll Oncost Settings" title="Payroll Oncost Settings"
description="Configure payroll taxes and benefits percentage" description="Configure payroll taxes and benefits percentage"
:dismissible="true" :dismissible="true">
>
<template #body> <template #body>
<div class="space-y-6"> <div class="space-y-6">
<!-- Explanation --> <!-- Explanation -->
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg"> <div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<div class="flex items-start"> <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"> <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"> <p class="text-blue-700 dark:text-blue-300">
Payroll oncosts cover taxes, benefits, and other employee-related expenses beyond base wages. Payroll oncosts cover taxes, benefits, and other
This typically includes employer payroll taxes, worker's compensation, benefits contributions, and other statutory requirements. employee-related expenses beyond base wages. This typically
includes employer payroll taxes, worker's compensation, benefits
contributions, and other statutory requirements.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<!-- Current Settings Display --> <!-- Current Settings Display -->
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg"> <div class="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
<h4 class="font-medium text-gray-900 dark:text-white mb-3">Current Impact</h4> <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 class="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<div class="text-gray-600 dark:text-gray-400">Base Payroll</div> <div class="text-neutral-600 dark:text-neutral-400">
<div class="font-medium">{{ formatCurrency(basePayroll) }}/month</div> Base Payroll
</div>
<div class="font-medium">
{{ formatCurrency(basePayroll) }}/month
</div>
</div> </div>
<div> <div>
<div class="text-gray-600 dark:text-gray-400">Oncosts ({{ currentOncostPct }}%)</div> <div class="text-neutral-600 dark:text-neutral-400">
<div class="font-medium">{{ formatCurrency(currentOncostAmount) }}/month</div> Oncosts ({{ currentOncostPct }}%)
</div>
<div class="font-medium">
{{ formatCurrency(currentOncostAmount) }}/month
</div>
</div> </div>
</div> </div>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"> <div
<div class="text-gray-600 dark:text-gray-400 text-sm">Total Payroll Cost</div> class="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="font-semibold text-lg">{{ formatCurrency(totalPayrollCost) }}/month</div> <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>
</div> </div>
<!-- Percentage Input --> <!-- Percentage Input -->
<div class="space-y-3"> <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 Oncost Percentage
</label> </label>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="flex-1"> <div class="flex-1">
<UInput <UInput
@ -56,10 +76,9 @@
max="100" max="100"
step="1" step="1"
placeholder="25" placeholder="25"
class="text-center" class="text-center" />
/>
</div> </div>
<span class="text-sm text-gray-500">%</span> <span class="text-sm text-neutral-500">%</span>
</div> </div>
<!-- Slider for easier adjustment --> <!-- Slider for easier adjustment -->
@ -70,9 +89,8 @@
min="0" min="0"
max="50" max="50"
step="1" step="1"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 slider" 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">
<div class="flex justify-between text-xs text-gray-500">
<span>0%</span> <span>0%</span>
<span>25%</span> <span>25%</span>
<span>50%</span> <span>50%</span>
@ -81,29 +99,44 @@
</div> </div>
<!-- Preview of New Settings --> <!-- Preview of New Settings -->
<div v-if="newOncostPct !== currentOncostPct" class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg"> <div
<h4 class="font-medium text-green-800 dark:text-green-200 mb-3">Preview Changes</h4> 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 class="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<div class="text-green-700 dark:text-green-300">New Oncosts ({{ newOncostPct }}%)</div> <div class="text-green-700 dark:text-green-300">
<div class="font-medium">{{ formatCurrency(newOncostAmount) }}/month</div> New Oncosts ({{ newOncostPct }}%)
</div>
<div class="font-medium">
{{ formatCurrency(newOncostAmount) }}/month
</div>
</div> </div>
<div> <div>
<div class="text-green-700 dark:text-green-300">New Total Cost</div> <div class="text-green-700 dark:text-green-300">
<div class="font-medium">{{ formatCurrency(newTotalCost) }}/month</div> New Total Cost
</div>
<div class="font-medium">
{{ formatCurrency(newTotalCost) }}/month
</div>
</div> </div>
</div> </div>
<div class="mt-2 text-xs"> <div class="mt-2 text-xs">
<span class="text-green-700 dark:text-green-300"> <span class="text-green-700 dark:text-green-300">
{{ newTotalCost > totalPayrollCost ? 'Increase' : 'Decrease' }} of {{ newTotalCost > totalPayrollCost ? "Increase" : "Decrease" }} of
{{ formatCurrency(Math.abs(newTotalCost - totalPayrollCost)) }}/month {{
formatCurrency(Math.abs(newTotalCost - totalPayrollCost))
}}/month
</span> </span>
</div> </div>
</div> </div>
<!-- Common Oncost Ranges --> <!-- Common Oncost Ranges -->
<div class="space-y-2"> <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 Common Ranges
</label> </label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@ -111,10 +144,9 @@
v-for="preset in commonRanges" v-for="preset in commonRanges"
:key="preset.value" :key="preset.value"
size="xs" size="xs"
color="gray" color="neutral"
variant="outline" variant="outline"
@click="newOncostPct = preset.value" @click="newOncostPct = preset.value">
>
{{ preset.label }} {{ preset.label }}
</UButton> </UButton>
</div> </div>
@ -124,14 +156,13 @@
<template #footer="{ close }"> <template #footer="{ close }">
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<UButton color="gray" variant="ghost" @click="handleCancel"> <UButton color="neutral" variant="ghost" @click="handleCancel">
Cancel Cancel
</UButton> </UButton>
<UButton <UButton
color="primary" color="primary"
@click="handleSave" @click="handleSave"
:disabled="!isValidPercentage" :disabled="!isValidPercentage">
>
Update Oncost Percentage Update Oncost Percentage
</UButton> </UButton>
</div> </div>
@ -140,120 +171,134 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { allocatePayroll as allocatePayrollImpl } from '~/types/members' import { allocatePayroll as allocatePayrollImpl } from "~/types/members";
interface Props { interface Props {
open: boolean open: boolean;
} }
interface Emits { interface Emits {
(e: 'update:open', value: boolean): void (e: "update:open", value: boolean): void;
(e: 'save', percentage: number): void (e: "save", percentage: number): void;
} }
const props = defineProps<Props>() const props = defineProps<Props>();
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>();
// Modal state // Modal state
const isOpen = computed({ const isOpen = computed({
get: () => props.open, get: () => props.open,
set: (value) => emit('update:open', value) set: (value) => emit("update:open", value),
}) });
// Get current payroll data // Get current payroll data
const coopStore = useCoopBuilderStore() const coopStore = useCoopBuilderStore();
const currentOncostPct = computed(() => coopStore.payrollOncostPct || 0) const currentOncostPct = computed(() => coopStore.payrollOncostPct || 0);
// Calculate current payroll values using the same logic as the budget store // Calculate current payroll values using the same logic as the budget store
const { allocatePayroll } = useCoopBuilder() const { allocatePayroll } = useCoopBuilder();
const basePayroll = computed(() => { const basePayroll = computed(() => {
// Calculate base payroll the same way the budget store does // 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 totalHours = coopStore.members.reduce(
const hourlyWage = coopStore.equalHourlyWage || 0 (sum, m) =>
const basePayrollBudget = totalHours * hourlyWage 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) { if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Use policy-driven allocation to get actual member pay amounts // Use policy-driven allocation to get actual member pay amounts
const payPolicy = { const payPolicy = {
relationship: coopStore.policy.relationship, relationship: coopStore.policy.relationship,
roleBands: coopStore.policy.roleBands roleBands: coopStore.policy.roleBands,
} };
// Convert members to the format expected by allocatePayroll // Convert members to the format expected by allocatePayroll
const membersForAllocation = coopStore.members.map(m => ({ const membersForAllocation = coopStore.members.map((m) => ({
...m, ...m,
displayName: m.name, displayName: m.name,
monthlyPayPlanned: m.monthlyPayPlanned || 0, monthlyPayPlanned: m.monthlyPayPlanned || 0,
minMonthlyNeeds: m.minMonthlyNeeds || 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 // Use the imported allocatePayroll function
const allocatedMembers = allocatePayrollImpl(membersForAllocation, payPolicy, basePayrollBudget) const allocatedMembers = allocatePayrollImpl(
membersForAllocation,
payPolicy,
basePayrollBudget
);
// Sum the allocated amounts for total payroll // 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(() => return 0;
basePayroll.value * (currentOncostPct.value / 100) });
)
const totalPayrollCost = computed(() => const currentOncostAmount = computed(
basePayroll.value + currentOncostAmount.value () => basePayroll.value * (currentOncostPct.value / 100)
) );
const totalPayrollCost = computed(
() => basePayroll.value + currentOncostAmount.value
);
// New percentage input // New percentage input
const newOncostPct = ref(currentOncostPct.value) const newOncostPct = ref(currentOncostPct.value);
// Computed values for preview // Computed values for preview
const newOncostAmount = computed(() => basePayroll.value * (newOncostPct.value / 100)) const newOncostAmount = computed(
const newTotalCost = computed(() => basePayroll.value + newOncostAmount.value) () => basePayroll.value * (newOncostPct.value / 100)
);
const newTotalCost = computed(() => basePayroll.value + newOncostAmount.value);
const isValidPercentage = computed(() => const isValidPercentage = computed(
newOncostPct.value >= 0 && newOncostPct.value <= 100 () => newOncostPct.value >= 0 && newOncostPct.value <= 100
) );
// Common oncost ranges // Common oncost ranges
const commonRanges = [ const commonRanges = [
{ label: '0% (No oncosts)', value: 0 }, { label: "0% (No oncosts)", value: 0 },
{ label: '15% (Basic)', value: 15 }, { label: "15% (Basic)", value: 15 },
{ label: '25% (Standard)', value: 25 }, { label: "25% (Standard)", value: 25 },
{ label: '35% (Comprehensive)', value: 35 } { label: "35% (Comprehensive)", value: 35 },
] ];
// Reset to current value when modal opens // Reset to current value when modal opens
watch(isOpen, (open) => { watch(isOpen, (open) => {
if (open) { if (open) {
newOncostPct.value = currentOncostPct.value newOncostPct.value = currentOncostPct.value;
} }
}) });
// Handlers // Handlers
function handleCancel() { function handleCancel() {
newOncostPct.value = currentOncostPct.value newOncostPct.value = currentOncostPct.value;
isOpen.value = false isOpen.value = false;
} }
function handleSave() { function handleSave() {
if (isValidPercentage.value) { if (isValidPercentage.value) {
emit('save', newOncostPct.value) emit("save", newOncostPct.value);
isOpen.value = false isOpen.value = false;
} }
} }
// Currency formatting // Currency formatting
function formatCurrency(amount: number): string { function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat("en-US", {
style: 'currency', style: "currency",
currency: 'USD', currency: "USD",
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(amount) }).format(amount);
} }
</script> </script>
@ -275,4 +320,4 @@ function formatCurrency(amount: number): string {
cursor: pointer; cursor: pointer;
border: none; border: none;
} }
</style> </style>

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@
<span class="font-medium">{{ stream.name }}</span> <span class="font-medium">{{ stream.name }}</span>
</div> </div>
<div class="flex items-center gap-2"> <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> <span class="font-medium min-w-[40px] text-right">{{ stream.percentage }}%</span>
</div> </div>
</div> </div>
@ -31,7 +31,7 @@
</div> </div>
<!-- Totals --> <!-- 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"> <div class="flex justify-between">
<span>Total monthly target:</span> <span>Total monthly target:</span>
<span class="font-medium">{{ formatCurrency(totalMonthly) }}</span> <span class="font-medium">{{ formatCurrency(totalMonthly) }}</span>

View file

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

View file

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

View file

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

View file

@ -2,13 +2,15 @@
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header --> <!-- Section Header -->
<div class="mb-8"> <div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">Who's on your team?</h3> <h3 class="text-2xl font-black text-black dark:text-white mb-2">
<p class="text-neutral-600"> 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, Add everyone who'll be working in the co-op. Based on your pay approach,
we'll collect the right information for each person. we'll collect the right information for each person.
</p> </p>
<!-- Debug info --> <!-- 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 = Debug: Policy = {{ currentPolicy }}, Needs field shown =
{{ isNeedsWeighted }} {{ isNeedsWeighted }}
</div> </div>
@ -18,9 +20,11 @@
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-if="members.length === 0" v-if="members.length === 0"
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm"> 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 mb-2">No team members yet</h4> <h4 class="font-medium text-neutral-900 dark:text-neutral-100 mb-2">
<p class="text-sm text-neutral-500 mb-4"> 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. Get started by adding your first team member.
</p> </p>
<UButton @click="addMember" size="lg" variant="solid" color="primary"> <UButton @click="addMember" size="lg" variant="solid" color="primary">
@ -32,7 +36,7 @@
<div <div
v-for="(member, index) in members" v-for="(member, index) in members"
:key="member.id" :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 --> <!-- Header row with name and optional coverage chip -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-4 flex-1"> <div class="flex items-center gap-4 flex-1">
@ -74,7 +78,9 @@
<!-- Show minimum needs field when needs-weighted policy is selected --> <!-- Show minimum needs field when needs-weighted policy is selected -->
<UFormField <UFormField
v-if="isNeedsWeighted" v-if="isNeedsWeighted"
:label="`Minimum needs (${getCurrencySymbol(coop.currency.value)}/month)`" :label="`Minimum needs (${getCurrencySymbol(
coop.currency.value
)}/month)`"
required> required>
<UInputNumber <UInputNumber
v-model="member.minMonthlyNeeds" v-model="member.minMonthlyNeeds"
@ -95,7 +101,6 @@
@click="addMember" @click="addMember"
size="lg" size="lg"
variant="solid" variant="solid"
color="success"
:ui="{ :ui="{
base: 'cursor-pointer hover:scale-105 transition-transform', base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform', leadingIcon: 'hover:rotate-90 transition-transform',

View file

@ -2,19 +2,20 @@
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header --> <!-- Section Header -->
<div class="mb-8"> <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? How will you share money?
</h3> </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 This is the foundation of your co-op's finances. Choose a pay approach
and set your hourly rate. and set your hourly rate.
</p> </p>
</div> </div>
<!-- Pay Policy Selection --> <!-- 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> <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? How should available money be shared among members?
</p> </p>
<URadioGroup <URadioGroup
@ -27,9 +28,10 @@
</div> </div>
<!-- Hourly Wage Input --> <!-- 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> <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 This hourly rate applies to all paid work in your co-op
</p> </p>
<div class="flex gap-4 items-start"> <div class="flex gap-4 items-start">

View file

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

View file

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

View file

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

View file

@ -20,7 +20,7 @@
<h4 class="font-semibold">Stress Test</h4> <h4 class="font-semibold">Stress Test</h4>
<div class="space-y-2"> <div class="space-y-2">
<div> <div>
<label class="text-xs text-gray-600" <label class="text-xs text-neutral-600"
>Revenue Delay (months)</label >Revenue Delay (months)</label
> >
<URange <URange
@ -29,24 +29,24 @@
:max="6" :max="6"
:step="1" :step="1"
class="mt-1" /> class="mt-1" />
<div class="text-xs text-gray-500"> <div class="text-xs text-neutral-500">
{{ stress.revenueDelay }} months {{ stress.revenueDelay }} months
</div> </div>
</div> </div>
<div> <div>
<label class="text-xs text-gray-600">Cost Shock (%)</label> <label class="text-xs text-neutral-600">Cost Shock (%)</label>
<URange <URange
v-model="stress.costShockPct" v-model="stress.costShockPct"
:min="0" :min="0"
:max="30" :max="30"
:step="1" :step="1"
class="mt-1" /> class="mt-1" />
<div class="text-xs text-gray-500"> <div class="text-xs text-neutral-500">
{{ stress.costShockPct }}% {{ stress.costShockPct }}%
</div> </div>
</div> </div>
<UCheckbox v-model="stress.grantLost" label="Grant lost" /> <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 }} Projected runway: {{ projectedRunway }}
</div> </div>
</div> </div>
@ -72,13 +72,13 @@
<span>{{ milestone.willReach ? "✅" : "⚠️" }}</span> <span>{{ milestone.willReach ? "✅" : "⚠️" }}</span>
<span>{{ milestone.label }}</span> <span>{{ milestone.label }}</span>
</div> </div>
<span class="text-xs text-gray-600">{{ <span class="text-xs text-neutral-600">{{
formatDate(milestone.date) formatDate(milestone.date)
}}</span> }}</span>
</div> </div>
<div <div
v-if="milestones.length === 0" v-if="milestones.length === 0"
class="text-sm text-gray-600 italic"> class="text-sm text-neutral-600 italic">
No milestones yet No milestones yet
</div> </div>
</div> </div>

View file

@ -4,27 +4,30 @@
<template #header> <template #header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="font-semibold">Individual Member Coverage</h3> <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"> <UTooltip
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" /> 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> </UTooltip>
</div> </div>
</template> </template>
<div v-if="allocatedMembers.length > 0" class="space-y-4"> <div v-if="allocatedMembers.length > 0" class="space-y-4">
<div <div
v-for="member in allocatedMembers" v-for="member in allocatedMembers"
:key="member.id" :key="member.id"
class="space-y-2" class="space-y-2">
>
<!-- Member name and coverage percentage --> <!-- Member name and coverage percentage -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span> <span class="font-medium text-neutral-900">{{
<UBadge member.displayName || member.name || "Unnamed Member"
:color="getCoverageColor(calculateCoverage(member))" }}</span>
<UBadge
:color="getCoverageColor(calculateCoverage(member))"
size="xs" size="xs"
:ui="{ base: 'font-medium' }" :ui="{ base: 'font-medium' }">
>
{{ Math.round(calculateCoverage(member)) }}% covered {{ Math.round(calculateCoverage(member)) }}% covered
</UBadge> </UBadge>
</div> </div>
@ -33,12 +36,18 @@
<!-- Financial breakdown --> <!-- Financial breakdown -->
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div class="space-y-1"> <div class="space-y-1">
<div class="text-gray-600">Needs from co-op</div> <div class="text-neutral-600">Needs from co-op</div>
<div class="font-medium">{{ formatCurrency(member.minMonthlyNeeds || 0) }}</div> <div class="font-medium">
{{ formatCurrency(member.minMonthlyNeeds || 0) }}
</div>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<div class="text-gray-600">Co-op can pay</div> <div class="text-neutral-600">Co-op can pay</div>
<div class="font-medium" :class="getAmountColor(member.monthlyPayPlanned, member.minMonthlyNeeds)"> <div
class="font-medium"
:class="
getAmountColor(member.monthlyPayPlanned, member.minMonthlyNeeds)
">
{{ formatCurrency(member.monthlyPayPlanned || 0) }} {{ formatCurrency(member.monthlyPayPlanned || 0) }}
</div> </div>
</div> </div>
@ -46,18 +55,24 @@
<!-- Visual progress bar --> <!-- Visual progress bar -->
<div class="space-y-1"> <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="h-3 rounded-full transition-all duration-300"
:class="getBarColor(calculateCoverage(member))" :class="getBarColor(calculateCoverage(member))"
:style="{ width: `${Math.min(100, calculateCoverage(member))}%` }" :style="{
/> width: `${Math.min(100, calculateCoverage(member))}%`,
}" />
<!-- 100% marker line --> <!-- 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
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" /> 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> </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>0%</span>
<span>100%</span> <span>100%</span>
<span>200%+</span> <span>200%+</span>
@ -65,21 +80,32 @@
</div> </div>
<!-- Gap/surplus indicator --> <!-- Gap/surplus indicator -->
<div v-if="getGapAmount(member) !== 0" class="flex items-center gap-1 text-xs"> <div
<UIcon v-if="getGapAmount(member) !== 0"
:name="getGapAmount(member) > 0 ? 'i-heroicons-arrow-trending-down' : 'i-heroicons-arrow-trending-up'" 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="h-3 w-3"
:class="getGapAmount(member) > 0 ? 'text-red-500' : 'text-green-500'" :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
:class="
getGapAmount(member) > 0 ? 'text-red-600' : 'text-green-600'
">
{{ getGapAmount(member) > 0 ? "Gap: " : "Surplus: "
}}{{ formatCurrency(Math.abs(getGapAmount(member))) }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<!-- Empty State --> <!-- 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" /> <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-sm mb-2">No members added yet</p>
<p class="text-xs">Complete setup wizard to add team members</p> <p class="text-xs">Complete setup wizard to add team members</p>
@ -87,16 +113,25 @@
<template #footer v-if="allocatedMembers.length > 0"> <template #footer v-if="allocatedMembers.length > 0">
<!-- Summary Stats --> <!-- 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"> <div class="flex items-center gap-4">
<span>Median coverage: {{ Math.round(stats.median || 0) }}%</span> <span>Median coverage: {{ Math.round(stats.median || 0) }}%</span>
<span :class="stats.under100 === 0 ? 'text-green-600' : 'text-amber-600'"> <span
{{ stats.under100 === 0 ? 'All covered ✓' : `${stats.under100} need more` }} :class="stats.under100 === 0 ? 'text-green-600' : 'text-amber-600'">
{{
stats.under100 === 0
? "All covered ✓"
: `${stats.under100} need more`
}}
</span> </span>
</div> </div>
<div class="text-xs"> <div class="text-xs">
<UTooltip text="Based on available revenue after overhead costs"> <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> </UTooltip>
</div> </div>
</div> </div>
@ -108,20 +143,22 @@
<UIcon name="i-heroicons-light-bulb" class="h-3 w-3" /> <UIcon name="i-heroicons-light-bulb" class="h-3 w-3" />
<span class="font-medium">To cover everyone:</span> <span class="font-medium">To cover everyone:</span>
</div> </div>
<p class="mt-1 text-gray-600 pl-5"> <p class="mt-1 text-neutral-600 pl-5">
Increase available payroll by <strong>{{ formatCurrency(totalGap) }}</strong> Increase available payroll by
<strong>{{ formatCurrency(totalGap) }}</strong>
through higher revenue or lower overhead costs. through higher revenue or lower overhead costs.
</p> </p>
</div> </div>
<div v-else-if="totalSurplus > 0" class="text-xs"> <div v-else-if="totalSurplus > 0" class="text-xs">
<div class="flex items-center gap-2 text-green-700"> <div class="flex items-center gap-2 text-green-700">
<UIcon name="i-heroicons-check-circle" class="h-3 w-3" /> <UIcon name="i-heroicons-check-circle" class="h-3 w-3" />
<span class="font-medium">Healthy position:</span> <span class="font-medium">Healthy position:</span>
</div> </div>
<p class="mt-1 text-gray-600 pl-5"> <p class="mt-1 text-neutral-600 pl-5">
You have <strong>{{ formatCurrency(totalSurplus) }}</strong> surplus after covering all member needs. You have <strong>{{ formatCurrency(totalSurplus) }}</strong> surplus
Consider growth opportunities or building reserves. after covering all member needs. Consider growth opportunities or
building reserves.
</p> </p>
</div> </div>
@ -130,7 +167,7 @@
<UIcon name="i-heroicons-scales" class="h-3 w-3" /> <UIcon name="i-heroicons-scales" class="h-3 w-3" />
<span class="font-medium">Perfect balance:</span> <span class="font-medium">Perfect balance:</span>
</div> </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. Available payroll exactly matches member needs.
</p> </p>
</div> </div>
@ -140,88 +177,99 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder() const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder();
const allocatedMembers = computed(() => { const allocatedMembers = computed(() => {
const members = allocatePayroll() const members = allocatePayroll();
console.log('🔍 allocatedMembers computed:', members) console.log("🔍 allocatedMembers computed:", members);
return members return members;
}) });
const stats = computed(() => teamCoverageStats()) const stats = computed(() => teamCoverageStats());
// Calculate total payroll // Calculate total payroll
const totalPayroll = computed(() => const totalPayroll = computed(() =>
allocatedMembers.value.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0) allocatedMembers.value.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
) );
// Color functions for coverage display // Color functions for coverage display
function getBarColor(pct: number): string { function getBarColor(pct: number): string {
if (!pct || pct < 80) return 'bg-red-500' if (!pct || pct < 80) return "bg-red-500";
if (pct < 100) return 'bg-amber-500' if (pct < 100) return "bg-amber-500";
return 'bg-green-500' return "bg-green-500";
} }
function getCoverageColor(pct: number): string { function getCoverageColor(pct: number): string {
if (!pct || pct < 80) return 'red' if (!pct || pct < 80) return "red";
if (pct < 100) return 'amber' if (pct < 100) return "amber";
return 'green' return "green";
} }
function getAmountColor(planned: number = 0, needed: number = 0): string { function getAmountColor(planned: number = 0, needed: number = 0): string {
if (!needed) return 'text-gray-900' if (!needed) return "text-neutral-900";
if (planned >= needed) return 'text-green-600' if (planned >= needed) return "text-green-600";
if (planned >= needed * 0.8) return 'text-amber-600' if (planned >= needed * 0.8) return "text-amber-600";
return 'text-red-600' return "text-red-600";
} }
// Calculate gap between what's needed vs what can be paid // Calculate gap between what's needed vs what can be paid
function getGapAmount(member: any): number { function getGapAmount(member: any): number {
const planned = member.monthlyPayPlanned || 0 const planned = member.monthlyPayPlanned || 0;
const needed = member.minMonthlyNeeds || 0 const needed = member.minMonthlyNeeds || 0;
return needed - planned // positive = gap, negative = surplus return needed - planned; // positive = gap, negative = surplus
} }
// Calculate total gap/surplus across all members // Calculate total gap/surplus across all members
const totalGap = computed(() => { const totalGap = computed(() => {
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0) const totalNeeded = allocatedMembers.value.reduce(
const totalPlanned = totalPayroll.value (sum, m) => sum + (m.minMonthlyNeeds || 0),
const gap = totalNeeded - totalPlanned 0
return gap > 0 ? gap : 0 );
}) const totalPlanned = totalPayroll.value;
const gap = totalNeeded - totalPlanned;
return gap > 0 ? gap : 0;
});
const totalSurplus = computed(() => { const totalSurplus = computed(() => {
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0) const totalNeeded = allocatedMembers.value.reduce(
const totalPlanned = totalPayroll.value (sum, m) => sum + (m.minMonthlyNeeds || 0),
const surplus = totalPlanned - totalNeeded 0
return surplus > 0 ? surplus : 0 );
}) const totalPlanned = totalPayroll.value;
const surplus = totalPlanned - totalNeeded;
return surplus > 0 ? surplus : 0;
});
// Local coverage calculation for debugging // Local coverage calculation for debugging
function calculateCoverage(member: any): number { function calculateCoverage(member: any): number {
const coopPay = member.monthlyPayPlanned || 0 const coopPay = member.monthlyPayPlanned || 0;
const needs = member.minMonthlyNeeds || 0 const needs = member.minMonthlyNeeds || 0;
console.log(`Coverage calc for ${member.name || member.displayName || 'Unknown'}:`, { console.log(
member: JSON.stringify(member, null, 2), `Coverage calc for ${member.name || member.displayName || "Unknown"}:`,
coopPay, {
needs, member: JSON.stringify(member, null, 2),
coverage: needs > 0 ? (coopPay / needs) * 100 : 100 coopPay,
}) needs,
coverage: needs > 0 ? (coopPay / needs) * 100 : 100,
}
);
if (needs === 0) { if (needs === 0) {
console.log(`⚠️ Member ${member.name} has NO minMonthlyNeeds - defaulting to 100%`) console.log(
return 100 `⚠️ 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 // Currency formatting
function formatCurrency(amount: number): string { function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat("en-US", {
style: 'currency', style: "currency",
currency: 'USD', currency: "USD",
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(amount) }).format(amount);
} }
</script> </script>

View file

@ -7,93 +7,126 @@
<UIcon name="i-heroicons-user-group" class="h-5 w-5" /> <UIcon name="i-heroicons-user-group" class="h-5 w-5" />
<h3 class="font-semibold">Member Needs Coverage</h3> <h3 class="font-semibold">Member Needs Coverage</h3>
</div> </div>
<UTooltip text="Shows how well the co-op can meet each member's stated financial needs"> <UTooltip
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" /> 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> </UTooltip>
</div> </div>
</template> </template>
<div v-if="hasMembers" class="space-y-4"> <div v-if="hasMembers" class="space-y-4">
<!-- Team Summary --> <!-- Team Summary -->
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-semibold" :class="statusColor"> <div class="text-2xl font-semibold" :class="statusColor">
{{ fullyCoveredCount }} of {{ totalMembers }} {{ fullyCoveredCount }} of {{ totalMembers }}
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-neutral-600">members fully covered</div>
members fully covered
</div>
</div> </div>
<!-- Coverage Stats --> <!-- Coverage Stats -->
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<div class="text-center"> <div class="text-center">
<div class="font-medium">{{ median }}%</div> <div class="font-medium">{{ median }}%</div>
<div class="text-gray-600">Median</div> <div class="text-neutral-600">Median</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="font-medium" :class="underCoveredColor">{{ stats.under100 }}</div> <div class="font-medium" :class="underCoveredColor">
<div class="text-gray-600">Under 100%</div> {{ stats.under100 }}
</div>
<div class="text-neutral-600">Under 100%</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="font-medium">{{ formatCurrency(availablePayroll) }}</div> <div class="font-medium">{{ formatCurrency(availablePayroll) }}</div>
<div class="text-gray-600">Available</div> <div class="text-neutral-600">Available</div>
</div> </div>
</div> </div>
<!-- Intelligent Financial Analysis --> <!-- Intelligent Financial Analysis -->
<div v-if="hasMembers" class="space-y-2"> <div v-if="hasMembers" class="space-y-2">
<!-- Coverage gap analysis --> <!-- 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"> <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"> <div class="space-y-1">
<p class="font-medium text-amber-800">Coverage Gap Analysis</p> <p class="font-medium text-amber-800">Coverage Gap Analysis</p>
<p class="text-amber-700"> <p class="text-amber-700">
To meet member needs, you need <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements, To meet member needs, you need
but you have <strong>{{ formatCurrency(availablePayroll) }}</strong> available for payroll. <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their
stated requirements, but you have
<strong>{{ formatCurrency(availablePayroll) }}</strong>
available for payroll.
</p> </p>
<p class="text-amber-600"> <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>
<p class="text-xs text-amber-600 mt-2"> <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> </p>
</div> </div>
</div> </div>
</div> </div>
<!-- Surplus analysis --> <!-- 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"> <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"> <div class="space-y-1">
<p class="font-medium text-green-800">Healthy Coverage</p> <p class="font-medium text-green-800">Healthy Coverage</p>
<p class="text-green-700"> <p class="text-green-700">
You have <strong>{{ formatCurrency(availablePayroll) }}</strong> available to cover You have
<strong>{{ formatCurrency(totalNeeds) }}</strong> in member needs. <strong>{{ formatCurrency(availablePayroll) }}</strong>
available to cover
<strong>{{ formatCurrency(totalNeeds) }}</strong> in member
needs.
</p> </p>
<p class="text-green-600"> <p class="text-green-600">
<strong>Surplus: {{ formatCurrency(availablePayroll - totalNeeds) }}</strong> <strong
>Surplus:
{{ formatCurrency(availablePayroll - totalNeeds) }}</strong
>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<!-- No payroll available --> <!-- 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"> <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"> <div class="space-y-1">
<p class="font-medium text-red-800">No Funds for Payroll</p> <p class="font-medium text-red-800">No Funds for Payroll</p>
<p class="text-red-700"> <p class="text-red-700">
Member needs total <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements, Member needs total
but current revenue minus costs leaves $0 for payroll. <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their
stated requirements, but current revenue minus costs leaves $0
for payroll.
</p> </p>
<p class="text-red-600"> <p class="text-red-600">
Consider increasing revenue or reducing overhead costs. Consider increasing revenue or reducing overhead costs.
</p> </p>
<p class="text-xs text-red-600 mt-2"> <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> </p>
</div> </div>
</div> </div>
@ -102,7 +135,7 @@
</div> </div>
<!-- Empty State --> <!-- 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" /> <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> <p class="text-sm">Add members in setup to see coverage</p>
</div> </div>
@ -110,56 +143,60 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { members, teamCoverageStats, allocatePayroll, streams } = useCoopBuilder() const { members, teamCoverageStats, allocatePayroll, streams } =
const coopStore = useCoopBuilderStore() useCoopBuilder();
const coopStore = useCoopBuilderStore();
const stats = computed(() => teamCoverageStats()) const stats = computed(() => teamCoverageStats());
const allocatedMembers = computed(() => allocatePayroll()) const allocatedMembers = computed(() => allocatePayroll());
const median = computed(() => Math.round(stats.value.median ?? 0)) const median = computed(() => Math.round(stats.value.median ?? 0));
// Team-level calculations // Team-level calculations
const hasMembers = computed(() => members.value.length > 0) const hasMembers = computed(() => members.value.length > 0);
const totalMembers = computed(() => members.value.length) const totalMembers = computed(() => members.value.length);
const fullyCoveredCount = computed(() => totalMembers.value - stats.value.under100) const fullyCoveredCount = computed(
() => totalMembers.value - stats.value.under100
);
// Financial calculations // Financial calculations
const totalNeeds = computed(() => const totalNeeds = computed(() =>
allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0) 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) 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) coopStore.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0)
) );
const availablePayroll = computed(() => const availablePayroll = computed(() =>
Math.max(0, totalRevenue.value - overheadCosts.value) Math.max(0, totalRevenue.value - overheadCosts.value)
) );
// Status colors based on coverage // Status colors based on coverage
const statusColor = computed(() => { const statusColor = computed(() => {
const ratio = fullyCoveredCount.value / Math.max(1, totalMembers.value) const ratio = fullyCoveredCount.value / Math.max(1, totalMembers.value);
if (ratio === 1) return 'text-green-600' if (ratio === 1) return "text-green-600";
if (ratio >= 0.8) return 'text-amber-600' if (ratio >= 0.8) return "text-amber-600";
return 'text-red-600' return "text-red-600";
}) });
const underCoveredColor = computed(() => { const underCoveredColor = computed(() => {
if (stats.value.under100 === 0) return 'text-green-600' if (stats.value.under100 === 0) return "text-green-600";
if (stats.value.under100 <= Math.ceil(totalMembers.value * 0.2)) return 'text-amber-600' if (stats.value.under100 <= Math.ceil(totalMembers.value * 0.2))
return 'text-red-600' return "text-amber-600";
}) return "text-red-600";
});
// Currency formatting // Currency formatting
function formatCurrency(amount: number): string { function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat("en-US", {
style: 'currency', style: "currency",
currency: 'USD', currency: "USD",
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(amount) }).format(amount);
} }
</script> </script>

View file

@ -7,12 +7,14 @@
<h3 class="font-semibold">Revenue Mix</h3> <h3 class="font-semibold">Revenue Mix</h3>
</div> </div>
</template> </template>
<div class="space-y-6"> <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. Add revenue streams to see mix.
</div> </div>
<div v-else> <div v-else>
<!-- Revenue bars --> <!-- Revenue bars -->
<div v-for="s in mix.slice(0, 3)" :key="s.label" class="mb-2"> <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 class="truncate">{{ s.label }}</span>
<span>{{ Math.round(s.pct * 100) }}%</span> <span>{{ Math.round(s.pct * 100) }}%</span>
</div> </div>
<div class="h-2 bg-gray-200 rounded"> <div class="h-2 bg-neutral-200 rounded">
<div <div
class="h-2 rounded" class="h-2 rounded"
:class="getBarColor(mix.indexOf(s))" :class="getBarColor(mix.indexOf(s))"
:style="{ width: (s.pct * 100) + '%' }" :style="{ width: s.pct * 100 + '%' }" />
/>
</div> </div>
</div> </div>
<!-- Subtext with concentration warning --> <!-- 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) }}% Top stream {{ Math.round(topPct * 100) }}%
<span v-if="topPct > 0.5" class="text-amber-600"></span> <span v-if="topPct > 0.5" class="text-amber-600"></span>
</div> </div>
@ -40,17 +41,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { revenueMix, concentrationPct } = useCoopBuilder() const { revenueMix, concentrationPct } = useCoopBuilder();
const mix = computed(() => revenueMix()) const mix = computed(() => revenueMix());
const topPct = computed(() => concentrationPct()) const topPct = computed(() => concentrationPct());
function getBarColor(index: number): string { function getBarColor(index: number): string {
const colors = [ const colors = ["bg-blue-500", "bg-green-500", "bg-amber-500"];
'bg-blue-500', return colors[index % colors.length];
'bg-green-500',
'bg-amber-500'
]
return colors[index % colors.length]
} }
</script> </script>

View file

@ -7,56 +7,53 @@
<div class="w-2 h-2 rounded-full" :class="statusDotColor" /> <div class="w-2 h-2 rounded-full" :class="statusDotColor" />
<h3 class="font-semibold">Runway</h3> <h3 class="font-semibold">Runway</h3>
</div> </div>
<UBadge <UBadge
:color="operatingMode === 'target' ? 'blue' : 'gray'" :color="operatingMode === 'target' ? 'blue' : 'neutral'"
size="xs" size="xs">
> {{ operatingMode === "target" ? "Target Mode" : "Min Mode" }}
{{ operatingMode === 'target' ? 'Target Mode' : 'Min Mode' }}
</UBadge> </UBadge>
</div> </div>
</template> </template>
<div class="text-center space-y-6"> <div class="text-center space-y-6">
<div class="text-2xl font-semibold" :class="statusColor"> <div class="text-2xl font-semibold" :class="statusColor">
{{ displayRunway }} {{ displayRunway }}
</div> </div>
<div class="text-sm text-gray-600"> <div class="text-sm text-neutral-600">at current spending</div>
at current spending
</div>
</div> </div>
</UCard> </UCard>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { runwayMonths, operatingMode } = useCoopBuilder() const { runwayMonths, operatingMode } = useCoopBuilder();
const runway = computed(() => runwayMonths()) const runway = computed(() => runwayMonths());
const displayRunway = computed(() => { const displayRunway = computed(() => {
const months = runway.value const months = runway.value;
if (!isFinite(months)) return '∞' if (!isFinite(months)) return "∞";
if (months < 1) return '<1 month' if (months < 1) return "<1 month";
return `${Math.round(months)} months` return `${Math.round(months)} months`;
}) });
const statusColor = computed(() => { const statusColor = computed(() => {
const months = runway.value const months = runway.value;
if (!isFinite(months) || months >= 6) return 'text-green-600' if (!isFinite(months) || months >= 6) return "text-green-600";
if (months >= 3) return 'text-amber-600' if (months >= 3) return "text-amber-600";
return 'text-red-600' return "text-red-600";
}) });
const statusDotColor = computed(() => { const statusDotColor = computed(() => {
const months = runway.value const months = runway.value;
if (!isFinite(months) || months >= 6) return 'bg-green-500' if (!isFinite(months) || months >= 6) return "bg-green-500";
if (months >= 3) return 'bg-amber-500' if (months >= 3) return "bg-amber-500";
return 'bg-red-500' return "bg-red-500";
}) });
const borderColor = computed(() => { const borderColor = computed(() => {
const months = runway.value const months = runway.value;
if (!isFinite(months) || months >= 6) return 'ring-1 ring-green-200' if (!isFinite(months) || months >= 6) return "ring-1 ring-green-200";
if (months >= 3) return 'ring-1 ring-amber-200' if (months >= 3) return "ring-1 ring-amber-200";
return 'ring-1 ring-red-200' return "ring-1 ring-red-200";
}) });
</script> </script>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="flex items-center gap-2"> <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 <div
class="h-full rounded-full transition-all duration-300" class="h-full rounded-full transition-all duration-300"
:class="barColor" :class="barColor"

View file

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

View file

@ -0,0 +1,81 @@
import { ref, watch, readonly } from 'vue'
export interface CoopInfo {
cooperativeName: string
dateEstablished: string
purpose: string
coreValues: string
legalStructure: string
registeredLocation: string
isLegallyRegistered: boolean
}
const STORAGE_KEY = 'coop-info'
// Global reactive state
const coopInfo = ref<CoopInfo>({
cooperativeName: '',
dateEstablished: '',
purpose: '',
coreValues: '',
legalStructure: '',
registeredLocation: '',
isLegallyRegistered: false
})
// Flag to prevent loading during initial save
let isInitialized = false
export const useCoopInfo = () => {
// Load data from localStorage on first use
if (!isInitialized && process.client) {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const parsedData = JSON.parse(saved)
coopInfo.value = { ...coopInfo.value, ...parsedData }
} catch (error) {
console.error('Error loading coop info:', error)
}
}
isInitialized = true
// Set up watcher to save changes
watch(
coopInfo,
(newData) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newData))
},
{ deep: true }
)
}
// Helper function to update specific fields
const updateCoopInfo = (updates: Partial<CoopInfo>) => {
coopInfo.value = { ...coopInfo.value, ...updates }
}
// Helper function to get display name (with fallback)
const getDisplayName = () => {
return coopInfo.value.cooperativeName || 'Worker Cooperative'
}
// Helper function to get organization name for different contexts
const getOrgName = () => {
return coopInfo.value.cooperativeName || 'Organization'
}
// Helper function to check if basic info is complete
const isBasicInfoComplete = () => {
return !!(coopInfo.value.cooperativeName && coopInfo.value.cooperativeName.trim())
}
return {
coopInfo: readonly(coopInfo),
updateCoopInfo,
getDisplayName,
getOrgName,
isBasicInfoComplete
}
}

View file

@ -1,6 +0,0 @@
export default defineNuxtRouteMiddleware((to) => {
// Redirect root path to dashboard
if (to.path === '/') {
return navigateTo('/dashboard')
}
})

BIN
pages/.DS_Store vendored

Binary file not shown.

View file

@ -11,7 +11,7 @@
'px-4 py-2 font-medium transition-none', 'px-4 py-2 font-medium transition-none',
activeView === 'monthly' activeView === 'monthly'
? 'bg-black text-white' ? 'bg-black text-white'
: 'bg-white text-black hover:bg-zinc-100', : 'bg-white text-black hover:bg-neutral-100',
]"> ]">
Monthly Monthly
</button> </button>
@ -21,7 +21,7 @@
'px-4 py-2 font-medium border-l-2 border-black transition-none', 'px-4 py-2 font-medium border-l-2 border-black transition-none',
activeView === 'annual' activeView === 'annual'
? 'bg-black text-white' ? 'bg-black text-white'
: 'bg-white text-black hover:bg-zinc-100', : 'bg-white text-black hover:bg-neutral-100',
]"> ]">
Annual Annual
</button> </button>
@ -48,14 +48,14 @@
<div class="max-w-md mx-auto space-y-6"> <div class="max-w-md mx-auto space-y-6">
<div class="text-6xl">📊</div> <div class="text-6xl">📊</div>
<h3 class="text-xl font-bold text-black">No budget data found</h3> <h3 class="text-xl font-bold text-black">No budget data found</h3>
<p class="text-gray-600"> <p class="text-neutral-600">
Your budget is empty. Complete the setup wizard to add your revenue Your budget is empty. Complete the setup wizard to add your revenue
streams, team members, and expenses. streams, team members, and expenses.
</p> </p>
<div class="flex justify-center"> <div class="flex justify-center">
<NuxtLink <NuxtLink
to="/coop-builder" to="/coop-builder"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-black hover:bg-zinc-800 border-2 border-black transition-colors"> class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-black hover:bg-neutral-800 border-2 border-black transition-colors">
Complete Setup Wizard Complete Setup Wizard
</NuxtLink> </NuxtLink>
</div> </div>
@ -79,7 +79,7 @@
<th <th
v-for="month in monthlyHeaders" v-for="month in monthlyHeaders"
:key="month.key" :key="month.key"
class="border-r border-gray-400 px-2 py-3 text-center font-medium min-w-[80px] last:border-r-0"> class="border-r border-neutral-400 px-2 py-3 text-center font-medium min-w-[80px] last:border-r-0">
{{ month.label }} {{ month.label }}
</th> </th>
</tr> </tr>
@ -94,7 +94,7 @@
@click="showAddRevenueModal = true" @click="showAddRevenueModal = true"
size="xs" size="xs"
:ui="{ :ui="{
base: 'bg-white text-black hover:bg-zinc-200 transition-none', base: 'bg-white text-black hover:bg-neutral-200 transition-none',
}"> }">
+ Add + Add
</UButton> </UButton>
@ -107,9 +107,9 @@
<template <template
v-for="(items, categoryName) in groupedRevenue" v-for="(items, categoryName) in groupedRevenue"
:key="`revenue-${categoryName}`"> :key="`revenue-${categoryName}`">
<tr v-if="items.length > 0" class="border-t border-gray-300"> <tr v-if="items.length > 0" class="border-t border-neutral-300">
<td <td
class="px-4 py-1 font-semibold sticky left-0 bg-zinc-100 z-10 border-black" class="px-4 py-1 font-semibold sticky left-0 bg-neutral-100 z-10 border-black"
:colspan="monthlyHeaders.length + 1"> :colspan="monthlyHeaders.length + 1">
{{ categoryName }} {{ categoryName }}
</td> </td>
@ -118,17 +118,17 @@
v-for="item in items" v-for="item in items"
:key="item.id" :key="item.id"
:class="[ :class="[
'border-t border-gray-200 hover:bg-zinc-50 transition-all duration-300', 'border-t border-neutral-200 hover:bg-neutral-50 transition-all duration-300',
highlightedItemId === item.id && highlightedItemId === item.id &&
'bg-yellow-100 animate-pulse', 'bg-yellow-100 animate-pulse',
]"> ]">
<td <td
class="border-r-1 border-zinc-200 px-4 py-2 sticky left-0 bg-white z-10"> class="border-r-1 border-neutral-200 px-4 py-2 sticky left-0 bg-white z-10">
<div class="flex items-center justify-between group"> <div class="flex items-center justify-between group">
<div class="flex-1"> <div class="flex-1">
<div class="text-left w-full"> <div class="text-left w-full">
<div class="font-medium">{{ item.name }}</div> <div class="font-medium">{{ item.name }}</div>
<div class="text-xs text-gray-600"> <div class="text-xs text-neutral-600">
{{ item.subcategory }} {{ item.subcategory }}
</div> </div>
</div> </div>
@ -147,7 +147,7 @@
<td <td
v-for="month in monthlyHeaders" v-for="month in monthlyHeaders"
:key="month.key" :key="month.key"
class="border-r border-gray-200 px-1 py-1 last:border-r-0"> class="border-r border-neutral-200 px-1 py-1 last:border-r-0">
<input <input
type="text" type="text"
:value="formatValue(item.monthlyValues?.[month.key] || 0)" :value="formatValue(item.monthlyValues?.[month.key] || 0)"
@ -157,9 +157,9 @@
" "
@blur="handleBlur($event, 'revenue', item.id, month.key)" @blur="handleBlur($event, 'revenue', item.id, month.key)"
@keydown.enter="handleEnter($event)" @keydown.enter="handleEnter($event)"
class="w-full text-right px-1 py-0.5 border border-transparent hover:border-gray-400 focus:border-black focus:outline-none transition-none" class="w-full text-right px-1 py-0.5 border border-transparent hover:border-neutral-400 focus:border-black focus:outline-none transition-none"
:class="{ :class="{
'bg-zinc-50': !item.monthlyValues?.[month.key], 'bg-neutral-50': !item.monthlyValues?.[month.key],
}" /> }" />
</td> </td>
</tr> </tr>
@ -167,9 +167,9 @@
<!-- Total Revenue Row --> <!-- Total Revenue Row -->
<tr <tr
class="border-t-1 border-black border-b-1 font-bold bg-zinc-100"> class="border-t-1 border-black border-b-1 font-bold bg-neutral-100">
<td <td
class="border-r-1 border-black px-4 py-2 sticky left-0 bg-zinc-100 z-10"> class="border-r-1 border-black px-4 py-2 sticky left-0 bg-neutral-100 z-10">
TOTAL REVENUE TOTAL REVENUE
</td> </td>
<td <td
@ -189,7 +189,7 @@
@click="showAddExpenseModal = true" @click="showAddExpenseModal = true"
size="xs" size="xs"
:ui="{ :ui="{
base: 'bg-white text-black hover:bg-zinc-200 transition-none', base: 'bg-white text-black hover:bg-neutral-200 transition-none',
}"> }">
+ Add + Add
</UButton> </UButton>
@ -202,9 +202,9 @@
<template <template
v-for="(items, categoryName) in groupedExpenses" v-for="(items, categoryName) in groupedExpenses"
:key="`expense-${categoryName}`"> :key="`expense-${categoryName}`">
<tr v-if="items.length > 0" class="border-t border-gray-300"> <tr v-if="items.length > 0" class="border-t border-neutral-300">
<td <td
class="px-4 py-1 font-semibold sticky left-0 bg-zinc-100 z-10" class="px-4 py-1 font-semibold sticky left-0 bg-neutral-100 z-10"
:colspan="monthlyHeaders.length + 1"> :colspan="monthlyHeaders.length + 1">
{{ categoryName }} {{ categoryName }}
</td> </td>
@ -213,12 +213,12 @@
v-for="item in items" v-for="item in items"
:key="item.id" :key="item.id"
:class="[ :class="[
'border-t border-gray-200 hover:bg-zinc-50 transition-all duration-300', 'border-t border-neutral-200 hover:bg-neutral-50 transition-all duration-300',
highlightedItemId === item.id && highlightedItemId === item.id &&
'bg-yellow-100 animate-pulse', 'bg-yellow-100 animate-pulse',
]"> ]">
<td <td
class="border-r-1 border-zinc-200 px-4 py-2 sticky left-0 bg-white z-10"> class="border-r-1 border-neutral-200 px-4 py-2 sticky left-0 bg-white z-10">
<div class="flex items-center justify-between group"> <div class="flex items-center justify-between group">
<div class="flex-1"> <div class="flex-1">
<div class="text-left w-full"> <div class="text-left w-full">
@ -236,7 +236,7 @@
Auto Auto
</span> </span>
</div> </div>
<div class="text-xs text-gray-600" v-if="!isPayrollItem(item.id)"> <div class="text-xs text-neutral-600" v-if="!isPayrollItem(item.id)">
{{ item.subcategory }} {{ item.subcategory }}
</div> </div>
</div> </div>
@ -258,7 +258,7 @@
<td <td
v-for="month in monthlyHeaders" v-for="month in monthlyHeaders"
:key="month.key" :key="month.key"
class="border-r border-gray-200 px-1 py-1 last:border-r-0"> class="border-r border-neutral-200 px-1 py-1 last:border-r-0">
<input <input
type="text" type="text"
:value="formatValue(item.monthlyValues?.[month.key] || 0)" :value="formatValue(item.monthlyValues?.[month.key] || 0)"
@ -271,12 +271,12 @@
@keydown.enter="handleEnter($event)" @keydown.enter="handleEnter($event)"
class="w-full text-right px-1 py-0.5 border-2 transition-none" class="w-full text-right px-1 py-0.5 border-2 transition-none"
:class="{ :class="{
'bg-zinc-50': 'bg-neutral-50':
!item.monthlyValues?.[month.key] && !item.monthlyValues?.[month.key] &&
!isPayrollItem(item.id), !isPayrollItem(item.id),
'bg-zinc-50 border-none cursor-not-allowed text-zinc-500': 'bg-neutral-50 border-none cursor-not-allowed text-neutral-500':
isPayrollItem(item.id), isPayrollItem(item.id),
'border-transparent hover:border-zinc-400 focus:border-black focus:outline-none': 'border-transparent hover:border-neutral-400 focus:border-black focus:outline-none':
!isPayrollItem(item.id), !isPayrollItem(item.id),
}" }"
:title=" :title="
@ -289,15 +289,15 @@
</template> </template>
<!-- Total Expenses Row --> <!-- Total Expenses Row -->
<tr class="border-t-1 border-black font-bold bg-zinc-100"> <tr class="border-t-1 border-black font-bold bg-neutral-100">
<td <td
class="border-r-1 border-black px-4 py-2 sticky left-0 bg-zinc-100 z-10"> class="border-r-1 border-black px-4 py-2 sticky left-0 bg-neutral-100 z-10">
TOTAL EXPENSES TOTAL EXPENSES
</td> </td>
<td <td
v-for="month in monthlyHeaders" v-for="month in monthlyHeaders"
:key="month.key" :key="month.key"
class="border-r border-gray-400 px-2 py-2 text-right last:border-r-0"> class="border-r border-neutral-400 px-2 py-2 text-right last:border-r-0">
{{ formatCurrency(monthlyTotals[month.key]?.expenses || 0) }} {{ formatCurrency(monthlyTotals[month.key]?.expenses || 0) }}
</td> </td>
</tr> </tr>
@ -311,7 +311,7 @@
<td <td
v-for="month in monthlyHeaders" v-for="month in monthlyHeaders"
:key="month.key" :key="month.key"
class="border-r border-gray-400 px-2 py-3 text-right last:border-r-0" class="border-r border-neutral-400 px-2 py-3 text-right last:border-r-0"
:class=" :class="
getNetIncomeClass(monthlyTotals[month.key]?.net || 0) getNetIncomeClass(monthlyTotals[month.key]?.net || 0)
"> ">
@ -320,7 +320,7 @@
</tr> </tr>
<!-- Cumulative Balance Row --> <!-- Cumulative Balance Row -->
<tr class="border-t-1 border-gray-400 font-bold text-lg bg-blue-50"> <tr class="border-t-1 border-neutral-400 font-bold text-lg bg-blue-50">
<td <td
class="border-r-1 border-black px-4 py-3 sticky left-0 bg-blue-50 z-10"> class="border-r-1 border-black px-4 py-3 sticky left-0 bg-blue-50 z-10">
CUMULATIVE BALANCE CUMULATIVE BALANCE
@ -328,7 +328,7 @@
<td <td
v-for="month in monthlyHeaders" v-for="month in monthlyHeaders"
:key="month.key" :key="month.key"
class="border-r border-gray-400 px-2 py-3 text-right last:border-r-0" class="border-r border-neutral-400 px-2 py-3 text-right last:border-r-0"
:class=" :class="
getCumulativeBalanceClass(cumulativeBalances[month.key] || 0) getCumulativeBalanceClass(cumulativeBalances[month.key] || 0)
"> ">
@ -403,19 +403,19 @@
placeholder="Enter annual amount (e.g., 12000)" placeholder="Enter annual amount (e.g., 12000)"
size="lg"> size="lg">
<template #leading> <template #leading>
<span class="text-gray-500 font-medium">$</span> <span class="text-neutral-500 font-medium">$</span>
</template> </template>
</UInput> </UInput>
</UFormField> </UFormField>
<div class="bg-white rounded-lg p-4 border border-gray-200"> <div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2"> <div class="mb-2">
<span class="text-sm font-medium text-gray-700" <span class="text-sm font-medium text-neutral-700"
>Distribution Preview</span >Distribution Preview</span
> >
</div> </div>
<p class="text-sm text-gray-600"> <p class="text-sm text-neutral-600">
This will divide This will divide
<span class="font-semibold text-gray-900" <span class="font-semibold text-neutral-900"
>${{ newRevenue.annualAmount || 0 }}</span >${{ newRevenue.annualAmount || 0 }}</span
> >
equally across all 12 months (<span equally across all 12 months (<span
@ -440,17 +440,17 @@
placeholder="Enter monthly amount (e.g., 1000)" placeholder="Enter monthly amount (e.g., 1000)"
size="lg"> size="lg">
<template #leading> <template #leading>
<span class="text-gray-500 font-medium">$</span> <span class="text-neutral-500 font-medium">$</span>
</template> </template>
</UInput> </UInput>
</UFormField> </UFormField>
<div class="bg-white rounded-lg p-4 border border-gray-200"> <div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2"> <div class="mb-2">
<span class="text-sm font-medium text-gray-700" <span class="text-sm font-medium text-neutral-700"
>Monthly Preview</span >Monthly Preview</span
> >
</div> </div>
<p class="text-sm text-gray-600"> <p class="text-sm text-neutral-600">
This will set This will set
<span class="font-semibold text-green-600" <span class="font-semibold text-green-600"
>${{ newRevenue.monthlyAmount || 0 }}</span >${{ newRevenue.monthlyAmount || 0 }}</span
@ -463,8 +463,8 @@
<!-- Start Empty --> <!-- Start Empty -->
<div v-else> <div v-else>
<div <div
class="bg-white rounded-lg p-6 border border-gray-200 text-center"> class="bg-white rounded-lg p-6 border border-neutral-200 text-center">
<p class="text-sm text-gray-600"> <p class="text-sm text-neutral-600">
The revenue item will be created with no initial values. You The revenue item will be created with no initial values. You
can fill them in later directly in the budget table. can fill them in later directly in the budget table.
</p> </p>
@ -545,19 +545,19 @@
size="lg" size="lg"
class="text-sm font-medium w-full"> class="text-sm font-medium w-full">
<template #leading> <template #leading>
<span class="text-gray-500 font-medium">$</span> <span class="text-neutral-500 font-medium">$</span>
</template> </template>
</UInput> </UInput>
</UFormField> </UFormField>
<div class="bg-white rounded-lg p-4 border border-gray-200"> <div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2"> <div class="mb-2">
<span class="text-sm font-medium text-gray-700" <span class="text-sm font-medium text-neutral-700"
>Distribution Preview</span >Distribution Preview</span
> >
</div> </div>
<p class="text-sm text-gray-600"> <p class="text-sm text-neutral-600">
This will divide This will divide
<span class="font-semibold text-gray-900" <span class="font-semibold text-neutral-900"
>${{ newExpense.annualAmount || 0 }}</span >${{ newExpense.annualAmount || 0 }}</span
> >
equally across all 12 months (<span equally across all 12 months (<span
@ -583,17 +583,17 @@
size="lg" size="lg"
class="text-sm font-medium w-full"> class="text-sm font-medium w-full">
<template #leading> <template #leading>
<span class="text-gray-500 font-medium">$</span> <span class="text-neutral-500 font-medium">$</span>
</template> </template>
</UInput> </UInput>
</UFormField> </UFormField>
<div class="bg-white rounded-lg p-4 border border-gray-200"> <div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2"> <div class="mb-2">
<span class="text-sm font-medium text-gray-700" <span class="text-sm font-medium text-neutral-700"
>Monthly Preview</span >Monthly Preview</span
> >
</div> </div>
<p class="text-sm text-gray-600"> <p class="text-sm text-neutral-600">
This will set This will set
<span class="font-semibold text-red-600" <span class="font-semibold text-red-600"
>${{ newExpense.monthlyAmount || 0 }}</span >${{ newExpense.monthlyAmount || 0 }}</span
@ -606,8 +606,8 @@
<!-- Start Empty --> <!-- Start Empty -->
<div v-else> <div v-else>
<div <div
class="bg-white rounded-lg p-6 border border-gray-200 text-center"> class="bg-white rounded-lg p-6 border border-neutral-200 text-center">
<p class="text-sm text-gray-600"> <p class="text-sm text-neutral-600">
The expense item will be created with no initial values. You The expense item will be created with no initial values. You
can fill them in later directly in the budget table. can fill them in later directly in the budget table.
</p> </p>
@ -650,8 +650,8 @@
<!-- Revenue Section --> <!-- Revenue Section -->
<div> <div>
<h4 class="font-semibold text-green-600 mb-2">📈 Revenue Calculation</h4> <h4 class="font-semibold text-green-600 mb-2">📈 Revenue Calculation</h4>
<p class="text-sm text-gray-600 mb-2">Revenue comes from your setup wizard streams and any manual additions:</p> <p class="text-sm text-neutral-600 mb-2">Revenue comes from your setup wizard streams and any manual additions:</p>
<ul class="text-sm text-gray-600 space-y-1 ml-4"> <ul class="text-sm text-neutral-600 space-y-1 ml-4">
<li> Monthly amounts you entered for each revenue stream</li> <li> Monthly amounts you entered for each revenue stream</li>
<li> Varies by month based on your specific projections</li> <li> Varies by month based on your specific projections</li>
</ul> </ul>
@ -660,7 +660,7 @@
<!-- Payroll Section --> <!-- Payroll Section -->
<div> <div>
<h4 class="font-semibold text-blue-600 mb-2">👥 Smart Payroll Calculation</h4> <h4 class="font-semibold text-blue-600 mb-2">👥 Smart Payroll Calculation</h4>
<p class="text-sm text-gray-600 mb-2">Payroll uses a <strong>cumulative balance approach</strong> to ensure sustainability:</p> <p class="text-sm text-neutral-600 mb-2">Payroll uses a <strong>cumulative balance approach</strong> to ensure sustainability:</p>
<div class="bg-blue-50 border border-blue-200 rounded p-3 text-sm"> <div class="bg-blue-50 border border-blue-200 rounded p-3 text-sm">
<p class="font-medium mb-2">Step-by-step process:</p> <p class="font-medium mb-2">Step-by-step process:</p>
<ol class="space-y-1 ml-4"> <ol class="space-y-1 ml-4">
@ -671,7 +671,7 @@
<li>5. Ensure cumulative balance doesn't fall below threshold</li> <li>5. Ensure cumulative balance doesn't fall below threshold</li>
</ol> </ol>
</div> </div>
<p class="text-sm text-gray-600 mt-2"> <p class="text-sm text-neutral-600 mt-2">
This means payroll varies by month - higher in good cash flow months, lower when cash is tight. This means payroll varies by month - higher in good cash flow months, lower when cash is tight.
</p> </p>
</div> </div>
@ -679,8 +679,8 @@
<!-- Cumulative Balance Section --> <!-- Cumulative Balance Section -->
<div> <div>
<h4 class="font-semibold text-purple-600 mb-2">💰 Cumulative Balance</h4> <h4 class="font-semibold text-purple-600 mb-2">💰 Cumulative Balance</h4>
<p class="text-sm text-gray-600 mb-2">Shows your running cash position over time:</p> <p class="text-sm text-neutral-600 mb-2">Shows your running cash position over time:</p>
<ul class="text-sm text-gray-600 space-y-1 ml-4"> <ul class="text-sm text-neutral-600 space-y-1 ml-4">
<li> Starts at $0 (current cash position)</li> <li> Starts at $0 (current cash position)</li>
<li> Adds each month's net income (Revenue - All Expenses)</li> <li> Adds each month's net income (Revenue - All Expenses)</li>
<li> Helps you see when cash might run low</li> <li> Helps you see when cash might run low</li>
@ -691,7 +691,7 @@
<!-- Policy Explanation --> <!-- Policy Explanation -->
<div> <div>
<h4 class="font-semibold text-orange-600 mb-2"> Pay Policy: {{ getPolicyName() }}</h4> <h4 class="font-semibold text-orange-600 mb-2"> Pay Policy: {{ getPolicyName() }}</h4>
<div class="text-sm text-gray-600"> <div class="text-sm text-neutral-600">
<p v-if="coopBuilderStore.policy?.relationship === 'equal-pay'"> <p v-if="coopBuilderStore.policy?.relationship === 'equal-pay'">
Everyone gets equal hourly wage (${{ coopBuilderStore.equalHourlyWage || 0 }}/hour) based on their monthly hours. Everyone gets equal hourly wage (${{ coopBuilderStore.equalHourlyWage || 0 }}/hour) based on their monthly hours.
</p> </p>
@ -704,8 +704,8 @@
</div> </div>
</div> </div>
<div class="bg-gray-50 border border-gray-200 rounded p-3"> <div class="bg-neutral-50 border border-neutral-200 rounded p-3">
<p class="text-sm text-gray-700"> <p class="text-sm text-neutral-700">
<strong>Key insight:</strong> This system prioritizes sustainability over theoretical maximums. <strong>Key insight:</strong> This system prioritizes sustainability over theoretical maximums.
You might not always get full theoretical wages, but you'll never run out of cash. You might not always get full theoretical wages, but you'll never run out of cash.
</p> </p>
@ -1231,7 +1231,7 @@ function formatCurrency(amount: number): string {
function getNetIncomeClass(amount: number): string { function getNetIncomeClass(amount: number): string {
if (amount > 0) return "text-green-600 font-bold"; if (amount > 0) return "text-green-600 font-bold";
if (amount < 0) return "text-red-600 font-bold"; if (amount < 0) return "text-red-600 font-bold";
return "text-gray-600"; return "text-neutral-600";
} }
function getCumulativeBalanceClass(amount: number): string { function getCumulativeBalanceClass(amount: number): string {

View file

@ -1,596 +0,0 @@
<template>
<div class="min-h-screen bg-neutral-50 pb-24">
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<div class="mb-8">
<div class="flex items-start justify-between">
<div>
<h1 class="text-3xl font-black text-black mb-2">
Turn skills into fair, sellable offers
</h1>
<p class="text-neutral-600">
Tell us what you're good at and who you help. We'll suggest offers that match your co-op's shared capacity.
</p>
</div>
<div class="flex items-center gap-3">
<button
@click="skipCoach"
class="px-4 py-2 text-sm bg-neutral-50 border-2 border-neutral-300 rounded-lg text-neutral-700 hover:bg-neutral-100 hover:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
:aria-label="'Skip coach and go to streams tab'"
>
Skip coach Streams
</button>
</div>
</div>
</div>
<!-- Section A: Name your strengths -->
<section class="mb-8" aria-labelledby="strengths-heading">
<div class="flex items-center gap-2 mb-4">
<h2 id="strengths-heading" class="text-xl font-bold text-black">
A) Name your strengths
</h2>
<div class="relative group">
<button
class="w-4 h-4 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-full"
aria-label="Why limit to 3 skills per member?"
>
<svg fill="currentColor" viewBox="0 0 20 20" class="w-4 h-4">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</button>
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-black text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Focus keeps offers shippable
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black"></div>
</div>
</div>
</div>
<p class="text-neutral-600 mb-6">
Pick what you can reliably do as a team. We'll keep it simple.
</p>
<div class="space-y-6">
<div
v-for="member in members"
:key="member.id"
class="p-6 bg-white border-2 border-neutral-200 rounded-xl shadow-sm"
>
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-bold text-black">{{ member.name }}</h3>
<p v-if="member.role" class="text-sm text-neutral-600">{{ member.role }}</p>
</div>
<div class="text-sm text-neutral-500">
{{ getSelectedSkillsCount(member.id) }}/3 skills selected
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="skill in availableSkills"
:key="skill.id"
@click="toggleSkill(member.id, skill.id)"
:disabled="!canSelectSkill(member.id, skill.id)"
:class="[
'px-3 py-1.5 text-sm rounded-full border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
isSkillSelected(member.id, skill.id)
? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700'
: canSelectSkill(member.id, skill.id)
? 'bg-white text-neutral-700 border-neutral-300 hover:border-blue-400 hover:text-blue-600'
: 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
]"
:aria-pressed="isSkillSelected(member.id, skill.id)"
:aria-label="`${isSkillSelected(member.id, skill.id) ? 'Remove' : 'Add'} ${skill.label} skill for ${member.name}`"
>
{{ skill.label }}
</button>
</div>
</div>
</div>
</section>
<!-- Section B: Who do you help? -->
<section class="mb-8" aria-labelledby="problems-heading">
<div class="flex items-center gap-2 mb-4">
<h2 id="problems-heading" class="text-xl font-bold text-black">
B) Who do you help?
</h2>
<div class="relative group">
<button
class="w-4 h-4 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-full"
aria-label="Why limit to 2 problem types?"
>
<svg fill="currentColor" viewBox="0 0 20 20" class="w-4 h-4">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</button>
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-black text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Focus keeps offers shippable
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black"></div>
</div>
</div>
</div>
<p class="text-neutral-600 mb-6">
Choose the problems you can solve this month. We'll suggest time-boxed offers.
</p>
<div class="flex flex-wrap gap-3">
<div
v-for="problem in availableProblems"
:key="problem.id"
class="relative"
>
<button
@click="toggleProblem(problem.id)"
:disabled="!canSelectProblem(problem.id)"
:class="[
'px-4 py-2 text-sm rounded-lg border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
isProblemSelected(problem.id)
? 'bg-green-600 text-white border-green-600 hover:bg-green-700'
: canSelectProblem(problem.id)
? 'bg-white text-neutral-700 border-neutral-300 hover:border-green-400 hover:text-green-600'
: 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
]"
:aria-pressed="isProblemSelected(problem.id)"
:aria-label="`${isProblemSelected(problem.id) ? 'Remove' : 'Add'} ${problem.label} problem type`"
>
{{ problem.label }}
</button>
<!-- Examples popover trigger -->
<button
@click="toggleExamples(problem.id)"
@keydown.escape="hideExamples"
class="ml-1 text-xs text-neutral-500 hover:text-neutral-700 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
:aria-label="`See examples for ${problem.label}`"
:aria-expanded="showExamples === problem.id"
>
see examples
</button>
<!-- Examples popover -->
<div
v-if="showExamples === problem.id"
class="absolute z-10 mt-2 p-3 bg-white border-2 border-neutral-200 rounded-lg shadow-lg min-w-64 max-w-sm"
role="tooltip"
:aria-label="`Examples for ${problem.label}`"
>
<div class="text-sm">
<p class="font-medium text-black mb-2">Examples:</p>
<ul class="space-y-1 text-neutral-700">
<li v-for="example in problem.examples" :key="example" class="flex items-start">
<span class="text-neutral-400 mr-2"></span>
<span>{{ example }}</span>
</li>
</ul>
</div>
<button
@click="hideExamples"
class="mt-2 text-xs text-blue-600 hover:text-blue-800 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Close examples"
>
Close
</button>
</div>
</div>
</div>
<div class="mt-4 text-sm text-neutral-500">
{{ selectedProblems.length }}/2 problem types selected
</div>
</section>
<!-- Section C: Suggested offers -->
<section class="mb-8" aria-labelledby="offers-heading">
<h2 id="offers-heading" class="text-xl font-bold text-black mb-4">
C) Suggested offers
</h2>
<!-- Loading state -->
<div
v-if="loading"
class="text-center py-12 bg-white border-2 border-dashed border-blue-200 rounded-xl"
>
<div class="max-w-md mx-auto">
<div class="w-16 h-16 mx-auto mb-4 bg-blue-50 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<h3 class="font-medium text-blue-900 mb-2">Generating offers...</h3>
<p class="text-blue-700">
Creating personalized revenue suggestions based on your selections.
</p>
</div>
</div>
<!-- Empty state -->
<div
v-else-if="suggestedOffers.length === 0"
class="text-center py-12 bg-white border-2 border-dashed border-neutral-300 rounded-xl"
>
<div class="max-w-md mx-auto">
<div class="w-16 h-16 mx-auto mb-4 bg-neutral-100 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 class="font-medium text-neutral-900 mb-2">No offers yet</h3>
<p class="text-neutral-600 mb-4">
Pick a few skills and a problemwe'll suggest something you can sell this month.
</p>
<p class="text-sm text-neutral-500">
We need at least one shared skill and one problem type to suggest offers.
</p>
</div>
</div>
<!-- Offer cards -->
<div v-else class="grid gap-6 md:grid-cols-2">
<div
v-for="offer in suggestedOffers"
:key="offer.id"
class="p-6 bg-white border-2 border-neutral-200 rounded-xl shadow-sm hover:shadow-md transition-shadow"
role="article"
:aria-label="`Offer: ${offer.name}`"
>
<h3 class="font-bold text-black mb-3">{{ offer.name }}</h3>
<!-- Offer chips -->
<div class="flex flex-wrap gap-2 mb-4">
<span class="inline-flex items-center px-2 py-1 text-xs bg-green-50 text-green-700 border border-green-200 rounded-full">
Covers ~{{ calculateMonthlyCoverage(offer) }}% of monthly needs at baseline
</span>
<span class="inline-flex items-center px-2 py-1 text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded-full">
Typical payout: {{ getPayoutDaysRange(offer) }}
</span>
<span class="inline-flex items-center px-2 py-1 text-xs bg-purple-50 text-purple-700 border border-purple-200 rounded-full">
Why this
</span>
</div>
<!-- Scope -->
<div class="mb-4">
<p class="text-sm font-medium text-neutral-700 mb-2">Scope:</p>
<ul class="space-y-1">
<li
v-for="item in offer.scope"
:key="item"
class="text-sm text-neutral-600 flex items-start"
>
<span class="text-neutral-400 mr-2"></span>
<span>{{ item }}</span>
</li>
</ul>
</div>
<!-- Price range -->
<div class="mb-4 p-3 bg-neutral-50 rounded-lg">
<div class="flex justify-between items-center mb-1">
<span class="text-sm font-medium text-neutral-700">Baseline:</span>
<span class="font-bold text-black">${{ offer.price.baseline.toLocaleString() }}</span>
</div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-neutral-700">Stretch:</span>
<span class="font-bold text-green-600">${{ offer.price.stretch.toLocaleString() }}</span>
</div>
<p class="text-xs text-neutral-500">{{ offer.price.calcNote }}</p>
</div>
<!-- Payout delay -->
<div class="mb-4 flex items-center justify-between text-sm">
<span class="text-neutral-600">Payment timing:</span>
<span class="font-medium text-black">{{ offer.payoutDelayDays }} days</span>
</div>
<!-- Why this works -->
<div class="mb-4">
<p class="text-sm font-medium text-neutral-700 mb-2">Why this works for your co-op:</p>
<ul class="space-y-1">
<li
v-for="reason in offer.whyThis"
:key="reason"
class="text-sm text-neutral-600 flex items-start"
>
<span class="text-green-500 mr-2"></span>
<span>{{ updateLanguageToCoopTerms(reason) }}</span>
</li>
</ul>
</div>
<!-- Risk notes (if any) -->
<div v-if="offer.riskNotes.length > 0" class="border-t border-neutral-200 pt-3">
<p class="text-sm font-medium text-amber-700 mb-2">Consider:</p>
<ul class="space-y-1">
<li
v-for="risk in offer.riskNotes"
:key="risk"
class="text-sm text-amber-600 flex items-start"
>
<span class="text-amber-500 mr-2"></span>
<span>{{ risk }}</span>
</li>
</ul>
</div>
</div>
</div>
</section>
</div>
<!-- Sticky Footer -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-neutral-200 shadow-lg">
<div class="max-w-4xl mx-auto p-4">
<div class="flex items-center justify-between">
<button
@click="goBack"
class="px-4 py-2 text-neutral-700 hover:text-black focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-lg transition-colors"
aria-label="Go back to previous page"
>
Back
</button>
<div class="flex items-center gap-3">
<button
@click="regenerateOffers"
:disabled="!canRegenerate"
:class="[
'px-4 py-2 rounded-lg border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
canRegenerate
? 'border-neutral-300 text-neutral-700 hover:border-blue-400 hover:text-blue-600'
: 'border-neutral-200 text-neutral-400 cursor-not-allowed'
]"
:aria-label="canRegenerate ? 'Regenerate offers with current selections' : 'Cannot regenerate - select skills and problems first'"
>
🔄 Regenerate
</button>
<button
@click="useOffers"
:disabled="suggestedOffers.length === 0"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
suggestedOffers.length > 0
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-neutral-200 text-neutral-400 cursor-not-allowed'
]"
:aria-label="suggestedOffers.length > 0 ? 'Add these offers to cover co-op needs' : 'No offers to use - generate offers first'"
>
Add to plan
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
import { useDebounceFn } from "@vueuse/core";
// REMOVED: All sample data imports to prevent demo data
// Store integration
const planStore = usePlanStore();
// Initialize with empty data
const members = ref<Member[]>([]);
const availableSkills = ref<SkillTag[]>([]);
const availableProblems = ref<ProblemTag[]>([]);
// Set members in store on component mount
onMounted(() => {
planStore.setMembers(members.value);
});
// Reactive state
const selectedSkills = ref<Record<string, string[]>>({});
const selectedProblems = ref<string[]>([]);
const showExamples = ref<string | null>(null);
const offers = ref<Offer[] | null>(null);
const loading = ref(false);
// Use offer suggestor composable
const { suggestOffers } = useOfferSuggestor();
// Catalogs for the suggestor
const catalogs = computed(() => ({
skills: availableSkills.value,
problems: availableProblems.value
}));
// Computed for suggested offers (for backward compatibility)
const suggestedOffers = computed(() => offers.value || []);
// Helper functions for offer chips
function calculateMonthlyCoverage(offer: Offer): number {
// Estimate monthly burn (simplified calculation)
const totalMemberHours = members.value.reduce((sum, m) => sum + m.availableHrs, 0);
const avgHourlyRate = members.value.reduce((sum, m) => sum + m.hourly, 0) / members.value.length;
const estimatedMonthlyBurn = totalMemberHours * avgHourlyRate * 1.25; // Add on-costs
return Math.round((offer.price.baseline / estimatedMonthlyBurn) * 100);
}
function getPayoutDaysRange(offer: Offer): string {
const days = offer.payoutDelayDays;
if (days <= 15) return "015 days";
if (days <= 30) return "1530 days";
if (days <= 45) return "3045 days";
return `${days} days`;
}
function updateLanguageToCoopTerms(text: string): string {
return text
.replace(/maximize|maximiz/gi, 'cover needs with')
.replace(/optimize|optimiz/gi, 'improve')
.replace(/competitive advantage/gi, 'shared capacity')
.replace(/market position/gi, 'community standing')
.replace(/profit/gi, 'surplus')
.replace(/revenue growth/gi, 'sustainable income')
.replace(/scale/gi, 'grow together')
.replace(/efficiency gains/gi, 'reduce risk')
.replace(/leverages/gi, 'uses')
.replace(/expertise/gi, 'shared skills')
.replace(/builds reputation/gi, 'builds trust in community')
.replace(/high-impact/gi, 'meaningful')
.replace(/productivity/gi, 'shared capacity');
}
// Debounced offer generation
const debouncedGenerateOffers = useDebounceFn(async () => {
const hasSkills = Object.values(selectedSkills.value).some(skills => skills.length > 0);
const hasProblems = selectedProblems.value.length > 0;
if (!hasSkills || !hasProblems) {
offers.value = null;
return;
}
loading.value = true;
try {
const input = {
members: members.value,
selectedSkillsByMember: selectedSkills.value,
selectedProblems: selectedProblems.value
};
const suggestedOffers = suggestOffers(input, catalogs.value);
offers.value = suggestedOffers;
} catch (error) {
console.error('Failed to generate offers:', error);
offers.value = null;
} finally {
loading.value = false;
}
}, 300);
// Skill management
function toggleSkill(memberId: string, skillId: string) {
if (!selectedSkills.value[memberId]) {
selectedSkills.value[memberId] = [];
}
const memberSkills = selectedSkills.value[memberId];
const index = memberSkills.indexOf(skillId);
if (index >= 0) {
memberSkills.splice(index, 1);
} else {
memberSkills.push(skillId);
}
debouncedGenerateOffers();
}
function isSkillSelected(memberId: string, skillId: string): boolean {
return selectedSkills.value[memberId]?.includes(skillId) || false;
}
function canSelectSkill(memberId: string, skillId: string): boolean {
if (isSkillSelected(memberId, skillId)) return true;
return getSelectedSkillsCount(memberId) < 3;
}
function getSelectedSkillsCount(memberId: string): number {
return selectedSkills.value[memberId]?.length || 0;
}
// Problem management
function toggleProblem(problemId: string) {
const index = selectedProblems.value.indexOf(problemId);
if (index >= 0) {
selectedProblems.value.splice(index, 1);
} else {
selectedProblems.value.push(problemId);
}
debouncedGenerateOffers();
}
function isProblemSelected(problemId: string): boolean {
return selectedProblems.value.includes(problemId);
}
function canSelectProblem(problemId: string): boolean {
if (isProblemSelected(problemId)) return true;
return selectedProblems.value.length < 2;
}
// Examples popover
function toggleExamples(problemId: string) {
showExamples.value = showExamples.value === problemId ? null : problemId;
}
function hideExamples() {
showExamples.value = null;
}
// Footer actions
const canRegenerate = computed(() => {
const hasSkills = Object.values(selectedSkills.value).some(skills => skills.length > 0);
const hasProblems = selectedProblems.value.length > 0;
return hasSkills && hasProblems;
});
function goBack() {
// Navigate back - would typically use router
window.history.back();
}
function regenerateOffers() {
if (canRegenerate.value) {
// Re-call suggestOffers with same inputs
debouncedGenerateOffers();
}
}
function useOffers() {
if (offers.value && offers.value.length > 0) {
// Add offers to plan store as streams
planStore.addStreamsFromOffers(offers.value);
// Navigate back to wizard with success message
const router = useRouter();
// Show success notification
console.log(`Added ${offers.value.length} offers as revenue streams to your plan.`);
// Navigate to wizard revenue step - adjust path as needed for your routing
router.push('/wizards'); // This would need to be the correct wizard path
// Note: The Streams tab activation would be handled by the wizard component
// when it detects new streams in the store
}
}
function skipCoach() {
// Navigate directly to wizard streams without adding offers
const router = useRouter();
router.push('/wizards'); // Navigate to wizard - streams tab would be activated there
}
// Close examples on click outside
onMounted(() => {
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest('[role="tooltip"]') && !target.closest('button[aria-expanded]')) {
showExamples.value = null;
}
};
document.addEventListener('click', handleClickOutside);
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
});
</script>

View file

@ -7,7 +7,7 @@
<div class="mb-10 text-center"> <div class="mb-10 text-center">
<h1 <h1
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4"> class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4">
Co-op Builder Budget Builder
</h1> </h1>
</div> </div>
@ -57,7 +57,7 @@
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-neutral-500 overflow-hidden',
focusedStep === 1 ? 'item-selected' : '', focusedStep === 1 ? 'item-selected' : '',
]"> ]">
<div <div
@ -94,7 +94,7 @@
<div <div
v-if="focusedStep === 1" v-if="focusedStep === 1"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"> class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-neutral-500">
<WizardPoliciesStep @save-status="handleSaveStatus" /> <WizardPoliciesStep @save-status="handleSaveStatus" />
</div> </div>
</div> </div>
@ -109,7 +109,7 @@
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-neutral-500 overflow-hidden',
focusedStep === 2 ? 'item-selected' : '', focusedStep === 2 ? 'item-selected' : '',
]"> ]">
<div <div
@ -121,8 +121,8 @@
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2" class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class=" :class="
membersValid membersValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white' ? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white' : 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
"> ">
<UIcon <UIcon
v-if="membersValid" v-if="membersValid"
@ -146,7 +146,7 @@
<div <div
v-if="focusedStep === 2" v-if="focusedStep === 2"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"> class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-neutral-500">
<WizardMembersStep @save-status="handleSaveStatus" /> <WizardMembersStep @save-status="handleSaveStatus" />
</div> </div>
</div> </div>
@ -161,7 +161,7 @@
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-neutral-500 overflow-hidden',
focusedStep === 3 ? 'item-selected' : '', focusedStep === 3 ? 'item-selected' : '',
]"> ]">
<div <div
@ -173,8 +173,8 @@
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2" class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class=" :class="
costsValid costsValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white' ? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white' : 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
"> ">
<UIcon <UIcon
v-if="costsValid" v-if="costsValid"
@ -198,7 +198,7 @@
<div <div
v-if="focusedStep === 3" v-if="focusedStep === 3"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"> class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-neutral-500">
<WizardCostsStep @save-status="handleSaveStatus" /> <WizardCostsStep @save-status="handleSaveStatus" />
</div> </div>
</div> </div>
@ -213,7 +213,7 @@
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-neutral-500 overflow-hidden',
focusedStep === 4 ? 'item-selected' : '', focusedStep === 4 ? 'item-selected' : '',
]"> ]">
<div <div
@ -225,8 +225,8 @@
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2" class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class=" :class="
streamsValid streamsValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white' ? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white' : 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
"> ">
<UIcon <UIcon
v-if="streamsValid" v-if="streamsValid"
@ -250,7 +250,7 @@
<div <div
v-if="focusedStep === 4" v-if="focusedStep === 4"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"> class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-neutral-500">
<WizardRevenueStep @save-status="handleSaveStatus" /> <WizardRevenueStep @save-status="handleSaveStatus" />
</div> </div>
</div> </div>
@ -262,7 +262,7 @@
class="export-btn" class="export-btn"
@click="resetWizard" @click="resetWizard"
:disabled="isResetting"> :disabled="isResetting">
Start Over Clear Data
</button> </button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@ -289,16 +289,6 @@
> >
</div> </div>
<!-- View Dashboard button (when partially complete) -->
<button
v-if="hasBasicData && !canComplete"
class="export-btn"
@click="navigateTo('/dashboard')"
>
<UIcon name="i-heroicons-chart-bar" class="mr-2" />
View Dashboard
</button>
<UTooltip :text="incompleteSectionsText" :prevent="canComplete"> <UTooltip :text="incompleteSectionsText" :prevent="canComplete">
<button <button
class="export-btn primary" class="export-btn primary"
@ -445,7 +435,7 @@ async function restartWizard() {
// SEO // SEO
useSeoMeta({ useSeoMeta({
title: "Co-op Builder - Build Your Financial Foundation", title: "Budget Builder",
description: description:
"Build your co-op's financial foundation: set up members, policies, costs, and revenue streams.", "Build your co-op's financial foundation: set up members, policies, costs, and revenue streams.",
}); });

View file

@ -2,7 +2,7 @@
<div class="space-y-8"> <div class="space-y-8">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Dashboard</h1> <h1 class="text-2xl font-semibold">Dashboard</h1>
<div class="text-sm text-gray-600"> <div class="text-sm text-neutral-600">
Mode: {{ currentMode }} Mode: {{ currentMode }}
</div> </div>
</div> </div>
@ -15,15 +15,15 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="text-center"> <div class="text-center">
<div class="text-3xl font-bold text-green-600">{{ runwayDisplay }}</div> <div class="text-3xl font-bold text-green-600">{{ runwayDisplay }}</div>
<div class="text-sm text-gray-600">Runway</div> <div class="text-sm text-neutral-600">Runway</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-3xl font-bold text-blue-600">{{ coverageDisplay }}</div> <div class="text-3xl font-bold text-blue-600">{{ coverageDisplay }}</div>
<div class="text-sm text-gray-600">Coverage</div> <div class="text-sm text-neutral-600">Coverage</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-3xl font-bold text-purple-600">{{ streamCount }}</div> <div class="text-3xl font-bold text-purple-600">{{ streamCount }}</div>
<div class="text-sm text-gray-600">Revenue Streams</div> <div class="text-sm text-neutral-600">Revenue Streams</div>
</div> </div>
</div> </div>
</UCard> </UCard>
@ -34,11 +34,11 @@
<h3 class="text-lg font-medium">Members ({{ memberCount }})</h3> <h3 class="text-lg font-medium">Members ({{ memberCount }})</h3>
</template> </template>
<div class="space-y-2"> <div class="space-y-2">
<div v-for="(member, index) in membersList" :key="index" class="flex items-center justify-between p-2 border border-gray-200 rounded"> <div v-for="(member, index) in membersList" :key="index" class="flex items-center justify-between p-2 border border-neutral-200 rounded">
<span class="font-medium">{{ member.name }}</span> <span class="font-medium">{{ member.name }}</span>
<span class="text-sm text-gray-600">{{ member.relationship }}</span> <span class="text-sm text-neutral-600">{{ member.relationship }}</span>
</div> </div>
<div v-if="memberCount === 0" class="text-sm text-gray-500 italic p-4"> <div v-if="memberCount === 0" class="text-sm text-neutral-500 italic p-4">
No members configured yet. No members configured yet.
</div> </div>
</div> </div>

View file

@ -1,10 +1,10 @@
<template> <template>
<div class="min-h-screen bg-gray-50 py-8"> <div class="min-h-screen bg-neutral-50 py-8">
<div class="container mx-auto max-w-4xl px-4"> <div class="container mx-auto max-w-4xl px-4">
<!-- Header --> <!-- Header -->
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-4">Budget Planning Help</h1> <h1 class="text-4xl font-bold mb-4">Budget Planning Help</h1>
<p class="text-xl text-gray-600">Learn how to build a sustainable financial plan for your co-op or studio</p> <p class="text-xl text-neutral-600">Learn how to build a sustainable financial plan for your co-op or studio</p>
</div> </div>
<!-- Navigation --> <!-- Navigation -->
@ -15,19 +15,19 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<a href="#revenue-diversification" class="block p-3 bg-blue-50 border-2 border-blue-200 rounded hover:bg-blue-100 transition-colors"> <a href="#revenue-diversification" class="block p-3 bg-blue-50 border-2 border-blue-200 rounded hover:bg-blue-100 transition-colors">
<span class="font-semibold">Revenue Diversification</span> <span class="font-semibold">Revenue Diversification</span>
<p class="text-sm text-gray-600">How to develop multiple income streams</p> <p class="text-sm text-neutral-600">How to develop multiple income streams</p>
</a> </a>
<a href="#budget-categories" class="block p-3 bg-green-50 border-2 border-green-200 rounded hover:bg-green-100 transition-colors"> <a href="#budget-categories" class="block p-3 bg-green-50 border-2 border-green-200 rounded hover:bg-green-100 transition-colors">
<span class="font-semibold">Budget Categories</span> <span class="font-semibold">Budget Categories</span>
<p class="text-sm text-gray-600">Understanding revenue and expense types</p> <p class="text-sm text-neutral-600">Understanding revenue and expense types</p>
</a> </a>
<a href="#planning-tips" class="block p-3 bg-yellow-50 border-2 border-yellow-200 rounded hover:bg-yellow-100 transition-colors"> <a href="#planning-tips" class="block p-3 bg-yellow-50 border-2 border-yellow-200 rounded hover:bg-yellow-100 transition-colors">
<span class="font-semibold">Planning Tips</span> <span class="font-semibold">Planning Tips</span>
<p class="text-sm text-gray-600">Best practices for financial planning</p> <p class="text-sm text-neutral-600">Best practices for financial planning</p>
</a> </a>
<a href="#getting-started" class="block p-3 bg-purple-50 border-2 border-purple-200 rounded hover:bg-purple-100 transition-colors"> <a href="#getting-started" class="block p-3 bg-purple-50 border-2 border-purple-200 rounded hover:bg-purple-100 transition-colors">
<span class="font-semibold">Getting Started</span> <span class="font-semibold">Getting Started</span>
<p class="text-sm text-gray-600">Step-by-step setup guide</p> <p class="text-sm text-neutral-600">Step-by-step setup guide</p>
</a> </a>
</div> </div>
</div> </div>

View file

@ -2,7 +2,7 @@
<div class="space-y-8"> <div class="space-y-8">
<div class="text-center"> <div class="text-center">
<h1 class="text-3xl font-bold mb-4">Project Budget Estimate</h1> <h1 class="text-3xl font-bold mb-4">Project Budget Estimate</h1>
<p class="text-gray-600 max-w-2xl mx-auto mb-4"> <p class="text-neutral-600 max-w-2xl mx-auto mb-4">
Get a quick estimate of what it would cost to build your project with fair pay. Get a quick estimate of what it would cost to build your project with fair pay.
This tool helps worker co-ops sketch project budgets and break-even scenarios. This tool helps worker co-ops sketch project budgets and break-even scenarios.
</p> </p>
@ -18,10 +18,10 @@
</div> </div>
<div v-if="membersWithPay.length === 0" class="text-center py-8"> <div v-if="membersWithPay.length === 0" class="text-center py-8">
<p class="text-gray-600 mb-4">No team members set up yet.</p> <p class="text-neutral-600 mb-4">No team members set up yet.</p>
<NuxtLink <NuxtLink
to="/coop-builder" to="/coop-builder"
class="px-4 py-2 border-2 border-black bg-white font-bold hover:bg-gray-100" class="px-4 py-2 border-2 border-black bg-white font-bold hover:bg-neutral-100"
> >
Set up your team in Setup Wizard Set up your team in Setup Wizard
</NuxtLink> </NuxtLink>

View file

@ -16,57 +16,30 @@
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 <h1
class="text-3xl md:text-5xl font-bold uppercase m-0 py-4 border-t-2 border-b-2 text-black dark:text-white border-black dark:border-white" class="text-3xl md:text-5xl font-bold uppercase m-0 py-4 border-t-2 border-b-2 text-black dark:text-white border-black dark:border-white"
:data-org-name="formData.orgName || 'Organization'"> :data-org-name="getOrgName()">
CONFLICT RESOLUTION CONFLICT RESOLUTION
</h1> </h1>
</div> </div>
<!-- Section 1: Organization Information --> <!-- Section 1: Cooperative Information -->
<div class="section-card"> <div class="section-card">
<h2 class="section-title">1. Organization Information</h2>
<div class="space-y-6"> <div class="space-y-6">
<UFormField label="Organization Name" class="form-group-large"> <UFormField label="Cooperative Name" class="form-group-large">
<UInput <UInput
v-model="formData.orgName" v-model="formData.orgName"
placeholder="Enter your organization name" placeholder="Enter your cooperative name"
size="xl" size="xl"
class="w-full" class="w-full"
:error="validationErrors.orgName" :error="validationErrors.orgName"
@input="debouncedAutoSave" /> @input="debouncedAutoSave" />
</UFormField> </UFormField>
<div class="flex flex-row gap-4 space-x-4">
<UFormField label="Organization Type" class="form-group-large">
<USelect
v-model="formData.orgType"
:items="orgTypeOptions"
placeholder="Select organization type..."
size="xl"
class="w-full"
:error="validationErrors.orgType"
@change="autoSave" />
</UFormField>
<UFormField
label="Number of Members/Staff"
class="form-group-large">
<UInput
v-model="formData.memberCount"
type="number"
min="2"
placeholder="e.g., 5"
size="xl"
:error="validationErrors.memberCount"
@change="autoSave" />
</UFormField>
</div>
</div> </div>
</div> </div>
<!-- Section 2: Core Values --> <!-- Section 2: Core Values -->
<div class="section-card"> <div class="section-card">
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<h2 class="section-title">2. Guiding Principles & Values</h2> <h2 class="section-title">2. Values</h2>
<div class="flex flex-row gap-2 items-center no-print no-pdf"> <div class="flex flex-row gap-2 items-center no-print no-pdf">
<USwitch <USwitch
v-model="sectionsEnabled.values" v-model="sectionsEnabled.values"
@ -80,7 +53,7 @@
<div class="space-y-6" v-show="sectionsEnabled.values"> <div class="space-y-6" v-show="sectionsEnabled.values">
<UFormField <UFormField
label="Select Core Values (check all that apply)" label="Select core values (check all that apply)"
class="form-group-large"> class="form-group-large">
<div class="values-grid"> <div class="values-grid">
<div <div
@ -110,12 +83,12 @@
</UFormField> </UFormField>
<UFormField <UFormField
label="Additional Values or Principles" label="Additional values or principles"
class="form-group-large"> class="form-group-large">
<UTextarea <UTextarea
v-model="formData.customValues" v-model="formData.customValues"
:rows="3" :rows="3"
placeholder="Add any additional values specific to your organization..." placeholder="Add any additional values specific to your cooperative..."
size="xl" size="xl"
class="w-full" class="w-full"
@input="debouncedAutoSave" /> @input="debouncedAutoSave" />
@ -230,13 +203,14 @@
</UFormField> </UFormField>
<UFormField <UFormField
label="Mediator/Facilitator Structure" label="Mediator/facilitator structure"
class="form-group-large"> class="form-group-large">
<USelect <USelect
v-model="formData.mediatorType" v-model="formData.mediatorType"
:items="mediatorTypeOptions" :items="mediatorTypeOptions"
placeholder="Select mediator structure..." placeholder="Select mediator structure..."
size="xl" size="xl"
class="w-full"
:error="validationErrors.mediatorType" :error="validationErrors.mediatorType"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
@ -381,7 +355,7 @@
<div class="space-y-6"> <div class="space-y-6">
<UFormField <UFormField
label="Available Actions (check all that apply)" label="Available actions (check all that apply)"
class="form-group-large"> class="form-group-large">
<div class="checkbox-group mt-4 space-y-3"> <div class="checkbox-group mt-4 space-y-3">
<div <div
@ -484,7 +458,7 @@
v-model="formData.training" v-model="formData.training"
:rows="3" :rows="3"
class="w-full" class="w-full"
placeholder="Describe any training needed for members, facilitators, or committee members..." placeholder="Describe any training needed for member-workers, facilitators, or committee members..."
size="xl" size="xl"
@input="debouncedAutoSave" /> @input="debouncedAutoSave" />
</UFormField> </UFormField>
@ -672,18 +646,18 @@
</UFormField> </UFormField>
<UFormField <UFormField
label="Staff Liaison for Conflict Resolution Committee" label="Member Liaison for Conflict Resolution Committee"
class="form-group-large"> class="form-group-large">
<UInput <UInput
v-model="formData.staffLiaison" v-model="formData.staffLiaison"
placeholder="Title/role of designated staff liaison" placeholder="Title/role of designated member liaison"
size="xl" size="xl"
class="w-full md:w-1/2" class="w-full md:w-1/2"
@input="debouncedAutoSave" /> @input="debouncedAutoSave" />
</UFormField> </UFormField>
<UFormField <UFormField
label="Board Chair Role in Conflict Resolution" label="Elected Board Chair Role in Conflict Resolution"
class="form-group-large"> class="form-group-large">
<USelect <USelect
v-model="formData.boardChairRole" v-model="formData.boardChairRole"
@ -762,7 +736,7 @@
v-model="formData.requireExternalAdvice" v-model="formData.requireExternalAdvice"
id="require-external-advice" id="require-external-advice"
label="Require external legal advice for complex complaints" label="Require external legal advice for complex complaints"
help="Seek external expertise for multi-party or staff/director complaints" help="Seek external expertise for multi-party or member-coordinator complaints"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
</div> </div>
@ -893,7 +867,10 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, computed, onMounted } from "vue"; import { ref, watch, computed } from "vue";
// Import centralized coop info
const { coopInfo, updateCoopInfo, getOrgName } = useCoopInfo();
definePageMeta({ definePageMeta({
layout: false, layout: false,
@ -910,21 +887,7 @@ useHead({
], ],
}); });
// Import PDF export composable
const { exportToPDF } = usePdfExportBasic();
const showPreview = ref(false); const showPreview = ref(false);
const copySuccess = ref(false);
// Options for dropdowns (using simple string arrays like the working membership template)
const orgTypeOptions = [
"Worker Cooperative",
"Consumer Cooperative",
"Nonprofit",
"Collective",
"Community Group",
"Other",
];
const approachOptions = [ const approachOptions = [
{ {
@ -1011,17 +974,17 @@ const reflectionPeriodOptions = [
]; ];
const internalAdvisorOptions = [ const internalAdvisorOptions = [
"Single Board-appointed advisor", "Single elected advisor",
"Rotating Board members", "Rotating member representatives",
"External neutral advisor", "External neutral advisor",
"Committee-designated advisor", "Committee-designated advisor",
"Staff member with training", "Trained member facilitator",
]; ];
const boardChairRoleOptions = [ const boardChairRoleOptions = [
"First contact for ED complaints", "First contact for coordinator complaints",
"Appeals reviewer", "Appeals reviewer",
"Final decision maker", "Participates in collective decision",
"Advisory role only", "Advisory role only",
"Not involved in conflicts", "Not involved in conflicts",
]; ];
@ -1086,21 +1049,23 @@ const coreValues = ref([
]); ]);
const conflictTypes = ref([ const conflictTypes = ref([
{ label: "Interpersonal disputes between members", checked: true }, { label: "Interpersonal disputes between member-workers", checked: true },
{ label: "Code of Conduct violations", checked: true }, { label: "Code of Conduct violations", checked: true },
{ label: "Work allocation and responsibility disagreements", checked: true },
{ label: "Decision-making process conflicts", checked: true },
{ label: "Harassment or discrimination", checked: false }, { label: "Harassment or discrimination", checked: false },
{ label: "Work performance issues", checked: false }, { label: "Member-owner responsibility disputes", checked: false },
{ label: "Conflicts of interest", checked: false }, { label: "Collective ownership tensions", checked: false },
{ label: "External organization disputes", checked: false }, { label: "External organization disputes", checked: false },
{ label: "Financial disagreements", checked: false }, { label: "Financial disagreements", checked: false },
]); ]);
const reportReceivers = ref([ const reportReceivers = ref([
{ label: "Designated conflict resolution committee", checked: true }, { label: "Designated conflict resolution committee", checked: true },
{ label: "Any board member", checked: false }, { label: "Any elected board member", checked: false },
{ label: "Executive Director(s)", checked: false }, { label: "Administrative Coordinator(s)", checked: false },
{ label: "Designated staff liaison", checked: false }, { label: "Designated member liaison", checked: false },
{ label: "Any member", checked: false }, { label: "Any member-worker", checked: false },
]); ]);
const processSteps = ref([ const processSteps = ref([
@ -1121,7 +1086,7 @@ const availableActions = ref([
{ label: "Temporary suspension", checked: true }, { label: "Temporary suspension", checked: true },
{ label: "Role/responsibility changes", checked: false }, { label: "Role/responsibility changes", checked: false },
{ label: "Mediated agreement", checked: false }, { label: "Mediated agreement", checked: false },
{ label: "Removal from organization", checked: true }, { label: "Removal from the cooperative", checked: true },
{ label: "Restorative circle/process", checked: false }, { label: "Restorative circle/process", checked: false },
]); ]);
@ -1165,7 +1130,7 @@ const formData = ref({
documentDirectResolution: true, documentDirectResolution: true,
internalAdvisorType: "Single Board-appointed advisor", internalAdvisorType: "Single Board-appointed advisor",
staffLiaison: "", staffLiaison: "",
boardChairRole: "First contact for ED complaints", boardChairRole: "First contact for coordinator complaints",
formalAcknowledgmentTime: "Within 1 week", formalAcknowledgmentTime: "Within 1 week",
formalReviewTime: "1 month", formalReviewTime: "1 month",
requireExternalAdvice: true, requireExternalAdvice: true,
@ -1180,165 +1145,12 @@ const formData = ref({
// Validation logic // Validation logic
const validationErrors = ref({}); const validationErrors = ref({});
const validateForm = () => {
const errors = {};
// Required text fields
if (!formData.value.orgName?.trim()) {
errors.orgName = "Organization name is required";
}
if (!formData.value.orgType?.trim()) {
errors.orgType = "Organization type is required";
}
if (!formData.value.memberCount?.toString().trim()) {
errors.memberCount = "Number of members/staff is required";
}
if (!formData.value.approach?.trim()) {
errors.approach = "Primary resolution approach is required";
}
if (!formData.value.mediatorType?.trim()) {
errors.mediatorType = "Mediator/facilitator structure is required";
}
if (!formData.value.initialResponse?.trim()) {
errors.initialResponse = "Initial response time is required";
}
if (!formData.value.resolutionTarget?.trim()) {
errors.resolutionTarget = "Target resolution time is required";
}
if (!formData.value.reviewSchedule?.trim()) {
errors.reviewSchedule = "Policy review schedule is required";
}
if (!formData.value.amendments?.trim()) {
errors.amendments = "Amendment process is required";
}
// Required checkbox groups (must have at least one checked)
const checkedConflictTypes = conflictTypes.value.filter(
(item) => item.checked
);
if (checkedConflictTypes.length === 0) {
errors.conflictTypes = "Please select at least one type of conflict";
}
// Note: Guiding Principles & Values section is optional - no validation needed
const checkedReportReceivers = reportReceivers.value.filter(
(item) => item.checked
);
if (checkedReportReceivers.length === 0) {
errors.reportReceivers = "Please select at least one report receiver";
}
const checkedProcessSteps = processSteps.value.filter((item) => item.checked);
if (checkedProcessSteps.length === 0) {
errors.processSteps = "Please select at least one process step";
}
const checkedAvailableActions = availableActions.value.filter(
(item) => item.checked
);
if (checkedAvailableActions.length === 0) {
errors.availableActions = "Please select at least one available action";
}
// Note: Special circumstances section is optional - no validation needed
validationErrors.value = errors;
const isValid = Object.keys(errors).length === 0;
// Provide user feedback
if (isValid) {
alert("✅ Form is complete and ready for export!");
} else {
const errorCount = Object.keys(errors).length;
alert(
`❌ Please complete ${errorCount} required field${
errorCount > 1 ? "s" : ""
} before exporting.`
);
}
return isValid;
};
// Completion percentage computation
const completionPercentage = computed(() => {
const allInputs = [
formData.value.orgName,
formData.value.orgType,
formData.value.memberCount,
formData.value.approach,
formData.value.mediatorType,
formData.value.initialResponse,
formData.value.resolutionTarget,
formData.value.reviewSchedule,
formData.value.amendments,
];
const checkboxInputs = [
...coreValues.value,
...conflictTypes.value,
...reportReceivers.value,
...processSteps.value,
...availableActions.value,
...specialCircumstances.value,
];
const filledInputs = allInputs.filter(
(val) => val && val.toString().trim() !== ""
).length;
const checkedBoxes = checkboxInputs.filter((item) => item.checked).length;
const totalFields = allInputs.length + checkboxInputs.length;
const completedFields = filledInputs + checkedBoxes;
return Math.round((completedFields / totalFields) * 100);
});
// Load saved data
const loadSavedData = () => {
if (process.client) {
const saved = localStorage.getItem("conflict-resolution-framework-data");
if (saved) {
try {
const parsedData = JSON.parse(saved);
// Load form data
if (parsedData.formData) {
formData.value = { ...formData.value, ...parsedData.formData };
}
// Load checkbox arrays
if (parsedData.coreValues) coreValues.value = parsedData.coreValues;
if (parsedData.conflictTypes)
conflictTypes.value = parsedData.conflictTypes;
if (parsedData.reportReceivers)
reportReceivers.value = parsedData.reportReceivers;
if (parsedData.processSteps)
processSteps.value = parsedData.processSteps;
if (parsedData.availableActions)
availableActions.value = parsedData.availableActions;
if (parsedData.specialCircumstances)
specialCircumstances.value = parsedData.specialCircumstances;
if (parsedData.communicationChannels)
communicationChannels.value = parsedData.communicationChannels;
if (parsedData.formalComplaintElements)
formalComplaintElements.value = parsedData.formalComplaintElements;
if (parsedData.sectionsEnabled)
sectionsEnabled.value = parsedData.sectionsEnabled;
} catch (error) {
console.error("Error loading saved data:", error);
}
}
}
};
// Auto-save functionality // Auto-save functionality
const autoSave = () => { const autoSave = () => {
// Clear validation errors when users start correcting fields // Clear validation errors when users start correcting fields
clearValidationErrors(); clearValidationErrors();
if (process.client) { if (typeof window !== "undefined") {
const dataToSave = { const dataToSave = {
formData: formData.value, formData: formData.value,
coreValues: coreValues.value, coreValues: coreValues.value,
@ -1380,7 +1192,7 @@ const markdownToHtml = (markdown) => {
.replace(/<\/li>\s*<ul>/g, "</li>") .replace(/<\/li>\s*<ul>/g, "</li>")
.replace(/<\/ul>\s*<li>/g, "<li>") .replace(/<\/ul>\s*<li>/g, "<li>")
// Tables (basic support) // Tables (basic support)
.replace(/^\|(.+)\|$/gm, (match, content) => { .replace(/^\|(.+)\|$/gm, (_, content) => {
const cells = content.split("|").map((cell) => cell.trim()); const cells = content.split("|").map((cell) => cell.trim());
if (cells.every((cell) => cell.match(/^-+$/))) { if (cells.every((cell) => cell.match(/^-+$/))) {
return ""; // Skip separator rows return ""; // Skip separator rows
@ -1429,6 +1241,27 @@ function debounce(func, wait) {
}; };
} }
// Sync with centralized coop info
watch(
() => coopInfo.value,
(newCoopInfo) => {
if (newCoopInfo.cooperativeName) {
formData.value.orgName = newCoopInfo.cooperativeName;
}
},
{ deep: true, immediate: true }
);
// Update centralized store when org name changes
watch(
() => formData.value.orgName,
(newOrgName) => {
if (newOrgName && newOrgName !== coopInfo.value.cooperativeName) {
updateCoopInfo({ cooperativeName: newOrgName });
}
}
);
// Watch for changes and auto-save // Watch for changes and auto-save
watch( watch(
[ [
@ -1447,29 +1280,309 @@ watch(
{ deep: true } { deep: true }
); );
// Export data for the ExportOptions component // Generate the complete policy document for preview and export
const exportData = computed(() => ({ const generatePolicyDocument = () => {
formData: formData.value, const cooperativeName = formData.value.orgName || "[Cooperative Name]";
orgName: formData.value.orgName || "Organization", let content = `# ${cooperativeName} Conflict Resolution Policy\n\n`;
orgType: formData.value.orgType,
memberCount: formData.value.memberCount, content += `*Framework Created: ${
sectionsEnabled: sectionsEnabled.value, formData.value.createdDate || new Date().toISOString().split("T")[0]
coreValues: formData.value.coreValues, }*\n`;
principles: formData.value.principles, if (formData.value.reviewDate) {
policies: { content += `*Next Review: ${formData.value.reviewDate}*\n`;
memberInvolvement: formData.value.memberInvolvement, }
communicationGuidelines: formData.value.communicationGuidelines, content += `\n---\n\n`;
processSteps: formData.value.processSteps,
escalationCriteria: formData.value.escalationCriteria, // Core Values section (if enabled)
mediation: formData.value.mediation, if (sectionsEnabled.value.values) {
finalDecision: formData.value.finalDecision, content += `## Our Values\n\n`;
learning: formData.value.learning, content += `This conflict resolution framework is guided by our core values:\n\n`;
emergencyProcedures: formData.value.emergencyProcedures,
annualReview: formData.value.annualReview, const selectedValues = coreValues.value.filter((v) => v.checked);
}, if (selectedValues.length > 0) {
exportedAt: new Date().toISOString(), selectedValues.forEach((value) => {
section: "conflict-resolution-framework", content += `- **${value.label}**\n`;
})); });
content += `\n`;
}
if (formData.value.customValues) {
content += `${formData.value.customValues}\n\n`;
}
}
// Resolution Philosophy
const approachDescriptions = {
restorative:
"We use a **restorative/loving justice** approach that focuses on healing, understanding root causes, and repairing relationships rather than punishment.",
mediation:
"We use a **mediation-first** approach where neutral third-party facilitators help parties dialogue and find solutions.",
progressive:
"We use **progressive discipline** with clear escalation steps and defined consequences for violations.",
hybrid:
"We use a **hybrid approach** that combines multiple methods based on the type and severity of conflict.",
};
if (
formData.value.approach &&
approachDescriptions[formData.value.approach]
) {
content += `## Our Approach\n\n`;
content += `${approachDescriptions[formData.value.approach]}\n\n`;
content += `We do our best to resolve conflicts at the lowest possible escalation step (direct resolution), but agree to escalate conflicts (to assisted resolution) if they are not resolved.\n\n`;
}
// Reflection Process (if enabled)
if (sectionsEnabled.value.reflection) {
content += `## Reflection\n\n`;
content += `Before engaging in direct resolution, we encourage taking time for reflection:\n\n`;
content += `1. **Set aside time to think** through what happened. What was the other person's behaviour? How did it affect you? *Distinguish other people's **actions** from your **feelings** about them.*\n`;
content += `2. **Consider uncertainties** or misunderstandings that may have occurred.\n`;
content += `3. **Distinguish disagreement from personal hostility.** Disagreement and dissent are part of healthy discussion. Hostility is not.\n`;
content += `4. **Use your personal support system** (friends, family, therapist, etc.) to work through and clarify your perspective.\n`;
content += `5. **Ask yourself** what part you played, how you could have behaved differently, and what your needs are.\n\n`;
if (formData.value.customReflectionPrompts) {
content += `### Additional Reflection Prompts\n\n`;
content += `${formData.value.customReflectionPrompts}\n\n`;
}
const reflectionTiming =
formData.value.reflectionPeriod || "Before any escalation";
content += `**Reflection Timing:** ${reflectionTiming}\n\n`;
}
// Direct Resolution (if enabled)
if (sectionsEnabled.value.directResolution) {
content += `## Direct Resolution\n\n`;
content += `A *direct resolution* process occurs when individuals communicate their concerns and work together to resolve disputes without filing an informal or formal complaint.\n\n`;
content += `### Have a Conversation\n\n`;
content += `When there is a disagreement, the involved people should first **communicate with each other** about their concerns.\n\n`;
content += `1. **Choose a time and place** to meet that is private and agreeable to both.\n`;
content += `2. **Allow reasonable time** for the conversation.\n`;
content += `3. **The point is mutual understanding**, not determining who is right or wrong. This requires patience and willingness to listen without immediately dismissing the other person's perspective.\n`;
content += `4. **Express thoughts and feelings directly** without belittling or dismissing. Use "I" statements and active listening techniques.\n`;
content += `5. **Communicate your wants and needs** and make offers and requests.\n`;
content += `6. **Learn for the future.** Ask questions like, "If what I/you said or did came across that way, what can we do to prevent this from happening in the future?"\n`;
if (formData.value.documentDirectResolution) {
content += `7. **Keep a written record** of the resolution agreed to by both parties.\n\n`;
} else {
content += `\n`;
}
// Communication Channels
const selectedChannels = communicationChannels.value.filter(
(c) => c.checked
);
if (selectedChannels.length > 0) {
content += `### Escalating Communication Bandwidth\n\n`;
content += `Whenever a misunderstanding or conflict arises, **escalate the bandwidth of the channel**:\n\n`;
selectedChannels.forEach((channel, index) => {
content += `${index + 1}. ${channel.label}\n`;
});
content += `\n`;
}
if (formData.value.requireDirectAttempt) {
content += `> **Note:** Direct resolution must be attempted before escalating to assisted resolution, unless safety concerns prevent this.\n\n`;
}
}
// Assisted Resolution
content += `## Assisted Resolution\n\n`;
content += `If talking things out doesn't work, you can ask a responsible contact person for help in writing.\n\n`;
// Responsible Contact People
const selectedReceivers = reportReceivers.value.filter((r) => r.checked);
if (selectedReceivers.length > 0) {
content += `### Initial Contact Options\n\n`;
content += `You can report conflicts to any of the following:\n\n`;
selectedReceivers.forEach((receiver) => {
content += `- ${receiver.label}\n`;
});
content += `\n`;
}
// Mediator Structure
if (formData.value.mediatorType) {
content += `### Mediation/Facilitation\n\n`;
content += `**Structure:** ${formData.value.mediatorType}\n\n`;
if (formData.value.supportPeople) {
content += `**Support People:** Parties may bring a trusted person for emotional support during mediation sessions.\n\n`;
}
}
// Timeline
content += `### Response Times\n\n`;
if (formData.value.initialResponse) {
content += `- **Initial Response:** ${formData.value.initialResponse}\n`;
}
if (formData.value.resolutionTarget) {
content += `- **Target Resolution:** ${formData.value.resolutionTarget}\n\n`;
}
// Formal Complaints
content += `## Formal Complaints\n\n`;
content += `If assisted resolution efforts do not result in an acceptable outcome within a reasonable timeframe, a *formal complaint* may be filed in writing.\n\n`;
// Required Elements
const selectedElements = formalComplaintElements.value.filter(
(e) => e.checked
);
if (selectedElements.length > 0) {
content += `### Written Complaint Requirements\n\n`;
content += `The formal complaint must include:\n\n`;
selectedElements.forEach((element, index) => {
content += `${index + 1}. ${element.label}\n`;
});
content += `\n`;
}
// Formal Process Timeline
content += `### Formal Process Timeline\n\n`;
if (formData.value.formalAcknowledgmentTime) {
content += `- **Acknowledgment:** ${formData.value.formalAcknowledgmentTime}\n`;
}
if (formData.value.formalReviewTime) {
content += `- **Review Completion:** ${formData.value.formalReviewTime}\n\n`;
}
if (formData.value.requireExternalAdvice) {
content += `> **External Expertise:** For complex complaints involving multiple parties or organizational leaders, external legal advice will be sought.\n\n`;
}
// Settlement Documentation
if (formData.value.requireMinutesOfSettlement) {
content += `### Reaching Agreement\n\n`;
content += `Any resolution agreed upon must be documented in "Minutes of Settlement" signed by both parties. These agreements will be kept confidential according to our privacy standards.\n\n`;
}
// Consequences and Actions
const selectedActions = availableActions.value.filter((a) => a.checked);
if (selectedActions.length > 0) {
content += `## Possible Outcomes\n\n`;
content += `Depending on the situation, resolution may include:\n\n`;
selectedActions.forEach((action) => {
content += `- ${action.label}\n`;
});
content += `\n`;
}
if (formData.value.appealProcess) {
content += `### Appeals Process\n\n`;
content += `Parties may request review of decisions through our appeals process.\n\n`;
}
// Documentation and Privacy
if (sectionsEnabled.value.documentation) {
content += `## Documentation & Privacy\n\n`;
if (formData.value.docLevel) {
content += `**Documentation Level:** ${formData.value.docLevel}\n\n`;
}
if (formData.value.confidentiality) {
content += `**Confidentiality:** ${formData.value.confidentiality}\n\n`;
}
if (formData.value.retention) {
content += `**Record Retention:** ${formData.value.retention}\n\n`;
}
}
// External Resources (if enabled)
if (sectionsEnabled.value.externalResources) {
content += `## External Resources\n\n`;
if (formData.value.includeHumanRights) {
content += `Individuals who are not satisfied with the outcome of a harassment or discrimination complaint may file a complaint with the [Canadian Human Rights Commission](https://www.chrc-ccdp.gc.ca/eng) or their provincial human rights tribunal.\n\n`;
}
if (formData.value.additionalResources) {
content += `### Additional Resources\n\n`;
content += `${formData.value.additionalResources}\n\n`;
}
}
// Implementation
content += `## Policy Management\n\n`;
if (formData.value.training) {
content += `### Training Requirements\n\n`;
content += `${formData.value.training}\n\n`;
}
content += `### Review and Updates\n\n`;
if (formData.value.reviewSchedule) {
content += `This policy will be reviewed ${formData.value.reviewSchedule.toLowerCase()}.\n\n`;
}
if (formData.value.amendments) {
content += `**Amendment Process:** ${formData.value.amendments}\n\n`;
}
// Acknowledgments
if (formData.value.acknowledgments) {
content += `### Acknowledgments\n\n`;
content += `${formData.value.acknowledgments}\n\n`;
}
return content;
};
// Export data for the ExportOptions component - structured to match ExportOptions expectations
const exportData = computed(() => {
// Get selected values for arrays
const selectedCoreValues = coreValues.value
.filter((v) => v.checked)
.map((v) => v.label);
const selectedConflictTypes = conflictTypes.value
.filter((c) => c.checked)
.map((c) => c.label);
const selectedProcessSteps = processSteps.value
.filter((s) => s.checked)
.map((s) => s.label);
const selectedActions = availableActions.value
.filter((a) => a.checked)
.map((a) => a.label);
const selectedReceivers = reportReceivers.value
.filter((r) => r.checked)
.map((r) => r.label);
const selectedChannels = communicationChannels.value
.filter((c) => c.checked)
.map((c) => c.label);
const selectedComplaintElements = formalComplaintElements.value
.filter((e) => e.checked)
.map((e) => e.label);
const selectedCircumstances = specialCircumstances.value
.filter((c) => c.checked)
.map((c) => c.label);
return {
section: "conflict-resolution-framework",
// Enhanced formData with processed arrays
formData: {
...formData.value,
// Add processed arrays as lists for the formatter
coreValuesList: selectedCoreValues,
conflictTypesList: selectedConflictTypes,
processStepsList: selectedProcessSteps,
actionsList: selectedActions,
receiversList: selectedReceivers,
channelsList: selectedChannels,
complaintElementsList: selectedComplaintElements,
circumstancesList: selectedCircumstances,
},
sectionsEnabled: sectionsEnabled.value,
reportReceivers: reportReceivers.value,
coreValues: coreValues.value,
conflictTypes: conflictTypes.value,
processSteps: processSteps.value,
availableActions: availableActions.value,
specialCircumstances: specialCircumstances.value,
communicationChannels: communicationChannels.value,
formalComplaintElements: formalComplaintElements.value,
exportedAt: new Date().toISOString(),
};
});
</script> </script>
<style scoped> <style scoped>

View file

@ -15,9 +15,7 @@
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 <h1
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100" class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100"
:data-coop-name=" :data-coop-name="getDisplayName()">
formData.cooperativeName || 'Worker Cooperative'
">
MEMBERSHIP AGREEMENT MEMBERSHIP AGREEMENT
</h1> </h1>
</div> </div>
@ -172,7 +170,7 @@
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
Any person who: Any person who:
</p> </p>
<UFormField label="Member Requirements" class="form-group-large"> <UFormField class="form-group-large">
<UTextarea <UTextarea
v-model="formData.memberRequirements" v-model="formData.memberRequirements"
:rows="4" :rows="4"
@ -193,7 +191,7 @@
<p class="content-paragraph"> <p class="content-paragraph">
New members join through a consent process, which means New members join through a consent process, which means
existing members must agree that adding this person won't harm existing members must agree that adding this person won't harm
the cooperative. {{ getDisplayName().toLowerCase() }}.
</p> </p>
<ol class="content-list numbered my-2 pl-6 list-decimal"> <ol class="content-list numbered my-2 pl-6 list-decimal">
@ -203,18 +201,17 @@
v-model="formData.trialPeriodMonths" v-model="formData.trialPeriodMonths"
type="number" type="number"
placeholder="3" placeholder="3"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
months working together months working together
</li> </li>
<li>Values alignment conversation</li> <li>Values alignment conversation</li>
<li>Consent decision by current members</li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Optional - Equal buy-in contribution of $<UInput Optional - Equal buy-in contribution of $<UInput
v-model="formData.buyInAmount" v-model="formData.buyInAmount"
type="number" type="number"
placeholder="1000" placeholder="1000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
(can be paid over time or waived based on need) (can be paid over time or waived based on need)
</li> </li>
@ -234,7 +231,7 @@
v-model="formData.noticeDays" v-model="formData.noticeDays"
type="number" type="number"
placeholder="30" placeholder="30"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
days notice. The cooperative will: days notice. The cooperative will:
</p> </p>
@ -246,7 +243,7 @@
v-model="formData.surplusPayoutDays" v-model="formData.surplusPayoutDays"
type="number" type="number"
placeholder="30" placeholder="30"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
days days
</li> </li>
@ -256,7 +253,7 @@
v-model="formData.buyInReturnDays" v-model="formData.buyInReturnDays"
type="number" type="number"
placeholder="90" placeholder="90"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
days days
</li> </li>
@ -276,16 +273,29 @@
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<!-- Decision Framework Selection -->
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"> class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
Consent-Based Decisions Primary Decision Framework
</h3> </h3>
<UFormField class="form-group-large mb-4">
<USelect
v-model="formData.decisionFramework"
:items="decisionFrameworkOptions"
placeholder="Select decision framework"
size="xl"
class="w-full"
@change="autoSave" />
</UFormField>
</div>
<div>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
We use consent, not consensus. This means we move forward when {{
no one has a principled objection that would harm the getFrameworkDetails(formData.decisionFramework)
cooperative. An objection must explain how the proposal would .practicalDescription
contradict our values or threaten our sustainability. }}
</p> </p>
</div> </div>
@ -301,7 +311,7 @@
v-model="formData.dayToDayLimit" v-model="formData.dayToDayLimit"
type="number" type="number"
placeholder="100" placeholder="100"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
can be made by any member. Just tell others what you did at can be made by any member. Just tell others what you did at
the next meeting. the next meeting.
@ -319,13 +329,13 @@
v-model="formData.regularDecisionMin" v-model="formData.regularDecisionMin"
type="number" type="number"
placeholder="100" placeholder="100"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
and $<UInput and $<UInput
v-model="formData.regularDecisionMax" v-model="formData.regularDecisionMax"
type="number" type="number"
placeholder="1000" placeholder="1000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
need consent from members present at a meeting (minimum 2 need consent from members present at a meeting (minimum 2
members). members).
@ -349,11 +359,14 @@
v-model="formData.majorDebtThreshold" v-model="formData.majorDebtThreshold"
type="number" type="number"
placeholder="5000" placeholder="5000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
</li> </li>
<li>Fundamental changes to our purpose or structure</li> <li>Fundamental changes to our purpose or structure</li>
<li>Dissolution of the cooperative</li> <li>
Dissolution of
{{ getDisplayName().toLowerCase() }}
</li>
</ul> </ul>
</div> </div>
@ -377,7 +390,7 @@
v-model="formData.emergencyNoticeHours" v-model="formData.emergencyNoticeHours"
type="number" type="number"
placeholder="24" placeholder="24"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
hours notice hours notice
</li> </li>
@ -404,8 +417,9 @@
Equal Ownership Equal Ownership
</h3> </h3>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
Each member owns an equal share of the cooperative, regardless Each member owns an equal share of
of hours worked or tenure. {{ getDisplayName().toLowerCase() }},
regardless of hours worked or tenure.
</p> </p>
</div> </div>
@ -414,7 +428,7 @@
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"> class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
Paying Ourselves Paying Ourselves
</h3> </h3>
<!-- Pay Policy Selection --> <!-- Pay Policy Selection -->
<UFormField label="Pay Policy" class="form-group-large mb-4"> <UFormField label="Pay Policy" class="form-group-large mb-4">
<USelect <USelect
@ -427,15 +441,20 @@
</UFormField> </UFormField>
<!-- Equal Pay Policy --> <!-- Equal Pay Policy -->
<div v-if="formData.payPolicy === 'equal-pay'" class="space-y-3"> <div
<p class="content-paragraph">All members receive equal compensation regardless of role or hours worked.</p> v-if="formData.payPolicy === 'equal-pay'"
class="space-y-3">
<p class="content-paragraph">
All members receive equal compensation regardless of role or
hours worked.
</p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Base rate: $<UInput Base rate: $<UInput
v-model="formData.baseRate" v-model="formData.baseRate"
type="number" type="number"
placeholder="25" placeholder="25"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" />/hour for all members @change="autoSave" />/hour for all members
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
@ -443,7 +462,7 @@
v-model="formData.monthlyDraw" v-model="formData.monthlyDraw"
type="number" type="number"
placeholder="2000" placeholder="2000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
per member per member
</li> </li>
@ -451,15 +470,19 @@
</div> </div>
<!-- Hours-Weighted Policy --> <!-- Hours-Weighted Policy -->
<div v-if="formData.payPolicy === 'hours-weighted'" class="space-y-3"> <div
<p class="content-paragraph">Compensation is proportional to hours worked by each member.</p> v-if="formData.payPolicy === 'hours-weighted'"
class="space-y-3">
<p class="content-paragraph">
Compensation is proportional to hours worked by each member.
</p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Hourly rate: $<UInput Hourly rate: $<UInput
v-model="formData.hourlyRate" v-model="formData.hourlyRate"
type="number" type="number"
placeholder="25" placeholder="25"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" />/hour @change="autoSave" />/hour
</li> </li>
<li>Members track their hours and are paid accordingly</li> <li>Members track their hours and are paid accordingly</li>
@ -468,18 +491,26 @@
</div> </div>
<!-- Needs-Weighted Policy --> <!-- Needs-Weighted Policy -->
<div v-if="formData.payPolicy === 'needs-weighted'" class="space-y-3"> <div
<p class="content-paragraph">Compensation is allocated based on each member's individual financial needs.</p> v-if="formData.payPolicy === 'needs-weighted'"
class="space-y-3">
<p class="content-paragraph">
Compensation is allocated based on each member's individual
financial needs.
</p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li>Members declare their minimum monthly needs</li> <li>Members declare their minimum monthly needs</li>
<li>Available payroll is distributed proportionally to cover needs</li> <li>
Available payroll is distributed proportionally to cover
needs
</li>
<li>Regular needs assessment and adjustment process</li> <li>Regular needs assessment and adjustment process</li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Minimum guaranteed amount: $<UInput Minimum guaranteed amount: $<UInput
v-model="formData.minGuaranteedPay" v-model="formData.minGuaranteedPay"
type="number" type="number"
placeholder="1000" placeholder="1000"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" />/month @change="autoSave" />/month
</li> </li>
</ul> </ul>
@ -487,7 +518,9 @@
<!-- Common payment details --> <!-- Common payment details -->
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
<p class="content-paragraph font-semibold">Payment Schedule:</p> <p class="content-paragraph font-semibold">
Payment Schedule:
</p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Paid on the Paid on the
@ -495,9 +528,9 @@
v-model="formData.paymentDay" v-model="formData.paymentDay"
:items="dayOptions" :items="dayOptions"
placeholder="15th" placeholder="15th"
arrow
class="inline-field" class="inline-field"
@change="autoSave" /> @change="autoSave" />
of each month of each month
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
@ -525,7 +558,7 @@
v-model="formData.targetHours" v-model="formData.targetHours"
type="number" type="number"
placeholder="40" placeholder="40"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
(flexible based on capacity) (flexible based on capacity)
</li> </li>
@ -548,10 +581,7 @@
All members can access all financial records anytime All members can access all financial records anytime
</li> </li>
<li>Monthly financial check-ins at meetings</li> <li>Monthly financial check-ins at meetings</li>
<li> <li>Quarterly reviews of our runway</li>
Quarterly reviews of our runway (how many months we can
operate)
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -578,11 +608,11 @@
v-model="formData.roleRotationMonths" v-model="formData.roleRotationMonths"
type="number" type="number"
placeholder="6" placeholder="6"
class="inline-field number-field" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
months. Current roles include: months. Current roles include:
</p> </p>
<UFormField label="Rotating Roles" class="form-group-large"> <UFormField class="form-group-large">
<UTextarea <UTextarea
v-model="formData.rotatingRoles" v-model="formData.rotatingRoles"
:rows="4" :rows="4"
@ -602,7 +632,7 @@
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
All members participate in: All members participate in:
</p> </p>
<UFormField label="Shared Responsibilities" class="form-group-large"> <UFormField class="form-group-large">
<UTextarea <UTextarea
v-model="formData.sharedResponsibilities" v-model="formData.sharedResponsibilities"
:rows="3" :rows="3"
@ -669,8 +699,13 @@
placeholder="year" placeholder="year"
class="inline-field" class="inline-field"
@change="autoSave" /> @change="autoSave" />
and update it through our consent process. Small clarifications and update it through our
can happen anytime; structural changes need full member consent. <span class="font-semibold">{{
getFrameworkLabel(formData.decisionFramework)
}}</span>
process. Small clarifications can happen anytime; structural
changes need
{{ getStructuralChangeRequirement(formData.decisionFramework) }}.
</p> </p>
</div> </div>
@ -683,7 +718,8 @@
<div class="space-y-4"> <div class="space-y-4">
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
If the cooperative dissolves: If
{{ getDisplayName().toLowerCase() }} dissolves:
</p> </p>
<ol class="content-list numbered my-2 pl-6 list-decimal"> <ol class="content-list numbered my-2 pl-6 list-decimal">
<li>Pay all debts and obligations</li> <li>Pay all debts and obligations</li>
@ -700,21 +736,33 @@
</div> </div>
</div> </div>
<!-- Section 9: Legal Bits --> <!-- Section 9: Legal Registration (Optional) -->
<div class="section-card"> <div class="section-card">
<h2 <div class="flex items-center justify-between mb-4">
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"> <h2
9. Legal Bits class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-0">
</h2> 9. Legal Registration
</h2>
<div class="flex items-center gap-2">
<label
class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Legally registered?
</label>
<USwitch
v-model="formData.isLegallyRegistered"
@change="autoSave" />
</div>
</div>
<div class="space-y-4"> <div v-if="formData.isLegallyRegistered" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Legal Structure" class="form-group-block"> <UFormField label="Legal Structure" class="form-group-block">
<UInput <UInput
v-model="formData.legalStructure" v-model="formData.legalStructure"
size="xl" size="xl"
class="w-full" class="w-full"
placeholder="Cooperative corporation, LLC, partnership, etc." /> placeholder="Cooperative corporation, LLC, partnership, etc."
@change="autoSave" />
</UFormField> </UFormField>
<UFormField label="Registered in" class="form-group-inline"> <UFormField label="Registered in" class="form-group-inline">
@ -754,8 +802,16 @@
but work to align our legal structure with our values. but work to align our legal structure with our values.
</p> </p>
</div> </div>
</div>
<div v-else class="text-neutral-600 dark:text-neutral-400 italic">
<p class="content-paragraph">
{{ getDisplayName() || "This cooperative" }} operates as
an informal collective. If we decide to register legally in the
future, we'll update this section with our legal structure
details.
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -771,6 +827,9 @@
<script setup> <script setup>
import { ref, watch } from "vue"; import { ref, watch } from "vue";
// Import centralized coop info
const { coopInfo, updateCoopInfo, getDisplayName } = useCoopInfo();
definePageMeta({ definePageMeta({
layout: false, layout: false,
}); });
@ -795,40 +854,194 @@ const monthOptions = [
const dayOptions = Array.from({ length: 31 }, (_, i) => ({ const dayOptions = Array.from({ length: 31 }, (_, i) => ({
value: i + 1, value: i + 1,
label: `${i + 1}${getOrdinalSuffix(i + 1)}` label: `${i + 1}${getOrdinalSuffix(i + 1)}`,
})); }));
// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.) // Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.)
function getOrdinalSuffix(num) { function getOrdinalSuffix(num) {
if (num >= 11 && num <= 13) { if (num >= 11 && num <= 13) {
return 'th'; return "th";
} }
switch (num % 10) { switch (num % 10) {
case 1: return 'st'; case 1:
case 2: return 'nd'; return "st";
case 3: return 'rd'; case 2:
default: return 'th'; return "nd";
case 3:
return "rd";
default:
return "th";
} }
} }
const payPolicyOptions = [ const payPolicyOptions = [
{ value: 'equal-pay', label: 'Equal Pay - All members receive equal compensation' }, {
{ value: 'hours-weighted', label: 'Hours-Weighted - Pay proportional to hours worked' }, value: "equal-pay",
{ value: 'needs-weighted', label: 'Needs-Weighted - Pay proportional to individual needs' } label: "Equal Pay - All members receive equal compensation",
},
{
value: "hours-weighted",
label: "Hours-Weighted - Pay proportional to hours worked",
},
{
value: "needs-weighted",
label: "Needs-Weighted - Pay proportional to individual needs",
},
]; ];
const decisionFrameworkOptions = [
{
value: "consent-based",
label: "Consent-Based - No one objects strongly enough to block",
},
{
value: "consensus",
label: "Full Consensus - Everyone agrees to support",
},
{
value: "consultative",
label: "Consultative - Gather input, then designated person decides",
},
{
value: "democratic-vote",
label: "Democratic Vote - Majority decides",
},
{
value: "advice-process",
label: "Advice Process - Decision-maker seeks input, then decides",
},
{
value: "delegation",
label: "Delegation - Empower responsible party to decide",
},
{
value: "defer-to-expert",
label: "Defer to Expert - Trust the person who knows best",
},
{
value: "facilitated-discussion",
label: "Facilitated Discussion - Talk it through with structure",
},
];
// Helper function to get framework label
function getFrameworkLabel(framework) {
const labels = {
"consent-based": "consent-based decision",
consensus: "consensus",
consultative: "consultative",
"democratic-vote": "democratic voting",
"advice-process": "advice",
delegation: "delegation",
"defer-to-expert": "expert-led",
"facilitated-discussion": "facilitated discussion",
};
return labels[framework] || "decision-making";
}
// Helper function to get structural change requirement text
function getStructuralChangeRequirement(framework) {
const requirements = {
"consent-based": "no blocking objections from any member",
consensus: "full consensus from all members",
consultative: "consultation with all members before the decision",
"democratic-vote": "a majority vote of all members",
"advice-process": "advice from all members before the decision",
delegation: "approval from the delegated authority",
"defer-to-expert":
"approval from the designated expert after member consultation",
"facilitated-discussion": "a facilitated discussion with all members",
};
return (
requirements[framework] || "approval through our chosen decision process"
);
}
// Helper function to get framework details
function getFrameworkDetails(framework) {
const details = {
"consent-based": {
tagline: "No one objects strongly enough to block",
description:
"Not everyone needs to love it, but no one sees it as harmful to our organization. Focus on addressing objections rather than optimizing preferences.",
practicalDescription:
"We use consent, not consensus. This means we move forward when no one has a principled objection that would harm our organization. An objection must explain how the proposal would contradict our values or threaten our sustainability.",
},
consensus: {
tagline: "Everyone agrees to support the decision",
description:
"Take the time to get real alignment on high-stakes decisions. Everyone can live with it, even if it's not their favorite option.",
practicalDescription:
"For major decisions, we work together until everyone can support the outcome. This doesn't mean it's everyone's first choice, but that everyone understands and commits to the decision.",
},
consultative: {
tagline: "Gather input, then designated person decides",
description:
"When no one has clear expertise but we need various perspectives, one person gathers input and makes the final call.",
practicalDescription:
"A designated decision owner seeks input from all stakeholders, researches options, and makes the decision with clear reasoning. They explain how input influenced the final decision.",
},
"democratic-vote": {
tagline: "Majority decides, move forward together",
description:
"For larger groups or time-sensitive decisions, voting provides clear resolution while respecting everyone's input.",
practicalDescription:
"After discussion, we vote and move forward with the majority decision. We document minority concerns and revisit them if needed. Anonymous voting reduces peer pressure.",
},
"advice-process": {
tagline: "Decision-maker seeks input, then decides",
description:
"Balances inclusion with efficiency. The decision owner genuinely considers input but isn't bound by it.",
practicalDescription:
"The person most affected or willing becomes the decision owner. They seek advice from those with expertise and those affected, then make the decision and explain their reasoning.",
},
delegation: {
tagline: "Empower the responsible party to decide",
description:
"Trust those closest to the work to make decisions within clear scope and constraints.",
practicalDescription:
"We delegate decisions to the people most affected or with the most expertise. They have authority within defined boundaries and report back on outcomes.",
},
"defer-to-expert": {
tagline: "Trust the person who knows this best",
description:
"When someone has clear expertise, let them lead while keeping everyone informed.",
practicalDescription:
"The expert proposes solutions with reasoning, answers clarifying questions, and makes the final call. They explain their thinking, not just the outcome.",
},
"facilitated-discussion": {
tagline: "Talk it through with structure",
description:
"Use structured discussion to find clarity before choosing a specific decision method.",
practicalDescription:
"We clarify what we're deciding, share all relevant information, and each person shares their perspective with time limits. We identify alignment and differences, then choose the appropriate method.",
},
};
return (
details[framework] || {
tagline: "Select a framework to see details",
description: "",
practicalDescription:
"Choose a decision-making framework above to see how it works in practice.",
}
);
}
const formData = ref({ const formData = ref({
cooperativeName: "", cooperativeName: "",
dateEstablished: "", dateEstablished: "",
purpose: "", purpose: "",
coreValues: "", coreValues: "",
memberRequirements: "Shares our values and purpose\nContributes labour to the cooperative (by doing actual work, not just investing money)\nCommits to collective decision-making\nParticipates in governance responsibilities", memberRequirements:
"Shares our values and purpose\nContributes labour to our organization (by doing actual work, not just investing money)\nCommits to collective decision-making\nParticipates in governance responsibilities",
members: [{ name: "", email: "", joinDate: "", role: "" }], members: [{ name: "", email: "", joinDate: "", role: "" }],
trialPeriodMonths: 3, trialPeriodMonths: 3,
buyInAmount: "", buyInAmount: "",
noticeDays: 30, noticeDays: 30,
surplusPayoutDays: 30, surplusPayoutDays: 30,
buyInReturnDays: 90, buyInReturnDays: 90,
decisionFramework: "consent-based", // Default to consent-based
dayToDayLimit: 100, dayToDayLimit: 100,
regularDecisionMin: 100, regularDecisionMin: 100,
regularDecisionMax: 1000, regularDecisionMax: 1000,
@ -845,10 +1058,13 @@ const formData = ref({
surplusFrequency: "quarter", surplusFrequency: "quarter",
targetHours: 40, targetHours: 40,
roleRotationMonths: 6, roleRotationMonths: 6,
rotatingRoles: "Financial coordinator (handles bookkeeping, not financial decisions)\nMeeting facilitator\nExternal communications\nOthers", rotatingRoles:
sharedResponsibilities: "Governance and decision-making\nStrategic planning\nMutual support and care", "Financial coordinator (handles bookkeeping, not financial decisions)\nMeeting facilitator\nExternal communications\nOthers",
sharedResponsibilities:
"Governance and decision-making\nStrategic planning\nMutual support and care",
reviewFrequency: "year", reviewFrequency: "year",
assetDonationTarget: "", assetDonationTarget: "",
isLegallyRegistered: false,
legalStructure: "", legalStructure: "",
registeredLocation: "", registeredLocation: "",
fiscalYearEndMonth: "December", fiscalYearEndMonth: "December",
@ -872,6 +1088,35 @@ const loadSavedData = () => {
// Load data immediately // Load data immediately
loadSavedData(); loadSavedData();
// Sync with centralized coop info
watch(
() => coopInfo.value,
(newCoopInfo) => {
formData.value.cooperativeName = newCoopInfo.cooperativeName || formData.value.cooperativeName;
formData.value.dateEstablished = newCoopInfo.dateEstablished || formData.value.dateEstablished;
formData.value.purpose = newCoopInfo.purpose || formData.value.purpose;
formData.value.coreValues = newCoopInfo.coreValues || formData.value.coreValues;
},
{ deep: true, immediate: true }
);
// Update centralized store when key fields change
watch(
() => ({
cooperativeName: formData.value.cooperativeName,
dateEstablished: formData.value.dateEstablished,
purpose: formData.value.purpose,
coreValues: formData.value.coreValues,
legalStructure: formData.value.legalStructure,
registeredLocation: formData.value.registeredLocation,
isLegallyRegistered: formData.value.isLegallyRegistered,
}),
(newData) => {
updateCoopInfo(newData);
},
{ deep: true }
);
// Auto-save to localStorage (removed immediate to prevent overwriting) // Auto-save to localStorage (removed immediate to prevent overwriting)
watch( watch(
formData, formData,
@ -1072,9 +1317,30 @@ const exportData = computed(() => ({
// Pass the complete formData object - this is what the export functions use // Pass the complete formData object - this is what the export functions use
formData: formData.value, formData: formData.value,
// Also provide direct access to key fields for backward compatibility // Also provide direct access to key fields for backward compatibility
cooperativeName: formData.value.cooperativeName || "Worker Cooperative", cooperativeName: getDisplayName(),
section: "membership-agreement", section: "membership-agreement",
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
// Include computed/dynamic values for complete export
decisionFrameworkDetails: getFrameworkDetails(
formData.value.decisionFramework
),
decisionFrameworkLabel: getFrameworkLabel(formData.value.decisionFramework),
structuralChangeRequirement: getStructuralChangeRequirement(
formData.value.decisionFramework
),
paymentDayLabel: formData.value.paymentDay
? `${formData.value.paymentDay}${getOrdinalSuffix(
formData.value.paymentDay
)}`
: "15th",
// Include selected dropdown labels for readability
decisionFrameworkName:
decisionFrameworkOptions.find(
(opt) => opt.value === formData.value.decisionFramework
)?.label || "Consent-Based - No one objects strongly enough to block",
payPolicyName:
payPolicyOptions.find((opt) => opt.value === formData.value.payPolicy)
?.label || "Equal Pay - All members receive equal compensation",
})); }));
</script> </script>