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:
parent
09d8794d72
commit
864a81065c
23 changed files with 3211 additions and 1978 deletions
965
pages/budget.vue
965
pages/budget.vue
File diff suppressed because it is too large
Load diff
30
pages/cash-flow.vue
Normal file
30
pages/cash-flow.vue
Normal 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>
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
261
pages/index.vue
261
pages/index.vue
|
|
@ -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>
|
||||
|
|
|
|||
345
pages/mix.vue
345
pages/mix.vue
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue