refactor: update routing paths in app.vue, enhance AnnualBudget component layout, and streamline dashboard and budget pages for improved user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-08 09:39:30 +01:00
parent 09d8794d72
commit 864a81065c
23 changed files with 3211 additions and 1978 deletions

File diff suppressed because it is too large Load diff

30
pages/cash-flow.vue Normal file
View file

@ -0,0 +1,30 @@
<template>
<div class="max-w-6xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Cash Flow Analysis
</h1>
<p class="text-gray-600 dark:text-gray-400">
Detailed cash flow projections with one-time events and scenario planning.
</p>
</div>
<div class="space-y-8">
<!-- Unified Cash Flow Dashboard -->
<UnifiedCashFlowDashboard />
<!-- One-Off Events Editor -->
<OneOffEventEditor />
</div>
</div>
</template>
<script setup lang="ts">
// Component auto-imported
// SEO
useSeoMeta({
title: 'Cash Flow Analysis - Plan Your Cooperative Finances',
description: 'Detailed cash flow analysis with runway projections, one-time events, and scenario planning for your cooperative.'
})
</script>

View file

@ -289,6 +289,16 @@
>
</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"
@ -347,6 +357,11 @@ const streamsValid = computed(() => {
);
});
// Check if we have basic data for scenario exploration
const hasBasicData = computed(() => {
return membersValid.value && (costsValid.value || streamsValid.value);
});
// Computed validation - all 4 steps must be valid
const canComplete = computed(() => {
return (

View file

@ -1,32 +1,166 @@
<template>
<div class="max-w-6xl mx-auto px-4 py-6 space-y-8" data-ui="dashboard_v1">
<section class="py-8 space-y-6 max-w-4xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Dashboard</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600">Min</span>
<UToggle
:model-value="operatingMode === 'target'"
@update:model-value="(value) => setOperatingMode(value ? 'target' : 'min')"
/>
<span class="text-sm text-gray-600">Target</span>
<h2 class="text-2xl font-semibold">Compensation</h2>
<div class="flex items-center gap-2">
<span class="text-sm">Mode:</span>
<button
@click="setOperatingMode('min')"
class="px-3 py-1 text-sm font-bold border-2 border-black"
:class="coopStore.operatingMode === 'min' ? 'bg-black text-white' : 'bg-white'">
MIN
</button>
<button
@click="setOperatingMode('target')"
class="px-3 py-1 text-sm font-bold border-2 border-black"
:class="coopStore.operatingMode === 'target' ? 'bg-black text-white' : 'bg-white'">
TARGET
</button>
</div>
</div>
<!-- Core Metrics -->
<DashboardCoreMetrics />
<!-- Simple Policy Display -->
<div class="border-2 border-black bg-white p-4">
<div class="text-lg font-bold mb-2">
{{ getPolicyName() }} Policy
</div>
<div class="text-2xl font-mono">
{{ getPolicyFormula() }}
</div>
</div>
<!-- Member Coverage -->
<MemberCoveragePanel />
<!-- Member List -->
<div class="border-2 border-black bg-white">
<div class="border-b-2 border-black p-4">
<h3 class="font-bold">Members ({{ coopStore.members.length }})</h3>
</div>
<div class="divide-y divide-gray-300">
<div v-if="coopStore.members.length === 0" class="p-4 text-gray-500 text-center">
No members yet. Add members in Setup Wizard.
</div>
<div v-for="member in membersWithPay" :key="member.id" class="p-4 flex justify-between items-center">
<div>
<div class="font-bold">{{ member.name || 'Unnamed' }}</div>
<div class="text-sm text-gray-600">
<span v-if="coopStore.policy?.relationship === 'needs-weighted'">
Needs: {{ $format.currency(member.minMonthlyNeeds || 0) }}/month
</span>
<span v-else>
{{ member.hoursPerMonth || 0 }} hrs/month
</span>
</div>
</div>
<div class="text-right">
<div class="font-mono font-bold">{{ $format.currency(member.expectedPay) }}</div>
<div class="text-xs" :class="member.coverage >= 100 ? 'text-green-600' : 'text-red-600'">
{{ member.coverage }}% covered
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Total -->
<div class="border-2 border-black bg-gray-100 p-4">
<div class="flex justify-between items-center">
<span class="font-bold">Total Monthly Payroll</span>
<span class="text-xl font-mono font-bold">{{ $format.currency(totalPayroll) }}</span>
</div>
<div class="flex justify-between items-center mt-2 text-sm text-gray-600">
<span>+ Oncosts ({{ coopStore.payrollOncostPct }}%)</span>
<span class="font-mono">{{ $format.currency(totalPayroll * coopStore.payrollOncostPct / 100) }}</span>
</div>
<div class="flex justify-between items-center mt-2 pt-2 border-t border-gray-400">
<span class="font-bold">Total Cost</span>
<span class="text-xl font-mono font-bold">{{ $format.currency(totalPayroll * (1 + coopStore.payrollOncostPct / 100)) }}</span>
</div>
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
@click="navigateTo('/coop-builder')"
class="px-4 py-2 border-2 border-black bg-white font-bold hover:bg-gray-100">
Edit in Setup Wizard
</button>
</div>
</section>
</template>
<script setup lang="ts">
// Import components explicitly to avoid auto-import issues
import DashboardCoreMetrics from '~/components/dashboard/DashboardCoreMetrics.vue'
import MemberCoveragePanel from '~/components/dashboard/MemberCoveragePanel.vue'
const { $format } = useNuxtApp();
const coopStore = useCoopBuilderStore();
// Access composable data
const { operatingMode, setOperatingMode } = useCoopBuilder()
// Calculate member pay based on policy
const membersWithPay = computed(() => {
const policyType = coopStore.policy?.relationship || 'equal-pay';
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
const totalNeeds = coopStore.members.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0);
return coopStore.members.map(member => {
let expectedPay = 0;
const hours = member.hoursPerMonth || 0;
if (policyType === 'equal-pay') {
// Equal pay: hours × wage
expectedPay = hours * coopStore.equalHourlyWage;
} else if (policyType === 'hours-weighted') {
// Hours weighted: proportion of total hours
expectedPay = totalHours > 0 ? (hours / totalHours) * (totalHours * coopStore.equalHourlyWage) : 0;
} else if (policyType === 'needs-weighted') {
// Needs weighted: based on individual needs
const needs = member.minMonthlyNeeds || 0;
expectedPay = totalNeeds > 0 ? (needs / totalNeeds) * (totalHours * coopStore.equalHourlyWage) : 0;
}
const actualPay = member.monthlyPayPlanned || expectedPay;
const coverage = expectedPay > 0 ? Math.round((actualPay / expectedPay) * 100) : 100;
return {
...member,
expectedPay,
actualPay,
coverage
};
});
});
// Total payroll
const totalPayroll = computed(() => {
return membersWithPay.value.reduce((sum, m) => sum + m.expectedPay, 0);
});
// Operating mode toggle
function setOperatingMode(mode: 'min' | 'target') {
coopStore.setOperatingMode(mode);
}
// Get current policy name
function getPolicyName() {
// Check both coopStore.policy and the root level policy.relationship
const policyType = coopStore.policy?.relationship || coopStore.policy || 'equal-pay';
if (policyType === 'equal-pay') return 'Equal Pay';
if (policyType === 'hours-weighted') return 'Hours Based';
if (policyType === 'needs-weighted') return 'Needs Based';
return 'Equal Pay'; // fallback
}
// Get policy formula display
function getPolicyFormula() {
const policyType = coopStore.policy?.relationship || coopStore.policy || 'equal-pay';
const mode = coopStore.operatingMode === 'target' ? 'Target' : 'Min';
if (policyType === 'equal-pay') {
return `${$format.currency(coopStore.equalHourlyWage)}/hour × ${mode} Hours`;
}
if (policyType === 'hours-weighted') {
return `Based on ${mode} Hours Proportion`;
}
if (policyType === 'needs-weighted') {
return `Based on Individual Needs`;
}
return `${$format.currency(coopStore.equalHourlyWage)}/hour × ${mode} Hours`;
}
</script>

View file

@ -2,40 +2,32 @@
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold">Dashboard</h2>
<h2 class="text-2xl font-semibold">Compensation</h2>
<div class="flex items-center gap-2 mt-1">
<UBadge
:color="policiesStore.operatingMode === 'target' ? 'primary' : 'gray'"
size="xs"
>
{{ policiesStore.operatingMode === 'target' ? '🎯 Target Mode' : '⚡ Min Mode' }}
</UBadge>
<span class="text-xs text-gray-500">
<span class="px-2 py-1 border border-black bg-white text-xs font-bold uppercase">
{{ policiesStore.operatingMode === 'target' ? 'Target Mode' : 'Min Mode' }}
</span>
<span class="text-xs font-mono">
Runway: {{ Math.round(metrics.runway) }}mo
</span>
</div>
</div>
<div class="flex gap-2">
<UButton
icon="i-heroicons-arrow-down-tray"
color="gray"
<button
@click="onExport"
>Export JSON</UButton
>
<UButton icon="i-heroicons-arrow-up-tray" color="gray" @click="onImport"
>Import JSON</UButton
>
class="px-4 py-2 border-2 border-black bg-white font-bold uppercase text-sm hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-shadow">
Export JSON
</button>
<button
@click="onImport"
class="px-4 py-2 border-2 border-black bg-white font-bold uppercase text-sm hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-shadow">
Import JSON
</button>
</div>
</div>
<!-- Key Metrics Row -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<RunwayMeter
:months="metrics.runway"
:description="`You have ${$format.number(
metrics.runway
)} months of runway with current spending.`" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<CoverageMeter
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
:target-hours="metrics.totalTargetHours"
@ -46,141 +38,152 @@
:savings-target-months="savingsProgress.targetMonths"
:monthly-burn="getMonthlyBurn()"
:description="`${savingsProgress.progressPct.toFixed(0)}% of savings target reached. ${savingsProgress.gap > 0 ? 'Gap: ' + $format.currency(savingsProgress.gap) : 'Target achieved!'}`" />
<UCard>
<div class="text-center space-y-3">
<div class="text-3xl font-bold" :class="concentrationColor">
{{ topSourcePct }}%
</div>
<div class="text-sm text-neutral-600">
<GlossaryTooltip
term="Concentration"
term-id="concentration"
definition="Dependence on few revenue sources. UI shows top source percentage." />
</div>
<ConcentrationChip
:status="concentrationStatus"
:top-source-pct="topSourcePct"
:show-percentage="false"
variant="soft" />
<p class="text-xs text-neutral-500 mt-2">
Most of your money comes from one place. Add another stream to
reduce risk.
</p>
</div>
</UCard>
</div>
<!-- Quick Wins Dashboard Components -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Dashboard Components with Wizard Styling -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Needs Coverage Bars -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Member Coverage</h3>
</template>
<NeedsCoverageBars />
</UCard>
<!-- Revenue Mix -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Revenue Mix</h3>
</template>
<RevenueMixTable />
</UCard>
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div class="border-b-2 border-black p-4">
<h3 class="text-lg font-bold uppercase">Member Coverage</h3>
</div>
<div class="p-4">
<NeedsCoverageBars />
</div>
</div>
<!-- Milestone-Runway Overlay -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Runway vs Milestones</h3>
</template>
<MilestoneRunwayOverlay />
</UCard>
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div class="border-b-2 border-black p-4">
<h3 class="text-lg font-bold uppercase">Runway vs Milestones</h3>
</div>
<div class="p-4">
<MilestoneRunwayOverlay />
</div>
</div>
</div>
<!-- Alerts Section -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Alerts</h3>
</template>
<div class="space-y-3">
<!-- Alerts Section with Wizard Styling -->
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div class="border-b-2 border-black p-4">
<h3 class="text-lg font-bold uppercase">Alerts</h3>
</div>
<div class="p-4 space-y-4">
<!-- Concentration Risk Alert -->
<UAlert
<div
v-if="topSourcePct > 50"
color="red"
variant="subtle"
icon="i-heroicons-exclamation-triangle"
title="Revenue Concentration Risk"
:description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
:actions="[
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') }
]" />
class="border-2 border-red-600 bg-red-50 p-4">
<div class="flex items-start gap-3">
<span class="text-red-600 font-bold text-xl">!</span>
<div class="flex-1">
<h4 class="font-bold uppercase mb-1">Revenue Concentration Risk</h4>
<p class="text-sm mb-2">{{ topStreamName }} = {{ topSourcePct }}% of total consider balancing</p>
<button
@click="handleAlertNavigation('/dashboard', 'concentration')"
class="text-sm underline font-bold">
VIEW DETAILS
</button>
</div>
</div>
</div>
<!-- Cushion Breach Alert -->
<UAlert
<div
v-if="alerts.cushionBreach"
color="orange"
variant="subtle"
icon="i-heroicons-calendar"
title="Cash Cushion Breach Forecast"
:description="`Projected to breach minimum cushion in week ${cushionForecast.firstBreachWeek || 'unknown'}`"
:actions="[
{ label: 'View Calendar', click: () => handleAlertNavigation('/cash', 'breach-forecast') },
{ label: 'Adjust Budget', click: () => handleAlertNavigation('/budget', 'expenses') }
]" />
class="border-2 border-orange-600 bg-orange-50 p-4">
<div class="flex items-start gap-3">
<span class="text-orange-600 font-bold text-xl">!</span>
<div class="flex-1">
<h4 class="font-bold uppercase mb-1">Cash Cushion Breach Forecast</h4>
<p class="text-sm mb-2">Projected to breach minimum cushion in week {{ cushionForecast.firstBreachWeek || 'unknown' }}</p>
<div class="flex gap-4">
<button
@click="handleAlertNavigation('/cash', 'breach-forecast')"
class="text-sm underline font-bold">
VIEW CALENDAR
</button>
<button
@click="handleAlertNavigation('/budget', 'expenses')"
class="text-sm underline font-bold">
ADJUST BUDGET
</button>
</div>
</div>
</div>
</div>
<!-- Savings Below Target Alert -->
<UAlert
<div
v-if="alerts.savingsBelowTarget"
color="yellow"
variant="subtle"
icon="i-heroicons-banknotes"
title="Savings Below Target"
:description="`${savingsProgress.progressPct.toFixed(0)}% of target reached. Build savings before increasing paid hours.`"
:actions="[
{ label: 'View Progress', click: () => handleAlertNavigation('/budget', 'savings') },
{ label: 'Adjust Policies', click: () => handleAlertNavigation('/coop-builder', 'policies') }
]" />
class="border-2 border-yellow-600 bg-yellow-50 p-4">
<div class="flex items-start gap-3">
<span class="text-yellow-600 font-bold text-xl">!</span>
<div class="flex-1">
<h4 class="font-bold uppercase mb-1">Savings Below Target</h4>
<p class="text-sm mb-2">{{ savingsProgress.progressPct.toFixed(0) }}% of target reached. Build savings before increasing paid hours.</p>
<div class="flex gap-4">
<button
@click="handleAlertNavigation('/budget', 'savings')"
class="text-sm underline font-bold">
VIEW PROGRESS
</button>
<button
@click="handleAlertNavigation('/coop-builder', 'policies')"
class="text-sm underline font-bold">
ADJUST POLICIES
</button>
</div>
</div>
</div>
</div>
<!-- Over-Deferred Member Alert -->
<UAlert
<div
v-if="deferredAlert.show"
color="purple"
variant="subtle"
icon="i-heroicons-user-group"
title="Member Over-Deferred"
:description="deferredAlert.description"
:actions="[
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
]" />
class="border-2 border-purple-600 bg-purple-50 p-4">
<div class="flex items-start gap-3">
<span class="text-purple-600 font-bold text-xl">!</span>
<div class="flex-1">
<h4 class="font-bold uppercase mb-1">Member Over-Deferred</h4>
<p class="text-sm mb-2">{{ deferredAlert.description }}</p>
<button
@click="handleAlertNavigation('/coop-builder', 'members')"
class="text-sm underline font-bold">
REVIEW MEMBERS
</button>
</div>
</div>
</div>
<!-- Success message when no alerts -->
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
class="text-center py-8 text-gray-500">
<UIcon name="i-heroicons-check-circle" class="w-8 h-8 mx-auto mb-2 text-green-500" />
<p class="font-medium">All systems looking good!</p>
<p class="text-sm">No critical alerts at this time.</p>
class="text-center py-8">
<span class="text-4xl font-bold"></span>
<p class="font-bold uppercase mt-2">All systems looking good!</p>
<p class="text-sm mt-1">No critical alerts at this time.</p>
</div>
</div>
</UCard>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<UButton
block
variant="ghost"
class="justify-start h-auto p-4"
@click="navigateTo('/mix')">
<div class="text-left">
<div class="font-medium">Revenue Mix</div>
<div class="text-xs text-neutral-500">Plan revenue streams</div>
</div>
</UButton>
<!-- Quick Actions with Wizard Styling -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
@click="navigateTo('/cash-flow')"
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
<div class="font-bold uppercase mb-1">Cash Flow Analysis</div>
<div class="text-sm">Detailed runway & one-time events</div>
</button>
<button
@click="navigateTo('/budget')"
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
<div class="font-bold uppercase mb-1">Budget Planning</div>
<div class="text-sm">Manage expenses & savings</div>
</button>
</div>
</section>
</template>

View file

@ -1,345 +0,0 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold">Revenue Mix Planner</h2>
<div v-if="isSetupComplete" class="flex items-center gap-2 mt-1">
<UBadge color="green" variant="subtle" size="xs">
Synchronized with Setup
</UBadge>
<UButton variant="ghost" size="xs" @click="goToSetup">
Edit in Setup
</UButton>
</div>
</div>
<UButton color="primary" @click="sendToBudget">
Send to Budget & Scenarios
</UButton>
</div>
<!-- Concentration Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UCard>
<template #header>
<h3 class="text-lg font-medium">Concentration Risk</h3>
</template>
<div class="space-y-4">
<div class="text-center">
<div class="text-4xl font-bold mb-2" :class="concentrationColor">
{{ topSourcePct }}%
</div>
<div class="text-sm text-neutral-600 mb-3">
Top source percentage
</div>
<ConcentrationChip
:status="concentrationStatus"
:top-source-pct="topSourcePct"
:show-percentage="false"
variant="solid"
size="md" />
</div>
<p class="text-sm text-neutral-600 text-center">
Most of your money comes from one place. Add another stream to
reduce risk.
</p>
</div>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Payout Delay Exposure</h3>
</template>
<div class="space-y-4">
<div class="text-center">
<div class="text-4xl font-bold text-yellow-600 mb-2">35 days</div>
<div class="text-sm text-neutral-600 mb-3">
Weighted average delay
</div>
<UBadge color="yellow" variant="subtle">Moderate Risk</UBadge>
</div>
<p class="text-sm text-neutral-600 text-center">
Money is earned now but arrives later. Delays can create mid-month
dips.
</p>
</div>
</UCard>
</div>
<!-- Revenue Streams Table -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Revenue Streams</h3>
<UButton icon="i-heroicons-plus" size="sm" @click="addStream">
Add Stream
</UButton>
</div>
</template>
<UTable :rows="streams" :columns="columns">
<template #name-data="{ row }">
<div>
<div class="font-medium">{{ row.name }}</div>
<div class="text-xs text-neutral-500">{{ row.category }}</div>
</div>
</template>
<template #targetPct-data="{ row }">
<div class="flex items-center gap-2">
<UInput
v-model="row.targetPct"
type="number"
size="xs"
class="w-16"
@update:model-value="updateStream(row.id, 'targetPct', $event)" />
<span class="text-xs text-neutral-500">%</span>
</div>
</template>
<template #targetAmount-data="{ row }">
<div class="flex items-center gap-2">
<span class="text-xs text-neutral-500"></span>
<UInput
v-model="row.targetMonthlyAmount"
type="number"
size="xs"
class="w-20"
@update:model-value="
updateStream(row.id, 'targetMonthlyAmount', $event)
" />
</div>
</template>
<template #fees-data="{ row }">
<div class="text-sm">
<div v-if="row.platformFeePct > 0">
Platform: {{ row.platformFeePct }}%
</div>
<div v-if="row.revenueSharePct > 0">
Share: {{ row.revenueSharePct }}%
</div>
<div
v-if="row.platformFeePct === 0 && row.revenueSharePct === 0"
class="text-neutral-400">
None
</div>
</div>
</template>
<template #delay-data="{ row }">
<div class="flex items-center gap-2">
<UInput
v-model="row.payoutDelayDays"
type="number"
size="xs"
class="w-16"
@update:model-value="
updateStream(row.id, 'payoutDelayDays', $event)
" />
<span class="text-xs text-neutral-500">days</span>
</div>
</template>
<template #restrictions-data="{ row }">
<RestrictionChip :restriction="row.restrictions" />
</template>
<template #certainty-data="{ row }">
<UBadge
:color="getCertaintyColor(row.certainty)"
variant="subtle"
size="xs">
{{ row.certainty }}
</UBadge>
</template>
<template #actions-data="{ row }">
<UDropdown :items="getRowActions(row)">
<UButton
icon="i-heroicons-ellipsis-horizontal"
size="xs"
variant="ghost" />
</UDropdown>
</template>
</UTable>
<div class="mt-4 p-4 bg-neutral-50 rounded-lg">
<div class="flex justify-between text-sm">
<span class="font-medium">Totals</span>
<div class="flex gap-6">
<span>{{ totalTargetPct }}%</span>
<span>{{ $format.currency(totalMonthlyAmount) }}</span>
</div>
</div>
</div>
</UCard>
</section>
</template>
<script setup lang="ts">
const { $format } = useNuxtApp();
// Use synchronized store data - setup is the source of truth
const { initSync, getStreams, unifiedStreams } = useStoreSync();
const { isSetupComplete, goToSetup } = useSetupState();
const streamsStore = useStreamsStore();
const coopStore = useCoopBuilderStore();
// Initialize synchronization on mount
onMounted(async () => {
await initSync();
});
// Use reactive synchronized streams data
const streams = unifiedStreams;
const columns = [
{ id: "name", key: "name", label: "Stream" },
{ id: "targetPct", key: "targetPct", label: "Target %" },
{ id: "targetAmount", key: "targetAmount", label: "Monthly €" },
{ id: "fees", key: "fees", label: "Fees" },
{ id: "delay", key: "delay", label: "Payout Delay" },
{ id: "restrictions", key: "restrictions", label: "Use" },
{ id: "certainty", key: "certainty", label: "Certainty" },
{ id: "actions", key: "actions", label: "" },
];
const totalTargetPct = computed(() => {
// Calculate from the unified streams data
return streams.value.reduce((sum, stream) => sum + (stream.targetPct || 0), 0);
});
const totalMonthlyAmount = computed(() => {
// Calculate from the unified streams data
return streams.value.reduce((sum, stream) => sum + (stream.targetMonthlyAmount || stream.monthly || 0), 0);
});
// Calculate concentration metrics
const topSourcePct = computed(() => {
if (streams.value.length === 0) return 0;
const amounts = streams.value.map((s) => s.targetMonthlyAmount || 0);
return (
Math.round((Math.max(...amounts) / totalMonthlyAmount.value) * 100) || 0
);
});
const concentrationStatus = computed(() => {
if (topSourcePct.value > 50) return "red";
if (topSourcePct.value > 35) return "yellow";
return "green";
});
const concentrationColor = computed(() => {
if (topSourcePct.value > 50) return "text-red-600";
if (topSourcePct.value > 35) return "text-yellow-600";
return "text-green-600";
});
function getCertaintyColor(certainty: string) {
switch (certainty) {
case "Committed":
return "green";
case "Probable":
return "blue";
case "Aspirational":
return "yellow";
default:
return "gray";
}
}
function getRowActions(row: any) {
return [
[
{
label: "Edit",
icon: "i-heroicons-pencil",
click: () => editStream(row),
},
{
label: "Duplicate",
icon: "i-heroicons-document-duplicate",
click: () => duplicateStream(row),
},
{
label: "Remove",
icon: "i-heroicons-trash",
click: () => removeStream(row),
},
],
];
}
function updateStream(id: string, field: string, value: any) {
// Update the primary CoopBuilder store (source of truth)
const coopStream = coopStore.streams.find((s) => s.id === id);
if (coopStream) {
if (field === 'targetMonthlyAmount') {
coopStream.monthly = Number(value) || 0;
} else {
coopStream[field] = Number(value) || value;
}
coopStore.upsertStream(coopStream);
}
// Also update the legacy store for backward compatibility
const legacyStream = streams.value.find((s) => s.id === id);
if (legacyStream) {
legacyStream[field] = Number(value) || value;
streamsStore.upsertStream(legacyStream);
}
}
function addStream() {
const newStreamId = Date.now().toString();
// Add to CoopBuilder store first (primary source)
const coopStream = {
id: newStreamId,
label: "",
monthly: 0,
category: "games",
certainty: "Aspirational",
};
coopStore.upsertStream(coopStream);
// Add to legacy store for compatibility
const legacyStream = {
id: newStreamId,
name: "",
category: "games",
subcategory: "",
targetPct: 0,
targetMonthlyAmount: 0,
certainty: "Aspirational",
payoutDelayDays: 30,
terms: "Net 30",
revenueSharePct: 0,
platformFeePct: 0,
restrictions: "General",
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0,
};
streamsStore.upsertStream(legacyStream);
}
function editStream(row: any) {
// Edit stream logic
console.log("Edit stream", row);
}
function duplicateStream(row: any) {
// Duplicate stream logic
console.log("Duplicate stream", row);
}
function removeStream(row: any) {
// Remove from both stores to maintain sync
coopStore.removeStream(row.id);
streamsStore.removeStream(row.id);
}
function sendToBudget() {
navigateTo("/budget");
}
</script>

View file

@ -1,23 +0,0 @@
<template>
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Runway Lite
</h1>
<p class="text-gray-600 dark:text-gray-400">
Quick runway assessment with revenue scenarios
</p>
</div>
<RunwayLite
:starting-cash="budgetData.startingCash.value"
:revenue-planned="budgetData.revenuePlanned.value"
:expense-planned="budgetData.expensePlanned.value"
:diversification-guidance="budgetData.diversification.value.guidance"
/>
</div>
</template>
<script setup lang="ts">
const budgetData = useBudget('default', new Date().getFullYear())
</script>

View file

@ -172,15 +172,16 @@
<p class="content-paragraph mb-3 leading-relaxed text-left">
Any person who:
</p>
<ul class="content-list my-2 pl-6 list-disc">
<li>Shares our values and purpose</li>
<li>
Contributes labour to the cooperative (by doing actual work,
not just investing money)
</li>
<li>Commits to collective decision-making</li>
<li>Participates in governance responsibilities</li>
</ul>
<UFormField label="Member Requirements" class="form-group-large">
<UTextarea
v-model="formData.memberRequirements"
:rows="4"
placeholder="Enter member requirements"
size="xl"
class="large-field"
@input="debouncedAutoSave"
@change="autoSave" />
</UFormField>
</div>
<div>
@ -413,43 +414,102 @@
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
Paying Ourselves
</h3>
<ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap">
Base rate: $<UInput
v-model="formData.baseRate"
type="number"
placeholder="25"
class="inline-field number-field"
@change="autoSave" />/hour for all members
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Or: Equal monthly draw of $<UInput
v-model="formData.monthlyDraw"
type="number"
placeholder="2000"
class="inline-field number-field"
@change="autoSave" />
per member
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Paid on the
<USelect
v-model="formData.paymentDay"
:items="dayOptions"
placeholder="15"
class="inline-field"
@change="autoSave" />
of each month
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Surplus (profit) distributed equally every
<UInput
v-model="formData.surplusFrequency"
placeholder="quarter"
class="inline-field"
@change="autoSave" />
</li>
</ul>
<!-- Pay Policy Selection -->
<UFormField label="Pay Policy" class="form-group-large mb-4">
<USelect
v-model="formData.payPolicy"
:items="payPolicyOptions"
placeholder="Select pay policy"
size="xl"
class="w-full"
@change="autoSave" />
</UFormField>
<!-- Equal Pay Policy -->
<div v-if="formData.payPolicy === 'equal-pay'" class="space-y-3">
<p class="content-paragraph">All members receive equal compensation regardless of role or hours worked.</p>
<ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap">
Base rate: $<UInput
v-model="formData.baseRate"
type="number"
placeholder="25"
class="inline-field number-field"
@change="autoSave" />/hour for all members
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Or: Equal monthly draw of $<UInput
v-model="formData.monthlyDraw"
type="number"
placeholder="2000"
class="inline-field number-field"
@change="autoSave" />
per member
</li>
</ul>
</div>
<!-- Hours-Weighted Policy -->
<div v-if="formData.payPolicy === 'hours-weighted'" class="space-y-3">
<p class="content-paragraph">Compensation is proportional to hours worked by each member.</p>
<ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap">
Hourly rate: $<UInput
v-model="formData.hourlyRate"
type="number"
placeholder="25"
class="inline-field number-field"
@change="autoSave" />/hour
</li>
<li>Members track their hours and are paid accordingly</li>
<li>Minimum hours commitment may apply</li>
</ul>
</div>
<!-- Needs-Weighted Policy -->
<div v-if="formData.payPolicy === 'needs-weighted'" class="space-y-3">
<p class="content-paragraph">Compensation is allocated based on each member's individual financial needs.</p>
<ul class="content-list my-2 pl-6 list-disc">
<li>Members declare their minimum monthly needs</li>
<li>Available payroll is distributed proportionally to cover needs</li>
<li>Regular needs assessment and adjustment process</li>
<li class="flex items-baseline gap-2 flex-wrap">
Minimum guaranteed amount: $<UInput
v-model="formData.minGuaranteedPay"
type="number"
placeholder="1000"
class="inline-field number-field"
@change="autoSave" />/month
</li>
</ul>
</div>
<!-- Common payment details -->
<div class="mt-4 space-y-2">
<p class="content-paragraph font-semibold">Payment Schedule:</p>
<ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap">
Paid on the
<USelect
v-model="formData.paymentDay"
:items="dayOptions"
placeholder="15th"
arrow
class="inline-field"
@change="autoSave" />
of each month
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Surplus (profit) distributed equally every
<UInput
v-model="formData.surplusFrequency"
placeholder="quarter"
class="inline-field"
@change="autoSave" />
</li>
</ul>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -522,15 +582,16 @@
@change="autoSave" />
months. Current roles include:
</p>
<ul class="content-list">
<li>
Financial coordinator (handles bookkeeping, not financial
decisions)
</li>
<li>Meeting facilitator</li>
<li>External communications</li>
<li>Others</li>
</ul>
<UFormField label="Rotating Roles" class="form-group-large">
<UTextarea
v-model="formData.rotatingRoles"
:rows="4"
placeholder="List rotating operational roles"
size="xl"
class="large-field"
@input="debouncedAutoSave"
@change="autoSave" />
</UFormField>
</div>
<div>
@ -541,11 +602,16 @@
<p class="content-paragraph mb-3 leading-relaxed text-left">
All members participate in:
</p>
<ul class="content-list my-2 pl-6 list-disc">
<li>Governance and decision-making</li>
<li>Strategic planning</li>
<li>Mutual support and care</li>
</ul>
<UFormField label="Shared Responsibilities" class="form-group-large">
<UTextarea
v-model="formData.sharedResponsibilities"
:rows="3"
placeholder="List shared responsibilities for all members"
size="xl"
class="large-field"
@input="debouncedAutoSave"
@change="autoSave" />
</UFormField>
</div>
</div>
</div>
@ -690,47 +756,6 @@
</div>
</div>
<!-- Section 10: Agreement Review -->
<div class="section-card">
<h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
10. Agreement Review
</h2>
<div class="space-y-4">
<p class="content-paragraph mb-3 leading-relaxed text-left">
By using this agreement, we commit to these principles and to
showing up for each other.
</p>
<div
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300">
<p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
This agreement was last updated on
<UInput
v-model="formData.lastUpdated"
type="date"
class="inline-field"
@change="autoSave" />. We commit to reviewing it on
<UInput
v-model="formData.nextReview"
type="date"
class="inline-field"
@change="autoSave" />
or sooner if circumstances require.
</p>
</div>
<div
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950">
<p
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic">
[Space for member signatures when printed]
</p>
</div>
</div>
</div>
</div>
</div>
</div>
@ -768,13 +793,36 @@ const monthOptions = [
"December",
];
const dayOptions = Array.from({ length: 31 }, (_, i) => (i + 1).toString());
const dayOptions = Array.from({ length: 31 }, (_, i) => ({
value: i + 1,
label: `${i + 1}${getOrdinalSuffix(i + 1)}`
}));
// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.)
function getOrdinalSuffix(num) {
if (num >= 11 && num <= 13) {
return 'th';
}
switch (num % 10) {
case 1: return 'st';
case 2: return 'nd';
case 3: return 'rd';
default: return 'th';
}
}
const payPolicyOptions = [
{ value: 'equal-pay', label: 'Equal Pay - All members receive equal compensation' },
{ value: 'hours-weighted', label: 'Hours-Weighted - Pay proportional to hours worked' },
{ value: 'needs-weighted', label: 'Needs-Weighted - Pay proportional to individual needs' }
];
const formData = ref({
cooperativeName: "",
dateEstablished: "",
purpose: "",
coreValues: "",
memberRequirements: "Shares our values and purpose\nContributes labour to the cooperative (by doing actual work, not just investing money)\nCommits to collective decision-making\nParticipates in governance responsibilities",
members: [{ name: "", email: "", joinDate: "", role: "" }],
trialPeriodMonths: 3,
buyInAmount: "",
@ -787,20 +835,24 @@ const formData = ref({
majorDebtThreshold: 5000,
meetingFrequency: "weekly",
emergencyNoticeHours: 24,
// Pay policy settings
payPolicy: "equal-pay",
baseRate: 25,
monthlyDraw: "",
hourlyRate: 25,
minGuaranteedPay: 1000,
paymentDay: 15,
surplusFrequency: "quarter",
targetHours: 40,
roleRotationMonths: 6,
rotatingRoles: "Financial coordinator (handles bookkeeping, not financial decisions)\nMeeting facilitator\nExternal communications\nOthers",
sharedResponsibilities: "Governance and decision-making\nStrategic planning\nMutual support and care",
reviewFrequency: "year",
assetDonationTarget: "",
legalStructure: "",
registeredLocation: "",
fiscalYearEndMonth: "December",
fiscalYearEndDay: 31,
lastUpdated: new Date().toISOString().split("T")[0],
nextReview: "",
});
// Load saved data immediately (before watchers)
@ -1017,28 +1069,12 @@ const handlePrint = () => {
// Export data for the ExportOptions component
const exportData = computed(() => ({
// Pass the complete formData object - this is what the export functions use
formData: formData.value,
// Also provide direct access to key fields for backward compatibility
cooperativeName: formData.value.cooperativeName || "Worker Cooperative",
dateEstablished: formData.value.dateEstablished,
purpose: formData.value.purpose,
coreValues: formData.value.coreValues,
members: formData.value.members,
trialPeriodMonths: formData.value.trialPeriodMonths,
policies: {
buyInAmount: formData.value.buyInAmount,
noticeDays: formData.value.noticeDays,
surplusPayoutDays: formData.value.surplusPayoutDays,
buyInReturnDays: formData.value.buyInReturnDays,
dayToDayLimit: formData.value.dayToDayLimit,
regularDecisionMin: formData.value.regularDecisionMin,
regularDecisionMax: formData.value.regularDecisionMax,
majorDebtThreshold: formData.value.majorDebtThreshold,
meetingFrequency: formData.value.meetingFrequency,
emergencyNoticeHours: formData.value.emergencyNoticeHours,
baseRate: formData.value.baseRate,
},
exportedAt: new Date().toISOString(),
section: "membership-agreement",
exportedAt: new Date().toISOString(),
}));
</script>