refactor: update app.vue and various components to enhance UI consistency, replace color classes for improved accessibility, and refine layout for better user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-10 11:02:54 +01:00
parent 7b4fb6c2fd
commit 24e8b7a3a8
41 changed files with 2395 additions and 1603 deletions

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

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

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

@ -21,52 +21,25 @@
</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,7 @@
</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();
@ -913,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 = [
{
@ -1014,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",
];
@ -1089,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([
@ -1124,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 },
]);
@ -1168,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,
@ -1183,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,
@ -1383,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
@ -1471,29 +1280,309 @@ watch(
{ deep: true }
);
// Export data for the ExportOptions component
const exportData = computed(() => ({
formData: formData.value,
orgName: getOrgName(),
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>