refactor: update app.vue and various components to enhance UI consistency, replace color classes for improved accessibility, and refine layout for better user experience
This commit is contained in:
parent
7b4fb6c2fd
commit
24e8b7a3a8
41 changed files with 2395 additions and 1603 deletions
42
app.config.ts
Normal file
42
app.config.ts
Normal 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
26
app.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@
|
|||
<div class="border border-black bg-white">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-black bg-gray-100">
|
||||
<tr class="border-b-2 border-black bg-neutral-100">
|
||||
<th class="border-r-1 border-black px-4 py-3 text-left font-bold">
|
||||
Category
|
||||
</th>
|
||||
<th
|
||||
class="border-r border-gray-400 px-4 py-3 text-right font-bold">
|
||||
class="border-r border-neutral-400 px-4 py-3 text-right font-bold">
|
||||
Planned
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right font-bold">%</th>
|
||||
|
|
@ -28,21 +28,21 @@
|
|||
<tr
|
||||
v-for="(category, index) in revenueCategories"
|
||||
:key="`rev-${index}`"
|
||||
class="border-t border-gray-200"
|
||||
class="border-t border-neutral-200"
|
||||
v-show="category.planned > 0">
|
||||
<td class="border-r-1 border-black px-4 py-2">
|
||||
{{ category.name }}
|
||||
</td>
|
||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||
<td class="border-r border-neutral-400 px-4 py-2 text-right">
|
||||
{{ formatCurrency(category.planned) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
|
||||
</tr>
|
||||
|
||||
<!-- Total Revenue -->
|
||||
<tr class="border-t-2 border-black font-semibold bg-gray-50">
|
||||
<tr class="border-t-2 border-black font-semibold bg-neutral-50">
|
||||
<td class="border-r-1 border-black px-4 py-2">Total Revenue</td>
|
||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||
<td class="border-r border-neutral-400 px-4 py-2 text-right">
|
||||
{{ formatCurrency(totalRevenuePlanned) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">100%</td>
|
||||
|
|
@ -50,11 +50,11 @@
|
|||
|
||||
<!-- Revenue Diversification Guidance -->
|
||||
<tr :class="guidanceBackgroundClass">
|
||||
<td colspan="3" class="border-t border-gray-300 px-4 py-3">
|
||||
<td colspan="3" class="border-t border-neutral-300 px-4 py-3">
|
||||
<div class="text-sm">
|
||||
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
|
||||
<p
|
||||
class="text-gray-600 mb-2"
|
||||
class="text-neutral-600 mb-2"
|
||||
v-if="suggestedCategories.length > 0">
|
||||
Consider developing: {{ suggestedCategories.join(", ") }}
|
||||
</p>
|
||||
|
|
@ -78,21 +78,21 @@
|
|||
<tr
|
||||
v-for="(category, index) in expenseCategories"
|
||||
:key="`exp-${index}`"
|
||||
class="border-t border-gray-200"
|
||||
class="border-t border-neutral-200"
|
||||
v-show="category.planned > 0">
|
||||
<td class="border-r-1 border-black px-4 py-2">
|
||||
{{ category.name }}
|
||||
</td>
|
||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||
<td class="border-r border-neutral-400 px-4 py-2 text-right">
|
||||
{{ formatCurrency(category.planned) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
|
||||
</tr>
|
||||
|
||||
<!-- Total Expenses -->
|
||||
<tr class="border-t-2 border-black font-semibold bg-gray-50">
|
||||
<tr class="border-t-2 border-black font-semibold bg-neutral-50">
|
||||
<td class="border-r-1 border-black px-4 py-2">Total Expenses</td>
|
||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||
<td class="border-r border-neutral-400 px-4 py-2 text-right">
|
||||
{{ formatCurrency(totalExpensesPlanned) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right">100%</td>
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
class="border-t-2 border-black font-bold text-lg"
|
||||
:class="netTotalClass">
|
||||
<td class="border-r-1 border-black px-4 py-3">NET TOTAL</td>
|
||||
<td class="border-r border-gray-400 px-4 py-3 text-right">
|
||||
<td class="border-r border-neutral-400 px-4 py-3 text-right">
|
||||
{{ formatCurrency(netTotal) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">-</td>
|
||||
|
|
@ -244,7 +244,7 @@ const netTotal = computed(
|
|||
const netTotalClass = computed(() => {
|
||||
if (netTotal.value > 0) return "bg-green-50";
|
||||
if (netTotal.value < 0) return "bg-red-50";
|
||||
return "bg-gray-50";
|
||||
return "bg-neutral-50";
|
||||
});
|
||||
|
||||
// Diversification guidance
|
||||
|
|
@ -397,7 +397,7 @@ function getPercentageClass(percentage: number): string {
|
|||
if (percentage > 50) return "text-red-600 font-bold";
|
||||
if (percentage > 35) return "text-yellow-600 font-semibold";
|
||||
if (percentage > 20) return "text-black font-medium";
|
||||
return "text-gray-500";
|
||||
return "text-neutral-500";
|
||||
}
|
||||
|
||||
// Initialize
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<select
|
||||
v-model="selectedCategory"
|
||||
@change="handleSelection(selectedCategory)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
class="w-full px-3 py-2 border border-neutral-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option v-for="option in options" :key="option" :value="option">
|
||||
{{ option }}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<UButton color="gray" variant="ghost" @click="toggle">
|
||||
<UButton color="neutral" variant="ghost" @click="toggle">
|
||||
<UIcon :name="icon" class="w-5 h-5" />
|
||||
</UButton>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="mb-12">
|
||||
<div v-if="isSetupCompleted" class="mb-12">
|
||||
<div class="w-full mx-auto">
|
||||
<nav
|
||||
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
|
||||
|
|
@ -24,16 +24,17 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const coop = useCoopBuilder();
|
||||
|
||||
const coopBuilderItems = [
|
||||
{
|
||||
id: "coop-builder",
|
||||
name: "Setup Wizard",
|
||||
name: "Settings",
|
||||
path: "/coop-builder",
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
name: "Studio Budget",
|
||||
path: "/budget",
|
||||
},
|
||||
{
|
||||
|
|
@ -43,6 +44,31 @@ const coopBuilderItems = [
|
|||
},
|
||||
];
|
||||
|
||||
// Check if setup wizard is completed using the same validation logic as coop-builder page
|
||||
const isSetupCompleted = computed(() => {
|
||||
// Members validation: at least one member with name and positive hours
|
||||
const membersValid = coop.members.value.some((m: any) => {
|
||||
const hasName = typeof m.name === "string" && m.name.trim().length > 0;
|
||||
const hours = Number((m as any).hoursPerMonth ?? 0);
|
||||
return hasName && Number.isFinite(hours) && hours > 0;
|
||||
});
|
||||
|
||||
// Streams validation: at least one stream with name and non-negative monthly amount
|
||||
const streamsValid = coop.streams.value.length > 0 &&
|
||||
coop.streams.value.every((s: any) => {
|
||||
const monthly = Number((s as any).monthly ?? 0);
|
||||
return (s.label || "").toString().trim().length > 0 && monthly >= 0;
|
||||
});
|
||||
|
||||
// Policies validation: has members (same logic as coop-builder page)
|
||||
const policiesValid = coop.members.value.length > 0;
|
||||
|
||||
// Costs are always valid (optional)
|
||||
const costsValid = true;
|
||||
|
||||
return policiesValid && membersValid && costsValid && streamsValid;
|
||||
});
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return route.path === path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ const progressColor = computed(() => {
|
|||
case "red":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
return "neutral";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ const badgeColor = computed(() => {
|
|||
case "red":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
return "neutral";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +1,16 @@
|
|||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Runway summary -->
|
||||
<div class="grid grid-cols-2 gap-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div class="grid grid-cols-2 gap-4 p-3 bg-neutral-50 rounded-lg text-sm">
|
||||
<div>
|
||||
<span class="text-gray-600">Min mode runway:</span>
|
||||
<span class="text-neutral-600">Min mode runway:</span>
|
||||
<div class="font-bold text-lg">{{ minRunwayMonths }} months</div>
|
||||
<div class="text-xs text-gray-500">Until {{ formatDate(minRunwayEndDate) }}</div>
|
||||
<div class="text-xs text-neutral-500">Until {{ formatDate(minRunwayEndDate) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Target mode runway:</span>
|
||||
<span class="text-neutral-600">Target mode runway:</span>
|
||||
<div class="font-bold text-lg">{{ targetRunwayMonths }} months</div>
|
||||
<div class="text-xs text-gray-500">Until {{ formatDate(targetRunwayEndDate) }}</div>
|
||||
<div class="text-xs text-neutral-500">Until {{ formatDate(targetRunwayEndDate) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -28,12 +28,12 @@
|
|||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-if="milestones.length === 0" class="text-xs text-gray-500 italic p-2">
|
||||
<div v-if="milestones.length === 0" class="text-xs text-neutral-500 italic p-2">
|
||||
No milestones set. Add key dates to track runway coverage.
|
||||
</div>
|
||||
|
||||
<div v-for="milestone in milestonesWithStatus" :key="milestone.id"
|
||||
class="flex items-center justify-between p-2 border border-gray-200 rounded text-sm">
|
||||
class="flex items-center justify-between p-2 border border-neutral-200 rounded text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
:name="milestone.status === 'safe' ? 'i-heroicons-check-circle' :
|
||||
|
|
@ -46,11 +46,11 @@
|
|||
/>
|
||||
<div>
|
||||
<div class="font-medium">{{ milestone.label }}</div>
|
||||
<div class="text-xs text-gray-500">{{ formatDate(milestone.date) }}</div>
|
||||
<div class="text-xs text-neutral-500">{{ formatDate(milestone.date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-600">
|
||||
<div class="text-xs text-neutral-600">
|
||||
{{ milestone.monthsFromNow > 0 ? `+${milestone.monthsFromNow}` : milestone.monthsFromNow }}mo
|
||||
</div>
|
||||
<UButton
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Add milestone form -->
|
||||
<div v-if="showAddForm" class="p-3 border border-gray-200 rounded-lg space-y-2">
|
||||
<div v-if="showAddForm" class="p-3 border border-neutral-200 rounded-lg space-y-2">
|
||||
<UInput
|
||||
v-model="newMilestone.label"
|
||||
placeholder="Milestone name (e.g., 'Prototype release')"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<div class="space-y-3">
|
||||
<div v-for="member in membersWithCoverage" :key="member.id" class="space-y-1">
|
||||
<div class="flex justify-between text-xs font-medium text-gray-700">
|
||||
<div class="flex justify-between text-xs font-medium text-neutral-700">
|
||||
<span>{{ member.displayName || 'Unnamed' }}</span>
|
||||
<span>{{ Math.round(member.coverageMinPct || 0) }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="relative h-6 bg-gray-100 rounded overflow-hidden">
|
||||
<div class="relative h-6 bg-neutral-100 rounded overflow-hidden">
|
||||
<!-- Min coverage bar -->
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full transition-all duration-300"
|
||||
|
|
@ -17,21 +17,21 @@
|
|||
<!-- Target coverage tick/ghost -->
|
||||
<div
|
||||
v-if="member.coverageTargetPct"
|
||||
class="absolute top-0 h-full w-0.5 bg-gray-400 opacity-50"
|
||||
class="absolute top-0 h-full w-0.5 bg-neutral-400 opacity-50"
|
||||
:style="{ left: `${Math.min(100, member.coverageTargetPct)}%` }"
|
||||
>
|
||||
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-400 rounded-full opacity-50" />
|
||||
<div class="absolute -top-1 -left-1 w-2 h-2 bg-neutral-400 rounded-full opacity-50" />
|
||||
</div>
|
||||
|
||||
<!-- 100% line -->
|
||||
<div class="absolute top-0 left-0 h-full w-full pointer-events-none">
|
||||
<div class="absolute top-0 h-full w-px bg-gray-600" style="left: 100%" />
|
||||
<div class="absolute top-0 h-full w-px bg-neutral-600" style="left: 100%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="pt-3 border-t border-gray-200 text-xs text-gray-600">
|
||||
<div class="pt-3 border-t border-neutral-200 text-xs text-neutral-600">
|
||||
<div class="flex justify-between">
|
||||
<span>Team median: {{ Math.round(teamStats.median || 0) }}%</span>
|
||||
<span v-if="teamStats.under100 > 0" class="text-yellow-600 font-medium">
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<h3 class="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
One-Off Transactions
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
Add one-time income or expense transactions with expected dates.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -18,11 +18,13 @@
|
|||
|
||||
<!-- Empty state -->
|
||||
<div v-if="sortedEvents.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-heroicons-banknotes" class="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
<UIcon
|
||||
name="i-heroicons-banknotes"
|
||||
class="w-12 h-12 mx-auto text-neutral-400 mb-4" />
|
||||
<h4 class="text-lg font-medium text-neutral-900 dark:text-white mb-2">
|
||||
No transactions yet
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
Add one-off income or expense transactions.
|
||||
</p>
|
||||
<UButton @click="addEvent" color="primary">
|
||||
|
|
@ -36,19 +38,26 @@
|
|||
<div
|
||||
v-for="monthGroup in eventsByMonth"
|
||||
:key="monthGroup.month"
|
||||
class="space-y-3"
|
||||
>
|
||||
class="space-y-3">
|
||||
<!-- Month header -->
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
<div
|
||||
class="flex items-center justify-between py-2 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h4 class="font-medium text-neutral-900 dark:text-white">
|
||||
{{ monthGroup.monthName }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge variant="subtle" color="gray">
|
||||
{{ monthGroup.events.length }} transaction{{ monthGroup.events.length !== 1 ? 's' : '' }}
|
||||
<UBadge variant="subtle" color="neutral">
|
||||
{{ monthGroup.events.length }} transaction{{
|
||||
monthGroup.events.length !== 1 ? "s" : ""
|
||||
}}
|
||||
</UBadge>
|
||||
<div class="text-sm font-medium" :class="monthGroup.netAmount >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ monthGroup.netAmount >= 0 ? '+' : '' }}{{ formatCurrency(monthGroup.netAmount) }}
|
||||
<div
|
||||
class="text-sm font-medium"
|
||||
:class="
|
||||
monthGroup.netAmount >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
">
|
||||
{{ monthGroup.netAmount >= 0 ? "+" : ""
|
||||
}}{{ formatCurrency(monthGroup.netAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -59,10 +68,15 @@
|
|||
v-for="event in monthGroup.events"
|
||||
:key="event.id"
|
||||
:ui="{
|
||||
background: event.type === 'income' ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20',
|
||||
ring: event.type === 'income' ? 'ring-green-200 dark:ring-green-800' : 'ring-red-200 dark:ring-red-800'
|
||||
}"
|
||||
>
|
||||
background:
|
||||
event.type === 'income'
|
||||
? 'bg-green-50 dark:bg-green-900/20'
|
||||
: 'bg-red-50 dark:bg-red-900/20',
|
||||
ring:
|
||||
event.type === 'income'
|
||||
? 'ring-green-200 dark:ring-green-800'
|
||||
: 'ring-red-200 dark:ring-red-800',
|
||||
}">
|
||||
<UForm :state="event" @submit="() => {}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Category -->
|
||||
|
|
@ -70,8 +84,9 @@
|
|||
<USelect
|
||||
v-model="event.category"
|
||||
:options="categoryOptions"
|
||||
@update:model-value="updateEvent(event.id, { category: $event })"
|
||||
/>
|
||||
@update:model-value="
|
||||
updateEvent(event.id, { category: $event })
|
||||
" />
|
||||
</UFormField>
|
||||
|
||||
<!-- Name -->
|
||||
|
|
@ -79,8 +94,9 @@
|
|||
<UInput
|
||||
v-model="event.name"
|
||||
placeholder="e.g., Equipment purchase"
|
||||
@update:model-value="updateEvent(event.id, { name: $event })"
|
||||
/>
|
||||
@update:model-value="
|
||||
updateEvent(event.id, { name: $event })
|
||||
" />
|
||||
</UFormField>
|
||||
|
||||
<!-- Type -->
|
||||
|
|
@ -88,8 +104,9 @@
|
|||
<USelect
|
||||
v-model="event.type"
|
||||
:options="typeOptions"
|
||||
@update:model-value="updateEvent(event.id, { type: $event })"
|
||||
/>
|
||||
@update:model-value="
|
||||
updateEvent(event.id, { type: $event })
|
||||
" />
|
||||
</UFormField>
|
||||
|
||||
<!-- Amount -->
|
||||
|
|
@ -98,10 +115,11 @@
|
|||
v-model="event.amount"
|
||||
type="number"
|
||||
placeholder="5000"
|
||||
@update:model-value="updateEvent(event.id, { amount: Number($event) })"
|
||||
>
|
||||
@update:model-value="
|
||||
updateEvent(event.id, { amount: Number($event) })
|
||||
">
|
||||
<template #leading>
|
||||
<span class="text-gray-500">$</span>
|
||||
<span class="text-neutral-500">$</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
|
@ -113,8 +131,9 @@
|
|||
<UInput
|
||||
v-model="event.dateExpected"
|
||||
type="date"
|
||||
@update:model-value="updateEventWithDate(event.id, $event)"
|
||||
/>
|
||||
@update:model-value="
|
||||
updateEventWithDate(event.id, $event)
|
||||
" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</UForm>
|
||||
|
|
@ -126,12 +145,15 @@
|
|||
color="red"
|
||||
size="sm"
|
||||
icon="i-heroicons-trash"
|
||||
@click="removeEvent(event.id)"
|
||||
>
|
||||
@click="removeEvent(event.id)">
|
||||
Delete
|
||||
</UButton>
|
||||
<UDropdown :items="getEventActions(event)">
|
||||
<UButton variant="ghost" color="gray" size="sm" icon="i-heroicons-ellipsis-horizontal" />
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
icon="i-heroicons-ellipsis-horizontal" />
|
||||
</UDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -142,11 +164,16 @@
|
|||
<!-- Summary -->
|
||||
<UCard>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
Total {{ sortedEvents.length }} transaction{{ sortedEvents.length !== 1 ? 's' : '' }}
|
||||
<span class="font-medium text-neutral-900 dark:text-white">
|
||||
Total {{ sortedEvents.length }} transaction{{
|
||||
sortedEvents.length !== 1 ? "s" : ""
|
||||
}}
|
||||
</span>
|
||||
<span class="text-lg font-bold" :class="totalAnnualImpact >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ totalAnnualImpact >= 0 ? '+' : '' }}{{ formatCurrency(totalAnnualImpact) }}
|
||||
<span
|
||||
class="text-lg font-bold"
|
||||
:class="totalAnnualImpact >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ totalAnnualImpact >= 0 ? "+" : ""
|
||||
}}{{ formatCurrency(totalAnnualImpact) }}
|
||||
</span>
|
||||
</div>
|
||||
</UCard>
|
||||
|
|
@ -155,126 +182,146 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OneOffEvent } from '~/types/cash'
|
||||
import type { OneOffEvent } from "~/types/cash";
|
||||
|
||||
const cashStore = useCashStore()
|
||||
const cashStore = useCashStore();
|
||||
|
||||
// Constants
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December']
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Income', value: 'income' },
|
||||
{ label: 'Expense', value: 'expense' }
|
||||
]
|
||||
{ label: "Income", value: "income" },
|
||||
{ label: "Expense", value: "expense" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: 'Equipment', value: 'Equipment' },
|
||||
{ label: 'Marketing', value: 'Marketing' },
|
||||
{ label: 'Legal', value: 'Legal' },
|
||||
{ label: 'Contractors', value: 'Contractors' },
|
||||
{ label: 'Office', value: 'Office' },
|
||||
{ label: 'Development', value: 'Development' },
|
||||
{ label: 'Other', value: 'Other' }
|
||||
]
|
||||
{ label: "Equipment", value: "Equipment" },
|
||||
{ label: "Marketing", value: "Marketing" },
|
||||
{ label: "Legal", value: "Legal" },
|
||||
{ label: "Contractors", value: "Contractors" },
|
||||
{ label: "Office", value: "Office" },
|
||||
{ label: "Development", value: "Development" },
|
||||
{ label: "Other", value: "Other" },
|
||||
];
|
||||
|
||||
// Computed
|
||||
const { oneOffEvents } = storeToRefs(cashStore)
|
||||
const { oneOffEvents } = storeToRefs(cashStore);
|
||||
|
||||
const sortedEvents = computed(() => {
|
||||
return oneOffEvents.value
|
||||
.slice()
|
||||
.sort((a, b) => a.month - b.month || a.name.localeCompare(b.name))
|
||||
})
|
||||
.sort((a, b) => a.month - b.month || a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
const eventsByMonth = computed(() => {
|
||||
const groups: Record<number, OneOffEvent[]> = {}
|
||||
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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="p-6 border-b-4 border-black bg-gray-100">
|
||||
<div class="p-6 border-b-4 border-black bg-neutral-100">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="duration" class="font-bold text-sm">Duration (months):</label>
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
<span class="font-bold">Monthly team cost:</span>
|
||||
<span class="font-mono">{{ currency(monthlyCost) }}</span>
|
||||
</li>
|
||||
<li class="text-xs text-gray-600 -mt-1">
|
||||
<li class="text-xs text-neutral-600 -mt-1">
|
||||
Sustainable payroll + {{ percent(props.oncostRate) }} benefits
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-xs text-gray-600">
|
||||
<p class="text-xs text-neutral-600">
|
||||
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not included.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Guidance -->
|
||||
<div v-if="guidanceText" class="p-4 bg-gray-50 text-sm text-gray-600">
|
||||
<div v-if="guidanceText" class="p-4 bg-neutral-50 text-sm text-neutral-600">
|
||||
{{ guidanceText }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const progressColor = computed(() => {
|
|||
case "red":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
return "neutral";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ const badgeColor = computed(() => {
|
|||
case "red":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
return "neutral";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,35 @@
|
|||
<template>
|
||||
<UBadge
|
||||
:color="badgeColor"
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
>
|
||||
<UBadge :color="badgeColor" :variant="variant" :size="size">
|
||||
{{ displayText }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Restriction = 'Restricted' | 'General'
|
||||
type Restriction = "Restricted" | "General";
|
||||
|
||||
interface Props {
|
||||
restriction: Restriction
|
||||
variant?: 'solid' | 'outline' | 'soft' | 'subtle'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
restriction: Restriction;
|
||||
variant?: "solid" | "outline" | "soft" | "subtle";
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'subtle',
|
||||
size: 'sm'
|
||||
})
|
||||
variant: "subtle",
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
const badgeColor = computed(() => {
|
||||
switch (props.restriction) {
|
||||
case 'Restricted':
|
||||
return 'orange'
|
||||
case 'General':
|
||||
return 'green'
|
||||
case "Restricted":
|
||||
return "orange";
|
||||
case "General":
|
||||
return "green";
|
||||
default:
|
||||
return 'gray'
|
||||
return "neutral";
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
return props.restriction
|
||||
})
|
||||
return props.restriction;
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<span class="font-medium">{{ stream.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-600">{{ formatCurrency(stream.targetMonthlyAmount || 0) }}</span>
|
||||
<span class="text-neutral-600">{{ formatCurrency(stream.targetMonthlyAmount || 0) }}</span>
|
||||
<span class="font-medium min-w-[40px] text-right">{{ stream.percentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="pt-2 border-t border-gray-200 text-xs text-gray-600">
|
||||
<div class="pt-2 border-t border-neutral-200 text-xs text-neutral-600">
|
||||
<div class="flex justify-between">
|
||||
<span>Total monthly target:</span>
|
||||
<span class="font-medium">{{ formatCurrency(totalMonthly) }}</span>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,37 @@
|
|||
<template>
|
||||
<UBadge
|
||||
:color="badgeColor"
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
>
|
||||
<UBadge :color="badgeColor" :variant="variant" :size="size">
|
||||
{{ displayText }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type RiskBand = 'Low' | 'Medium' | 'High'
|
||||
type RiskBand = "Low" | "Medium" | "High";
|
||||
|
||||
interface Props {
|
||||
risk: RiskBand
|
||||
variant?: 'solid' | 'outline' | 'soft' | 'subtle'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
risk: RiskBand;
|
||||
variant?: "solid" | "outline" | "soft" | "subtle";
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'subtle',
|
||||
size: 'sm'
|
||||
})
|
||||
variant: "subtle",
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
const badgeColor = computed(() => {
|
||||
switch (props.risk) {
|
||||
case 'Low':
|
||||
return 'green'
|
||||
case 'Medium':
|
||||
return 'yellow'
|
||||
case 'High':
|
||||
return 'red'
|
||||
case "Low":
|
||||
return "green";
|
||||
case "Medium":
|
||||
return "yellow";
|
||||
case "High":
|
||||
return "red";
|
||||
default:
|
||||
return 'gray'
|
||||
return "neutral";
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
return `${props.risk} Risk`
|
||||
})
|
||||
return `${props.risk} Risk`;
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const progressColor = computed(() => {
|
|||
case "red":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
return "neutral";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ const badgeColor = computed(() => {
|
|||
case "red":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
return "neutral";
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black mb-2">
|
||||
<h3 class="text-2xl font-black text-black dark:text-white mb-2">
|
||||
Where does your money go?
|
||||
</h3>
|
||||
<p class="text-neutral-600">
|
||||
<p class="text-neutral-600 dark:text-neutral-200">
|
||||
Add costs like rent + utilities, software licenses, insurance, lawyer
|
||||
fees, accountant fees, and other recurring expenses.
|
||||
</p>
|
||||
|
|
@ -16,14 +16,16 @@
|
|||
<div
|
||||
v-if="overheadCosts.length > 0"
|
||||
class="flex items-center justify-between">
|
||||
<h4 class="text-lg font-bold text-black">Overhead</h4>
|
||||
<h4 class="text-lg font-bold text-black dark:text-white">Overhead</h4>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="overheadCosts.length === 0"
|
||||
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
||||
<h4 class="font-medium text-neutral-900 mb-2">No overhead costs yet</h4>
|
||||
<p class="text-sm text-neutral-500 mb-4">
|
||||
class="text-center py-12 border-2 border-dashed border-neutral-300 dark:border-neutral-600 rounded-xl bg-white dark:bg-neutral-950 shadow-sm">
|
||||
<h4 class="font-medium text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
No overhead costs yet
|
||||
</h4>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
Get started by adding your first overhead cost.
|
||||
</p>
|
||||
<UButton
|
||||
|
|
@ -39,7 +41,7 @@
|
|||
<div
|
||||
v-for="cost in overheadCosts"
|
||||
:key="cost.id"
|
||||
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
class="p-6 border-2 border-black dark:border-neutral-400 rounded-xl bg-white dark:bg-neutral-950 shadow-md">
|
||||
<!-- Header row with name and delete button -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
|
|
@ -112,7 +114,7 @@
|
|||
</UButton>
|
||||
</UButtonGroup>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
<template v-if="cost.amountType === 'annual'">
|
||||
{{ currencySymbol
|
||||
}}{{ Math.round((cost.annualAmount || 0) / 12) }} per month
|
||||
|
|
@ -131,7 +133,6 @@
|
|||
@click="addOverheadCost"
|
||||
size="lg"
|
||||
variant="solid"
|
||||
color="success"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black mb-2">Who's on your team?</h3>
|
||||
<p class="text-neutral-600">
|
||||
<h3 class="text-2xl font-black text-black dark:text-white mb-2">
|
||||
Who's on your team?
|
||||
</h3>
|
||||
<p class="text-neutral-600 dark:text-neutral-200">
|
||||
Add everyone who'll be working in the co-op. Based on your pay approach,
|
||||
we'll collect the right information for each person.
|
||||
</p>
|
||||
<!-- Debug info -->
|
||||
<div class="mt-2 p-2 bg-gray-100 rounded text-xs">
|
||||
<div class="mt-2 p-2 bg-neutral-100 dark:bg-neutral-800 rounded text-xs">
|
||||
Debug: Policy = {{ currentPolicy }}, Needs field shown =
|
||||
{{ isNeedsWeighted }}
|
||||
</div>
|
||||
|
|
@ -18,9 +20,11 @@
|
|||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="members.length === 0"
|
||||
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
||||
<h4 class="font-medium text-neutral-900 mb-2">No team members yet</h4>
|
||||
<p class="text-sm text-neutral-500 mb-4">
|
||||
class="text-center py-12 border-2 border-dashed border-neutral-300 dark:border-neutral-600 rounded-xl bg-white dark:bg-neutral-950 shadow-sm">
|
||||
<h4 class="font-medium text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
No team members yet
|
||||
</h4>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
Get started by adding your first team member.
|
||||
</p>
|
||||
<UButton @click="addMember" size="lg" variant="solid" color="primary">
|
||||
|
|
@ -32,7 +36,7 @@
|
|||
<div
|
||||
v-for="(member, index) in members"
|
||||
:key="member.id"
|
||||
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
class="p-6 border-2 border-black dark:border-neutral-400 rounded-xl bg-white dark:bg-neutral-950 shadow-md">
|
||||
<!-- Header row with name and optional coverage chip -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
|
|
@ -74,7 +78,9 @@
|
|||
<!-- Show minimum needs field when needs-weighted policy is selected -->
|
||||
<UFormField
|
||||
v-if="isNeedsWeighted"
|
||||
:label="`Minimum needs (${getCurrencySymbol(coop.currency.value)}/month)`"
|
||||
:label="`Minimum needs (${getCurrencySymbol(
|
||||
coop.currency.value
|
||||
)}/month)`"
|
||||
required>
|
||||
<UInputNumber
|
||||
v-model="member.minMonthlyNeeds"
|
||||
|
|
@ -95,7 +101,6 @@
|
|||
@click="addMember"
|
||||
size="lg"
|
||||
variant="solid"
|
||||
color="success"
|
||||
:ui="{
|
||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||
|
|
|
|||
|
|
@ -2,19 +2,20 @@
|
|||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black mb-2">
|
||||
<h3 class="text-2xl font-black text-black dark:text-white mb-2">
|
||||
How will you share money?
|
||||
</h3>
|
||||
<p class="text-neutral-600">
|
||||
<p class="text-neutral-600 dark:text-neutral-200">
|
||||
This is the foundation of your co-op's finances. Choose a pay approach
|
||||
and set your hourly rate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pay Policy Selection -->
|
||||
<div class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
<div
|
||||
class="p-6 border-2 border-black dark:border-netural-400 bg-white dark:bg-neutral-950 shadow-md">
|
||||
<h4 class="font-bold mb-2">Step 1: Choose your pay approach</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-200 mb-4">
|
||||
How should available money be shared among members?
|
||||
</p>
|
||||
<URadioGroup
|
||||
|
|
@ -27,9 +28,10 @@
|
|||
</div>
|
||||
|
||||
<!-- Hourly Wage Input -->
|
||||
<div class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||
<div
|
||||
class="p-6 border-2 border-black dark:border-neutral-950 bg-white dark:bg-neutral-950 shadow-md">
|
||||
<h4 class="font-bold mb-2">Step 2: Set your base wage</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-200 mb-4">
|
||||
This hourly rate applies to all paid work in your co-op
|
||||
</p>
|
||||
<div class="flex gap-4 items-start">
|
||||
|
|
|
|||
|
|
@ -2,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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
<h4 class="font-semibold">Stress Test</h4>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-600"
|
||||
<label class="text-xs text-neutral-600"
|
||||
>Revenue Delay (months)</label
|
||||
>
|
||||
<URange
|
||||
|
|
@ -29,24 +29,24 @@
|
|||
:max="6"
|
||||
:step="1"
|
||||
class="mt-1" />
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-neutral-500">
|
||||
{{ stress.revenueDelay }} months
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Cost Shock (%)</label>
|
||||
<label class="text-xs text-neutral-600">Cost Shock (%)</label>
|
||||
<URange
|
||||
v-model="stress.costShockPct"
|
||||
:min="0"
|
||||
:max="30"
|
||||
:step="1"
|
||||
class="mt-1" />
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-neutral-500">
|
||||
{{ stress.costShockPct }}%
|
||||
</div>
|
||||
</div>
|
||||
<UCheckbox v-model="stress.grantLost" label="Grant lost" />
|
||||
<div class="text-sm text-gray-600 pt-2 border-t">
|
||||
<div class="text-sm text-neutral-600 pt-2 border-t">
|
||||
Projected runway: {{ projectedRunway }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -72,13 +72,13 @@
|
|||
<span>{{ milestone.willReach ? "✅" : "⚠️" }}</span>
|
||||
<span>{{ milestone.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600">{{
|
||||
<span class="text-xs text-neutral-600">{{
|
||||
formatDate(milestone.date)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="milestones.length === 0"
|
||||
class="text-sm text-gray-600 italic">
|
||||
class="text-sm text-neutral-600 italic">
|
||||
No milestones yet
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="flex-1 h-3 bg-neutral-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="barColor"
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600">Mode:</span>
|
||||
<span class="text-sm text-neutral-600">Mode:</span>
|
||||
<UButtonGroup>
|
||||
<UButton
|
||||
:variant="modelValue === 'minimum' ? 'solid' : 'ghost'"
|
||||
color="gray"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
@click="$emit('update:modelValue', 'minimum')"
|
||||
>
|
||||
@click="$emit('update:modelValue', 'minimum')">
|
||||
Min Mode
|
||||
</UButton>
|
||||
<UButton
|
||||
:variant="modelValue === 'target' ? 'solid' : 'ghost'"
|
||||
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>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export default defineNuxtRouteMiddleware((to) => {
|
||||
// Redirect root path to dashboard
|
||||
if (to.path === '/') {
|
||||
return navigateTo('/dashboard')
|
||||
}
|
||||
})
|
||||
BIN
pages/.DS_Store
vendored
BIN
pages/.DS_Store
vendored
Binary file not shown.
128
pages/budget.vue
128
pages/budget.vue
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue