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">
<NuxtLink
to="/"
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
class="flex items-center gap-2 hover:opacity-80 transition-opacity">
<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
</h1>
</NuxtLink>
@ -24,16 +22,14 @@
<nav
class="mt-4 flex items-center justify-center gap-1"
role="navigation"
aria-label="Main navigation"
>
aria-label="Main navigation">
<NuxtLink
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="{
'bg-neutral-100 dark:bg-neutral-800': isCoopBuilderSection,
}"
>
Co-Op Builder
}">
Budget Builder
</NuxtLink>
<!-- Coach feature - hidden for now -->
<!-- <NuxtLink
@ -49,18 +45,18 @@
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="{
'bg-neutral-100 dark:bg-neutral-800': $route.path === '/wizards',
}"
>
'bg-neutral-100 dark:bg-neutral-800':
$route.path === '/wizards',
}">
Wizards
</NuxtLink>
<NuxtLink
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="{
'bg-neutral-100 dark:bg-neutral-800': $route.path === '/resources',
}"
>
'bg-neutral-100 dark:bg-neutral-800':
$route.path === '/resources',
}">
More Resources & Templates
</NuxtLink>
</nav>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,25 +2,24 @@
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">
<h3 class="text-2xl font-black text-black dark:text-white mb-2">
Where will your money come from?
</h3>
<p class="text-neutral-600">
<p class="text-neutral-600 dark:text-neutral-200">
Add sources like client work, grants, product sales, or donations.
</p>
</div>
<!-- Removed Tab Navigation - showing streams directly -->
<div class="space-y-6">
<div class="space-y-3">
<div
v-if="streams.length === 0"
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
<h4 class="font-medium text-neutral-900 mb-2">
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 mb-4">
<p class="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Get started by adding your first revenue source.
</p>
<UButton
@ -36,7 +35,7 @@
<div
v-for="stream in streams"
:key="stream.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">
<!-- First row: Category and Name with delete button -->
<div class="flex gap-4 mb-4">
<UFormField label="Category" required class="flex-1">
@ -75,12 +74,24 @@
<!-- 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>
<UFormField
:label="
stream.amountType === 'annual'
? 'Annual amount'
: 'Monthly amount'
"
required>
<div class="flex gap-2">
<UInput
:value="stream.amountType === 'annual' ? stream.targetAnnualAmount : stream.targetMonthlyAmount"
:value="
stream.amountType === 'annual'
? stream.targetAnnualAmount
: stream.targetMonthlyAmount
"
type="text"
:placeholder="stream.amountType === 'annual' ? '60000' : '5000'"
:placeholder="
stream.amountType === 'annual' ? '60000' : '5000'
"
size="md"
class="text-sm font-medium w-full"
@update:model-value="validateAndSaveAmount($event, stream)"
@ -91,14 +102,18 @@
</UInput>
<UButtonGroup size="md">
<UButton
:variant="stream.amountType === 'monthly' ? 'solid' : 'outline'"
:variant="
stream.amountType === 'monthly' ? 'solid' : 'outline'
"
color="primary"
@click="switchAmountType(stream, 'monthly')"
class="text-xs">
Monthly
</UButton>
<UButton
:variant="stream.amountType === 'annual' ? 'solid' : 'outline'"
:variant="
stream.amountType === 'annual' ? 'solid' : 'outline'
"
color="primary"
@click="switchAmountType(stream, 'annual')"
class="text-xs">
@ -106,12 +121,15 @@
</UButton>
</UButtonGroup>
</div>
<p class="text-xs text-neutral-500 mt-1">
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
<template v-if="stream.amountType === 'annual'">
{{ currencySymbol }}{{ Math.round((stream.targetAnnualAmount || 0) / 12) }} per month
{{ currencySymbol
}}{{ Math.round((stream.targetAnnualAmount || 0) / 12) }} per
month
</template>
<template v-else>
{{ currencySymbol }}{{ (stream.targetMonthlyAmount || 0) * 12 }} per year
{{ currencySymbol
}}{{ (stream.targetMonthlyAmount || 0) * 12 }} per year
</template>
</p>
</UFormField>
@ -124,7 +142,6 @@
@click="addRevenueStream"
size="lg"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
@ -149,22 +166,22 @@ const emit = defineEmits<{
const coop = useCoopBuilder();
const { currencySymbol } = useCurrency();
const streams = computed(() =>
coop.streams.value.map(s => ({
coop.streams.value.map((s) => ({
// Map store fields to component expectations
id: s.id,
name: s.label,
category: s.category || 'games',
category: s.category || "games",
targetMonthlyAmount: s.monthly || 0,
targetAnnualAmount: (s.annual || (s.monthly || 0) * 12),
amountType: s.amountType || 'monthly',
subcategory: '',
targetAnnualAmount: s.annual || (s.monthly || 0) * 12,
amountType: s.amountType || "monthly",
subcategory: "",
targetPct: 0,
certainty: s.certainty || 'Aspirational',
certainty: s.certainty || "Aspirational",
payoutDelayDays: 30,
terms: 'Net 30',
terms: "Net 30",
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
restrictions: "General",
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0,
}))
@ -226,9 +243,10 @@ const nameOptionsByCategory: Record<string, string[]> = {
// Computed
const totalMonthlyAmount = computed(() =>
streams.value.reduce((sum, s) => {
const monthly = s.amountType === 'annual'
const monthly =
s.amountType === "annual"
? Math.round((s.targetAnnualAmount || 0) / 12)
: (s.targetMonthlyAmount || 0);
: s.targetMonthlyAmount || 0;
return sum + monthly;
}, 0)
);
@ -239,18 +257,19 @@ const debouncedSave = useDebounceFn((stream: any) => {
try {
// Convert component format back to store format
const monthly = stream.amountType === 'annual'
const monthly =
stream.amountType === "annual"
? Math.round((stream.targetAnnualAmount || 0) / 12)
: (stream.targetMonthlyAmount || 0);
: stream.targetMonthlyAmount || 0;
const streamData = {
id: stream.id,
label: stream.name || '',
label: stream.name || "",
monthly: monthly,
annual: stream.targetAnnualAmount || monthly * 12,
amountType: stream.amountType || 'monthly',
category: stream.category || 'games',
certainty: stream.certainty || 'Aspirational'
amountType: stream.amountType || "monthly",
category: stream.category || "games",
certainty: stream.certainty || "Aspirational",
};
coop.upsertStream(streamData);
@ -262,7 +281,8 @@ const debouncedSave = useDebounceFn((stream: any) => {
}, 300);
function saveStream(stream: any) {
const hasValidAmount = stream.amountType === 'annual'
const hasValidAmount =
stream.amountType === "annual"
? stream.targetAnnualAmount >= 0
: stream.targetMonthlyAmount >= 0;
@ -281,18 +301,19 @@ function saveCategoryChange(stream: any) {
function saveStreamImmediate(stream: any) {
try {
// Convert component format back to store format
const monthly = stream.amountType === 'annual'
const monthly =
stream.amountType === "annual"
? Math.round((stream.targetAnnualAmount || 0) / 12)
: (stream.targetMonthlyAmount || 0);
: stream.targetMonthlyAmount || 0;
const streamData = {
id: stream.id,
label: stream.name || '',
label: stream.name || "",
monthly: monthly,
annual: stream.targetAnnualAmount || monthly * 12,
amountType: stream.amountType || 'monthly',
category: stream.category || 'games',
certainty: stream.certainty || 'Aspirational'
amountType: stream.amountType || "monthly",
category: stream.category || "games",
certainty: stream.certainty || "Aspirational",
};
coop.upsertStream(streamData);
@ -306,7 +327,7 @@ function validateAndSaveAmount(value: string, stream: any) {
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
if (stream.amountType === 'annual') {
if (stream.amountType === "annual") {
stream.targetAnnualAmount = validValue;
stream.targetMonthlyAmount = Math.round(validValue / 12);
} else {
@ -318,17 +339,19 @@ function validateAndSaveAmount(value: string, stream: any) {
}
// Function to switch between annual and monthly
function switchAmountType(stream: any, type: 'annual' | 'monthly') {
function switchAmountType(stream: any, type: "annual" | "monthly") {
stream.amountType = type;
// Recalculate values based on new type
if (type === 'annual') {
if (type === "annual") {
if (!stream.targetAnnualAmount) {
stream.targetAnnualAmount = (stream.targetMonthlyAmount || 0) * 12;
}
} else {
if (!stream.targetMonthlyAmount) {
stream.targetMonthlyAmount = Math.round((stream.targetAnnualAmount || 0) / 12);
stream.targetMonthlyAmount = Math.round(
(stream.targetAnnualAmount || 0) / 12
);
}
}
@ -344,7 +367,7 @@ function addRevenueStream() {
annual: 0,
amountType: "monthly",
category: "games",
certainty: "Aspirational"
certainty: "Aspirational",
};
coop.upsertStream(newStream);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,9 @@
</template>
<div class="space-y-6">
<div v-if="mix.length === 0" class="text-sm text-gray-600 text-center py-8">
<div
v-if="mix.length === 0"
class="text-sm text-neutral-600 text-center py-8">
Add revenue streams to see mix.
</div>
@ -20,17 +22,16 @@
<span class="truncate">{{ s.label }}</span>
<span>{{ Math.round(s.pct * 100) }}%</span>
</div>
<div class="h-2 bg-gray-200 rounded">
<div class="h-2 bg-neutral-200 rounded">
<div
class="h-2 rounded"
:class="getBarColor(mix.indexOf(s))"
:style="{ width: (s.pct * 100) + '%' }"
/>
:style="{ width: s.pct * 100 + '%' }" />
</div>
</div>
<!-- Subtext with concentration warning -->
<div class="text-sm text-gray-600 text-center">
<div class="text-sm text-neutral-600 text-center">
Top stream {{ Math.round(topPct * 100) }}%
<span v-if="topPct > 0.5" class="text-amber-600"></span>
</div>
@ -40,17 +41,13 @@
</template>
<script setup lang="ts">
const { revenueMix, concentrationPct } = useCoopBuilder()
const { revenueMix, concentrationPct } = useCoopBuilder();
const mix = computed(() => revenueMix())
const topPct = computed(() => concentrationPct())
const mix = computed(() => revenueMix());
const topPct = computed(() => concentrationPct());
function getBarColor(index: number): string {
const colors = [
'bg-blue-500',
'bg-green-500',
'bg-amber-500'
]
return colors[index % colors.length]
const colors = ["bg-blue-500", "bg-green-500", "bg-amber-500"];
return colors[index % colors.length];
}
</script>

View file

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

View file

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

View file

@ -1,21 +1,19 @@
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Mode:</span>
<span class="text-sm text-neutral-600">Mode:</span>
<UButtonGroup>
<UButton
:variant="modelValue === 'minimum' ? 'solid' : 'ghost'"
color="gray"
color="neutral"
size="xs"
@click="$emit('update:modelValue', 'minimum')"
>
@click="$emit('update:modelValue', 'minimum')">
Min Mode
</UButton>
<UButton
:variant="modelValue === 'target' ? 'solid' : 'ghost'"
color="primary"
size="xs"
@click="$emit('update:modelValue', 'target')"
>
@click="$emit('update:modelValue', 'target')">
Target Mode
</UButton>
</UButtonGroup>
@ -24,13 +22,13 @@
<script setup lang="ts">
interface Props {
modelValue: 'minimum' | 'target'
modelValue: "minimum" | "target";
}
interface Emits {
(e: 'update:modelValue', value: 'minimum' | 'target'): void
(e: "update:modelValue", value: "minimum" | "target"): void;
}
defineProps<Props>()
defineEmits<Emits>()
defineProps<Props>();
defineEmits<Emits>();
</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',
activeView === 'monthly'
? 'bg-black text-white'
: 'bg-white text-black hover:bg-zinc-100',
: 'bg-white text-black hover:bg-neutral-100',
]">
Monthly
</button>
@ -21,7 +21,7 @@
'px-4 py-2 font-medium border-l-2 border-black transition-none',
activeView === 'annual'
? 'bg-black text-white'
: 'bg-white text-black hover:bg-zinc-100',
: 'bg-white text-black hover:bg-neutral-100',
]">
Annual
</button>
@ -48,14 +48,14 @@
<div class="max-w-md mx-auto space-y-6">
<div class="text-6xl">📊</div>
<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
streams, team members, and expenses.
</p>
<div class="flex justify-center">
<NuxtLink
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
</NuxtLink>
</div>
@ -79,7 +79,7 @@
<th
v-for="month in monthlyHeaders"
: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 }}
</th>
</tr>
@ -94,7 +94,7 @@
@click="showAddRevenueModal = true"
size="xs"
:ui="{
base: 'bg-white text-black hover:bg-zinc-200 transition-none',
base: 'bg-white text-black hover:bg-neutral-200 transition-none',
}">
+ Add
</UButton>
@ -107,9 +107,9 @@
<template
v-for="(items, categoryName) in groupedRevenue"
: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
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">
{{ categoryName }}
</td>
@ -118,17 +118,17 @@
v-for="item in items"
:key="item.id"
: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 &&
'bg-yellow-100 animate-pulse',
]">
<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-1">
<div class="text-left w-full">
<div class="font-medium">{{ item.name }}</div>
<div class="text-xs text-gray-600">
<div class="text-xs text-neutral-600">
{{ item.subcategory }}
</div>
</div>
@ -147,7 +147,7 @@
<td
v-for="month in monthlyHeaders"
: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
type="text"
:value="formatValue(item.monthlyValues?.[month.key] || 0)"
@ -157,9 +157,9 @@
"
@blur="handleBlur($event, 'revenue', item.id, month.key)"
@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="{
'bg-zinc-50': !item.monthlyValues?.[month.key],
'bg-neutral-50': !item.monthlyValues?.[month.key],
}" />
</td>
</tr>
@ -167,9 +167,9 @@
<!-- Total Revenue Row -->
<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
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
</td>
<td
@ -189,7 +189,7 @@
@click="showAddExpenseModal = true"
size="xs"
:ui="{
base: 'bg-white text-black hover:bg-zinc-200 transition-none',
base: 'bg-white text-black hover:bg-neutral-200 transition-none',
}">
+ Add
</UButton>
@ -202,9 +202,9 @@
<template
v-for="(items, categoryName) in groupedExpenses"
: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
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">
{{ categoryName }}
</td>
@ -213,12 +213,12 @@
v-for="item in items"
:key="item.id"
: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 &&
'bg-yellow-100 animate-pulse',
]">
<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-1">
<div class="text-left w-full">
@ -236,7 +236,7 @@
Auto
</span>
</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 }}
</div>
</div>
@ -258,7 +258,7 @@
<td
v-for="month in monthlyHeaders"
: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
type="text"
:value="formatValue(item.monthlyValues?.[month.key] || 0)"
@ -271,12 +271,12 @@
@keydown.enter="handleEnter($event)"
class="w-full text-right px-1 py-0.5 border-2 transition-none"
:class="{
'bg-zinc-50':
'bg-neutral-50':
!item.monthlyValues?.[month.key] &&
!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),
'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),
}"
:title="
@ -289,15 +289,15 @@
</template>
<!-- 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
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
</td>
<td
v-for="month in monthlyHeaders"
: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) }}
</td>
</tr>
@ -311,7 +311,7 @@
<td
v-for="month in monthlyHeaders"
: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="
getNetIncomeClass(monthlyTotals[month.key]?.net || 0)
">
@ -320,7 +320,7 @@
</tr>
<!-- 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
class="border-r-1 border-black px-4 py-3 sticky left-0 bg-blue-50 z-10">
CUMULATIVE BALANCE
@ -328,7 +328,7 @@
<td
v-for="month in monthlyHeaders"
: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="
getCumulativeBalanceClass(cumulativeBalances[month.key] || 0)
">
@ -403,19 +403,19 @@
placeholder="Enter annual amount (e.g., 12000)"
size="lg">
<template #leading>
<span class="text-gray-500 font-medium">$</span>
<span class="text-neutral-500 font-medium">$</span>
</template>
</UInput>
</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">
<span class="text-sm font-medium text-gray-700"
<span class="text-sm font-medium text-neutral-700"
>Distribution Preview</span
>
</div>
<p class="text-sm text-gray-600">
<p class="text-sm text-neutral-600">
This will divide
<span class="font-semibold text-gray-900"
<span class="font-semibold text-neutral-900"
>${{ newRevenue.annualAmount || 0 }}</span
>
equally across all 12 months (<span
@ -440,17 +440,17 @@
placeholder="Enter monthly amount (e.g., 1000)"
size="lg">
<template #leading>
<span class="text-gray-500 font-medium">$</span>
<span class="text-neutral-500 font-medium">$</span>
</template>
</UInput>
</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">
<span class="text-sm font-medium text-gray-700"
<span class="text-sm font-medium text-neutral-700"
>Monthly Preview</span
>
</div>
<p class="text-sm text-gray-600">
<p class="text-sm text-neutral-600">
This will set
<span class="font-semibold text-green-600"
>${{ newRevenue.monthlyAmount || 0 }}</span
@ -463,8 +463,8 @@
<!-- Start Empty -->
<div v-else>
<div
class="bg-white rounded-lg p-6 border border-gray-200 text-center">
<p class="text-sm text-gray-600">
class="bg-white rounded-lg p-6 border border-neutral-200 text-center">
<p class="text-sm text-neutral-600">
The revenue item will be created with no initial values. You
can fill them in later directly in the budget table.
</p>
@ -545,19 +545,19 @@
size="lg"
class="text-sm font-medium w-full">
<template #leading>
<span class="text-gray-500 font-medium">$</span>
<span class="text-neutral-500 font-medium">$</span>
</template>
</UInput>
</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">
<span class="text-sm font-medium text-gray-700"
<span class="text-sm font-medium text-neutral-700"
>Distribution Preview</span
>
</div>
<p class="text-sm text-gray-600">
<p class="text-sm text-neutral-600">
This will divide
<span class="font-semibold text-gray-900"
<span class="font-semibold text-neutral-900"
>${{ newExpense.annualAmount || 0 }}</span
>
equally across all 12 months (<span
@ -583,17 +583,17 @@
size="lg"
class="text-sm font-medium w-full">
<template #leading>
<span class="text-gray-500 font-medium">$</span>
<span class="text-neutral-500 font-medium">$</span>
</template>
</UInput>
</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">
<span class="text-sm font-medium text-gray-700"
<span class="text-sm font-medium text-neutral-700"
>Monthly Preview</span
>
</div>
<p class="text-sm text-gray-600">
<p class="text-sm text-neutral-600">
This will set
<span class="font-semibold text-red-600"
>${{ newExpense.monthlyAmount || 0 }}</span
@ -606,8 +606,8 @@
<!-- Start Empty -->
<div v-else>
<div
class="bg-white rounded-lg p-6 border border-gray-200 text-center">
<p class="text-sm text-gray-600">
class="bg-white rounded-lg p-6 border border-neutral-200 text-center">
<p class="text-sm text-neutral-600">
The expense item will be created with no initial values. You
can fill them in later directly in the budget table.
</p>
@ -650,8 +650,8 @@
<!-- Revenue Section -->
<div>
<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>
<ul class="text-sm text-gray-600 space-y-1 ml-4">
<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-neutral-600 space-y-1 ml-4">
<li> Monthly amounts you entered for each revenue stream</li>
<li> Varies by month based on your specific projections</li>
</ul>
@ -660,7 +660,7 @@
<!-- Payroll Section -->
<div>
<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">
<p class="font-medium mb-2">Step-by-step process:</p>
<ol class="space-y-1 ml-4">
@ -671,7 +671,7 @@
<li>5. Ensure cumulative balance doesn't fall below threshold</li>
</ol>
</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.
</p>
</div>
@ -679,8 +679,8 @@
<!-- Cumulative Balance Section -->
<div>
<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>
<ul class="text-sm text-gray-600 space-y-1 ml-4">
<p class="text-sm text-neutral-600 mb-2">Shows your running cash position over time:</p>
<ul class="text-sm text-neutral-600 space-y-1 ml-4">
<li> Starts at $0 (current cash position)</li>
<li> Adds each month's net income (Revenue - All Expenses)</li>
<li> Helps you see when cash might run low</li>
@ -691,7 +691,7 @@
<!-- Policy Explanation -->
<div>
<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'">
Everyone gets equal hourly wage (${{ coopBuilderStore.equalHourlyWage || 0 }}/hour) based on their monthly hours.
</p>
@ -704,8 +704,8 @@
</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded p-3">
<p class="text-sm text-gray-700">
<div class="bg-neutral-50 border border-neutral-200 rounded p-3">
<p class="text-sm text-neutral-700">
<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.
</p>
@ -1231,7 +1231,7 @@ function formatCurrency(amount: number): string {
function getNetIncomeClass(amount: number): string {
if (amount > 0) return "text-green-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 {

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">
<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">
Co-op Builder
Budget Builder
</h1>
</div>
@ -57,7 +57,7 @@
<div
: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' : '',
]">
<div
@ -94,7 +94,7 @@
<div
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" />
</div>
</div>
@ -109,7 +109,7 @@
<div
: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' : '',
]">
<div
@ -121,8 +121,8 @@
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
membersValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white 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-neutral-500'
">
<UIcon
v-if="membersValid"
@ -146,7 +146,7 @@
<div
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" />
</div>
</div>
@ -161,7 +161,7 @@
<div
: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' : '',
]">
<div
@ -173,8 +173,8 @@
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
costsValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white 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-neutral-500'
">
<UIcon
v-if="costsValid"
@ -198,7 +198,7 @@
<div
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" />
</div>
</div>
@ -213,7 +213,7 @@
<div
: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' : '',
]">
<div
@ -225,8 +225,8 @@
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
streamsValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white 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-neutral-500'
">
<UIcon
v-if="streamsValid"
@ -250,7 +250,7 @@
<div
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" />
</div>
</div>
@ -262,7 +262,7 @@
class="export-btn"
@click="resetWizard"
:disabled="isResetting">
Start Over
Clear Data
</button>
<div class="flex items-center gap-4">
@ -289,16 +289,6 @@
>
</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">
<button
class="export-btn primary"
@ -445,7 +435,7 @@ async function restartWizard() {
// SEO
useSeoMeta({
title: "Co-op Builder - Build Your Financial Foundation",
title: "Budget Builder",
description:
"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="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Dashboard</h1>
<div class="text-sm text-gray-600">
<div class="text-sm text-neutral-600">
Mode: {{ currentMode }}
</div>
</div>
@ -15,15 +15,15 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="text-center">
<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 class="text-center">
<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 class="text-center">
<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>
</UCard>
@ -34,11 +34,11 @@
<h3 class="text-lg font-medium">Members ({{ memberCount }})</h3>
</template>
<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="text-sm text-gray-600">{{ member.relationship }}</span>
<span class="text-sm text-neutral-600">{{ member.relationship }}</span>
</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.
</div>
</div>

View file

@ -1,10 +1,10 @@
<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">
<!-- Header -->
<div class="mb-8 text-center">
<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>
<!-- Navigation -->
@ -15,19 +15,19 @@
<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">
<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 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>
<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 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>
<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 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>
<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>
</div>
</div>

View file

@ -2,7 +2,7 @@
<div class="space-y-8">
<div class="text-center">
<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.
This tool helps worker co-ops sketch project budgets and break-even scenarios.
</p>
@ -18,10 +18,10 @@
</div>
<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
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
</NuxtLink>

View file

@ -16,57 +16,30 @@
<div class="text-center mb-8">
<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"
:data-org-name="formData.orgName || 'Organization'">
:data-org-name="getOrgName()">
CONFLICT RESOLUTION
</h1>
</div>
<!-- Section 1: Organization Information -->
<!-- Section 1: Cooperative Information -->
<div class="section-card">
<h2 class="section-title">1. Organization Information</h2>
<div class="space-y-6">
<UFormField label="Organization Name" class="form-group-large">
<UFormField label="Cooperative Name" class="form-group-large">
<UInput
v-model="formData.orgName"
placeholder="Enter your organization name"
placeholder="Enter your cooperative name"
size="xl"
class="w-full"
:error="validationErrors.orgName"
@input="debouncedAutoSave" />
</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>
<!-- Section 2: Core Values -->
<div class="section-card">
<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">
<USwitch
v-model="sectionsEnabled.values"
@ -80,7 +53,7 @@
<div class="space-y-6" v-show="sectionsEnabled.values">
<UFormField
label="Select Core Values (check all that apply)"
label="Select core values (check all that apply)"
class="form-group-large">
<div class="values-grid">
<div
@ -110,12 +83,12 @@
</UFormField>
<UFormField
label="Additional Values or Principles"
label="Additional values or principles"
class="form-group-large">
<UTextarea
v-model="formData.customValues"
:rows="3"
placeholder="Add any additional values specific to your organization..."
placeholder="Add any additional values specific to your cooperative..."
size="xl"
class="w-full"
@input="debouncedAutoSave" />
@ -230,13 +203,14 @@
</UFormField>
<UFormField
label="Mediator/Facilitator Structure"
label="Mediator/facilitator structure"
class="form-group-large">
<USelect
v-model="formData.mediatorType"
:items="mediatorTypeOptions"
placeholder="Select mediator structure..."
size="xl"
class="w-full"
:error="validationErrors.mediatorType"
@change="autoSave" />
</UFormField>
@ -381,7 +355,7 @@
<div class="space-y-6">
<UFormField
label="Available Actions (check all that apply)"
label="Available actions (check all that apply)"
class="form-group-large">
<div class="checkbox-group mt-4 space-y-3">
<div
@ -484,7 +458,7 @@
v-model="formData.training"
:rows="3"
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"
@input="debouncedAutoSave" />
</UFormField>
@ -672,18 +646,18 @@
</UFormField>
<UFormField
label="Staff Liaison for Conflict Resolution Committee"
label="Member Liaison for Conflict Resolution Committee"
class="form-group-large">
<UInput
v-model="formData.staffLiaison"
placeholder="Title/role of designated staff liaison"
placeholder="Title/role of designated member liaison"
size="xl"
class="w-full md:w-1/2"
@input="debouncedAutoSave" />
</UFormField>
<UFormField
label="Board Chair Role in Conflict Resolution"
label="Elected Board Chair Role in Conflict Resolution"
class="form-group-large">
<USelect
v-model="formData.boardChairRole"
@ -762,7 +736,7 @@
v-model="formData.requireExternalAdvice"
id="require-external-advice"
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" />
</UFormField>
</div>
@ -893,7 +867,10 @@
</template>
<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({
layout: false,
@ -910,21 +887,7 @@ useHead({
],
});
// Import PDF export composable
const { exportToPDF } = usePdfExportBasic();
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 = [
{
@ -1011,17 +974,17 @@ const reflectionPeriodOptions = [
];
const internalAdvisorOptions = [
"Single Board-appointed advisor",
"Rotating Board members",
"Single elected advisor",
"Rotating member representatives",
"External neutral advisor",
"Committee-designated advisor",
"Staff member with training",
"Trained member facilitator",
];
const boardChairRoleOptions = [
"First contact for ED complaints",
"First contact for coordinator complaints",
"Appeals reviewer",
"Final decision maker",
"Participates in collective decision",
"Advisory role only",
"Not involved in conflicts",
];
@ -1086,21 +1049,23 @@ const coreValues = 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: "Work allocation and responsibility disagreements", checked: true },
{ label: "Decision-making process conflicts", checked: true },
{ label: "Harassment or discrimination", checked: false },
{ label: "Work performance issues", checked: false },
{ label: "Conflicts of interest", checked: false },
{ label: "Member-owner responsibility disputes", checked: false },
{ label: "Collective ownership tensions", checked: false },
{ label: "External organization disputes", checked: false },
{ label: "Financial disagreements", checked: false },
]);
const reportReceivers = ref([
{ label: "Designated conflict resolution committee", checked: true },
{ label: "Any board member", checked: false },
{ label: "Executive Director(s)", checked: false },
{ label: "Designated staff liaison", checked: false },
{ label: "Any member", checked: false },
{ label: "Any elected board member", checked: false },
{ label: "Administrative Coordinator(s)", checked: false },
{ label: "Designated member liaison", checked: false },
{ label: "Any member-worker", checked: false },
]);
const processSteps = ref([
@ -1121,7 +1086,7 @@ const availableActions = ref([
{ label: "Temporary suspension", checked: true },
{ label: "Role/responsibility changes", 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 },
]);
@ -1165,7 +1130,7 @@ const formData = ref({
documentDirectResolution: true,
internalAdvisorType: "Single Board-appointed advisor",
staffLiaison: "",
boardChairRole: "First contact for ED complaints",
boardChairRole: "First contact for coordinator complaints",
formalAcknowledgmentTime: "Within 1 week",
formalReviewTime: "1 month",
requireExternalAdvice: true,
@ -1180,165 +1145,12 @@ const formData = ref({
// Validation logic
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
const autoSave = () => {
// Clear validation errors when users start correcting fields
clearValidationErrors();
if (process.client) {
if (typeof window !== "undefined") {
const dataToSave = {
formData: formData.value,
coreValues: coreValues.value,
@ -1380,7 +1192,7 @@ const markdownToHtml = (markdown) => {
.replace(/<\/li>\s*<ul>/g, "</li>")
.replace(/<\/ul>\s*<li>/g, "<li>")
// Tables (basic support)
.replace(/^\|(.+)\|$/gm, (match, content) => {
.replace(/^\|(.+)\|$/gm, (_, content) => {
const cells = content.split("|").map((cell) => cell.trim());
if (cells.every((cell) => cell.match(/^-+$/))) {
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(
[
@ -1447,29 +1280,309 @@ watch(
{ deep: true }
);
// Export data for the ExportOptions component
const exportData = computed(() => ({
formData: formData.value,
orgName: formData.value.orgName || "Organization",
orgType: formData.value.orgType,
memberCount: formData.value.memberCount,
sectionsEnabled: sectionsEnabled.value,
coreValues: formData.value.coreValues,
principles: formData.value.principles,
policies: {
memberInvolvement: formData.value.memberInvolvement,
communicationGuidelines: formData.value.communicationGuidelines,
processSteps: formData.value.processSteps,
escalationCriteria: formData.value.escalationCriteria,
mediation: formData.value.mediation,
finalDecision: formData.value.finalDecision,
learning: formData.value.learning,
emergencyProcedures: formData.value.emergencyProcedures,
annualReview: formData.value.annualReview,
},
exportedAt: new Date().toISOString(),
// Generate the complete policy document for preview and export
const generatePolicyDocument = () => {
const cooperativeName = formData.value.orgName || "[Cooperative Name]";
let content = `# ${cooperativeName} Conflict Resolution Policy\n\n`;
content += `*Framework Created: ${
formData.value.createdDate || new Date().toISOString().split("T")[0]
}*\n`;
if (formData.value.reviewDate) {
content += `*Next Review: ${formData.value.reviewDate}*\n`;
}
content += `\n---\n\n`;
// Core Values section (if enabled)
if (sectionsEnabled.value.values) {
content += `## Our Values\n\n`;
content += `This conflict resolution framework is guided by our core values:\n\n`;
const selectedValues = coreValues.value.filter((v) => v.checked);
if (selectedValues.length > 0) {
selectedValues.forEach((value) => {
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>
<style scoped>

View file

@ -15,9 +15,7 @@
<div class="text-center mb-8">
<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"
:data-coop-name="
formData.cooperativeName || 'Worker Cooperative'
">
:data-coop-name="getDisplayName()">
MEMBERSHIP AGREEMENT
</h1>
</div>
@ -172,7 +170,7 @@
<p class="content-paragraph mb-3 leading-relaxed text-left">
Any person who:
</p>
<UFormField label="Member Requirements" class="form-group-large">
<UFormField class="form-group-large">
<UTextarea
v-model="formData.memberRequirements"
:rows="4"
@ -193,7 +191,7 @@
<p class="content-paragraph">
New members join through a consent process, which means
existing members must agree that adding this person won't harm
the cooperative.
{{ getDisplayName().toLowerCase() }}.
</p>
<ol class="content-list numbered my-2 pl-6 list-decimal">
@ -203,18 +201,17 @@
v-model="formData.trialPeriodMonths"
type="number"
placeholder="3"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
months working together
</li>
<li>Values alignment conversation</li>
<li>Consent decision by current members</li>
<li class="flex items-baseline gap-2 flex-wrap">
Optional - Equal buy-in contribution of $<UInput
v-model="formData.buyInAmount"
type="number"
placeholder="1000"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
(can be paid over time or waived based on need)
</li>
@ -234,7 +231,7 @@
v-model="formData.noticeDays"
type="number"
placeholder="30"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
days notice. The cooperative will:
</p>
@ -246,7 +243,7 @@
v-model="formData.surplusPayoutDays"
type="number"
placeholder="30"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
days
</li>
@ -256,7 +253,7 @@
v-model="formData.buyInReturnDays"
type="number"
placeholder="90"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
days
</li>
@ -276,16 +273,29 @@
</h2>
<div class="space-y-4">
<!-- Decision Framework Selection -->
<div>
<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">
Consent-Based Decisions
Primary Decision Framework
</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">
We use consent, not consensus. This means we move forward when
no one has a principled objection that would harm the
cooperative. An objection must explain how the proposal would
contradict our values or threaten our sustainability.
{{
getFrameworkDetails(formData.decisionFramework)
.practicalDescription
}}
</p>
</div>
@ -301,7 +311,7 @@
v-model="formData.dayToDayLimit"
type="number"
placeholder="100"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
can be made by any member. Just tell others what you did at
the next meeting.
@ -319,13 +329,13 @@
v-model="formData.regularDecisionMin"
type="number"
placeholder="100"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
and $<UInput
v-model="formData.regularDecisionMax"
type="number"
placeholder="1000"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
need consent from members present at a meeting (minimum 2
members).
@ -349,11 +359,14 @@
v-model="formData.majorDebtThreshold"
type="number"
placeholder="5000"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
</li>
<li>Fundamental changes to our purpose or structure</li>
<li>Dissolution of the cooperative</li>
<li>
Dissolution of
{{ getDisplayName().toLowerCase() }}
</li>
</ul>
</div>
@ -377,7 +390,7 @@
v-model="formData.emergencyNoticeHours"
type="number"
placeholder="24"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
hours notice
</li>
@ -404,8 +417,9 @@
Equal Ownership
</h3>
<p class="content-paragraph mb-3 leading-relaxed text-left">
Each member owns an equal share of the cooperative, regardless
of hours worked or tenure.
Each member owns an equal share of
{{ getDisplayName().toLowerCase() }},
regardless of hours worked or tenure.
</p>
</div>
@ -427,15 +441,20 @@
</UFormField>
<!-- Equal Pay Policy -->
<div 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>
<div
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">
<li class="flex items-baseline gap-2 flex-wrap">
Base rate: $<UInput
v-model="formData.baseRate"
type="number"
placeholder="25"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />/hour for all members
</li>
<li class="flex items-baseline gap-2 flex-wrap">
@ -443,7 +462,7 @@
v-model="formData.monthlyDraw"
type="number"
placeholder="2000"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
per member
</li>
@ -451,15 +470,19 @@
</div>
<!-- Hours-Weighted Policy -->
<div v-if="formData.payPolicy === 'hours-weighted'" class="space-y-3">
<p class="content-paragraph">Compensation is proportional to hours worked by each member.</p>
<div
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">
<li class="flex items-baseline gap-2 flex-wrap">
Hourly rate: $<UInput
v-model="formData.hourlyRate"
type="number"
placeholder="25"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />/hour
</li>
<li>Members track their hours and are paid accordingly</li>
@ -468,18 +491,26 @@
</div>
<!-- Needs-Weighted Policy -->
<div 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>
<div
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">
<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 class="flex items-baseline gap-2 flex-wrap">
Minimum guaranteed amount: $<UInput
v-model="formData.minGuaranteedPay"
type="number"
placeholder="1000"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />/month
</li>
</ul>
@ -487,7 +518,9 @@
<!-- Common payment details -->
<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">
<li class="flex items-baseline gap-2 flex-wrap">
Paid on the
@ -495,9 +528,9 @@
v-model="formData.paymentDay"
:items="dayOptions"
placeholder="15th"
arrow
class="inline-field"
@change="autoSave" />
of each month
</li>
<li class="flex items-baseline gap-2 flex-wrap">
@ -525,7 +558,7 @@
v-model="formData.targetHours"
type="number"
placeholder="40"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
(flexible based on capacity)
</li>
@ -548,10 +581,7 @@
All members can access all financial records anytime
</li>
<li>Monthly financial check-ins at meetings</li>
<li>
Quarterly reviews of our runway (how many months we can
operate)
</li>
<li>Quarterly reviews of our runway</li>
</ul>
</div>
</div>
@ -578,11 +608,11 @@
v-model="formData.roleRotationMonths"
type="number"
placeholder="6"
class="inline-field number-field"
class="inline-field number-field w-10"
@change="autoSave" />
months. Current roles include:
</p>
<UFormField label="Rotating Roles" class="form-group-large">
<UFormField class="form-group-large">
<UTextarea
v-model="formData.rotatingRoles"
:rows="4"
@ -602,7 +632,7 @@
<p class="content-paragraph mb-3 leading-relaxed text-left">
All members participate in:
</p>
<UFormField label="Shared Responsibilities" class="form-group-large">
<UFormField class="form-group-large">
<UTextarea
v-model="formData.sharedResponsibilities"
:rows="3"
@ -669,8 +699,13 @@
placeholder="year"
class="inline-field"
@change="autoSave" />
and update it through our consent process. Small clarifications
can happen anytime; structural changes need full member consent.
and update it through our
<span class="font-semibold">{{
getFrameworkLabel(formData.decisionFramework)
}}</span>
process. Small clarifications can happen anytime; structural
changes need
{{ getStructuralChangeRequirement(formData.decisionFramework) }}.
</p>
</div>
@ -683,7 +718,8 @@
<div class="space-y-4">
<p class="content-paragraph mb-3 leading-relaxed text-left">
If the cooperative dissolves:
If
{{ getDisplayName().toLowerCase() }} dissolves:
</p>
<ol class="content-list numbered my-2 pl-6 list-decimal">
<li>Pay all debts and obligations</li>
@ -700,21 +736,33 @@
</div>
</div>
<!-- Section 9: Legal Bits -->
<!-- Section 9: Legal Registration (Optional) -->
<div class="section-card">
<div class="flex items-center justify-between mb-4">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
9. Legal Bits
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-0">
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">
<UFormField label="Legal Structure" class="form-group-block">
<UInput
v-model="formData.legalStructure"
size="xl"
class="w-full"
placeholder="Cooperative corporation, LLC, partnership, etc." />
placeholder="Cooperative corporation, LLC, partnership, etc."
@change="autoSave" />
</UFormField>
<UFormField label="Registered in" class="form-group-inline">
@ -754,8 +802,16 @@
but work to align our legal structure with our values.
</p>
</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>
@ -771,6 +827,9 @@
<script setup>
import { ref, watch } from "vue";
// Import centralized coop info
const { coopInfo, updateCoopInfo, getDisplayName } = useCoopInfo();
definePageMeta({
layout: false,
});
@ -795,40 +854,194 @@ const monthOptions = [
const dayOptions = Array.from({ length: 31 }, (_, i) => ({
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.)
function getOrdinalSuffix(num) {
if (num >= 11 && num <= 13) {
return 'th';
return "th";
}
switch (num % 10) {
case 1: return 'st';
case 2: return 'nd';
case 3: return 'rd';
default: return 'th';
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
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: 'needs-weighted', label: 'Needs-Weighted - Pay proportional to individual needs' }
{
value: "equal-pay",
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({
cooperativeName: "",
dateEstablished: "",
purpose: "",
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: "" }],
trialPeriodMonths: 3,
buyInAmount: "",
noticeDays: 30,
surplusPayoutDays: 30,
buyInReturnDays: 90,
decisionFramework: "consent-based", // Default to consent-based
dayToDayLimit: 100,
regularDecisionMin: 100,
regularDecisionMax: 1000,
@ -845,10 +1058,13 @@ const formData = ref({
surplusFrequency: "quarter",
targetHours: 40,
roleRotationMonths: 6,
rotatingRoles: "Financial coordinator (handles bookkeeping, not financial decisions)\nMeeting facilitator\nExternal communications\nOthers",
sharedResponsibilities: "Governance and decision-making\nStrategic planning\nMutual support and care",
rotatingRoles:
"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",
assetDonationTarget: "",
isLegallyRegistered: false,
legalStructure: "",
registeredLocation: "",
fiscalYearEndMonth: "December",
@ -872,6 +1088,35 @@ const loadSavedData = () => {
// Load data immediately
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)
watch(
formData,
@ -1072,9 +1317,30 @@ const exportData = computed(() => ({
// Pass the complete formData object - this is what the export functions use
formData: formData.value,
// Also provide direct access to key fields for backward compatibility
cooperativeName: formData.value.cooperativeName || "Worker Cooperative",
cooperativeName: getDisplayName(),
section: "membership-agreement",
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>