refactor: remove deprecated components and streamline member coverage calculations, enhance budget management with improved payroll handling, and update UI elements for better clarity
This commit is contained in:
parent
983aeca2dc
commit
09d8794d72
42 changed files with 2166 additions and 2974 deletions
168
pages/budget.vue
168
pages/budget.vue
|
|
@ -57,8 +57,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State Message -->
|
||||
<div v-if="activeView === 'monthly' && budgetWorksheet.revenue.length === 0 && budgetWorksheet.expenses.length === 0" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-12 text-center">
|
||||
<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">
|
||||
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-gray-800 border-2 border-black transition-colors"
|
||||
>
|
||||
Complete Setup Wizard
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly View -->
|
||||
<div v-if="activeView === 'monthly'" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div v-else-if="activeView === 'monthly'" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
|
|
@ -236,13 +255,38 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Special settings gear for payroll oncosts -->
|
||||
<div v-if="item.id === 'expense-payroll-oncosts'" class="flex items-center gap-1">
|
||||
<UButton
|
||||
@click="showPayrollOncostModal = true"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-cog-6-tooth"
|
||||
:ui="{
|
||||
base: 'text-gray-500 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-all',
|
||||
}"
|
||||
/>
|
||||
<UButton
|
||||
@click="removeItem('expenses', item.id)"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
:ui="{
|
||||
base: 'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
|
||||
}"
|
||||
>
|
||||
×
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Regular delete button for non-payroll items -->
|
||||
<UButton
|
||||
v-else
|
||||
@click="removeItem('expenses', item.id)"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
:ui="{
|
||||
base:
|
||||
'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
|
||||
base: 'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
|
||||
}"
|
||||
>
|
||||
×
|
||||
|
|
@ -316,7 +360,7 @@
|
|||
<!-- Add Revenue Modal -->
|
||||
<UModal v-model:open="showAddRevenueModal">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between border-b-4 border-black pb-4">
|
||||
<div class="flex items-center justify-between pb-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-black">Add Revenue Source</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
|
|
@ -334,7 +378,7 @@
|
|||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="space-y-5 py-4">
|
||||
<div class="space-y-6 py-4">
|
||||
<UFormGroup label="Category" required>
|
||||
<USelectMenu
|
||||
v-model="newRevenue.category"
|
||||
|
|
@ -417,7 +461,7 @@
|
|||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 border-t-2 border-gray-200 pt-4">
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<UButton @click="showAddRevenueModal = false" variant="outline" size="md">
|
||||
Cancel
|
||||
</UButton>
|
||||
|
|
@ -438,7 +482,7 @@
|
|||
<!-- Add Expense Modal -->
|
||||
<UModal v-model:open="showAddExpenseModal">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between border-b-4 border-black pb-4">
|
||||
<div class="flex items-center justify-between pb-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-black">Add Expense Item</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">Create a new expense for your budget</p>
|
||||
|
|
@ -454,7 +498,7 @@
|
|||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="space-y-5 py-4">
|
||||
<div class="space-y-6 py-4">
|
||||
<UFormGroup label="Category" required>
|
||||
<USelectMenu
|
||||
v-model="newExpense.category"
|
||||
|
|
@ -528,7 +572,7 @@
|
|||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 border-t-2 border-gray-200 pt-4">
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<UButton @click="showAddExpenseModal = false" variant="outline" size="md">
|
||||
Cancel
|
||||
</UButton>
|
||||
|
|
@ -544,20 +588,102 @@
|
|||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- Payroll Oncost Settings Modal -->
|
||||
<PayrollOncostModal
|
||||
v-model:open="showPayrollOncostModal"
|
||||
@save="handlePayrollOncostUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Stores
|
||||
// Stores and synchronization
|
||||
const budgetStore = useBudgetStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const coopBuilderStore = useCoopBuilderStore();
|
||||
const { initSync, getStreams, getMembers, unifiedStreams, unifiedMembers } = useStoreSync();
|
||||
|
||||
// Initialize synchronization and budget data
|
||||
const initializeBudgetPage = async () => {
|
||||
console.log('📊 Budget Page: Starting initialization');
|
||||
|
||||
// First, sync stores to ensure data is available (now async)
|
||||
await initSync();
|
||||
|
||||
// Additional wait to ensure all reactive updates have propagated
|
||||
await nextTick();
|
||||
|
||||
// Now check if we need to initialize budget data
|
||||
const hasCoopData = coopBuilderStore.streams.length > 0 || coopBuilderStore.members.length > 0;
|
||||
const hasBudgetData = budgetStore.budgetWorksheet.revenue.length > 0 || budgetStore.budgetWorksheet.expenses.length > 0;
|
||||
|
||||
console.log('📊 Budget Page: After sync - hasCoopData:', hasCoopData, 'hasBudgetData:', hasBudgetData);
|
||||
console.log('📊 Budget Page: Coop streams:', coopBuilderStore.streams);
|
||||
console.log('📊 Budget Page: Coop members:', coopBuilderStore.members);
|
||||
|
||||
if (hasCoopData && !hasBudgetData) {
|
||||
console.log('📊 Budget Page: Initializing budget from coop data');
|
||||
// Force initialization since we have coop data but no budget data
|
||||
await budgetStore.forceInitializeFromWizardData();
|
||||
// Refresh the page data after initialization
|
||||
await nextTick();
|
||||
} else if (!hasCoopData && !hasBudgetData) {
|
||||
console.log('📊 Budget Page: No data found in any store');
|
||||
// Try one more time to get the data after a delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const retryCoopData = coopBuilderStore.streams.length > 0 || coopBuilderStore.members.length > 0;
|
||||
if (retryCoopData) {
|
||||
console.log('📊 Budget Page: Found data on retry, initializing');
|
||||
await budgetStore.forceInitializeFromWizardData();
|
||||
}
|
||||
} else if (hasBudgetData) {
|
||||
console.log('📊 Budget Page: Budget data already exists, using existing data');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
// Always force reinitialize when navigating to budget page
|
||||
// This ensures changes from setup are reflected
|
||||
await budgetStore.forceInitializeFromWizardData();
|
||||
// Mark initial load as complete after initialization
|
||||
await nextTick();
|
||||
initialLoadComplete = true;
|
||||
});
|
||||
|
||||
// Track if initial load is complete
|
||||
let initialLoadComplete = false;
|
||||
|
||||
// Re-initialize when coop data changes (but not on initial load)
|
||||
watch([() => coopBuilderStore.streams, () => coopBuilderStore.members], async (newVal, oldVal) => {
|
||||
// Skip the initial trigger
|
||||
if (!initialLoadComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only reinitialize if we actually have new data
|
||||
const hasNewStreams = coopBuilderStore.streams.length > 0;
|
||||
const hasNewMembers = coopBuilderStore.members.length > 0;
|
||||
|
||||
if (hasNewStreams || hasNewMembers) {
|
||||
console.log('📊 Budget Page: Coop data changed, reinitializing');
|
||||
await nextTick();
|
||||
await initializeBudgetPage();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// Use reactive synchronized data
|
||||
const syncedStreams = unifiedStreams;
|
||||
const syncedMembers = unifiedMembers;
|
||||
|
||||
// State
|
||||
const activeView = ref('monthly');
|
||||
const showAddRevenueModal = ref(false);
|
||||
const showAddExpenseModal = ref(false);
|
||||
const showPayrollOncostModal = ref(false);
|
||||
const activeTab = ref(0);
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
|
||||
|
|
@ -656,21 +782,15 @@ const monthlyHeaders = computed(() => {
|
|||
return headers;
|
||||
});
|
||||
|
||||
// Grouped data
|
||||
// Grouped data with safe fallbacks
|
||||
const budgetWorksheet = computed(() => budgetStore.budgetWorksheet || { revenue: [], expenses: [] });
|
||||
const groupedRevenue = computed(() => budgetStore.groupedRevenue);
|
||||
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
|
||||
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
|
||||
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Only initialize if not already done (preserve persisted data)
|
||||
await budgetStore.initializeFromWizardData();
|
||||
} catch (error) {
|
||||
console.error("Error initializing budget page:", error);
|
||||
}
|
||||
});
|
||||
// Removed duplicate onMounted - initialization is now handled above
|
||||
|
||||
// Add revenue item
|
||||
function addRevenueItem() {
|
||||
|
|
@ -824,6 +944,7 @@ function resetWorksheet() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Export budget
|
||||
function exportBudget() {
|
||||
const data = {
|
||||
|
|
@ -861,6 +982,15 @@ function getNetIncomeClass(amount: number): string {
|
|||
return "text-gray-600";
|
||||
}
|
||||
|
||||
// Payroll oncost handling
|
||||
function handlePayrollOncostUpdate(newPercentage: number) {
|
||||
// Update the coop store
|
||||
coopBuilderStore.payrollOncostPct = newPercentage;
|
||||
|
||||
// Refresh the budget to reflect the new oncost percentage
|
||||
budgetStore.refreshPayrollInBudget();
|
||||
}
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: "Budget Worksheet - Plan Your Co-op's Financial Future",
|
||||
|
|
|
|||
101
pages/cash.vue
101
pages/cash.vue
|
|
@ -1,101 +0,0 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Cash Calendar</h2>
|
||||
<UBadge v-if="firstBreachWeek" color="red" variant="subtle"
|
||||
>Week {{ firstBreachWeek }} cushion breach</UBadge
|
||||
>
|
||||
<UBadge v-else color="green" variant="subtle"
|
||||
>No cushion breach projected</UBadge
|
||||
>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">13-Week Cash Flow</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-neutral-600">
|
||||
Week-by-week cash inflows and outflows with minimum cushion tracking.
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-7 gap-2 text-xs font-medium text-neutral-500">
|
||||
<div>Week</div>
|
||||
<div>Inflow</div>
|
||||
<div>Outflow</div>
|
||||
<div>Net</div>
|
||||
<div>Balance</div>
|
||||
<div>Cushion</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="week in weeks"
|
||||
:key="week.number"
|
||||
class="grid grid-cols-7 gap-2 text-sm py-2 border-b border-neutral-100"
|
||||
:class="{ 'bg-red-50': week.breachesCushion }">
|
||||
<div class="font-medium">{{ week.number }}</div>
|
||||
<div class="text-green-600">+€{{ week.inflow.toLocaleString() }}</div>
|
||||
<div class="text-red-600">-€{{ week.outflow.toLocaleString() }}</div>
|
||||
<div :class="week.net >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ week.net >= 0 ? "+" : "" }}€{{ week.net.toLocaleString() }}
|
||||
</div>
|
||||
<div class="font-medium">€{{ week.balance.toLocaleString() }}</div>
|
||||
<div
|
||||
:class="
|
||||
week.breachesCushion
|
||||
? 'text-red-600 font-medium'
|
||||
: 'text-neutral-600'
|
||||
">
|
||||
€{{ week.cushion.toLocaleString() }}
|
||||
</div>
|
||||
<div>
|
||||
<UBadge v-if="week.breachesCushion" color="red" size="xs">
|
||||
Breach
|
||||
</UBadge>
|
||||
<UBadge v-else color="green" size="xs"> OK </UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-orange-50 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="text-orange-500" />
|
||||
<span class="text-sm font-medium text-orange-800">
|
||||
This week would drop below your minimum cushion.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const cashStore = useCashStore();
|
||||
const { weeklyProjections } = storeToRefs(cashStore);
|
||||
|
||||
const weeks = computed(() => {
|
||||
// If no projections, show empty state
|
||||
if (weeklyProjections.value.length === 0) {
|
||||
return Array.from({ length: 13 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
inflow: 0,
|
||||
outflow: 0,
|
||||
net: 0,
|
||||
balance: 0,
|
||||
cushion: 0,
|
||||
breachesCushion: false,
|
||||
}));
|
||||
}
|
||||
return weeklyProjections.value;
|
||||
});
|
||||
|
||||
// Find first week that breaches cushion
|
||||
const firstBreachWeek = computed(() => {
|
||||
const breachWeek = weeks.value.find((week) => week.breachesCushion);
|
||||
return breachWeek ? breachWeek.number : null;
|
||||
});
|
||||
</script>
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
|
||||
<!-- Vertical Steps Layout -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Step 1: Members -->
|
||||
<!-- Step 1: Pay Policy -->
|
||||
<div class="relative">
|
||||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
|
|
@ -68,12 +68,12 @@
|
|||
<div
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||
:class="
|
||||
membersValid
|
||||
policiesValid
|
||||
? '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'
|
||||
">
|
||||
<UIcon
|
||||
v-if="membersValid"
|
||||
v-if="policiesValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>1</span>
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Add your team
|
||||
Choose pay approach
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -95,12 +95,12 @@
|
|||
<div
|
||||
v-if="focusedStep === 1"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardMembersStep @save-status="handleSaveStatus" />
|
||||
<WizardPoliciesStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Wage -->
|
||||
<!-- Step 2: Members -->
|
||||
<div class="relative">
|
||||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
|
|
@ -120,12 +120,12 @@
|
|||
<div
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||
:class="
|
||||
policiesValid
|
||||
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'
|
||||
">
|
||||
<UIcon
|
||||
v-if="policiesValid"
|
||||
v-if="membersValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>2</span>
|
||||
|
|
@ -133,7 +133,7 @@
|
|||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Set your wage
|
||||
Add your team
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
<div
|
||||
v-if="focusedStep === 2"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardPoliciesStep @save-status="handleSaveStatus" />
|
||||
<WizardMembersStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -170,13 +170,22 @@
|
|||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white">
|
||||
<UIcon name="i-heroicons-check" class="w-4 h-4" />
|
||||
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'
|
||||
">
|
||||
<UIcon
|
||||
v-if="costsValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>3</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Monthly costs
|
||||
Expenses
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -247,60 +256,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Review -->
|
||||
<div class="relative">
|
||||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 5"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
|
||||
focusedStep === 5 ? 'item-selected' : '',
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(5)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||
:class="
|
||||
canComplete
|
||||
? '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'
|
||||
">
|
||||
<UIcon
|
||||
v-if="canComplete"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>5</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Review & finish
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 5 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 5"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardReviewStep
|
||||
@complete="completeWizard"
|
||||
@reset="resetWizard" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Actions -->
|
||||
<div class="flex justify-between items-center pt-8">
|
||||
<button
|
||||
|
|
@ -334,12 +289,15 @@
|
|||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="canComplete"
|
||||
class="export-btn primary"
|
||||
@click="completeWizard">
|
||||
Complete Setup
|
||||
</button>
|
||||
<UTooltip :text="incompleteSectionsText" :prevent="canComplete">
|
||||
<button
|
||||
class="export-btn primary"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !canComplete }"
|
||||
:disabled="!canComplete"
|
||||
@click="canComplete ? completeWizard() : null">
|
||||
Complete Setup
|
||||
</button>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -357,11 +315,6 @@ const saveStatus = ref("");
|
|||
const isResetting = ref(false);
|
||||
const isCompleted = ref(false);
|
||||
|
||||
// Computed validation
|
||||
const canComplete = computed(() => {
|
||||
return coop.members.value.length > 0 && coop.streams.value.length > 0;
|
||||
});
|
||||
|
||||
// Local validity flags for step headers
|
||||
const membersValid = computed(() => {
|
||||
// Valid if at least one member with a name and positive hours
|
||||
|
|
@ -375,7 +328,12 @@ const membersValid = computed(() => {
|
|||
const policiesValid = computed(() => {
|
||||
// Placeholder policy validity; mark true when wage text or policy set exists
|
||||
// Since policy not persisted yet in this store, consider valid when any member exists
|
||||
return membersValid.value;
|
||||
return coop.members.value.length > 0;
|
||||
});
|
||||
|
||||
const costsValid = computed(() => {
|
||||
// Costs are optional, so always mark as valid for now
|
||||
return true;
|
||||
});
|
||||
|
||||
const streamsValid = computed(() => {
|
||||
|
|
@ -389,6 +347,29 @@ const streamsValid = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
// Computed validation - all 4 steps must be valid
|
||||
const canComplete = computed(() => {
|
||||
return (
|
||||
policiesValid.value &&
|
||||
membersValid.value &&
|
||||
costsValid.value &&
|
||||
streamsValid.value
|
||||
);
|
||||
});
|
||||
|
||||
// Generate tooltip text for incomplete sections
|
||||
const incompleteSectionsText = computed(() => {
|
||||
if (canComplete.value) return "";
|
||||
|
||||
const incomplete = [];
|
||||
if (!policiesValid.value) incomplete.push("Choose pay approach");
|
||||
if (!membersValid.value) incomplete.push("Add team members");
|
||||
if (!costsValid.value) incomplete.push("Add monthly costs");
|
||||
if (!streamsValid.value) incomplete.push("Add revenue streams");
|
||||
|
||||
return `Complete these sections: ${incomplete.join(", ")}`;
|
||||
});
|
||||
|
||||
// Save status handler
|
||||
function handleSaveStatus(status: "saving" | "saved" | "error") {
|
||||
saveStatus.value = status;
|
||||
|
|
@ -404,12 +385,14 @@ function handleSaveStatus(status: "saving" | "saved" | "error") {
|
|||
|
||||
// Step management
|
||||
function setFocusedStep(step: number) {
|
||||
console.log("Setting focused step to:", step, "Current:", focusedStep.value);
|
||||
// Toggle if clicking on already focused step
|
||||
if (focusedStep.value === step) {
|
||||
focusedStep.value = 0; // Close the section
|
||||
} else {
|
||||
focusedStep.value = step; // Open the section
|
||||
}
|
||||
console.log("Focused step is now:", focusedStep.value);
|
||||
}
|
||||
|
||||
function completeWizard() {
|
||||
|
|
|
|||
|
|
@ -43,31 +43,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Value Accounting Section -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium">Value Accounting</h3>
|
||||
<UBadge color="blue" variant="subtle">January 2024</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
Next Value Session due January 2024
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<UProgress :value="50" :max="100" color="blue" class="w-32" />
|
||||
<span class="text-sm text-gray-600">2/4 prep steps done</span>
|
||||
</div>
|
||||
</div>
|
||||
<UButton color="primary" @click="navigateTo('/session')">
|
||||
Start Session
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,21 +19,6 @@
|
|||
<!-- Member Coverage -->
|
||||
<MemberCoveragePanel />
|
||||
|
||||
<!-- Advanced Tools -->
|
||||
<AdvancedAccordion />
|
||||
|
||||
<!-- Next Session -->
|
||||
<UCard class="shadow-sm rounded-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-semibold">Next Value Session</h3>
|
||||
<p class="text-sm text-gray-600">Review contributions and distribute surplus</p>
|
||||
</div>
|
||||
<UButton color="primary" @click="navigateTo('/session')">
|
||||
Start Session
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -41,7 +26,6 @@
|
|||
// Import components explicitly to avoid auto-import issues
|
||||
import DashboardCoreMetrics from '~/components/dashboard/DashboardCoreMetrics.vue'
|
||||
import MemberCoveragePanel from '~/components/dashboard/MemberCoveragePanel.vue'
|
||||
import AdvancedAccordion from '~/components/dashboard/AdvancedAccordion.vue'
|
||||
|
||||
// Access composable data
|
||||
const { operatingMode, setOperatingMode } = useCoopBuilder()
|
||||
|
|
|
|||
|
|
@ -57,12 +57,6 @@ const glossaryTerms = ref([
|
|||
"Month-by-month plan of money in and money out. Not exact dates.",
|
||||
example: "January budget shows €12,000 revenue and €9,900 costs",
|
||||
},
|
||||
{
|
||||
id: "cash-flow",
|
||||
term: "Cash Flow",
|
||||
definition: "The actual dates money moves. Shows timing risk.",
|
||||
example: "Client pays Net 30, so January work arrives in February",
|
||||
},
|
||||
{
|
||||
id: "concentration",
|
||||
term: "Concentration",
|
||||
|
|
@ -143,13 +137,6 @@ const glossaryTerms = ref([
|
|||
definition: "Money left over after all costs are paid.",
|
||||
example: "€12,000 revenue - €9,900 costs = €2,100 surplus",
|
||||
},
|
||||
{
|
||||
id: "value-accounting",
|
||||
term: "Value Accounting",
|
||||
definition:
|
||||
"Monthly process to review contributions and distribute surplus.",
|
||||
example: "January session: review work, repay deferred pay, fund training",
|
||||
},
|
||||
]);
|
||||
|
||||
// Filter terms based on search
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@
|
|||
|
||||
<h3 class="text-xl font-semibold mb-3">Monthly vs Annual Planning</h3>
|
||||
<p class="mb-4">
|
||||
[Add content about balancing monthly cash flow with annual strategic planning]
|
||||
[Add content about balancing monthly budgets with annual strategic planning]
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-semibold mb-3">Setting Realistic Goals</h3>
|
||||
|
|
|
|||
416
pages/index.vue
416
pages/index.vue
|
|
@ -113,8 +113,7 @@
|
|||
title="Revenue Concentration Risk"
|
||||
:description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
|
||||
:actions="[
|
||||
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') },
|
||||
{ label: 'Scenarios', click: () => handleAlertNavigation('/scenarios', 'diversification') }
|
||||
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') }
|
||||
]" />
|
||||
|
||||
<!-- Cushion Breach Alert -->
|
||||
|
|
@ -153,7 +152,6 @@
|
|||
:description="deferredAlert.description"
|
||||
:actions="[
|
||||
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
|
||||
{ label: 'Value Session', click: () => handleAlertNavigation('/session', 'distributions') }
|
||||
]" />
|
||||
|
||||
<!-- Success message when no alerts -->
|
||||
|
|
@ -166,224 +164,8 @@
|
|||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Scenario Snapshots -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Scenario Snapshots</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Current Scenario -->
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">{{ scenarios.current.name }}</h4>
|
||||
<UBadge color="green" variant="subtle" size="xs">{{ scenarios.current.status }}</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.current.runway)">
|
||||
{{ Math.round(scenarios.current.runway * 10) / 10 }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">
|
||||
Net: {{ $format.currency(scenarios.current.monthlyNet) }}/mo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quit Jobs Scenario -->
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">{{ scenarios.quitJobs.name }}</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.quitJobs.status }}</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.quitJobs.runway)">
|
||||
{{ Math.round(scenarios.quitJobs.runway * 10) / 10 }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">
|
||||
Net: {{ $format.currency(scenarios.quitJobs.monthlyNet) }}/mo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Start Production Scenario -->
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">{{ scenarios.startProduction.name }}</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.startProduction.status }}</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.startProduction.runway)">
|
||||
{{ Math.round(scenarios.startProduction.runway * 10) / 10 }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">
|
||||
Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<UButton variant="outline" @click="navigateTo('/scenarios')">
|
||||
Compare All Scenarios
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Next Value Accounting Session -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium">Next Value Accounting Session</h3>
|
||||
<UBadge color="blue" variant="subtle">January 2024</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Session Preparation</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
||||
<span class="text-sm">Month closed & reviewed</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
||||
<span class="text-sm">Contributions logged</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
|
||||
<span class="text-sm text-neutral-600">Surplus calculated</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
|
||||
<span class="text-sm text-neutral-600"
|
||||
>Member needs reviewed</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<UProgress value="50" :max="100" color="blue" />
|
||||
<p class="text-xs text-neutral-600 mt-1">2 of 4 items complete</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Available for Distribution</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">Surplus</span>
|
||||
<span class="font-medium text-green-600">{{
|
||||
$format.currency(metrics.finances.surplus || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">Deferred owed</span>
|
||||
<span class="font-medium text-orange-600">{{
|
||||
$format.currency(
|
||||
metrics.finances.deferredLiabilities.totalDeferred
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">Savings gap</span>
|
||||
<span class="font-medium text-blue-600">{{
|
||||
$format.currency(metrics.finances.savingsGap || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<UButton color="primary" @click="navigateTo('/session')">
|
||||
Start Session
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Advanced Planning Panel -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium">Advanced Planning</h3>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
:icon="showAdvanced ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
|
||||
>
|
||||
{{ showAdvanced ? 'Hide' : 'Show' }} Advanced
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-show="showAdvanced" class="space-y-6">
|
||||
<!-- Stress Tests -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h4 class="font-medium mb-3">Stress Tests</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Revenue Delay (months)</label>
|
||||
<UInput
|
||||
v-model="stressTests.revenueDelay"
|
||||
type="number"
|
||||
min="0"
|
||||
max="6"
|
||||
size="sm"
|
||||
@input="updateStressTest"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Cost Shock (%)</label>
|
||||
<UInput
|
||||
v-model="stressTests.costShockPct"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
size="sm"
|
||||
@input="updateStressTest"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Major Grant Lost</label>
|
||||
<UToggle
|
||||
v-model="stressTests.grantLost"
|
||||
@update:model-value="updateStressTest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stress Test Results -->
|
||||
<div v-if="hasStressTest" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 class="font-medium text-yellow-800">Stress Test Results</h5>
|
||||
<p class="text-sm text-yellow-700">
|
||||
Runway under stress: {{ Math.round(stressedRunway * 10) / 10 }} months
|
||||
({{ Math.round((stressedRunway - metrics.runway) * 10) / 10 }} month change)
|
||||
</p>
|
||||
</div>
|
||||
<UButton size="xs" @click="applyStressTest">Apply to Plan</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policy Sandbox -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h4 class="font-medium mb-3">Policy Sandbox</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Try different pay relationships without overwriting your current plan.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Test Pay Policy</label>
|
||||
<USelect
|
||||
v-model="sandboxPolicy"
|
||||
:options="policyOptions"
|
||||
size="sm"
|
||||
@update:model-value="updateSandboxPolicy"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="sandboxRunway">
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Projected Runway</label>
|
||||
<div class="text-lg font-bold" :class="getRunwayColor(sandboxRunway)">
|
||||
{{ Math.round(sandboxRunway * 10) / 10 }} months
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
|
@ -398,38 +180,7 @@
|
|||
</div>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
class="justify-start h-auto p-4"
|
||||
@click="navigateTo('/cash')">
|
||||
<div class="text-left">
|
||||
<div class="font-medium">Cash Calendar</div>
|
||||
<div class="text-xs text-neutral-500">13-week cash flow</div>
|
||||
</div>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
class="justify-start h-auto p-4"
|
||||
@click="navigateTo('/scenarios')">
|
||||
<div class="text-left">
|
||||
<div class="font-medium">Scenarios</div>
|
||||
<div class="text-xs text-neutral-500">What-if analysis</div>
|
||||
</div>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
block
|
||||
color="primary"
|
||||
class="justify-start h-auto p-4"
|
||||
@click="navigateTo('/session')">
|
||||
<div class="text-left">
|
||||
<div class="font-medium">Next Session</div>
|
||||
<div class="text-xs">Value Accounting</div>
|
||||
</div>
|
||||
</UButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -451,27 +202,6 @@ const { getDualModeRunway, getMonthlyBurn } = useRunway();
|
|||
// Cushion forecast and savings progress
|
||||
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
|
||||
|
||||
// Scenario calculations
|
||||
const { scenarios } = useScenarios();
|
||||
|
||||
// Advanced panel state
|
||||
const showAdvanced = ref(false);
|
||||
|
||||
// Stress testing
|
||||
const stressTests = ref({
|
||||
revenueDelay: 0,
|
||||
costShockPct: 0,
|
||||
grantLost: false
|
||||
});
|
||||
|
||||
// Policy sandbox
|
||||
const sandboxPolicy = ref('equal-pay');
|
||||
const policyOptions = [
|
||||
{ label: 'Equal Pay', value: 'equal-pay' },
|
||||
{ label: 'Needs Weighted', value: 'needs-weighted' },
|
||||
{ label: 'Hours Weighted', value: 'hours-weighted' },
|
||||
{ label: 'Role Banded', value: 'role-banded' }
|
||||
];
|
||||
|
||||
// Calculate metrics from real store data
|
||||
const metrics = computed(() => {
|
||||
|
|
@ -573,21 +303,6 @@ function getRunwayColor(months: number): string {
|
|||
return 'text-red-600'
|
||||
}
|
||||
|
||||
// Calculate scenario metrics
|
||||
const scenarioMetrics = computed(() => {
|
||||
const baseRunway = metrics.value.runway;
|
||||
return {
|
||||
current: {
|
||||
runway: Math.round(baseRunway * 100) / 100 || 0,
|
||||
},
|
||||
quitJobs: {
|
||||
runway: Math.round(baseRunway * 0.7 * 100) / 100 || 0, // Shorter runway due to higher costs
|
||||
},
|
||||
startProduction: {
|
||||
runway: Math.round(baseRunway * 0.8 * 100) / 100 || 0, // Moderate impact
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Cash breach description
|
||||
const cashBreachDescription = computed(() => {
|
||||
|
|
@ -629,135 +344,6 @@ const onImport = async () => {
|
|||
|
||||
const { exportAll, importAll } = useFixtureIO();
|
||||
|
||||
// Advanced panel computed properties and methods
|
||||
const hasStressTest = computed(() => {
|
||||
return stressTests.value.revenueDelay > 0 ||
|
||||
stressTests.value.costShockPct > 0 ||
|
||||
stressTests.value.grantLost;
|
||||
});
|
||||
|
||||
const stressedRunway = computed(() => {
|
||||
if (!hasStressTest.value) return metrics.value.runway;
|
||||
|
||||
const cash = cashStore.currentCash || 50000;
|
||||
const savings = cashStore.currentSavings || 15000;
|
||||
|
||||
// Apply stress test adjustments
|
||||
let adjustedRevenue = streamsStore.streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0);
|
||||
let adjustedCosts = getMonthlyBurn();
|
||||
|
||||
// Revenue delay impact (reduce revenue by delay percentage)
|
||||
if (stressTests.value.revenueDelay > 0) {
|
||||
adjustedRevenue *= Math.max(0, 1 - (stressTests.value.revenueDelay / 12));
|
||||
}
|
||||
|
||||
// Cost shock impact
|
||||
if (stressTests.value.costShockPct > 0) {
|
||||
adjustedCosts *= (1 + stressTests.value.costShockPct / 100);
|
||||
}
|
||||
|
||||
// Grant lost (remove largest revenue stream if it's a grant)
|
||||
if (stressTests.value.grantLost) {
|
||||
const grantStreams = streamsStore.streams.filter(s =>
|
||||
s.category?.toLowerCase().includes('grant') ||
|
||||
s.name.toLowerCase().includes('grant')
|
||||
);
|
||||
if (grantStreams.length > 0) {
|
||||
const largestGrant = Math.max(...grantStreams.map(s => s.targetMonthlyAmount || 0));
|
||||
adjustedRevenue -= largestGrant;
|
||||
}
|
||||
}
|
||||
|
||||
const netMonthly = adjustedRevenue - adjustedCosts;
|
||||
const burnRate = netMonthly < 0 ? Math.abs(netMonthly) : adjustedCosts;
|
||||
|
||||
return burnRate > 0 ? (cash + savings) / burnRate : Infinity;
|
||||
});
|
||||
|
||||
const sandboxRunway = computed(() => {
|
||||
if (!sandboxPolicy.value || sandboxPolicy.value === policiesStore.payPolicy?.relationship) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate runway with sandbox policy
|
||||
const cash = cashStore.currentCash || 50000;
|
||||
const savings = cashStore.currentSavings || 15000;
|
||||
|
||||
// Create sandbox policy object
|
||||
const testPolicy = {
|
||||
relationship: sandboxPolicy.value,
|
||||
equalHourlyWage: policiesStore.equalHourlyWage,
|
||||
roleBands: policiesStore.payPolicy?.roleBands || []
|
||||
};
|
||||
|
||||
// Use scenario calculation with sandbox policy
|
||||
const { calculateScenarioRunway } = useScenarios();
|
||||
const result = calculateScenarioRunway(membersStore.members, streamsStore.streams);
|
||||
|
||||
// Apply simple adjustment based on policy type
|
||||
let policyMultiplier = 1;
|
||||
switch (sandboxPolicy.value) {
|
||||
case 'needs-weighted':
|
||||
policyMultiplier = 0.9; // Slightly higher costs
|
||||
break;
|
||||
case 'role-banded':
|
||||
policyMultiplier = 0.85; // Higher costs due to senior roles
|
||||
break;
|
||||
case 'hours-weighted':
|
||||
policyMultiplier = 0.95; // Moderate increase
|
||||
break;
|
||||
}
|
||||
|
||||
return result.runway * policyMultiplier;
|
||||
});
|
||||
|
||||
function updateStressTest() {
|
||||
// Reactive computed will handle updates automatically
|
||||
}
|
||||
|
||||
function updateSandboxPolicy() {
|
||||
// Reactive computed will handle updates automatically
|
||||
}
|
||||
|
||||
function applyStressTest() {
|
||||
// Apply stress test adjustments to the actual plan
|
||||
if (stressTests.value.revenueDelay > 0) {
|
||||
// Reduce all stream targets by delay impact
|
||||
streamsStore.streams.forEach(stream => {
|
||||
const reduction = (stressTests.value.revenueDelay / 12) * (stream.targetMonthlyAmount || 0);
|
||||
streamsStore.updateStream(stream.id, {
|
||||
targetMonthlyAmount: Math.max(0, (stream.targetMonthlyAmount || 0) - reduction)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (stressTests.value.costShockPct > 0) {
|
||||
// Increase overhead costs
|
||||
const shockMultiplier = 1 + (stressTests.value.costShockPct / 100);
|
||||
budgetStore.overheadCosts.forEach(cost => {
|
||||
budgetStore.updateOverheadCost(cost.id, {
|
||||
amount: (cost.amount || 0) * shockMultiplier
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (stressTests.value.grantLost) {
|
||||
// Remove or reduce grant streams
|
||||
const grantStreams = streamsStore.streams.filter(s =>
|
||||
s.category?.toLowerCase().includes('grant') ||
|
||||
s.name.toLowerCase().includes('grant')
|
||||
);
|
||||
if (grantStreams.length > 0) {
|
||||
const largestGrant = grantStreams.reduce((prev, current) =>
|
||||
(prev.targetMonthlyAmount || 0) > (current.targetMonthlyAmount || 0) ? prev : current
|
||||
);
|
||||
streamsStore.updateStream(largestGrant.id, { targetMonthlyAmount: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Reset stress tests
|
||||
stressTests.value = { revenueDelay: 0, costShockPct: 0, grantLost: false };
|
||||
};
|
||||
|
||||
// Deferred alert logic
|
||||
const deferredAlert = computed(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Revenue Mix Planner</h2>
|
||||
<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>
|
||||
|
|
@ -169,9 +179,19 @@
|
|||
<script setup lang="ts">
|
||||
const { $format } = useNuxtApp();
|
||||
|
||||
// Use real store data instead of fixtures
|
||||
// Use synchronized store data - setup is the source of truth
|
||||
const { initSync, getStreams, unifiedStreams } = useStoreSync();
|
||||
const { isSetupComplete, goToSetup } = useSetupState();
|
||||
const streamsStore = useStreamsStore();
|
||||
const { streams } = storeToRefs(streamsStore);
|
||||
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" },
|
||||
|
|
@ -184,8 +204,15 @@ const columns = [
|
|||
{ id: "actions", key: "actions", label: "" },
|
||||
];
|
||||
|
||||
const totalTargetPct = computed(() => streamsStore.totalTargetPct);
|
||||
const totalMonthlyAmount = computed(() => streamsStore.totalMonthlyAmount);
|
||||
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(() => {
|
||||
|
|
@ -244,16 +271,41 @@ function getRowActions(row: any) {
|
|||
}
|
||||
|
||||
function updateStream(id: string, field: string, value: any) {
|
||||
const stream = streams.value.find((s) => s.id === id);
|
||||
if (stream) {
|
||||
stream[field] = Number(value) || value;
|
||||
streamsStore.upsertStream(stream);
|
||||
// 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 newStream = {
|
||||
id: Date.now().toString(),
|
||||
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: "",
|
||||
|
|
@ -268,7 +320,7 @@ function addStream() {
|
|||
seasonalityWeights: new Array(12).fill(1),
|
||||
effortHoursPerMonth: 0,
|
||||
};
|
||||
streamsStore.upsertStream(newStream);
|
||||
streamsStore.upsertStream(legacyStream);
|
||||
}
|
||||
|
||||
function editStream(row: any) {
|
||||
|
|
@ -282,6 +334,8 @@ function duplicateStream(row: any) {
|
|||
}
|
||||
|
||||
function removeStream(row: any) {
|
||||
// Remove from both stores to maintain sync
|
||||
coopStore.removeStream(row.id);
|
||||
streamsStore.removeStream(row.id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,575 +0,0 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Scenarios & Runway</h2>
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="red"
|
||||
size="sm"
|
||||
@click="restartWizard"
|
||||
:disabled="isResetting">
|
||||
<UIcon name="i-heroicons-arrow-path" class="mr-1" />
|
||||
Restart Setup (Testing)
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- 6-Month Preset Card -->
|
||||
<UCard class="bg-blue-50 border-blue-200">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-blue-900">
|
||||
6-Month Plan Analysis
|
||||
</h3>
|
||||
<UBadge color="info" variant="solid">Recommended</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600 mb-2">
|
||||
{{ sixMonthScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600 mb-3">Extended runway</div>
|
||||
<UProgress value="91" color="info" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Key Changes</h4>
|
||||
<ul class="text-sm text-neutral-600 space-y-1">
|
||||
<li>• Diversify revenue mix</li>
|
||||
<li>• Build 6-month savings buffer</li>
|
||||
<li>• Gradual capacity scaling</li>
|
||||
<li>• Risk mitigation focus</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Feasibility Gates</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
||||
<span class="text-sm">Savings target achievable</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
||||
<span class="text-sm">Cash floor maintained</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="text-yellow-500" />
|
||||
<span class="text-sm">Requires 2 new streams</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Scenario Comparison -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<UCard class="border-green-200 bg-green-50">
|
||||
<div class="text-center space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium text-sm">Operate Current</h4>
|
||||
<UBadge color="success" variant="solid" size="xs">Active</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-orange-600">
|
||||
{{ currentScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Baseline scenario</div>
|
||||
<UButton size="xs" variant="ghost" @click="setScenario('current')">
|
||||
<UIcon name="i-heroicons-play" class="mr-1" />
|
||||
Continue
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-center space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium text-sm">Quit Day Jobs</h4>
|
||||
<UBadge color="error" variant="subtle" size="xs">High Risk</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
{{ quitDayJobsScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Full-time co-op work</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@click="setScenario('quitDayJobs')">
|
||||
<UIcon name="i-heroicons-briefcase" class="mr-1" />
|
||||
Analyze
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-center space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium text-sm">Start Production</h4>
|
||||
<UBadge color="warning" variant="subtle" size="xs"
|
||||
>Medium Risk</UBadge
|
||||
>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-yellow-600">
|
||||
{{ startProductionScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Launch development</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@click="setScenario('startProduction')">
|
||||
<UIcon name="i-heroicons-rocket-launch" class="mr-1" />
|
||||
Analyze
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="border-blue-200">
|
||||
<div class="text-center space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium text-sm">6-Month Plan</h4>
|
||||
<UBadge color="info" variant="solid" size="xs">Planned</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
{{ sixMonthScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Extended planning</div>
|
||||
<UButton size="xs" color="primary" @click="setScenario('sixMonth')">
|
||||
<UIcon name="i-heroicons-calendar" class="mr-1" />
|
||||
Plan
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Feasibility Analysis -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Feasibility Analysis</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Gate Checks</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Savings Target Reached</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-x-circle" class="text-red-500" />
|
||||
<span class="text-sm text-neutral-600">€5,200 short</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Cash Floor Maintained</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
||||
<span class="text-sm text-neutral-600">Week 4+</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Revenue Diversification</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="text-yellow-500" />
|
||||
<span class="text-sm text-neutral-600"
|
||||
>Top: {{ topSourcePct }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Key Dates</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-neutral-600">Savings gate clear:</span>
|
||||
<span class="text-sm font-medium">{{
|
||||
keyDates.savingsGate || "Not projected"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-neutral-600">First cash breach:</span>
|
||||
<span class="text-sm font-medium text-red-600">{{
|
||||
keyDates.firstBreach || "None projected"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-neutral-600">Deferred cap reset:</span>
|
||||
<span class="text-sm font-medium">{{
|
||||
keyDates.deferredReset || "Not scheduled"
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- What-If Sliders -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium">What-If Analysis</h3>
|
||||
<UButton size="sm" variant="ghost" @click="resetSliders">
|
||||
Reset
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="revenue-slider" class="block text-sm font-medium mb-2">
|
||||
<GlossaryTooltip
|
||||
term="Monthly Revenue"
|
||||
term-id="revenue"
|
||||
definition="Total money earned from all streams in one month." />:
|
||||
{{ $format.currency(revenue) }}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<URange
|
||||
id="revenue-slider"
|
||||
v-model="revenue"
|
||||
:min="5000"
|
||||
:max="20000"
|
||||
:step="500"
|
||||
:aria-label="`Monthly revenue: ${$format.currency(revenue)}`"
|
||||
class="flex-1" />
|
||||
<UInput
|
||||
v-model="revenue"
|
||||
type="number"
|
||||
:min="5000"
|
||||
:max="20000"
|
||||
:step="500"
|
||||
class="w-24"
|
||||
size="xs"
|
||||
aria-label="Monthly revenue input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="hours-slider" class="block text-sm font-medium mb-2">
|
||||
Paid Hours: {{ paidHours }}h/month
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<URange
|
||||
id="hours-slider"
|
||||
v-model="paidHours"
|
||||
:min="100"
|
||||
:max="600"
|
||||
:step="20"
|
||||
:aria-label="`Paid hours: ${paidHours} per month`"
|
||||
class="flex-1" />
|
||||
<UInput
|
||||
v-model="paidHours"
|
||||
type="number"
|
||||
:min="100"
|
||||
:max="600"
|
||||
:step="20"
|
||||
class="w-20"
|
||||
size="xs"
|
||||
aria-label="Paid hours input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="winrate-slider" class="block text-sm font-medium mb-2">
|
||||
Win Rate: {{ winRate }}%
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<URange
|
||||
id="winrate-slider"
|
||||
v-model="winRate"
|
||||
:min="40"
|
||||
:max="95"
|
||||
:step="5"
|
||||
:aria-label="`Win rate: ${winRate} percent`"
|
||||
class="flex-1" />
|
||||
<UInput
|
||||
v-model="winRate"
|
||||
type="number"
|
||||
:min="40"
|
||||
:max="95"
|
||||
:step="5"
|
||||
class="w-16"
|
||||
size="xs"
|
||||
aria-label="Win rate input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-neutral-50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-sm mb-3">Impact on Runway</h4>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="text-2xl font-bold"
|
||||
:class="getRunwayColor(calculatedRunway)">
|
||||
{{ calculatedRunway }} months
|
||||
</div>
|
||||
<UProgress
|
||||
:value="Math.min(calculatedRunway * 10, 100)"
|
||||
:max="100"
|
||||
:color="getProgressColor(calculatedRunway)"
|
||||
class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">Monthly burn:</span>
|
||||
<span class="font-medium"
|
||||
>€{{ monthlyBurn.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">Coverage ratio:</span>
|
||||
<span class="font-medium"
|
||||
>{{ Math.round((paidHours / 400) * 100) }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { $format } = useNuxtApp();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const scenariosStore = useScenariosStore();
|
||||
|
||||
// Restart wizard functionality
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const coopBuilderStore = useCoopBuilderStore();
|
||||
|
||||
const isResetting = ref(false);
|
||||
|
||||
// Get initial values from stores
|
||||
const initialRevenue = computed(() => streamsStore.totalMonthlyAmount || 0);
|
||||
const initialHours = computed(
|
||||
() => membersStore.capacityTotals.targetHours || 0
|
||||
);
|
||||
|
||||
const revenue = ref(initialRevenue.value || 0);
|
||||
const paidHours = ref(initialHours.value || 0);
|
||||
const winRate = ref(0);
|
||||
|
||||
// Watch for store changes and update sliders
|
||||
watch(initialRevenue, (newVal) => {
|
||||
if (newVal > 0) revenue.value = newVal;
|
||||
});
|
||||
watch(initialHours, (newVal) => {
|
||||
if (newVal > 0) paidHours.value = newVal;
|
||||
});
|
||||
|
||||
// Calculate dynamic metrics from real store data
|
||||
const monthlyBurn = computed(() => {
|
||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
||||
const payroll = paidHours.value * hourlyWage * (1 + oncostPct / 100);
|
||||
const overhead =
|
||||
budgetStore.overheadCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
) || 0;
|
||||
const production =
|
||||
budgetStore.productionCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
) || 0;
|
||||
return payroll + overhead + production;
|
||||
});
|
||||
|
||||
const calculatedRunway = computed(() => {
|
||||
const totalCash = cashStore.currentCash + cashStore.currentSavings;
|
||||
const adjustedRevenue = revenue.value * (winRate.value / 100);
|
||||
const netPerMonth = adjustedRevenue - monthlyBurn.value;
|
||||
|
||||
if (netPerMonth >= 0)
|
||||
return monthlyBurn.value > 0
|
||||
? Math.round((totalCash / monthlyBurn.value) * 100) / 100
|
||||
: 0;
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round((totalCash / Math.abs(netPerMonth)) * 100) / 100
|
||||
);
|
||||
});
|
||||
|
||||
// Scenario calculations based on real data
|
||||
const totalCash = computed(
|
||||
() => cashStore.currentCash + cashStore.currentSavings
|
||||
);
|
||||
|
||||
const baseRunway = computed(() => {
|
||||
const baseBurn = monthlyBurn.value;
|
||||
return baseBurn > 0
|
||||
? Math.round((totalCash.value / baseBurn) * 100) / 100
|
||||
: 0;
|
||||
});
|
||||
|
||||
const currentScenario = computed(() => ({
|
||||
runway: baseRunway.value || 0,
|
||||
}));
|
||||
|
||||
const quitDayJobsScenario = computed(() => ({
|
||||
runway:
|
||||
monthlyBurn.value > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round((totalCash.value / (monthlyBurn.value * 1.8)) * 100) / 100
|
||||
)
|
||||
: 0, // Higher burn rate
|
||||
}));
|
||||
|
||||
const startProductionScenario = computed(() => ({
|
||||
runway:
|
||||
monthlyBurn.value > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round((totalCash.value / (monthlyBurn.value * 1.4)) * 100) / 100
|
||||
)
|
||||
: 0, // Medium higher burn
|
||||
}));
|
||||
|
||||
const sixMonthScenario = computed(() => ({
|
||||
runway:
|
||||
monthlyBurn.value > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round((totalCash.value / (monthlyBurn.value * 0.6)) * 100) / 100
|
||||
)
|
||||
: 0, // Lower burn with optimization
|
||||
}));
|
||||
|
||||
// Calculate concentration from real data
|
||||
const topSourcePct = computed(() => {
|
||||
if (streamsStore.streams.length === 0) return 0;
|
||||
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
|
||||
const total = amounts.reduce((sum, amt) => sum + amt, 0);
|
||||
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
|
||||
});
|
||||
|
||||
// Calculate key dates from real data
|
||||
const keyDates = computed(() => {
|
||||
const currentDate = new Date();
|
||||
|
||||
// Calculate savings gate clear date based on current savings and target
|
||||
const savingsNeeded =
|
||||
(policiesStore.savingsTargetMonths || 0) * monthlyBurn.value;
|
||||
const currentSavings = cashStore.currentSavings;
|
||||
const monthlyNet = revenue.value - monthlyBurn.value;
|
||||
|
||||
let savingsGate = null;
|
||||
if (savingsNeeded > 0 && currentSavings < savingsNeeded && monthlyNet > 0) {
|
||||
const monthsToTarget = Math.ceil(
|
||||
(savingsNeeded - currentSavings) / monthlyNet
|
||||
);
|
||||
const targetDate = new Date(currentDate);
|
||||
targetDate.setMonth(targetDate.getMonth() + monthsToTarget);
|
||||
savingsGate = targetDate.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// First cash breach from cash store projections
|
||||
const firstBreachWeek = cashStore.firstBreachWeek;
|
||||
let firstBreach = null;
|
||||
if (firstBreachWeek) {
|
||||
const breachDate = new Date(currentDate);
|
||||
breachDate.setDate(breachDate.getDate() + firstBreachWeek * 7);
|
||||
firstBreach = `Week ${firstBreachWeek} (${breachDate.toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "short", day: "numeric" }
|
||||
)})`;
|
||||
}
|
||||
|
||||
// Deferred cap reset - quarterly (every 3 months)
|
||||
let deferredReset = null;
|
||||
if (policiesStore.deferredCapHoursPerQtr > 0) {
|
||||
const nextQuarter = new Date(currentDate);
|
||||
const currentMonth = nextQuarter.getMonth();
|
||||
const quarterStartMonth = Math.floor(currentMonth / 3) * 3;
|
||||
nextQuarter.setMonth(quarterStartMonth + 3, 1);
|
||||
deferredReset = nextQuarter.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
savingsGate,
|
||||
firstBreach,
|
||||
deferredReset,
|
||||
};
|
||||
});
|
||||
|
||||
function getRunwayColor(months: number) {
|
||||
if (months >= 6) return "text-green-600";
|
||||
if (months >= 3) return "text-blue-600";
|
||||
if (months >= 2) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
}
|
||||
|
||||
function getProgressColor(months: number) {
|
||||
if (months >= 6) return "success";
|
||||
if (months >= 3) return "info";
|
||||
if (months >= 2) return "warning";
|
||||
return "error";
|
||||
}
|
||||
|
||||
function setScenario(scenario: string) {
|
||||
scenariosStore.setActiveScenario(scenario);
|
||||
router.replace({ query: { ...route.query, scenario } });
|
||||
}
|
||||
|
||||
function resetSliders() {
|
||||
revenue.value = initialRevenue.value || 0;
|
||||
paidHours.value = initialHours.value || 0;
|
||||
winRate.value = 0;
|
||||
}
|
||||
|
||||
async function restartWizard() {
|
||||
isResetting.value = true;
|
||||
|
||||
// Clear all localStorage persistence
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem("urgent-tools-members");
|
||||
localStorage.removeItem("urgent-tools-policies");
|
||||
localStorage.removeItem("urgent-tools-streams");
|
||||
localStorage.removeItem("urgent-tools-budget");
|
||||
localStorage.removeItem("urgent-tools-cash");
|
||||
localStorage.removeItem("urgent-tools-session");
|
||||
localStorage.removeItem("urgent-tools-scenarios");
|
||||
}
|
||||
|
||||
// Reset all stores
|
||||
membersStore.resetMembers();
|
||||
policiesStore.resetPolicies();
|
||||
streamsStore.resetStreams();
|
||||
budgetStore.resetBudgetOverhead();
|
||||
sessionStore.resetSession();
|
||||
|
||||
// Reset wizard state
|
||||
coopBuilderStore.reset();
|
||||
|
||||
// Small delay for UX
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
isResetting.value = false;
|
||||
|
||||
// Navigate to coop planner
|
||||
await navigateTo("/coop-planner");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const q = route.query.scenario;
|
||||
if (typeof q === "string") {
|
||||
scenariosStore.setActiveScenario(q);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Value Accounting Session</h2>
|
||||
<UBadge color="primary" variant="subtle">January 2024</UBadge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Checklist</h3>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<UCheckbox v-model="checklist.monthClosed" />
|
||||
<span class="text-sm">Month closed & reviewed</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UCheckbox v-model="checklist.contributionsLogged" />
|
||||
<span class="text-sm">Contributions logged</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UCheckbox v-model="checklist.surplusCalculated" />
|
||||
<span class="text-sm">Surplus calculated</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UCheckbox v-model="checklist.needsReviewed" />
|
||||
<span class="text-sm">Member needs reviewed</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Available</h3>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">Surplus</span>
|
||||
<span class="font-medium text-green-600"
|
||||
>€{{ availableAmounts.surplus.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">Deferred owed</span>
|
||||
<span class="font-medium text-orange-600"
|
||||
>€{{ availableAmounts.deferredOwed.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">Savings gap</span>
|
||||
<span class="font-medium text-blue-600"
|
||||
>€{{ availableAmounts.savingsNeeded.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Distribution</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Deferred Repay</label>
|
||||
<UInput
|
||||
v-model.number="draftAllocations.deferredRepay"
|
||||
type="number"
|
||||
size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Savings</label>
|
||||
<UInput
|
||||
v-model.number="draftAllocations.savings"
|
||||
type="number"
|
||||
size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Training</label>
|
||||
<UInput
|
||||
v-model.number="draftAllocations.training"
|
||||
type="number"
|
||||
size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Retained</label>
|
||||
<UInput
|
||||
v-model.number="draftAllocations.retained"
|
||||
type="number"
|
||||
size="sm"
|
||||
readonly />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Decision Record</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<UTextarea
|
||||
v-model="rationale"
|
||||
placeholder="Brief rationale for this month's distribution decisions..."
|
||||
rows="3" />
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton variant="ghost"> Save Draft </UButton>
|
||||
<UButton color="primary" :disabled="!allChecklistComplete">
|
||||
Complete Session
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Use stores
|
||||
const sessionStore = useSessionStore();
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
|
||||
// Use store refs
|
||||
const { checklist, draftAllocations, rationale, availableAmounts } =
|
||||
storeToRefs(sessionStore);
|
||||
|
||||
const allChecklistComplete = computed(() => {
|
||||
return Object.values(checklist.value).every(Boolean);
|
||||
});
|
||||
|
||||
// Calculate available amounts from real data
|
||||
const calculatedAvailableAmounts = computed(() => {
|
||||
// Calculate surplus from budget metrics
|
||||
const totalRevenue = streamsStore.totalMonthlyAmount || 0;
|
||||
const totalHours = membersStore.capacityTotals.targetHours || 0;
|
||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
||||
|
||||
const totalPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
|
||||
const totalOverhead = budgetStore.overheadCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
);
|
||||
const surplus = Math.max(0, totalRevenue - totalPayroll - totalOverhead);
|
||||
|
||||
// Calculate deferred owed
|
||||
const deferredOwed = membersStore.members.reduce((sum, member) => {
|
||||
return sum + (member.deferredHours || 0) * hourlyWage;
|
||||
}, 0);
|
||||
|
||||
// Calculate savings gap
|
||||
const savingsTarget =
|
||||
(policiesStore.savingsTargetMonths || 0) * (totalPayroll + totalOverhead);
|
||||
const savingsNeeded = Math.max(0, savingsTarget);
|
||||
|
||||
return {
|
||||
surplus,
|
||||
deferredOwed,
|
||||
savingsNeeded,
|
||||
};
|
||||
});
|
||||
|
||||
// Update store available amounts when calculated values change
|
||||
watch(
|
||||
calculatedAvailableAmounts,
|
||||
(newAmounts) => {
|
||||
sessionStore.updateAvailableAmounts(newAmounts);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
|
@ -121,6 +121,53 @@
|
|||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Member Management Section -->
|
||||
<UCard id="members">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium">Team Members</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge v-if="isSetupComplete" color="green" variant="subtle" size="xs">
|
||||
Synchronized with Setup
|
||||
</UBadge>
|
||||
<UButton variant="ghost" size="xs" @click="goToSetup">
|
||||
Edit in Setup
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-if="members.length === 0" class="text-center py-8 text-neutral-500">
|
||||
<p class="mb-4">No team members found.</p>
|
||||
<UButton @click="goToSetup" variant="outline">
|
||||
Add Members in Setup
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="p-4 border border-neutral-200 rounded-lg bg-neutral-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium">{{ member.displayName || member.name }}</h4>
|
||||
<div class="text-sm text-neutral-600 space-y-1">
|
||||
<div v-if="member.role">Role: {{ member.role }}</div>
|
||||
<div>Monthly Target: {{ $format.currency(member.monthlyPayPlanned || 0) }}</div>
|
||||
<div v-if="member.minMonthlyNeeds">Min Needs: {{ $format.currency(member.minMonthlyNeeds) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-sm text-neutral-600">
|
||||
<div>{{ Math.round((member.hoursPerMonth || member.hoursPerWeek * 4.33)) }} hrs/month</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UButton color="primary"> Save Policies </UButton>
|
||||
</div>
|
||||
|
|
@ -128,13 +175,37 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const policies = ref({
|
||||
hourlyWage: 20,
|
||||
payrollOncost: 25,
|
||||
savingsTargetMonths: 3,
|
||||
minCashCushion: 3000,
|
||||
deferredCapHours: 240,
|
||||
deferredSunsetMonths: 12,
|
||||
// Store sync and setup state
|
||||
const { initSync, getMembers, unifiedMembers } = useStoreSync();
|
||||
const { isSetupComplete, goToSetup } = useSetupState();
|
||||
const coopStore = useCoopBuilderStore();
|
||||
const { $format } = useNuxtApp();
|
||||
|
||||
// Initialize synchronization on mount
|
||||
onMounted(async () => {
|
||||
await initSync();
|
||||
});
|
||||
|
||||
// Get reactive synchronized member data
|
||||
const members = unifiedMembers;
|
||||
|
||||
// Get synchronized policy data from setup
|
||||
const policies = computed({
|
||||
get: () => ({
|
||||
hourlyWage: coopStore.equalHourlyWage,
|
||||
payrollOncost: coopStore.payrollOncostPct,
|
||||
savingsTargetMonths: coopStore.savingsTargetMonths,
|
||||
minCashCushion: coopStore.minCashCushion,
|
||||
deferredCapHours: 240, // These fields might not be in coop store yet
|
||||
deferredSunsetMonths: 12,
|
||||
}),
|
||||
set: (newPolicies) => {
|
||||
// Update the CoopBuilder store when policies change
|
||||
coopStore.setEqualWage(newPolicies.hourlyWage);
|
||||
coopStore.setOncostPct(newPolicies.payrollOncost);
|
||||
coopStore.savingsTargetMonths = newPolicies.savingsTargetMonths;
|
||||
coopStore.minCashCushion = newPolicies.minCashCushion;
|
||||
}
|
||||
});
|
||||
|
||||
const distributionOrder = ref([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue