refactor: enhance routing and state management in CoopBuilder, add migration checks on startup, and update Tailwind configuration for improved component styling
This commit is contained in:
parent
848386e3dd
commit
4cea1f71fe
55 changed files with 4053 additions and 1486 deletions
|
|
@ -357,30 +357,15 @@
|
|||
</UModal>
|
||||
|
||||
<!-- Helper Modal -->
|
||||
<UModal v-model:open="showHelperModal" :ui="{ wrapper: 'sm:max-w-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 modal-header">
|
||||
Quick Entry Tools
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 modal-header">
|
||||
{{ selectedItemDetails?.label || "Budget item" }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
@click="showHelperModal = false"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
class="-mr-2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UModal
|
||||
v-model:open="showHelperModal"
|
||||
:ui="{ wrapper: 'sm:max-w-lg', footer: 'justify-end' }"
|
||||
title="Quick Entry Tools"
|
||||
:description="selectedItemDetails?.label || 'Budget item'"
|
||||
>
|
||||
<template #body>
|
||||
<div class="isolate">
|
||||
<UTabs :items="helperTabs" class="w-full">
|
||||
<UTabs v-model="activeHelperTab" :items="helperTabs" class="w-full">
|
||||
<template #content="{ item }">
|
||||
<!-- Annual Distribution Content -->
|
||||
<div v-if="item.key === 'annual'" class="pt-4 space-y-4">
|
||||
|
|
@ -399,18 +384,6 @@
|
|||
<p class="text-sm text-gray-600">
|
||||
This will divide the amount equally across all 12 months
|
||||
</p>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<UButton @click="showHelperModal = false" variant="ghost" color="neutral">
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="distributeAnnualAmount"
|
||||
:disabled="!helperConfig.annualAmount || helperConfig.annualAmount <= 0"
|
||||
color="primary"
|
||||
>
|
||||
Distribute Amount
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Amount Content -->
|
||||
|
|
@ -430,25 +403,31 @@
|
|||
<p class="text-sm text-gray-600">
|
||||
This will set the same value for all months
|
||||
</p>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<UButton @click="showHelperModal = false" variant="ghost" color="neutral">
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="setAllMonths"
|
||||
:disabled="
|
||||
!helperConfig.monthlyAmount || helperConfig.monthlyAmount <= 0
|
||||
"
|
||||
color="primary"
|
||||
>
|
||||
Apply to All Months
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ close }">
|
||||
<UButton @click="close" variant="outline" color="neutral"> Cancel </UButton>
|
||||
<UButton
|
||||
v-if="activeHelperTab === 0"
|
||||
@click="distributeAnnualAmount"
|
||||
:disabled="!helperConfig.annualAmount || helperConfig.annualAmount <= 0"
|
||||
color="primary"
|
||||
>
|
||||
Distribute Amount
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
@click="setAllMonths"
|
||||
:disabled="!helperConfig.monthlyAmount || helperConfig.monthlyAmount <= 0"
|
||||
color="primary"
|
||||
>
|
||||
Apply to All Months
|
||||
</UButton>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- Add Expense Modal -->
|
||||
|
|
@ -525,8 +504,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Store
|
||||
// Stores
|
||||
const budgetStore = useBudgetStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
|
||||
// State
|
||||
const showAddRevenueModal = ref(false);
|
||||
|
|
@ -567,6 +549,7 @@ const selectedItemDetails = computed(() => {
|
|||
});
|
||||
|
||||
// Helper tabs configuration
|
||||
const activeHelperTab = ref(0); // UTabs uses index, not key
|
||||
const helperTabs = [
|
||||
{
|
||||
key: "annual",
|
||||
|
|
@ -641,9 +624,9 @@ const allBudgetItems = computed(() => {
|
|||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!budgetStore.isInitialized) {
|
||||
await budgetStore.initializeFromWizardData();
|
||||
}
|
||||
// Always re-initialize to get latest wizard data
|
||||
budgetStore.isInitialized = false;
|
||||
await budgetStore.initializeFromWizardData();
|
||||
} catch (error) {
|
||||
console.error("Error initializing budget page:", error);
|
||||
}
|
||||
|
|
@ -807,7 +790,9 @@ function resetWorksheet() {
|
|||
if (confirm("Are you sure you want to reset all budget data? This cannot be undone.")) {
|
||||
budgetStore.resetBudgetWorksheet();
|
||||
budgetStore.isInitialized = false;
|
||||
budgetStore.initializeFromWizardData();
|
||||
nextTick(() => {
|
||||
budgetStore.initializeFromWizardData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,18 +20,6 @@
|
|||
>
|
||||
Skip coach → Streams
|
||||
</button>
|
||||
<button
|
||||
@click="loadSampleData"
|
||||
class="px-4 py-2 text-sm bg-blue-50 border-2 border-blue-200 rounded-lg text-blue-700 hover:bg-blue-100 hover:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
:aria-label="'Load sample data to see example offers'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Load sample data
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -384,74 +372,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import {
|
||||
membersSample,
|
||||
skillsCatalogSample,
|
||||
problemsCatalogSample,
|
||||
sampleSelections
|
||||
} from "~/sample/skillsToOffersSamples";
|
||||
// REMOVED: All sample data imports to prevent demo data
|
||||
|
||||
// Store integration
|
||||
const planStore = usePlanStore();
|
||||
|
||||
// Initialize with default data
|
||||
const members = ref<Member[]>([
|
||||
{ id: "1", name: "Alex Chen", role: "Game Designer", hourly: 75, availableHrs: 30 },
|
||||
{ id: "2", name: "Jordan Smith", role: "Developer", hourly: 80, availableHrs: 35 },
|
||||
{ id: "3", name: "Sam Rodriguez", role: "Artist", hourly: 70, availableHrs: 25 }
|
||||
]);
|
||||
// Initialize with empty data
|
||||
const members = ref<Member[]>([]);
|
||||
|
||||
const availableSkills = ref<SkillTag[]>([
|
||||
{ id: "unity", label: "Unity Development" },
|
||||
{ id: "art", label: "2D/3D Art" },
|
||||
{ id: "design", label: "Game Design" },
|
||||
{ id: "audio", label: "Audio Design" },
|
||||
{ id: "writing", label: "Narrative Writing" },
|
||||
{ id: "marketing", label: "Marketing" },
|
||||
{ id: "business", label: "Business Strategy" },
|
||||
{ id: "web", label: "Web Development" },
|
||||
{ id: "mobile", label: "Mobile Development" },
|
||||
{ id: "consulting", label: "Technical Consulting" }
|
||||
]);
|
||||
const availableSkills = ref<SkillTag[]>([]);
|
||||
|
||||
const availableProblems = ref<ProblemTag[]>([
|
||||
{
|
||||
id: "indie-games",
|
||||
label: "Indie game development",
|
||||
examples: [
|
||||
"Small studios needing extra development capacity",
|
||||
"Solo developers wanting art/audio support",
|
||||
"Teams needing game design consultation"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "corporate-training",
|
||||
label: "Corporate training games",
|
||||
examples: [
|
||||
"Companies wanting engaging employee training",
|
||||
"HR departments needing onboarding tools",
|
||||
"Safety training for industrial workers"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "educational",
|
||||
label: "Educational technology",
|
||||
examples: [
|
||||
"Schools needing interactive learning tools",
|
||||
"Universities wanting research simulations",
|
||||
"Non-profits creating awareness campaigns"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "prototypes",
|
||||
label: "Rapid prototyping",
|
||||
examples: [
|
||||
"Startups validating game concepts",
|
||||
"Publishers testing market fit",
|
||||
"Researchers creating proof-of-concepts"
|
||||
]
|
||||
}
|
||||
]);
|
||||
const availableProblems = ref<ProblemTag[]>([]);
|
||||
|
||||
// Set members in store on component mount
|
||||
onMounted(() => {
|
||||
|
|
@ -512,25 +443,6 @@ function updateLanguageToCoopTerms(text: string): string {
|
|||
.replace(/productivity/gi, 'shared capacity');
|
||||
}
|
||||
|
||||
// Sample data loading
|
||||
function loadSampleData() {
|
||||
// Replace data with samples
|
||||
members.value = [...membersSample];
|
||||
availableSkills.value = [...skillsCatalogSample];
|
||||
availableProblems.value = [...problemsCatalogSample];
|
||||
|
||||
// Set pre-selected skills and problems
|
||||
selectedSkills.value = { ...sampleSelections.selectedSkillsByMember };
|
||||
selectedProblems.value = [...sampleSelections.selectedProblems];
|
||||
|
||||
// Update store with new members
|
||||
planStore.setMembers(members.value);
|
||||
|
||||
// Trigger offer generation immediately
|
||||
nextTick(() => {
|
||||
debouncedGenerateOffers();
|
||||
});
|
||||
}
|
||||
|
||||
// Debounced offer generation
|
||||
const debouncedGenerateOffers = useDebounceFn(async () => {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
<!-- Header -->
|
||||
<div class="mb-10 text-center">
|
||||
<h1
|
||||
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4"
|
||||
>
|
||||
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4">
|
||||
Co-op Builder
|
||||
</h1>
|
||||
</div>
|
||||
|
|
@ -18,16 +17,15 @@
|
|||
<div 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 p-8"
|
||||
>
|
||||
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
|
||||
<div
|
||||
class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<UIcon name="i-heroicons-check" class="w-8 h-8 text-white dark:text-black" />
|
||||
class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4">
|
||||
<UIcon
|
||||
name="i-heroicons-check"
|
||||
class="w-8 h-8 text-white dark:text-black" />
|
||||
</div>
|
||||
<h2
|
||||
class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide">
|
||||
You're all set!
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-6">
|
||||
|
|
@ -35,7 +33,10 @@
|
|||
</p>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<button class="export-btn" @click="restartWizard" :disabled="isResetting">
|
||||
<button
|
||||
class="export-btn"
|
||||
@click="restartWizard"
|
||||
:disabled="isResetting">
|
||||
Start Over
|
||||
</button>
|
||||
<button class="export-btn primary" @click="navigateTo('/budget')">
|
||||
|
|
@ -52,40 +53,34 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 1"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
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 === 1 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(1)"
|
||||
>
|
||||
@click="setFocusedStep(1)">
|
||||
<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="
|
||||
membersStore.isValid
|
||||
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="membersStore.isValid"
|
||||
v-if="membersValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
class="w-4 h-4" />
|
||||
<span v-else>1</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Add your team
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -93,15 +88,13 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 1 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 1 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 1"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardMembersStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -112,40 +105,34 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 2"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
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 === 2 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(2)"
|
||||
>
|
||||
@click="setFocusedStep(2)">
|
||||
<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="
|
||||
policiesStore.isValid
|
||||
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="policiesStore.isValid"
|
||||
v-if="policiesValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
class="w-4 h-4" />
|
||||
<span v-else>2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Set your wage
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -153,15 +140,13 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 2 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 2 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 2"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardPoliciesStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -172,30 +157,25 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 3"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
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 === 3 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(3)"
|
||||
>
|
||||
@click="setFocusedStep(3)">
|
||||
<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"
|
||||
>
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Monthly costs
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -203,15 +183,13 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 3 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 3 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 3"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardCostsStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -222,40 +200,34 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 4"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
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 === 4 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(4)"
|
||||
>
|
||||
@click="setFocusedStep(4)">
|
||||
<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="
|
||||
streamsStore.hasValidStreams
|
||||
streamsValid
|
||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
||||
"
|
||||
>
|
||||
">
|
||||
<UIcon
|
||||
v-if="streamsStore.hasValidStreams"
|
||||
v-if="streamsValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
class="w-4 h-4" />
|
||||
<span v-else>4</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Revenue streams
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -263,15 +235,13 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 4 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 4 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 4"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardRevenueStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -282,19 +252,16 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 5"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
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)"
|
||||
>
|
||||
@click="setFocusedStep(5)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
|
|
@ -303,15 +270,16 @@
|
|||
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" />
|
||||
">
|
||||
<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"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Review & finish
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -319,52 +287,57 @@
|
|||
<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 }"
|
||||
/>
|
||||
: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" />
|
||||
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 class="export-btn" @click="resetWizard" :disabled="isResetting">
|
||||
<button
|
||||
class="export-btn"
|
||||
@click="resetWizard"
|
||||
:disabled="isResetting">
|
||||
Start Over
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Save status -->
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide"
|
||||
>
|
||||
class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide">
|
||||
<UIcon
|
||||
v-if="saveStatus === 'saving'"
|
||||
name="i-heroicons-arrow-path"
|
||||
class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400"
|
||||
/>
|
||||
class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400" />
|
||||
<UIcon
|
||||
v-if="saveStatus === 'saved'"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-4 h-4 text-black dark:text-white"
|
||||
/>
|
||||
class="w-4 h-4 text-black dark:text-white" />
|
||||
<span
|
||||
v-if="saveStatus === 'saving'"
|
||||
class="text-neutral-500 dark:text-neutral-400"
|
||||
>Saving...</span
|
||||
>
|
||||
<span v-if="saveStatus === 'saved'" class="text-black dark:text-white"
|
||||
<span
|
||||
v-if="saveStatus === 'saved'"
|
||||
class="text-black dark:text-white"
|
||||
>Saved</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<button v-if="canComplete" class="export-btn primary" @click="completeWizard">
|
||||
<button
|
||||
v-if="canComplete"
|
||||
class="export-btn primary"
|
||||
@click="completeWizard">
|
||||
Complete Setup
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -375,12 +348,8 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Stores
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const coopBuilderStore = useCoopBuilderStore();
|
||||
// Store
|
||||
const coop = useCoopBuilder();
|
||||
|
||||
// UI state
|
||||
const focusedStep = ref(1);
|
||||
|
|
@ -389,9 +358,36 @@ const isResetting = ref(false);
|
|||
const isCompleted = ref(false);
|
||||
|
||||
// Computed validation
|
||||
const canComplete = computed(
|
||||
() => membersStore.isValid && policiesStore.isValid && streamsStore.hasValidStreams
|
||||
);
|
||||
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
|
||||
return coop.members.value.some((m: any) => {
|
||||
const hasName = typeof m.name === "string" && m.name.trim().length > 0;
|
||||
const hours = Number((m as any).hoursPerMonth ?? 0);
|
||||
return hasName && Number.isFinite(hours) && hours > 0;
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const streamsValid = computed(() => {
|
||||
// Valid if all streams have name, category, and non-negative monthly
|
||||
return (
|
||||
coop.streams.value.length > 0 &&
|
||||
coop.streams.value.every((s: any) => {
|
||||
const monthly = Number((s as any).monthly ?? 0);
|
||||
return (s.label || "").toString().trim().length > 0 && monthly >= 0;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Save status handler
|
||||
function handleSaveStatus(status: "saving" | "saved" | "error") {
|
||||
|
|
@ -424,14 +420,8 @@ function completeWizard() {
|
|||
async function resetWizard() {
|
||||
isResetting.value = true;
|
||||
|
||||
// Reset all stores
|
||||
membersStore.resetMembers();
|
||||
policiesStore.resetPolicies();
|
||||
streamsStore.resetStreams();
|
||||
budgetStore.resetBudgetOverhead();
|
||||
|
||||
// Reset coop builder state
|
||||
coopBuilderStore.reset();
|
||||
// Reset centralized store
|
||||
coop.reset();
|
||||
saveStatus.value = "";
|
||||
|
||||
// Small delay for UX
|
||||
|
|
@ -446,12 +436,8 @@ async function restartWizard() {
|
|||
isCompleted.value = false;
|
||||
focusedStep.value = 1;
|
||||
|
||||
// Reset all stores and coop builder state
|
||||
membersStore.resetMembers();
|
||||
policiesStore.resetPolicies();
|
||||
streamsStore.resetStreams();
|
||||
budgetStore.resetBudgetOverhead();
|
||||
coopBuilderStore.reset();
|
||||
// Reset centralized store
|
||||
coop.reset();
|
||||
saveStatus.value = "";
|
||||
|
||||
// Small delay for UX
|
||||
|
|
|
|||
112
pages/dashboard-simple.vue
Normal file
112
pages/dashboard-simple.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
||||
<div class="text-sm text-gray-600">
|
||||
Mode: {{ currentMode }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple Core Metrics -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Core Metrics</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ runwayDisplay }}</div>
|
||||
<div class="text-sm text-gray-600">Runway</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ coverageDisplay }}</div>
|
||||
<div class="text-sm text-gray-600">Coverage</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-purple-600">{{ streamCount }}</div>
|
||||
<div class="text-sm text-gray-600">Revenue Streams</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Simple Member List -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Members ({{ memberCount }})</h3>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(member, index) in membersList" :key="index" class="flex items-center justify-between p-2 border border-gray-200 rounded">
|
||||
<span class="font-medium">{{ member.name }}</span>
|
||||
<span class="text-sm text-gray-600">{{ member.relationship }}</span>
|
||||
</div>
|
||||
<div v-if="memberCount === 0" class="text-sm text-gray-500 italic p-4">
|
||||
No members configured yet.
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Simple reactive data without complex computations
|
||||
const currentMode = ref('minimum')
|
||||
const runwayDisplay = ref('∞')
|
||||
const coverageDisplay = ref('100%')
|
||||
const streamCount = ref(0)
|
||||
const memberCount = ref(0)
|
||||
const membersList = ref([])
|
||||
|
||||
// Try to initialize with store data
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Simple store access without composable
|
||||
const membersStore = useMembersStore()
|
||||
const streamsStore = useStreamsStore()
|
||||
const policiesStore = usePoliciesStore()
|
||||
|
||||
// Update reactive values
|
||||
currentMode.value = policiesStore.operatingMode || 'minimum'
|
||||
memberCount.value = membersStore.members?.length || 0
|
||||
streamCount.value = streamsStore.streams?.length || 0
|
||||
|
||||
// Simple member list
|
||||
membersList.value = membersStore.members?.map(m => ({
|
||||
name: m.displayName || 'Unknown',
|
||||
relationship: m.payRelationship || 'Unknown'
|
||||
})) || []
|
||||
|
||||
console.log('Dashboard initialized:', {
|
||||
mode: currentMode.value,
|
||||
members: memberCount.value,
|
||||
streams: streamCount.value
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing simple dashboard:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
48
pages/dashboard.vue
Normal file
48
pages/dashboard.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 space-y-8" data-ui="dashboard_v1">
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Metrics -->
|
||||
<DashboardCoreMetrics />
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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'
|
||||
import AdvancedAccordion from '~/components/dashboard/AdvancedAccordion.vue'
|
||||
|
||||
// Access composable data
|
||||
const { operatingMode, setOperatingMode } = useCoopBuilder()
|
||||
</script>
|
||||
464
pages/index.vue
464
pages/index.vue
|
|
@ -1,7 +1,20 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Dashboard</h2>
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">Dashboard</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">
|
||||
Runway: {{ Math.round(metrics.runway) }}mo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-down-tray"
|
||||
|
|
@ -29,10 +42,10 @@
|
|||
description="Funded hours vs target capacity across all members." />
|
||||
|
||||
<ReserveMeter
|
||||
:current-savings="metrics.finances.currentBalances.savings"
|
||||
:savings-target-months="metrics.finances.policies.savingsTargetMonths"
|
||||
:monthly-burn="metrics.monthlyBurn"
|
||||
description="Build savings to your target before increasing paid hours." />
|
||||
:current-savings="savingsProgress.current"
|
||||
: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">
|
||||
|
|
@ -58,43 +71,98 @@
|
|||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Quick Wins Dashboard Components -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 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>
|
||||
|
||||
<!-- Milestone-Runway Overlay -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Runway vs Milestones</h3>
|
||||
</template>
|
||||
<MilestoneRunwayOverlay />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Alerts Section -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Alerts</h3>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<!-- Concentration Risk Alert -->
|
||||
<UAlert
|
||||
v-if="topSourcePct > 50"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
title="Revenue Concentration Risk"
|
||||
description="Most of your money comes from one place. Add another stream to reduce risk."
|
||||
:actions="[{ label: 'Plan Mix', click: () => navigateTo('/mix') }]" />
|
||||
:description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
|
||||
:actions="[
|
||||
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') },
|
||||
{ label: 'Scenarios', click: () => handleAlertNavigation('/scenarios', 'diversification') }
|
||||
]" />
|
||||
|
||||
<!-- Cushion Breach Alert -->
|
||||
<UAlert
|
||||
v-if="alerts.cushionBreach"
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-calendar"
|
||||
title="Cash Cushion Breach Forecast"
|
||||
:description="cashBreachDescription"
|
||||
:description="`Projected to breach minimum cushion in week ${cushionForecast.firstBreachWeek || 'unknown'}`"
|
||||
:actions="[
|
||||
{ label: 'View Calendar', click: () => navigateTo('/cash') },
|
||||
{ label: 'View Calendar', click: () => handleAlertNavigation('/cash', 'breach-forecast') },
|
||||
{ label: 'Adjust Budget', click: () => handleAlertNavigation('/budget', 'expenses') }
|
||||
]" />
|
||||
|
||||
<!-- Savings Below Target Alert -->
|
||||
<UAlert
|
||||
v-if="alerts.savingsBelowTarget"
|
||||
color="yellow"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-banknotes"
|
||||
title="Savings Below Target"
|
||||
description="Build savings to your target before increasing paid hours."
|
||||
:description="`${savingsProgress.progressPct.toFixed(0)}% of target reached. Build savings before increasing paid hours.`"
|
||||
:actions="[
|
||||
{ label: 'View Progress', click: () => navigateTo('/budget') },
|
||||
{ label: 'View Progress', click: () => handleAlertNavigation('/budget', 'savings') },
|
||||
{ label: 'Adjust Policies', click: () => handleAlertNavigation('/coop-builder', 'policies') }
|
||||
]" />
|
||||
|
||||
<!-- Over-Deferred Member Alert -->
|
||||
<UAlert
|
||||
color="amber"
|
||||
v-if="deferredAlert.show"
|
||||
color="purple"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-clock"
|
||||
title="Over-Deferred Member"
|
||||
description="Alex has reached 85% of quarterly deferred cap." />
|
||||
icon="i-heroicons-user-group"
|
||||
title="Member Over-Deferred"
|
||||
:description="deferredAlert.description"
|
||||
:actions="[
|
||||
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
|
||||
{ label: 'Value Session', click: () => handleAlertNavigation('/session', 'distributions') }
|
||||
]" />
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
|
|
@ -104,37 +172,46 @@
|
|||
<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">Operate Current</h4>
|
||||
<UBadge color="green" variant="subtle" size="xs">Active</UBadge>
|
||||
<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 text-orange-600 mb-1">
|
||||
{{ scenarioMetrics.current.runway }} months
|
||||
<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">Continue existing plan</p>
|
||||
<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">Quit Day Jobs</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge>
|
||||
<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 text-red-600 mb-1">
|
||||
{{ scenarioMetrics.quitJobs.runway }} months
|
||||
<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">Full-time co-op work</p>
|
||||
<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">Start Production</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge>
|
||||
<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 text-yellow-600 mb-1">
|
||||
{{ scenarioMetrics.startProduction.runway }} months
|
||||
<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">Launch development</p>
|
||||
<p class="text-xs text-neutral-600">
|
||||
Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
|
@ -214,6 +291,100 @@
|
|||
</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">
|
||||
<UButton
|
||||
|
|
@ -274,6 +445,34 @@ const streamsStore = useStreamsStore();
|
|||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
|
||||
// Runway composable with operating mode integration
|
||||
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(() => {
|
||||
const totalTargetHours = membersStore.members.reduce(
|
||||
|
|
@ -291,24 +490,26 @@ const metrics = computed(() => {
|
|||
0
|
||||
);
|
||||
|
||||
const monthlyPayroll =
|
||||
totalTargetHours *
|
||||
policiesStore.equalHourlyWage *
|
||||
(1 + policiesStore.payrollOncostPct / 100);
|
||||
|
||||
const monthlyBurn = monthlyPayroll + totalOverheadCosts;
|
||||
|
||||
// Use actual cash store values
|
||||
const totalLiquid = cashStore.currentCash + cashStore.currentSavings;
|
||||
|
||||
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0;
|
||||
// Use integrated runway calculations that respect operating mode
|
||||
const currentMode = policiesStore.operatingMode || 'minimum';
|
||||
const monthlyBurn = getMonthlyBurn(currentMode);
|
||||
|
||||
// Use actual cash store values with fallback
|
||||
const cash = cashStore.currentCash || 50000;
|
||||
const savings = cashStore.currentSavings || 15000;
|
||||
const totalLiquid = cash + savings;
|
||||
|
||||
// Get dual-mode runway data
|
||||
const runwayData = getDualModeRunway(cash, savings);
|
||||
const runway = currentMode === 'target' ? runwayData.target : runwayData.minimum;
|
||||
|
||||
return {
|
||||
totalTargetHours,
|
||||
totalTargetRevenue,
|
||||
monthlyPayroll,
|
||||
monthlyPayroll: runwayData.minBurn, // Use actual calculated payroll
|
||||
monthlyBurn,
|
||||
runway,
|
||||
runwayData, // Include dual-mode data
|
||||
finances: {
|
||||
currentBalances: {
|
||||
cash: cashStore.currentCash,
|
||||
|
|
@ -346,6 +547,14 @@ const topSourcePct = computed(() => {
|
|||
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
|
||||
});
|
||||
|
||||
const topStreamName = computed(() => {
|
||||
if (streamsStore.streams.length === 0) return 'No streams';
|
||||
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
|
||||
const maxAmount = Math.max(...amounts);
|
||||
const topStream = streamsStore.streams.find(s => (s.targetMonthlyAmount || 0) === maxAmount);
|
||||
return topStream?.name || 'Unknown stream';
|
||||
});
|
||||
|
||||
const concentrationStatus = computed(() => {
|
||||
if (topSourcePct.value > 50) return "red";
|
||||
if (topSourcePct.value > 35) return "yellow";
|
||||
|
|
@ -358,6 +567,12 @@ const concentrationColor = computed(() => {
|
|||
return "text-green-600";
|
||||
});
|
||||
|
||||
function getRunwayColor(months: number): string {
|
||||
if (months >= 6) return 'text-green-600'
|
||||
if (months >= 3) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
// Calculate scenario metrics
|
||||
const scenarioMetrics = computed(() => {
|
||||
const baseRunway = metrics.value.runway;
|
||||
|
|
@ -413,4 +628,169 @@ 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(() => {
|
||||
const maxDeferredRatio = 1.5; // From CLAUDE.md - flag if >1.5× monthly payroll
|
||||
const monthlyPayrollCost = getMonthlyBurn() * 0.7; // Estimate payroll as 70% of burn
|
||||
const totalDeferred = membersStore.members.reduce(
|
||||
(sum, m) => sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
|
||||
0
|
||||
);
|
||||
|
||||
const deferredRatio = monthlyPayrollCost > 0 ? totalDeferred / monthlyPayrollCost : 0;
|
||||
const show = deferredRatio > maxDeferredRatio;
|
||||
|
||||
const overDeferredMembers = membersStore.members.filter(m => {
|
||||
const memberDeferred = (m.deferredHours || 0) * policiesStore.equalHourlyWage;
|
||||
const memberMonthlyPay = m.monthlyPayPlanned || 0;
|
||||
return memberDeferred > memberMonthlyPay * 2; // Member has >2 months of pay deferred
|
||||
});
|
||||
|
||||
return {
|
||||
show,
|
||||
description: show
|
||||
? `${overDeferredMembers.length} member(s) over deferred cap. Total: ${(deferredRatio * 100).toFixed(0)}% of monthly payroll.`
|
||||
: ''
|
||||
};
|
||||
});
|
||||
|
||||
// Alert navigation with context
|
||||
function handleAlertNavigation(path: string, section?: string) {
|
||||
// Store alert context for target page to highlight relevant section
|
||||
if (section) {
|
||||
localStorage.setItem('urgent-tools-alert-context', JSON.stringify({ section, timestamp: Date.now() }));
|
||||
}
|
||||
navigateTo(path);
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue