diff --git a/app.vue b/app.vue index 6aade1c..1c4e66d 100644 --- a/app.vue +++ b/app.vue @@ -79,14 +79,25 @@ const isCoopBuilderSection = computed( route.path === "/coop-planner" || route.path === "/coop-builder" || route.path === "/" || + route.path === "/dashboard" || route.path === "/mix" || route.path === "/budget" || route.path === "/scenarios" || route.path === "/cash" || + route.path === "/session" || + route.path === "/settings" || route.path === "/glossary" ); const isWizardSection = computed( () => route.path === "/wizards" || route.path.startsWith("/templates/") ); + +// Run migrations on app startup +onMounted(() => { + const { migrate, needsMigration } = useMigrations(); + if (needsMigration()) { + migrate(); + } +}); diff --git a/components/CoopBuilderSubnav.vue b/components/CoopBuilderSubnav.vue index c6c0d69..eb3a668 100644 --- a/components/CoopBuilderSubnav.vue +++ b/components/CoopBuilderSubnav.vue @@ -26,6 +26,11 @@ const route = useRoute(); const coopBuilderItems = [ + { + id: "dashboard", + name: "Dashboard", + path: "/dashboard", + }, { id: "coop-builder", name: "Setup Wizard", @@ -36,6 +41,26 @@ const coopBuilderItems = [ name: "Budget", path: "/budget", }, + { + id: "mix", + name: "Revenue Mix", + path: "/mix", + }, + { + id: "scenarios", + name: "Scenarios", + path: "/scenarios", + }, + { + id: "cash", + name: "Cash Flow", + path: "/cash", + }, + { + id: "session", + name: "Value Session", + path: "/session", + }, ]; function isActive(path: string): boolean { diff --git a/components/CoverageChip.vue b/components/CoverageChip.vue new file mode 100644 index 0000000..1fc3225 --- /dev/null +++ b/components/CoverageChip.vue @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/components/MilestoneRunwayOverlay.vue b/components/MilestoneRunwayOverlay.vue new file mode 100644 index 0000000..bab80a6 --- /dev/null +++ b/components/MilestoneRunwayOverlay.vue @@ -0,0 +1,211 @@ + + + \ No newline at end of file diff --git a/components/NeedsCoverageBars.vue b/components/NeedsCoverageBars.vue new file mode 100644 index 0000000..23f1535 --- /dev/null +++ b/components/NeedsCoverageBars.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/components/RevenueMixTable.vue b/components/RevenueMixTable.vue new file mode 100644 index 0000000..3b2f437 --- /dev/null +++ b/components/RevenueMixTable.vue @@ -0,0 +1,93 @@ + + + \ No newline at end of file diff --git a/components/WizardCostsStep.vue b/components/WizardCostsStep.vue index 797a332..efe8792 100644 --- a/components/WizardCostsStep.vue +++ b/components/WizardCostsStep.vue @@ -18,6 +18,27 @@ + +
+
+
+

Operating Mode

+

+ Choose between minimum needs or target pay for payroll calculations +

+
+ +
+
+ {{ useTargetMode ? '🎯 Target Mode' : '⚡ Minimum Mode' }}: + {{ useTargetMode ? 'Uses target pay allocations' : 'Uses minimum needs allocations' }} +
+
+
import { useDebounceFn } from "@vueuse/core"; -import { storeToRefs } from "pinia"; const emit = defineEmits<{ "save-status": [status: "saving" | "saved" | "error"]; }>(); // Store -const budgetStore = useBudgetStore(); -const { overheadCosts } = storeToRefs(budgetStore); +const coop = useCoopBuilder(); + +// Get the store directly for overhead costs +const store = useCoopBuilderStore(); + +// Computed for overhead costs (from store) +const overheadCosts = computed(() => store.overheadCosts || []); + +// Operating mode toggle +const useTargetMode = ref(coop.operatingMode.value === 'target'); + +function updateOperatingMode(value: boolean) { + coop.setOperatingMode(value ? 'target' : 'min'); + emit("save-status", "saved"); +} // Category options const categoryOptions = [ @@ -168,13 +201,8 @@ const debouncedSave = useDebounceFn((cost: any) => { emit("save-status", "saving"); try { - // Find and update existing cost - const existingCost = overheadCosts.value.find((c) => c.id === cost.id); - if (existingCost) { - // Store will handle reactivity through the ref - Object.assign(existingCost, cost); - } - + // Use store's upsert method + store.upsertOverheadCost(cost); emit("save-status", "saved"); } catch (error) { console.error("Failed to save cost:", error); @@ -204,15 +232,13 @@ function addOverheadCost() { recurring: true, }; - budgetStore.addOverheadLine({ - name: newCost.name, - amountMonthly: newCost.amount, - category: newCost.category, - }); + store.addOverheadCost(newCost); + emit("save-status", "saved"); } function removeCost(id: string) { - budgetStore.removeOverheadLine(id); + store.removeOverheadCost(id); + emit("save-status", "saved"); } function exportCosts() { diff --git a/components/WizardMembersStep.vue b/components/WizardMembersStep.vue index 28171c3..39665b2 100644 --- a/components/WizardMembersStep.vue +++ b/components/WizardMembersStep.vue @@ -53,58 +53,22 @@ v-for="(member, index) in members" :key="member.id" class="p-6 border-3 border-black rounded-xl bg-white shadow-md"> -
- + +
+
- - - - - - - - - -
- -
- - - - -
- - -
+ +
+ + +
+ + + + + + + + + + + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
@@ -139,14 +175,30 @@ \ No newline at end of file diff --git a/components/advanced/ScenariosPanel.vue b/components/advanced/ScenariosPanel.vue new file mode 100644 index 0000000..a2f765b --- /dev/null +++ b/components/advanced/ScenariosPanel.vue @@ -0,0 +1,49 @@ + + + \ No newline at end of file diff --git a/components/advanced/StressTestPanel.vue b/components/advanced/StressTestPanel.vue new file mode 100644 index 0000000..022e09c --- /dev/null +++ b/components/advanced/StressTestPanel.vue @@ -0,0 +1,93 @@ + + + \ No newline at end of file diff --git a/components/dashboard/AdvancedAccordion.vue b/components/dashboard/AdvancedAccordion.vue new file mode 100644 index 0000000..92deca5 --- /dev/null +++ b/components/dashboard/AdvancedAccordion.vue @@ -0,0 +1,186 @@ + + + \ No newline at end of file diff --git a/components/dashboard/DashboardCoreMetrics.vue b/components/dashboard/DashboardCoreMetrics.vue new file mode 100644 index 0000000..d4a997d --- /dev/null +++ b/components/dashboard/DashboardCoreMetrics.vue @@ -0,0 +1,15 @@ + + + \ No newline at end of file diff --git a/components/dashboard/MemberCoveragePanel.vue b/components/dashboard/MemberCoveragePanel.vue new file mode 100644 index 0000000..0b745a7 --- /dev/null +++ b/components/dashboard/MemberCoveragePanel.vue @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/components/dashboard/NeedsCoverageCard.vue b/components/dashboard/NeedsCoverageCard.vue new file mode 100644 index 0000000..4c8dabb --- /dev/null +++ b/components/dashboard/NeedsCoverageCard.vue @@ -0,0 +1,37 @@ + + + \ No newline at end of file diff --git a/components/dashboard/RevenueMixCard.vue b/components/dashboard/RevenueMixCard.vue new file mode 100644 index 0000000..aec8196 --- /dev/null +++ b/components/dashboard/RevenueMixCard.vue @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/components/dashboard/RunwayCard.vue b/components/dashboard/RunwayCard.vue new file mode 100644 index 0000000..3f1967a --- /dev/null +++ b/components/dashboard/RunwayCard.vue @@ -0,0 +1,62 @@ + + + \ No newline at end of file diff --git a/components/shared/CoverageBar.vue b/components/shared/CoverageBar.vue new file mode 100644 index 0000000..415b128 --- /dev/null +++ b/components/shared/CoverageBar.vue @@ -0,0 +1,41 @@ + + + \ No newline at end of file diff --git a/components/shared/OperatingModeToggle.vue b/components/shared/OperatingModeToggle.vue new file mode 100644 index 0000000..9628c45 --- /dev/null +++ b/components/shared/OperatingModeToggle.vue @@ -0,0 +1,36 @@ + + + \ No newline at end of file diff --git a/composables/useCoopBuilder.ts b/composables/useCoopBuilder.ts new file mode 100644 index 0000000..5a9e979 --- /dev/null +++ b/composables/useCoopBuilder.ts @@ -0,0 +1,364 @@ +import { allocatePayroll as allocatePayrollImpl, monthlyPayroll, type Member, type PayPolicy } from '~/types/members' + +export function useCoopBuilder() { + // Use the centralized Pinia store + const store = useCoopBuilderStore() + + // Initialize store (but don't auto-load demo data) + onMounted(() => { + // Give the persistence plugin time to hydrate + nextTick(() => { + // Just ensure store is initialized but don't load demo data + // store.initializeDefaults() is disabled to prevent auto demo data + }) + }) + + // Core computed values with error handling + const members = computed(() => { + try { + return store.members || [] + } catch (e) { + console.warn('Error accessing members:', e) + return [] + } + }) + + const streams = computed(() => { + try { + return store.streams || [] + } catch (e) { + console.warn('Error accessing streams:', e) + return [] + } + }) + + const policy = computed(() => { + try { + return store.policy || { relationship: 'equal-pay', roleBands: {} } + } catch (e) { + console.warn('Error accessing policy:', e) + return { relationship: 'equal-pay', roleBands: {} } + } + }) + + const operatingMode = computed({ + get: () => { + try { + return store.operatingMode || 'min' + } catch (e) { + console.warn('Error accessing operating mode:', e) + return 'min' as 'min' | 'target' + } + }, + set: (value: 'min' | 'target') => { + try { + store.setOperatingMode(value) + } catch (e) { + console.warn('Error setting operating mode:', e) + } + } + }) + + const scenario = computed({ + get: () => store.scenario, + set: (value) => store.setScenario(value) + }) + + const stress = computed({ + get: () => store.stress, + set: (value) => store.updateStress(value) + }) + + const milestones = computed(() => store.milestones) + + // Helper: Get scenario-transformed data + function getScenarioData() { + const baseMembers = [...members.value] + const baseStreams = [...streams.value] + + switch (scenario.value) { + case 'quit-jobs': + return { + members: baseMembers.map(m => ({ ...m, externalMonthlyIncome: 0 })), + streams: baseStreams + } + + case 'start-production': + return { + members: baseMembers, + streams: baseStreams.map(s => { + // Reduce service revenue by 30% + if (s.category?.toLowerCase().includes('service') || s.label.toLowerCase().includes('service')) { + return { ...s, monthly: (s.monthly || 0) * 0.7 } + } + return s + }) + } + + default: + return { members: baseMembers, streams: baseStreams } + } + } + + // Helper: Apply stress test to scenario data + function getStressedData(baseData?: { members: Member[]; streams: any[] }) { + const data = baseData || getScenarioData() + const { revenueDelay, costShockPct, grantLost } = stress.value + + if (revenueDelay === 0 && costShockPct === 0 && !grantLost) { + return data + } + + let adjustedStreams = [...data.streams] + + // Apply revenue delay (reduce revenue by delay percentage) + if (revenueDelay > 0) { + adjustedStreams = adjustedStreams.map(s => ({ + ...s, + monthly: (s.monthly || 0) * Math.max(0, 1 - (revenueDelay / 12)) + })) + } + + // Grant lost - remove largest grant + if (grantLost) { + const grantStreams = adjustedStreams.filter(s => + s.category?.toLowerCase().includes('grant') || + s.label.toLowerCase().includes('grant') + ) + if (grantStreams.length > 0) { + const largestGrant = grantStreams.reduce((prev, current) => + (prev.monthly || 0) > (current.monthly || 0) ? prev : current + ) + adjustedStreams = adjustedStreams.map(s => + s.id === largestGrant.id ? { ...s, monthly: 0 } : s + ) + } + } + + return { + members: data.members, + streams: adjustedStreams + } + } + + // Payroll allocation + function allocatePayroll() { + const { members: scenarioMembers } = getScenarioData() + const payPolicy = policy.value + const totalRevenue = streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0) + const overheadCosts = store.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0) + const availableForPayroll = Math.max(0, totalRevenue - overheadCosts) + + return allocatePayrollImpl(scenarioMembers, payPolicy as PayPolicy, availableForPayroll) + } + + // Coverage calculation for a single member + function coverage(member: Member): { minPct: number; targetPct: number } { + const totalIncome = (member.monthlyPayPlanned || 0) + (member.externalMonthlyIncome || 0) + + const minPct = member.minMonthlyNeeds > 0 + ? Math.min(200, (totalIncome / member.minMonthlyNeeds) * 100) + : 100 + + const targetPct = member.targetMonthlyPay > 0 + ? Math.min(200, (totalIncome / member.targetMonthlyPay) * 100) + : 100 + + return { minPct, targetPct } + } + + // Team coverage statistics + function teamCoverageStats() { + try { + const allocatedMembers = allocatePayroll() || [] + const coverages = allocatedMembers.map(m => coverage(m).minPct).filter(c => !isNaN(c)) + + if (coverages.length === 0) { + return { median: 0, under100: 0, over100Pct: 0, gini: 0 } + } + + const sorted = [...coverages].sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length / 2)] || 0 + const under100 = coverages.filter(c => c < 100).length + const over100Pct = coverages.length > 0 + ? Math.round(((coverages.length - under100) / coverages.length) * 100) + : 0 + + // Simple Gini coefficient approximation + const mean = coverages.reduce((sum, c) => sum + c, 0) / coverages.length + const gini = coverages.length > 1 && mean > 0 + ? coverages.reduce((sum, c) => sum + Math.abs(c - mean), 0) / (2 * coverages.length * mean) + : 0 + + return { median, under100, over100Pct, gini } + } catch (e) { + console.warn('Error calculating team coverage stats:', e) + return { median: 0, under100: 0, over100Pct: 0, gini: 0 } + } + } + + // Revenue mix + function revenueMix() { + try { + const { streams: scenarioStreams } = getStressedData() || { streams: [] } + const validStreams = scenarioStreams.filter(s => s && typeof s === 'object') + const total = validStreams.reduce((sum, s) => sum + (s.monthly || 0), 0) + + return validStreams + .filter(s => (s.monthly || 0) > 0) + .map(s => ({ + label: s.label || 'Unnamed Stream', + monthly: s.monthly || 0, + pct: total > 0 ? (s.monthly || 0) / total : 0 + })) + .sort((a, b) => b.monthly - a.monthly) + } catch (e) { + console.warn('Error calculating revenue mix:', e) + return [] + } + } + + // Concentration percentage (highest stream) + function concentrationPct(): number { + try { + const mix = revenueMix() + return mix.length > 0 ? (mix[0].pct || 0) : 0 + } catch (e) { + console.warn('Error calculating concentration:', e) + return 0 + } + } + + // Runway calculation + function runwayMonths(mode?: 'min' | 'target', opts?: { useStress?: boolean }): number { + try { + const inputMode = mode || operatingMode.value + // Map to internal store format for compatibility + const currentMode = inputMode === 'min' ? 'minimum' : inputMode === 'target' ? 'target' : 'minimum' + const { members: scenarioMembers, streams: scenarioStreams } = opts?.useStress + ? getStressedData() + : getScenarioData() + + // Calculate monthly payroll + const payrollCost = monthlyPayroll(scenarioMembers || [], currentMode) || 0 + const oncostPct = store.payrollOncostPct || 0 + const totalPayroll = payrollCost * (1 + Math.max(0, oncostPct) / 100) + + // Calculate revenue and costs + const totalRevenue = (scenarioStreams || []).reduce((sum, s) => sum + (s.monthly || 0), 0) + const overheadCost = (store.overheadCosts || []).reduce((sum, cost) => sum + (cost.amount || 0), 0) + + // Apply cost shock if in stress mode + const adjustedOverhead = opts?.useStress && stress.value.costShockPct > 0 + ? overheadCost * (1 + Math.max(0, stress.value.costShockPct) / 100) + : overheadCost + + // Monthly net and burn + const monthlyNet = totalRevenue - totalPayroll - adjustedOverhead + const monthlyBurn = totalPayroll + adjustedOverhead + + // Cash reserves with safe defaults + const cash = Math.max(0, store.currentCash || 50000) + const savings = Math.max(0, store.currentSavings || 15000) + const totalLiquid = cash + savings + + // Runway calculation + if (monthlyNet >= 0) { + return Infinity // Sustainable + } + + return monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0 + } catch (e) { + console.warn('Error calculating runway:', e) + return 0 + } + } + + // Milestone status + function milestoneStatus(mode?: 'min' | 'target') { + const currentMode = mode || operatingMode.value + const runway = runwayMonths(currentMode) + const runwayEndDate = new Date() + runwayEndDate.setMonth(runwayEndDate.getMonth() + Math.floor(runway)) + + return milestones.value.map(milestone => ({ + ...milestone, + willReach: new Date(milestone.date) <= runwayEndDate + })) + } + + // Actions + function setOperatingMode(mode: 'min' | 'target') { + store.setOperatingMode(mode) + } + + function setScenario(newScenario: 'current' | 'quit-jobs' | 'start-production' | 'custom') { + store.setScenario(newScenario) + } + + function updateStress(newStress: Partial) { + store.updateStress(newStress) + } + + function addMilestone(label: string, date: string) { + store.addMilestone(label, date) + } + + function removeMilestone(id: string) { + store.removeMilestone(id) + } + + // Testing helpers + function clearAll() { + store.clearAll() + } + + function loadDefaults() { + store.loadDefaultData() + } + + // Reset helper function + function reset() { + store.clearAll() + store.loadDefaultData() + } + + return { + // State + members, + streams, + policy, + operatingMode, + scenario, + stress, + milestones, + + // Computed + allocatePayroll, + coverage, + teamCoverageStats, + revenueMix, + concentrationPct, + runwayMonths, + milestoneStatus, + + // Actions + setOperatingMode, + setScenario, + updateStress, + addMilestone, + removeMilestone, + upsertMember: (member: any) => store.upsertMember(member), + removeMember: (id: string) => store.removeMember(id), + upsertStream: (stream: any) => store.upsertStream(stream), + removeStream: (id: string) => store.removeStream(id), + setPolicy: (relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") => store.setPolicy(relationship), + setRoleBands: (bands: Record) => store.setRoleBands(bands), + setEqualWage: (wage: number) => store.setEqualWage(wage), + + // Testing helpers + clearAll, + loadDefaults, + reset + } +} \ No newline at end of file diff --git a/composables/useCushionForecast.ts b/composables/useCushionForecast.ts new file mode 100644 index 0000000..5d70cc4 --- /dev/null +++ b/composables/useCushionForecast.ts @@ -0,0 +1,92 @@ +import { monthlyPayroll } from '~/types/members' + +export function useCushionForecast() { + const cashStore = useCashStore() + const membersStore = useMembersStore() + const policiesStore = usePoliciesStore() + const budgetStore = useBudgetStore() + + // Savings progress calculation + const savingsProgress = computed(() => { + const current = cashStore.currentSavings || 0 + const targetMonths = policiesStore.savingsTargetMonths || 3 + const monthlyBurn = getMonthlyBurn() + const target = targetMonths * monthlyBurn + + return { + current, + target, + targetMonths, + progressPct: target > 0 ? Math.min(100, (current / target) * 100) : 0, + gap: Math.max(0, target - current), + status: getProgressStatus(current, target) + } + }) + + // 13-week cushion breach forecast + const cushionForecast = computed(() => { + const minCushion = policiesStore.minCashCushionAmount || 5000 + const currentBalance = cashStore.currentCash || 0 + const monthlyBurn = getMonthlyBurn() + const weeklyBurn = monthlyBurn / 4.33 // Convert monthly to weekly + + const weeks = [] + let runningBalance = currentBalance + + for (let week = 1; week <= 13; week++) { + // Simple projection: subtract weekly burn + runningBalance -= weeklyBurn + + const breachesCushion = runningBalance < minCushion + + weeks.push({ + week, + balance: runningBalance, + breachesCushion, + cushionAmount: minCushion + }) + } + + const firstBreachWeek = weeks.find(w => w.breachesCushion)?.week + const breachesWithin13Weeks = Boolean(firstBreachWeek) + + return { + weeks, + firstBreachWeek, + breachesWithin13Weeks, + minCushion, + weeksUntilBreach: firstBreachWeek || null + } + }) + + function getMonthlyBurn() { + const operatingMode = policiesStore.operatingMode || 'minimum' + const payrollCost = monthlyPayroll(membersStore.members, operatingMode) + const oncostPct = policiesStore.payrollOncostPct || 0 + const totalPayroll = payrollCost * (1 + oncostPct / 100) + const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0) + + return totalPayroll + overheadCost + } + + function getProgressStatus(current: number, target: number): 'green' | 'yellow' | 'red' { + if (target === 0) return 'green' + const pct = (current / target) * 100 + if (pct >= 80) return 'green' + if (pct >= 50) return 'yellow' + return 'red' + } + + // Alert conditions (matching CLAUDE.md) + const alerts = computed(() => ({ + savingsBelowTarget: savingsProgress.value.current < savingsProgress.value.target, + cushionBreach: cushionForecast.value.breachesWithin13Weeks, + })) + + return { + savingsProgress, + cushionForecast, + alerts, + getMonthlyBurn + } +} \ No newline at end of file diff --git a/composables/useFixtureIO.ts b/composables/useFixtureIO.ts index c56b190..d870247 100644 --- a/composables/useFixtureIO.ts +++ b/composables/useFixtureIO.ts @@ -33,10 +33,13 @@ export function useFixtureIO() { payrollOncostPct: policies.payrollOncostPct, savingsTargetMonths: policies.savingsTargetMonths, minCashCushionAmount: policies.minCashCushionAmount, + operatingMode: policies.operatingMode, + payPolicy: policies.payPolicy, deferredCapHoursPerQtr: policies.deferredCapHoursPerQtr, deferredSunsetMonths: policies.deferredSunsetMonths, surplusOrder: policies.surplusOrder, - paymentPriority: policies.paymentPriority + paymentPriority: policies.paymentPriority, + volunteerScope: policies.volunteerScope }, streams: streams.streams, budget: { @@ -66,9 +69,65 @@ export function useFixtureIO() { } const importAll = (snapshot: AppSnapshot) => { - // TODO: Implement import functionality for all stores - // This will patch each store with the snapshot data - console.log('Import functionality to be implemented', snapshot) + const members = useMembersStore() + const policies = usePoliciesStore() + const streams = useStreamsStore() + const budget = useBudgetStore() + const scenarios = useScenariosStore() + const cash = useCashStore() + const session = useSessionStore() + + try { + // Import members + if (snapshot.members && Array.isArray(snapshot.members)) { + members.$patch({ members: snapshot.members }) + } + + // Import policies + if (snapshot.policies) { + policies.$patch(snapshot.policies) + } + + // Import streams + if (snapshot.streams && Array.isArray(snapshot.streams)) { + streams.$patch({ streams: snapshot.streams }) + } + + // Import budget + if (snapshot.budget) { + budget.$patch(snapshot.budget) + } + + // Import scenarios + if (snapshot.scenarios) { + scenarios.$patch(snapshot.scenarios) + } + + // Import cash + if (snapshot.cash) { + cash.updateCurrentBalances( + snapshot.cash.currentCash || 0, + snapshot.cash.currentSavings || 0 + ) + // Handle cash events and payment queue if present + if (snapshot.cash.cashEvents) { + cash.$patch({ cashEvents: snapshot.cash.cashEvents }) + } + if (snapshot.cash.paymentQueue) { + cash.$patch({ paymentQueue: snapshot.cash.paymentQueue }) + } + } + + // Import session + if (snapshot.session) { + session.$patch(snapshot.session) + } + + console.log('Successfully imported data snapshot') + } catch (error) { + console.error('Failed to import snapshot:', error) + throw error + } } return { exportAll, importAll } diff --git a/composables/useFixtures.ts b/composables/useFixtures.ts index 9460c2e..cfbaf8d 100644 --- a/composables/useFixtures.ts +++ b/composables/useFixtures.ts @@ -1,238 +1,73 @@ /** - * Composable for loading and managing fixture data - * Provides centralized access to demo data for all screens + * DISABLED: All fixture data removed to prevent automatic demo data + * This composable previously loaded sample data but is now empty */ export const useFixtures = () => { - // Load fixture data (in real app, this would come from API or stores) + // All sample data functions now return empty data const loadMembers = async () => { - // In production, this would fetch from content/fixtures/members.json - // For now, return inline data that matches the fixture structure return { - members: [ - { - id: 'member-1', - displayName: 'Alex Chen', - roleFocus: 'Technical Lead', - payRelationship: 'Hybrid', - capacity: { minHours: 20, targetHours: 120, maxHours: 160 }, - riskBand: 'Medium', - externalCoveragePct: 60, - privacyNeeds: 'aggregate_ok', - deferredHours: 85, - quarterlyDeferredCap: 240 - }, - { - id: 'member-2', - displayName: 'Jordan Silva', - roleFocus: 'Design & UX', - payRelationship: 'FullyPaid', - capacity: { minHours: 30, targetHours: 140, maxHours: 180 }, - riskBand: 'Low', - externalCoveragePct: 20, - privacyNeeds: 'aggregate_ok', - deferredHours: 0, - quarterlyDeferredCap: 240 - }, - { - id: 'member-3', - displayName: 'Sam Rodriguez', - roleFocus: 'Operations & Growth', - payRelationship: 'Supplemental', - capacity: { minHours: 10, targetHours: 60, maxHours: 100 }, - riskBand: 'High', - externalCoveragePct: 85, - privacyNeeds: 'steward_only', - deferredHours: 32, - quarterlyDeferredCap: 120 - } - ] + members: [] } } const loadStreams = async () => { return { - revenueStreams: [ - { - id: 'stream-1', - name: 'Client Services', - category: 'Services', - subcategory: 'Development', - targetPct: 65, - targetMonthlyAmount: 13000, - certainty: 'Committed', - payoutDelayDays: 30, - terms: 'Net 30', - revenueSharePct: 0, - platformFeePct: 0, - restrictions: 'General', - effortHoursPerMonth: 180 - }, - { - id: 'stream-2', - name: 'Platform Sales', - category: 'Product', - subcategory: 'Digital Tools', - targetPct: 20, - targetMonthlyAmount: 4000, - certainty: 'Probable', - payoutDelayDays: 14, - terms: 'Platform payout', - revenueSharePct: 0, - platformFeePct: 5, - restrictions: 'General', - effortHoursPerMonth: 40 - }, - { - id: 'stream-3', - name: 'Innovation Grant', - category: 'Grant', - subcategory: 'Government', - targetPct: 10, - targetMonthlyAmount: 2000, - certainty: 'Committed', - payoutDelayDays: 45, - terms: 'Quarterly disbursement', - revenueSharePct: 0, - platformFeePct: 0, - restrictions: 'Restricted', - effortHoursPerMonth: 8 - }, - { - id: 'stream-4', - name: 'Community Donations', - category: 'Donation', - subcategory: 'Individual', - targetPct: 3, - targetMonthlyAmount: 600, - certainty: 'Aspirational', - payoutDelayDays: 3, - terms: 'Immediate', - revenueSharePct: 0, - platformFeePct: 2.9, - restrictions: 'General', - effortHoursPerMonth: 5 - }, - { - id: 'stream-5', - name: 'Consulting & Training', - category: 'Other', - subcategory: 'Professional Services', - targetPct: 2, - targetMonthlyAmount: 400, - certainty: 'Probable', - payoutDelayDays: 21, - terms: 'Net 21', - revenueSharePct: 0, - platformFeePct: 0, - restrictions: 'General', - effortHoursPerMonth: 12 - } - ] + revenueStreams: [] } } const loadFinances = async () => { return { currentBalances: { - cash: 5000, - savings: 8000, - totalLiquid: 13000 + cash: 0, + savings: 0, + totalLiquid: 0 }, policies: { - equalHourlyWage: 20, - payrollOncostPct: 25, - savingsTargetMonths: 3, - minCashCushionAmount: 3000, - deferredCapHoursPerQtr: 240, - deferredSunsetMonths: 12 + equalHourlyWage: 0, + payrollOncostPct: 0, + savingsTargetMonths: 0, + minCashCushionAmount: 0, + deferredCapHoursPerQtr: 0, + deferredSunsetMonths: 0 }, deferredLiabilities: { - totalDeferred: 2340, - byMember: { - 'member-1': 1700, - 'member-2': 0, - 'member-3': 640 - } + totalDeferred: 0, + byMember: {} } } } const loadCosts = async () => { return { - overheadCosts: [ - { - id: 'overhead-1', - name: 'Coworking Space', - amount: 0, - category: 'Workspace', - recurring: true - }, - { - id: 'overhead-2', - name: 'Tools & Software', - amount: 0, - category: 'Technology', - recurring: true - }, - { - id: 'overhead-3', - name: 'Business Insurance', - amount: 0, - category: 'Legal & Compliance', - recurring: true - } - ], - productionCosts: [ - { - id: 'production-1', - name: 'Development Kits', - amount: 0, - category: 'Hardware', - period: '2024-01' - } - ] + overheadCosts: [], + productionCosts: [] } } - // Calculate derived metrics from fixture data + // Return empty metrics const calculateMetrics = async () => { - const [members, streams, finances, costs] = await Promise.all([ - loadMembers(), - loadStreams(), - loadFinances(), - loadCosts() - ]) - - const totalTargetHours = members.members.reduce((sum, member) => - sum + member.capacity.targetHours, 0 - ) - - const totalTargetRevenue = streams.revenueStreams.reduce((sum, stream) => - sum + stream.targetMonthlyAmount, 0 - ) - - const totalOverheadCosts = costs.overheadCosts.reduce((sum, cost) => - sum + cost.amount, 0 - ) - - const monthlyPayroll = totalTargetHours * finances.policies.equalHourlyWage * - (1 + finances.policies.payrollOncostPct / 100) - - const monthlyBurn = monthlyPayroll + totalOverheadCosts + - costs.productionCosts.reduce((sum, cost) => sum + cost.amount, 0) - - const runway = finances.currentBalances.totalLiquid / monthlyBurn - return { - totalTargetHours, - totalTargetRevenue, - monthlyPayroll, - monthlyBurn, - runway, - members: members.members, - streams: streams.revenueStreams, - finances: finances, - costs: costs + totalTargetHours: 0, + totalTargetRevenue: 0, + monthlyPayroll: 0, + monthlyBurn: 0, + runway: 0, + members: [], + streams: [], + finances: { + currentBalances: { cash: 0, savings: 0, totalLiquid: 0 }, + policies: { + equalHourlyWage: 0, + payrollOncostPct: 0, + savingsTargetMonths: 0, + minCashCushionAmount: 0, + deferredCapHoursPerQtr: 0, + deferredSunsetMonths: 0 + }, + deferredLiabilities: { totalDeferred: 0, byMember: {} } + }, + costs: { overheadCosts: [], productionCosts: [] } } } diff --git a/composables/useMigrations.ts b/composables/useMigrations.ts new file mode 100644 index 0000000..030cd82 --- /dev/null +++ b/composables/useMigrations.ts @@ -0,0 +1,142 @@ +export function useMigrations() { + const CURRENT_SCHEMA_VERSION = "1.1" + const SCHEMA_KEY = "urgent-tools-schema-version" + + // Get stored schema version + function getStoredVersion(): string { + if (process.client) { + return localStorage.getItem(SCHEMA_KEY) || "1.0" + } + return "1.0" + } + + // Set schema version + function setVersion(version: string) { + if (process.client) { + localStorage.setItem(SCHEMA_KEY, version) + } + } + + // Migration functions + const migrations = { + "1.0": () => { + // Initial schema - no migration needed + }, + + "1.1": () => { + // Add new member needs fields + const membersData = localStorage.getItem("urgent-tools-members") + if (membersData) { + try { + const parsed = JSON.parse(membersData) + if (Array.isArray(parsed.members)) { + // Add default values for new fields + parsed.members = parsed.members.map((member: any) => ({ + ...member, + minMonthlyNeeds: member.minMonthlyNeeds || 0, + targetMonthlyPay: member.targetMonthlyPay || 0, + externalMonthlyIncome: member.externalMonthlyIncome || 0, + monthlyPayPlanned: member.monthlyPayPlanned || 0, + })) + localStorage.setItem("urgent-tools-members", JSON.stringify(parsed)) + } + } catch (error) { + console.warn("Failed to migrate members data:", error) + } + } + + // Add new policy fields + const policiesData = localStorage.getItem("urgent-tools-policies") + if (policiesData) { + try { + const parsed = JSON.parse(policiesData) + parsed.operatingMode = parsed.operatingMode || 'minimum' + parsed.payPolicy = parsed.payPolicy || { + relationship: 'equal-pay', + roleBands: [] + } + localStorage.setItem("urgent-tools-policies", JSON.stringify(parsed)) + } catch (error) { + console.warn("Failed to migrate policies data:", error) + } + } + + // DISABLED: No automatic cash balance initialization + // The app should start completely empty - no demo data + // + // Previously this would auto-initialize cash balances, but this + // created unwanted demo data. Users must explicitly set up their data. + // + // const cashData = localStorage.getItem("urgent-tools-cash") + // if (!cashData) { + // localStorage.setItem("urgent-tools-cash", JSON.stringify({ + // currentCash: 50000, + // currentSavings: 15000, + // cashEvents: [], + // paymentQueue: [] + // })) + // } + } + } + + // Run all necessary migrations + function migrate() { + if (!process.client) return + + const currentVersion = getStoredVersion() + + if (currentVersion === CURRENT_SCHEMA_VERSION) { + return // Already up to date + } + + console.log(`Migrating from schema version ${currentVersion} to ${CURRENT_SCHEMA_VERSION}`) + + // Run migrations in order + const versions = Object.keys(migrations).sort() + const currentIndex = versions.indexOf(currentVersion) + + if (currentIndex === -1) { + console.warn(`Unknown schema version: ${currentVersion}, running all migrations`) + // Run all migrations + versions.forEach(version => { + try { + migrations[version as keyof typeof migrations]() + console.log(`Applied migration: ${version}`) + } catch (error) { + console.error(`Migration ${version} failed:`, error) + } + }) + } else { + // Run migrations from current version + 1 to latest + for (let i = currentIndex + 1; i < versions.length; i++) { + const version = versions[i] + try { + migrations[version as keyof typeof migrations]() + console.log(`Applied migration: ${version}`) + } catch (error) { + console.error(`Migration ${version} failed:`, error) + } + } + } + + // Update stored version + setVersion(CURRENT_SCHEMA_VERSION) + console.log(`Migration complete. Schema version: ${CURRENT_SCHEMA_VERSION}`) + } + + // Check if migration is needed + function needsMigration(): boolean { + // Don't run migrations if data was intentionally cleared + if (process.client && localStorage.getItem('urgent-tools-cleared-flag') === 'true') { + return false + } + return getStoredVersion() !== CURRENT_SCHEMA_VERSION + } + + return { + migrate, + needsMigration, + currentVersion: CURRENT_SCHEMA_VERSION, + storedVersion: getStoredVersion() + } +} \ No newline at end of file diff --git a/composables/usePayrollAllocation.ts b/composables/usePayrollAllocation.ts new file mode 100644 index 0000000..1562e1b --- /dev/null +++ b/composables/usePayrollAllocation.ts @@ -0,0 +1,52 @@ +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { allocatePayroll } from '~/types/members' + +export function usePayrollAllocation() { + const membersStore = useMembersStore() + const policiesStore = usePoliciesStore() + const budgetStore = useBudgetStore() + + const { members, payPolicy } = storeToRefs(membersStore) + const { equalHourlyWage, payrollOncostPct } = storeToRefs(policiesStore) + const { capacityTotals } = storeToRefs(membersStore) + + // Calculate base payroll budget from hours and wage + const basePayrollBudget = computed(() => { + const totalHours = capacityTotals.value.targetHours || 0 + const wage = equalHourlyWage.value || 0 + return totalHours * wage + }) + + // Allocate payroll to members based on policy + const allocatedMembers = computed(() => { + if (members.value.length === 0 || basePayrollBudget.value === 0) { + return members.value + } + + return allocatePayroll( + members.value, + payPolicy.value, + basePayrollBudget.value + ) + }) + + // Total payroll with oncosts + const totalPayrollWithOncosts = computed(() => { + return basePayrollBudget.value * (1 + payrollOncostPct.value / 100) + }) + + // Update member planned pay when allocation changes + watchEffect(() => { + allocatedMembers.value.forEach(member => { + membersStore.setPlannedPay(member.id, member.monthlyPayPlanned || 0) + }) + }) + + return { + basePayrollBudget, + allocatedMembers, + totalPayrollWithOncosts, + payPolicy + } +} \ No newline at end of file diff --git a/composables/useRunway.ts b/composables/useRunway.ts index 4561708..da9cd46 100644 --- a/composables/useRunway.ts +++ b/composables/useRunway.ts @@ -1,13 +1,49 @@ +import { monthlyPayroll } from '~/types/members' + /** * Computes months of runway from cash, reserves, and burn rate * Formula: (cash + savings) ÷ average monthly burn in scenario */ export const useRunway = () => { + const membersStore = useMembersStore() + const policiesStore = usePoliciesStore() + const budgetStore = useBudgetStore() + const calculateRunway = (cash: number, savings: number, monthlyBurn: number): number => { if (monthlyBurn <= 0) return Infinity return (cash + savings) / monthlyBurn } + // Calculate monthly burn based on operating mode + const getMonthlyBurn = (mode?: 'minimum' | 'target') => { + const operatingMode = mode || policiesStore.operatingMode || 'minimum' + + // Get payroll costs based on mode + const payrollCost = monthlyPayroll(membersStore.members, operatingMode) + + // Add oncosts + const oncostPct = policiesStore.payrollOncostPct || 0 + const totalPayroll = payrollCost * (1 + oncostPct / 100) + + // Add overhead costs + const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0) + + return totalPayroll + overheadCost + } + + // Calculate runway for both modes + const getDualModeRunway = (cash: number, savings: number) => { + const minBurn = getMonthlyBurn('minimum') + const targetBurn = getMonthlyBurn('target') + + return { + minimum: calculateRunway(cash, savings, minBurn), + target: calculateRunway(cash, savings, targetBurn), + minBurn, + targetBurn + } + } + const getRunwayStatus = (months: number): 'green' | 'yellow' | 'red' => { if (months >= 3) return 'green' if (months >= 2) return 'yellow' @@ -22,6 +58,8 @@ export const useRunway = () => { return { calculateRunway, getRunwayStatus, - formatRunway + formatRunway, + getMonthlyBurn, + getDualModeRunway } } diff --git a/composables/useScenarios.ts b/composables/useScenarios.ts new file mode 100644 index 0000000..388e5dd --- /dev/null +++ b/composables/useScenarios.ts @@ -0,0 +1,109 @@ +import { monthlyPayroll } from '~/types/members' + +export function useScenarios() { + const membersStore = useMembersStore() + const streamsStore = useStreamsStore() + const policiesStore = usePoliciesStore() + const budgetStore = useBudgetStore() + const cashStore = useCashStore() + + // Base runway calculation + function calculateScenarioRunway( + members: any[], + streams: any[], + operatingMode: 'minimum' | 'target' = 'minimum' + ) { + // Calculate payroll for scenario + const payrollCost = monthlyPayroll(members, operatingMode) + const oncostPct = policiesStore.payrollOncostPct || 0 + const totalPayroll = payrollCost * (1 + oncostPct / 100) + + // Calculate revenue + const totalRevenue = streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0) + + // Add overhead + const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0) + + // Net monthly + const monthlyNet = totalRevenue - totalPayroll - overheadCost + + // Cash + savings + const cash = cashStore.currentCash || 50000 + const savings = cashStore.currentSavings || 15000 + const totalLiquid = cash + savings + + // Runway calculation + const monthlyBurn = totalPayroll + overheadCost + const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : Infinity + + return { + runway: Math.max(0, runway), + monthlyNet, + monthlyBurn, + totalRevenue, + totalPayroll + } + } + + // Scenario transformations per CLAUDE.md + const scenarioTransforms = { + current: () => ({ + members: [...membersStore.members], + streams: [...streamsStore.streams] + }), + + quitJobs: () => ({ + // Set external income to 0 for members who have day jobs + members: membersStore.members.map(m => ({ + ...m, + externalMonthlyIncome: 0 // Assume everyone quits their day job + })), + streams: [...streamsStore.streams] + }), + + startProduction: () => ({ + members: [...membersStore.members], + // Reduce service revenue, increase production costs + streams: streamsStore.streams.map(s => { + // Reduce service contracts by 30% + if (s.category?.toLowerCase().includes('service') || s.name.toLowerCase().includes('service')) { + return { ...s, targetMonthlyAmount: (s.targetMonthlyAmount || 0) * 0.7 } + } + return s + }) + }) + } + + // Calculate all scenarios + const scenarios = computed(() => { + const currentMode = policiesStore.operatingMode || 'minimum' + + const current = scenarioTransforms.current() + const quitJobs = scenarioTransforms.quitJobs() + const startProduction = scenarioTransforms.startProduction() + + return { + current: { + name: 'Operate Current', + status: 'Active', + ...calculateScenarioRunway(current.members, current.streams, currentMode) + }, + quitJobs: { + name: 'Quit Day Jobs', + status: 'Scenario', + ...calculateScenarioRunway(quitJobs.members, quitJobs.streams, currentMode) + }, + startProduction: { + name: 'Start Production', + status: 'Scenario', + ...calculateScenarioRunway(startProduction.members, startProduction.streams, currentMode) + } + } + }) + + return { + scenarios, + calculateScenarioRunway, + scenarioTransforms + } +} \ No newline at end of file diff --git a/content/fixtures/cash-events.json b/content/fixtures/cash-events.json deleted file mode 100644 index 4675ce4..0000000 --- a/content/fixtures/cash-events.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "cashEvents": [ - { - "id": "event-1", - "date": "2024-01-08", - "week": 1, - "type": "Influx", - "amount": 2600, - "sourceRef": "stream-1", - "policyTag": "Revenue", - "description": "Client Services - Project Alpha payment" - }, - { - "id": "event-2", - "date": "2024-01-12", - "week": 2, - "type": "Influx", - "amount": 400, - "sourceRef": "stream-2", - "policyTag": "Revenue", - "description": "Platform Sales - December payout" - }, - { - "id": "event-3", - "date": "2024-01-15", - "week": 3, - "type": "Outflow", - "amount": 2200, - "sourceRef": "payroll-jan-1", - "policyTag": "Payroll", - "description": "Payroll - First half January" - }, - { - "id": "event-4", - "date": "2024-01-22", - "week": 4, - "type": "Influx", - "amount": 4000, - "sourceRef": "stream-1", - "policyTag": "Revenue", - "description": "Client Services - Large project milestone" - }, - { - "id": "event-5", - "date": "2024-01-29", - "week": 5, - "type": "Outflow", - "amount": 2200, - "sourceRef": "payroll-jan-2", - "policyTag": "Payroll", - "description": "Payroll - Second half January" - }, - { - "id": "event-6", - "date": "2024-02-05", - "week": 6, - "type": "Outflow", - "amount": 1400, - "sourceRef": "overhead-monthly", - "policyTag": "CriticalOps", - "description": "Monthly overhead costs" - }, - { - "id": "event-7", - "date": "2024-02-12", - "week": 7, - "type": "Influx", - "amount": 1000, - "sourceRef": "stream-2", - "policyTag": "Revenue", - "description": "Platform Sales - Reduced month" - }, - { - "id": "event-8", - "date": "2024-02-19", - "week": 8, - "type": "Outflow", - "amount": 2200, - "sourceRef": "payroll-feb-1", - "policyTag": "Payroll", - "description": "Payroll - First half February" - } - ], - "paymentQueue": [ - { - "id": "payment-1", - "amount": 500, - "recipient": "Development Kits Supplier", - "scheduledWeek": 9, - "priority": "Vendors", - "canStage": true, - "description": "Hardware purchase for Q1 development" - }, - { - "id": "payment-2", - "amount": 1700, - "recipient": "Alex Chen - Deferred Pay", - "scheduledWeek": 10, - "priority": "Payroll", - "canStage": false, - "description": "Deferred wage repayment" - }, - { - "id": "payment-3", - "amount": 800, - "recipient": "Tax Authority", - "scheduledWeek": 12, - "priority": "Taxes", - "canStage": false, - "description": "Quarterly tax payment" - } - ] -} diff --git a/content/fixtures/costs.json b/content/fixtures/costs.json deleted file mode 100644 index 4a62e18..0000000 --- a/content/fixtures/costs.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "overheadCosts": [ - { - "id": "overhead-1", - "name": "Coworking Space", - "amount": 800, - "category": "Workspace", - "recurring": true, - "description": "Shared workspace membership for 3 desks" - }, - { - "id": "overhead-2", - "name": "Tools & Software", - "amount": 420, - "category": "Technology", - "recurring": true, - "description": "Development tools, design software, project management" - }, - { - "id": "overhead-3", - "name": "Business Insurance", - "amount": 180, - "category": "Legal & Compliance", - "recurring": true, - "description": "Professional liability and general business insurance" - } - ], - "productionCosts": [ - { - "id": "production-1", - "name": "Development Kits", - "amount": 500, - "category": "Hardware", - "period": "2024-01", - "description": "Testing devices and development hardware" - } - ] -} diff --git a/content/fixtures/finances.json b/content/fixtures/finances.json deleted file mode 100644 index c4ea0a6..0000000 --- a/content/fixtures/finances.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "currentBalances": { - "cash": 5000, - "savings": 8000, - "totalLiquid": 13000 - }, - "policies": { - "equalHourlyWage": 20, - "payrollOncostPct": 25, - "savingsTargetMonths": 3, - "minCashCushionAmount": 3000, - "deferredCapHoursPerQtr": 240, - "deferredSunsetMonths": 12, - "surplusOrder": [ - "Deferred", - "Savings", - "Hardship", - "Training", - "Patronage", - "Retained" - ], - "paymentPriority": [ - "Payroll", - "Taxes", - "CriticalOps", - "Vendors" - ], - "volunteerScope": { - "allowedFlows": ["Care", "SharedLearning"] - } - }, - "deferredLiabilities": { - "totalDeferred": 2340, - "byMember": { - "member-1": 1700, - "member-2": 0, - "member-3": 640 - } - } -} diff --git a/content/fixtures/members.json b/content/fixtures/members.json deleted file mode 100644 index e1f0bb7..0000000 --- a/content/fixtures/members.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "members": [ - { - "id": "member-1", - "displayName": "Alex Chen", - "roleFocus": "Technical Lead", - "payRelationship": "Hybrid", - "capacity": { - "minHours": 20, - "targetHours": 120, - "maxHours": 160 - }, - "riskBand": "Medium", - "externalCoveragePct": 60, - "privacyNeeds": "aggregate_ok", - "deferredHours": 85, - "quarterlyDeferredCap": 240 - }, - { - "id": "member-2", - "displayName": "Jordan Silva", - "roleFocus": "Design & UX", - "payRelationship": "FullyPaid", - "capacity": { - "minHours": 30, - "targetHours": 140, - "maxHours": 180 - }, - "riskBand": "Low", - "externalCoveragePct": 20, - "privacyNeeds": "aggregate_ok", - "deferredHours": 0, - "quarterlyDeferredCap": 240 - }, - { - "id": "member-3", - "displayName": "Sam Rodriguez", - "roleFocus": "Operations & Growth", - "payRelationship": "Supplemental", - "capacity": { - "minHours": 10, - "targetHours": 60, - "maxHours": 100 - }, - "riskBand": "High", - "externalCoveragePct": 85, - "privacyNeeds": "steward_only", - "deferredHours": 32, - "quarterlyDeferredCap": 120 - } - ] -} diff --git a/content/fixtures/streams.json b/content/fixtures/streams.json deleted file mode 100644 index 76b7f36..0000000 --- a/content/fixtures/streams.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "revenueStreams": [ - { - "id": "stream-1", - "name": "Client Services", - "category": "Services", - "subcategory": "Development", - "targetPct": 65, - "targetMonthlyAmount": 7800, - "certainty": "Committed", - "payoutDelayDays": 30, - "terms": "Net 30", - "revenueSharePct": 0, - "platformFeePct": 0, - "restrictions": "General", - "seasonalityWeights": [1.0, 1.0, 1.1, 1.1, 0.9, 0.8, 0.7, 0.8, 1.1, 1.2, 1.1, 1.0], - "effortHoursPerMonth": 180 - }, - { - "id": "stream-2", - "name": "Platform Sales", - "category": "Product", - "subcategory": "Digital Tools", - "targetPct": 20, - "targetMonthlyAmount": 2400, - "certainty": "Probable", - "payoutDelayDays": 14, - "terms": "Platform payout", - "revenueSharePct": 0, - "platformFeePct": 5, - "restrictions": "General", - "seasonalityWeights": [0.8, 0.9, 1.0, 1.1, 1.2, 1.1, 1.0, 0.9, 1.1, 1.2, 1.3, 1.1], - "effortHoursPerMonth": 40 - }, - { - "id": "stream-3", - "name": "Innovation Grant", - "category": "Grant", - "subcategory": "Government", - "targetPct": 10, - "targetMonthlyAmount": 1200, - "certainty": "Committed", - "payoutDelayDays": 45, - "terms": "Quarterly disbursement", - "revenueSharePct": 0, - "platformFeePct": 0, - "restrictions": "Restricted", - "seasonalityWeights": [1.0, 1.0, 1.0, 1.5, 1.0, 1.0, 0.5, 1.0, 1.0, 1.5, 1.0, 1.0], - "effortHoursPerMonth": 8 - }, - { - "id": "stream-4", - "name": "Community Donations", - "category": "Donation", - "subcategory": "Individual", - "targetPct": 3, - "targetMonthlyAmount": 360, - "certainty": "Aspirational", - "payoutDelayDays": 3, - "terms": "Immediate", - "revenueSharePct": 0, - "platformFeePct": 2.9, - "restrictions": "General", - "seasonalityWeights": [0.8, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.3, 1.4], - "effortHoursPerMonth": 5 - }, - { - "id": "stream-5", - "name": "Consulting & Training", - "category": "Other", - "subcategory": "Professional Services", - "targetPct": 2, - "targetMonthlyAmount": 240, - "certainty": "Probable", - "payoutDelayDays": 21, - "terms": "Net 21", - "revenueSharePct": 0, - "platformFeePct": 0, - "restrictions": "General", - "seasonalityWeights": [1.2, 1.1, 1.0, 0.9, 0.8, 0.7, 0.8, 0.9, 1.2, 1.3, 1.2, 1.0], - "effortHoursPerMonth": 12 - } - ] -} diff --git a/middleware/redirect-dashboard.global.ts b/middleware/redirect-dashboard.global.ts new file mode 100644 index 0000000..461a947 --- /dev/null +++ b/middleware/redirect-dashboard.global.ts @@ -0,0 +1,6 @@ +export default defineNuxtRouteMiddleware((to) => { + // Redirect root path to dashboard + if (to.path === '/') { + return navigateTo('/dashboard') + } +}) \ No newline at end of file diff --git a/middleware/setup.global.ts b/middleware/setup.global.ts index 07a57a9..a80abb1 100644 --- a/middleware/setup.global.ts +++ b/middleware/setup.global.ts @@ -1,24 +1,51 @@ export default defineNuxtRouteMiddleware((to) => { - // Skip middleware for coop-planner, wizards, templates, and API routes + // Allowlist: routes that should always be accessible before setup if ( to.path === "/coop-planner" || + to.path === "/coop-builder" || to.path === "/wizards" || to.path.startsWith("/templates") || + to.path.startsWith("/coach") || to.path.startsWith("/api/") ) { return; } + // Only guard core dashboards until setup is complete + const protectedRoutes = new Set([ + "/dashboard", + "/dashboard-simple", + "/budget", + "/mix", + "/cash", + ]); + + if (!protectedRoutes.has(to.path)) { + return; + } + // Use actual store state to determine whether setup is complete const membersStore = useMembersStore(); const policiesStore = usePoliciesStore(); const streamsStore = useStreamsStore(); + const coopStore = useCoopBuilderStore?.(); - const setupComplete = + // Legacy stores OR new coop builder store (either is enough) + const legacyComplete = membersStore.isValid && policiesStore.isValid && streamsStore.hasValidStreams; + const coopComplete = Boolean( + coopStore && + Array.isArray(coopStore.members) && + coopStore.members.length > 0 && + Array.isArray(coopStore.streams) && + coopStore.streams.length > 0 + ); + + const setupComplete = legacyComplete || coopComplete; + if (!setupComplete) { return navigateTo("/coop-planner"); } diff --git a/pages/budget.vue b/pages/budget.vue index ea621be..f9a1cc9 100644 --- a/pages/budget.vue +++ b/pages/budget.vue @@ -357,30 +357,15 @@ - - - + + + @@ -525,8 +504,11 @@ \ No newline at end of file diff --git a/pages/dashboard.vue b/pages/dashboard.vue new file mode 100644 index 0000000..c905f7e --- /dev/null +++ b/pages/dashboard.vue @@ -0,0 +1,48 @@ + + + \ No newline at end of file diff --git a/pages/index.vue b/pages/index.vue index 96bee70..b2dbecb 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,7 +1,20 @@
+
-

Operate Current

- Active +

{{ scenarios.current.name }}

+ {{ scenarios.current.status }}
-
- {{ scenarioMetrics.current.runway }} months +
+ {{ Math.round(scenarios.current.runway * 10) / 10 }} months
-

Continue existing plan

+

+ Net: {{ $format.currency(scenarios.current.monthlyNet) }}/mo +

+
-

Quit Day Jobs

- Scenario +

{{ scenarios.quitJobs.name }}

+ {{ scenarios.quitJobs.status }}
-
- {{ scenarioMetrics.quitJobs.runway }} months +
+ {{ Math.round(scenarios.quitJobs.runway * 10) / 10 }} months
-

Full-time co-op work

+

+ Net: {{ $format.currency(scenarios.quitJobs.monthlyNet) }}/mo +

+
-

Start Production

- Scenario +

{{ scenarios.startProduction.name }}

+ {{ scenarios.startProduction.status }}
-
- {{ scenarioMetrics.startProduction.runway }} months +
+ {{ Math.round(scenarios.startProduction.runway * 10) / 10 }} months
-

Launch development

+

+ Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo +

@@ -214,6 +291,100 @@
+ + + + +
+ +
+

Stress Tests

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
Stress Test Results
+

+ Runway under stress: {{ Math.round(stressedRunway * 10) / 10 }} months + ({{ Math.round((stressedRunway - metrics.runway) * 10) / 10 }} month change) +

+
+ Apply to Plan +
+
+
+ + +
+

Policy Sandbox

+

+ Try different pay relationships without overwriting your current plan. +

+
+
+ + +
+
+ +
+ {{ Math.round(sandboxRunway * 10) / 10 }} months +
+
+
+
+
+
+
{ 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); +}; diff --git a/plugins/piniaPersistedState.client.ts b/plugins/piniaPersistedState.client.ts index a7d6242..9e437d0 100644 --- a/plugins/piniaPersistedState.client.ts +++ b/plugins/piniaPersistedState.client.ts @@ -1,7 +1,6 @@ -import { defineNuxtPlugin } from "#app"; -import { createPersistedState } from "pinia-plugin-persistedstate"; +import { defineNuxtPlugin } from '#app' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' export default defineNuxtPlugin((nuxtApp) => { - // Register persisted state plugin for Pinia on client - nuxtApp.$pinia.use(createPersistedState()); -}); + nuxtApp.$pinia.use(piniaPluginPersistedstate) +}) diff --git a/sample/skillsToOffersSamples.ts b/sample/skillsToOffersSamples.ts deleted file mode 100644 index 7cb8da2..0000000 --- a/sample/skillsToOffersSamples.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Member, SkillTag, ProblemTag } from "~/types/coaching"; -import { skillsCatalog, problemsCatalog } from "~/data/skillsProblems"; - -export const membersSample: Member[] = [ - { - id: "sample-1", - name: "Maya Chen", - role: "Design Lead", - hourly: 32, - availableHrs: 40 - }, - { - id: "sample-2", - name: "Alex Rivera", - role: "Developer", - hourly: 45, - availableHrs: 30 - }, - { - id: "sample-3", - name: "Jordan Blake", - role: "Content Writer", - hourly: 28, - availableHrs: 20 - } -]; - -export const skillsCatalogSample: SkillTag[] = skillsCatalog; - -export const problemsCatalogSample: ProblemTag[] = problemsCatalog; - -// Pre-selected sample data for quick demos -export const sampleSelections = { - selectedSkillsByMember: { - "sample-1": ["design", "facilitation"], // Maya: Design + Facilitation - "sample-2": ["dev", "pm"], // Alex: Dev + PM - "sample-3": ["writing", "marketing"] // Jordan: Writing + Marketing - }, - selectedProblems: ["unclear-pitch", "need-landing-store-page"] -}; \ No newline at end of file diff --git a/stores/budget.ts b/stores/budget.ts index 0b39232..66b2318 100644 --- a/stores/budget.ts +++ b/stores/budget.ts @@ -1,4 +1,5 @@ import { defineStore } from "pinia"; +import { allocatePayroll } from "~/types/members"; export const useBudgetStore = defineStore( "budget", @@ -69,8 +70,26 @@ export const useBudgetStore = defineStore( "Other Expenses", ]); + // Define budget item type + interface BudgetItem { + id: string; + name: string; + mainCategory: string; + subcategory: string; + source: string; + monthlyValues: Record; + values: { + year1: { best: number; worst: number; mostLikely: number }; + year2: { best: number; worst: number; mostLikely: number }; + year3: { best: number; worst: number; mostLikely: number }; + }; + } + // NEW: Budget worksheet structure (starts empty, populated from wizard data) - const budgetWorksheet = ref({ + const budgetWorksheet = ref<{ + revenue: BudgetItem[]; + expenses: BudgetItem[]; + }>({ revenue: [], expenses: [], }); @@ -271,6 +290,30 @@ export const useBudgetStore = defineStore( currentPeriod.value = period; } + // Helper function to map stream category to budget category + function mapStreamToBudgetCategory(streamCategory) { + const categoryLower = (streamCategory || "").toLowerCase(); + + // More comprehensive category mapping + if (categoryLower.includes("game") || categoryLower.includes("product")) { + return "Games & Products"; + } else if (categoryLower.includes("service") || categoryLower.includes("consulting")) { + return "Services & Contracts"; + } else if (categoryLower.includes("grant") || categoryLower.includes("funding")) { + return "Grants & Funding"; + } else if (categoryLower.includes("community") || categoryLower.includes("donation")) { + return "Community Support"; + } else if (categoryLower.includes("partnership")) { + return "Partnerships"; + } else if (categoryLower.includes("investment")) { + return "Investment Income"; + } else if (categoryLower.includes("other")) { + return "Services & Contracts"; // Map "Other" to services as fallback + } else { + return "Games & Products"; // Default fallback + } + } + // Initialize worksheet from wizard data async function initializeFromWizardData() { if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) { @@ -280,340 +323,232 @@ export const useBudgetStore = defineStore( console.log("Initializing budget from wizard data..."); - // Import stores dynamically to avoid circular deps - const { useStreamsStore } = await import("./streams"); - const { useMembersStore } = await import("./members"); - const { usePoliciesStore } = await import("./policies"); + try { + // Use the new coopBuilder store instead of the old stores + const coopStore = useCoopBuilderStore(); - const streamsStore = useStreamsStore(); - const membersStore = useMembersStore(); - const policiesStore = usePoliciesStore(); + console.log("Streams:", coopStore.streams.length, "streams"); + console.log("Members:", coopStore.members.length, "members"); + console.log("Equal wage:", coopStore.equalHourlyWage || "No wage set"); + console.log("Overhead costs:", coopStore.overheadCosts.length, "costs"); - console.log("Streams:", streamsStore.streams.length, "streams"); - console.log("Members capacity:", membersStore.capacityTotals); - console.log("Policies wage:", policiesStore.equalHourlyWage); + // Clear existing data + budgetWorksheet.value.revenue = []; + budgetWorksheet.value.expenses = []; - // Clear existing data - budgetWorksheet.value.revenue = []; - budgetWorksheet.value.expenses = []; + // Add revenue streams from wizard (but don't auto-load fixtures) + // Note: We don't auto-load fixtures anymore, but wizard data should still work - // Add revenue streams from wizard - if (streamsStore.streams.length === 0) { - console.log("No wizard streams found, adding sample data"); - // Initialize with minimal demo if no wizard data exists - await streamsStore.initializeWithFixtures(); - } + coopStore.streams.forEach((stream) => { + const monthlyAmount = stream.monthly || 0; + console.log( + "Adding stream:", + stream.label, + "category:", + stream.category, + "amount:", + monthlyAmount + ); - streamsStore.streams.forEach((stream) => { - const monthlyAmount = stream.targetMonthlyAmount || 0; - console.log( - "Adding stream:", - stream.name, - "category:", - stream.category, - "subcategory:", - stream.subcategory, - "amount:", - monthlyAmount - ); - console.log("Full stream object:", stream); + // Use the helper function for category mapping + const mappedCategory = mapStreamToBudgetCategory(stream.category); - // Simple category mapping - just map the key categories we know exist - let mappedCategory = "Games & Products"; // Default - const categoryLower = (stream.category || "").toLowerCase(); - if (categoryLower === "games" || categoryLower === "product") - mappedCategory = "Games & Products"; - else if (categoryLower === "services" || categoryLower === "service") - mappedCategory = "Services & Contracts"; - else if (categoryLower === "grants" || categoryLower === "grant") - mappedCategory = "Grants & Funding"; - else if (categoryLower === "community") - mappedCategory = "Community Support"; - else if ( - categoryLower === "partnerships" || - categoryLower === "partnership" - ) - mappedCategory = "Partnerships"; - else if (categoryLower === "investment") - mappedCategory = "Investment Income"; + console.log( + "Mapped category from", + stream.category, + "to", + mappedCategory + ); - console.log( - "Mapped category from", - stream.category, - "to", - mappedCategory - ); - - // Create monthly values - split the annual target evenly across 12 months - const monthlyValues = {}; - const today = new Date(); - for (let i = 0; i < 12; i++) { - const date = new Date(today.getFullYear(), today.getMonth() + i, 1); - const monthKey = `${date.getFullYear()}-${String( - date.getMonth() + 1 - ).padStart(2, "0")}`; - monthlyValues[monthKey] = monthlyAmount; - } - console.log( - "Created monthly values for", - stream.name, - ":", - monthlyValues - ); - - budgetWorksheet.value.revenue.push({ - id: `revenue-${stream.id}`, - name: stream.name, - mainCategory: mappedCategory, - subcategory: stream.subcategory || "Direct sales", // Use actual subcategory from stream - source: "wizard", - monthlyValues, - values: { - year1: { - best: monthlyAmount * 12, - worst: monthlyAmount * 6, - mostLikely: monthlyAmount * 10, - }, - year2: { - best: monthlyAmount * 15, - worst: monthlyAmount * 8, - mostLikely: monthlyAmount * 12, - }, - year3: { - best: monthlyAmount * 18, - worst: monthlyAmount * 10, - mostLikely: monthlyAmount * 15, - }, - }, - }); - }); - - // Add payroll from wizard data - const totalHours = membersStore.capacityTotals.targetHours || 0; - const hourlyWage = policiesStore.equalHourlyWage || 0; - const oncostPct = policiesStore.payrollOncostPct || 0; - if (totalHours > 0 && hourlyWage > 0) { - const monthlyPayroll = totalHours * hourlyWage * (1 + oncostPct / 100); - - // Create monthly values for payroll - const monthlyValues = {}; - const today = new Date(); - for (let i = 0; i < 12; i++) { - const date = new Date(today.getFullYear(), today.getMonth() + i, 1); - const monthKey = `${date.getFullYear()}-${String( - date.getMonth() + 1 - ).padStart(2, "0")}`; - monthlyValues[monthKey] = monthlyPayroll; - } - - budgetWorksheet.value.expenses.push({ - id: "expense-payroll", - name: "Payroll", - mainCategory: "Salaries & Benefits", - subcategory: "Base wages and benefits", - source: "wizard", - monthlyValues, - values: { - year1: { - best: monthlyPayroll * 12, - worst: monthlyPayroll * 8, - mostLikely: monthlyPayroll * 12, - }, - year2: { - best: monthlyPayroll * 14, - worst: monthlyPayroll * 10, - mostLikely: monthlyPayroll * 13, - }, - year3: { - best: monthlyPayroll * 16, - worst: monthlyPayroll * 12, - mostLikely: monthlyPayroll * 15, - }, - }, - }); - } - - // Add overhead costs from wizard - overheadCosts.value.forEach((cost) => { - if (cost.amount > 0) { - const annualAmount = cost.amount * 12; - // Map overhead cost categories to expense categories - let expenseCategory = "Other Expenses"; // Default - if (cost.category === "Operations") - expenseCategory = "Office & Operations"; - else if (cost.category === "Technology") - expenseCategory = "Equipment & Technology"; - else if (cost.category === "Legal") - expenseCategory = "Legal & Professional"; - else if (cost.category === "Marketing") - expenseCategory = "Marketing & Outreach"; - - // Create monthly values for overhead costs - const monthlyValues = {}; + // Create monthly values - split the annual target evenly across 12 months + const monthlyValues: Record = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String( date.getMonth() + 1 ).padStart(2, "0")}`; - monthlyValues[monthKey] = cost.amount; + monthlyValues[monthKey] = monthlyAmount; } + console.log( + "Created monthly values for", + stream.label, + ":", + monthlyValues + ); - budgetWorksheet.value.expenses.push({ - id: `expense-${cost.id}`, - name: cost.name, - mainCategory: expenseCategory, - subcategory: cost.name, // Use the cost name as subcategory + budgetWorksheet.value.revenue.push({ + id: `revenue-${stream.id}`, + name: stream.label, + mainCategory: mappedCategory, + subcategory: "Direct sales", // Default subcategory for coopStore streams source: "wizard", monthlyValues, values: { year1: { - best: annualAmount, - worst: annualAmount * 0.8, - mostLikely: annualAmount, + best: monthlyAmount * 12, + worst: monthlyAmount * 6, + mostLikely: monthlyAmount * 10, }, year2: { - best: annualAmount * 1.1, - worst: annualAmount * 0.9, - mostLikely: annualAmount * 1.05, + best: monthlyAmount * 15, + worst: monthlyAmount * 8, + mostLikely: monthlyAmount * 12, }, year3: { - best: annualAmount * 1.2, - worst: annualAmount, - mostLikely: annualAmount * 1.1, + best: monthlyAmount * 18, + worst: monthlyAmount * 10, + mostLikely: monthlyAmount * 15, }, }, }); - } - }); + }); - // Add production costs from wizard - productionCosts.value.forEach((cost) => { - if (cost.amount > 0) { - const annualAmount = cost.amount * 12; - // Create monthly values for production costs - const monthlyValues = {}; + // Add payroll from wizard data using the allocatePayroll function + const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0); + const hourlyWage = coopStore.equalHourlyWage || 0; + const oncostPct = coopStore.payrollOncostPct || 0; + + // Calculate total payroll budget (before oncosts) + const basePayrollBudget = totalHours * hourlyWage; + + if (basePayrollBudget > 0 && coopStore.members.length > 0) { + // Calculate total with oncosts + const monthlyPayroll = basePayrollBudget * (1 + oncostPct / 100); + + // Create monthly values for payroll + const monthlyValues: Record = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String( date.getMonth() + 1 ).padStart(2, "0")}`; - monthlyValues[monthKey] = cost.amount; + monthlyValues[monthKey] = monthlyPayroll; } budgetWorksheet.value.expenses.push({ - id: `expense-${cost.id}`, - name: cost.name, - mainCategory: "Development Costs", - subcategory: cost.name, // Use the cost name as subcategory + id: "expense-payroll", + name: "Payroll", + mainCategory: "Salaries & Benefits", + subcategory: "Base wages and benefits", source: "wizard", monthlyValues, values: { year1: { - best: annualAmount, - worst: annualAmount * 0.7, - mostLikely: annualAmount * 0.9, + best: monthlyPayroll * 12, + worst: monthlyPayroll * 8, + mostLikely: monthlyPayroll * 12, }, year2: { - best: annualAmount * 1.2, - worst: annualAmount * 0.8, - mostLikely: annualAmount, + best: monthlyPayroll * 14, + worst: monthlyPayroll * 10, + mostLikely: monthlyPayroll * 13, }, year3: { - best: annualAmount * 1.3, - worst: annualAmount * 0.9, - mostLikely: annualAmount * 1.1, + best: monthlyPayroll * 16, + worst: monthlyPayroll * 12, + mostLikely: monthlyPayroll * 15, }, }, }); } - }); - // If still no data after initialization, add a sample row - if (budgetWorksheet.value.revenue.length === 0) { - console.log("Adding sample revenue line"); - // Create monthly values for sample revenue - const monthlyValues = {}; - const today = new Date(); - for (let i = 0; i < 12; i++) { - const date = new Date(today.getFullYear(), today.getMonth() + i, 1); - const monthKey = `${date.getFullYear()}-${String( - date.getMonth() + 1 - ).padStart(2, "0")}`; - monthlyValues[monthKey] = 667; // ~8000/12 - } + // Add overhead costs from wizard + coopStore.overheadCosts.forEach((cost) => { + if (cost.amount && cost.amount > 0) { + const annualAmount = cost.amount * 12; + // Map overhead cost categories to expense categories + let expenseCategory = "Other Expenses"; // Default + if (cost.category === "Operations") + expenseCategory = "Office & Operations"; + else if (cost.category === "Tools") + expenseCategory = "Equipment & Technology"; + else if (cost.category === "Professional") + expenseCategory = "Legal & Professional"; + else if (cost.category === "Marketing") + expenseCategory = "Marketing & Outreach"; - budgetWorksheet.value.revenue.push({ - id: "revenue-sample", - name: "Sample Revenue", - mainCategory: "Games & Products", - subcategory: "Direct sales", - source: "user", - monthlyValues, - values: { - year1: { best: 10000, worst: 5000, mostLikely: 8000 }, - year2: { best: 12000, worst: 6000, mostLikely: 10000 }, - year3: { best: 15000, worst: 8000, mostLikely: 12000 }, - }, + // Create monthly values for overhead costs + const monthlyValues: Record = {}; + const today = new Date(); + for (let i = 0; i < 12; i++) { + const date = new Date(today.getFullYear(), today.getMonth() + i, 1); + const monthKey = `${date.getFullYear()}-${String( + date.getMonth() + 1 + ).padStart(2, "0")}`; + monthlyValues[monthKey] = cost.amount; + } + + budgetWorksheet.value.expenses.push({ + id: `expense-${cost.id}`, + name: cost.name, + mainCategory: expenseCategory, + subcategory: cost.name, // Use the cost name as subcategory + source: "wizard", + monthlyValues, + values: { + year1: { + best: annualAmount, + worst: annualAmount * 0.8, + mostLikely: annualAmount, + }, + year2: { + best: annualAmount * 1.1, + worst: annualAmount * 0.9, + mostLikely: annualAmount * 1.05, + }, + year3: { + best: annualAmount * 1.2, + worst: annualAmount, + mostLikely: annualAmount * 1.1, + }, + }, + }); + } }); - } - if (budgetWorksheet.value.expenses.length === 0) { - console.log("Adding sample expense line"); - // Create monthly values for sample expense - const monthlyValues = {}; - const today = new Date(); - for (let i = 0; i < 12; i++) { - const date = new Date(today.getFullYear(), today.getMonth() + i, 1); - const monthKey = `${date.getFullYear()}-${String( - date.getMonth() + 1 - ).padStart(2, "0")}`; - monthlyValues[monthKey] = 67; // ~800/12 - } + // Production costs are handled within overhead costs in the new architecture - budgetWorksheet.value.expenses.push({ - id: "expense-sample", - name: "Sample Expense", - mainCategory: "Other Expenses", - subcategory: "Miscellaneous", - source: "user", - monthlyValues, - values: { - year1: { best: 1000, worst: 500, mostLikely: 800 }, - year2: { best: 1200, worst: 600, mostLikely: 1000 }, - year3: { best: 1500, worst: 800, mostLikely: 1200 }, - }, + // DISABLED: No sample data - budget should start empty + // if (budgetWorksheet.value.revenue.length === 0) { + // console.log("Adding sample revenue line"); + // // ... sample revenue creation code removed + // } + + // DISABLED: No sample data - expenses should start empty + // if (budgetWorksheet.value.expenses.length === 0) { + // console.log("Adding sample expense line"); + // // ... sample expense creation code removed + // } + + // Debug: Log all revenue items and their categories + console.log("Final revenue items:"); + budgetWorksheet.value.revenue.forEach((item) => { + console.log( + `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})` + ); }); - } - // Debug: Log all revenue items and their categories - console.log("Final revenue items:"); - budgetWorksheet.value.revenue.forEach((item) => { - console.log( - `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})` - ); - }); + console.log("Final expense items:"); + budgetWorksheet.value.expenses.forEach((item) => { + console.log( + `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})` + ); + }); - console.log("Final expense items:"); - budgetWorksheet.value.expenses.forEach((item) => { - console.log( - `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})` - ); - }); + // Ensure all items have monthlyValues and new structure - migrate existing items + [ + ...budgetWorksheet.value.revenue, + ...budgetWorksheet.value.expenses, + ].forEach((item) => { + // Migrate to new structure if needed + if (item.category && !item.mainCategory) { + console.log("Migrating item structure for:", item.name); + item.mainCategory = item.category; // Old category becomes mainCategory - // Ensure all items have monthlyValues and new structure - migrate existing items - [ - ...budgetWorksheet.value.revenue, - ...budgetWorksheet.value.expenses, - ].forEach((item) => { - // Migrate to new structure if needed - if (item.category && !item.mainCategory) { - console.log("Migrating item structure for:", item.name); - item.mainCategory = item.category; // Old category becomes mainCategory - - // Set appropriate subcategory based on the main category and item name - if (item.category === "Games & Products") { + // Set appropriate subcategory based on the main category and item name + if (item.category === "Games & Products") { const gameSubcategories = [ "Direct sales", "Platform revenue share", @@ -688,14 +623,22 @@ export const useBudgetStore = defineStore( } }); - console.log( - "Initialization complete. Revenue items:", - budgetWorksheet.value.revenue.length, - "Expense items:", - budgetWorksheet.value.expenses.length - ); + console.log( + "Initialization complete. Revenue items:", + budgetWorksheet.value.revenue.length, + "Expense items:", + budgetWorksheet.value.expenses.length + ); - isInitialized.value = true; + isInitialized.value = true; + } catch (error) { + console.error("Error initializing budget from wizard data:", error); + + // DISABLED: No fallback sample data - budget should remain empty on errors + // Budget initialization complete (without automatic fallback data) + + isInitialized.value = true; + } } // NEW: Budget worksheet functions diff --git a/stores/cash.ts b/stores/cash.ts index dfe8263..a0d9c7f 100644 --- a/stores/cash.ts +++ b/stores/cash.ts @@ -10,7 +10,7 @@ export const useCashStore = defineStore("cash", () => { // Week that first breaches minimum cushion const firstBreachWeek = ref(null); - // Current cash and savings balances - start with zeros + // Current cash and savings balances - start empty const currentCash = ref(0); const currentSavings = ref(0); @@ -111,4 +111,14 @@ export const useCashStore = defineStore("cash", () => { stagePayment, updateCurrentBalances, }; +}, { + persist: { + key: "urgent-tools-cash", + paths: [ + "currentCash", + "currentSavings", + "cashEvents", + "paymentQueue" + ], + }, }); diff --git a/stores/coop-builder.ts b/stores/coop-builder.ts deleted file mode 100644 index 104fd88..0000000 --- a/stores/coop-builder.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineStore } from "pinia"; - -export const useCoopBuilderStore = defineStore( - "coop-builder", - () => { - const currentStep = ref(1); - - function setStep(step: number) { - currentStep.value = Math.min(Math.max(1, step), 5); - } - - function reset() { - currentStep.value = 1; - } - - return { - currentStep, - setStep, - reset, - }; - }, - { - persist: { - key: "urgent-tools-wizard", - paths: ["currentStep"], - }, - } -); diff --git a/stores/coopBuilder.ts b/stores/coopBuilder.ts new file mode 100644 index 0000000..ab75a80 --- /dev/null +++ b/stores/coopBuilder.ts @@ -0,0 +1,277 @@ +import { defineStore } from "pinia"; + +export const useCoopBuilderStore = defineStore("coop", { + state: () => ({ + operatingMode: "min" as "min" | "target", + + // Flag to track if data was intentionally cleared + _wasCleared: false, + + members: [] as Array<{ + id: string; + name: string; + role?: string; + hoursPerMonth?: number; + minMonthlyNeeds: number; + targetMonthlyPay: number; + externalMonthlyIncome: number; + monthlyPayPlanned: number; + }>, + + streams: [] as Array<{ + id: string; + label: string; + monthly: number; + category?: string; + certainty?: string; + }>, + + milestones: [] as Array<{ + id: string; + label: string; + date: string; + }>, + + // Scenario and stress test state + scenario: "current" as + | "current" + | "quit-jobs" + | "start-production" + | "custom", + stress: { + revenueDelay: 0, + costShockPct: 0, + grantLost: false, + }, + + // Policy settings + policy: { + relationship: "equal-pay" as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded", + roleBands: {} as Record, + }, + equalHourlyWage: 50, + payrollOncostPct: 25, + savingsTargetMonths: 6, + minCashCushion: 10000, + + // Cash reserves + currentCash: 50000, + currentSavings: 15000, + + // Overhead costs + overheadCosts: [] as Array<{ + id?: string; + name: string; + amount: number; + category?: string; + }>, + }), + + getters: { + totalRevenue: (state) => { + return state.streams.reduce((sum, s) => sum + (s.monthly || 0), 0); + }, + + totalOverhead: (state) => { + return state.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0); + }, + + totalLiquid: (state) => { + return state.currentCash + state.currentSavings; + }, + }, + + actions: { + // Member actions + upsertMember(m: any) { + const i = this.members.findIndex((x) => x.id === m.id); + // Ensure all keys exist (prevents undefined stripping) + const withDefaults = { + id: m.id || Date.now().toString(), + name: m.name || m.displayName || "", + role: m.role ?? "", + hoursPerMonth: m.hoursPerMonth ?? 0, + minMonthlyNeeds: m.minMonthlyNeeds ?? 0, + targetMonthlyPay: m.targetMonthlyPay ?? 0, + externalMonthlyIncome: m.externalMonthlyIncome ?? 0, + monthlyPayPlanned: m.monthlyPayPlanned ?? 0, + }; + if (i === -1) { + this.members.push(withDefaults); + } else { + this.members[i] = withDefaults; + } + }, + + removeMember(id: string) { + this.members = this.members.filter((m) => m.id !== id); + }, + + // Stream actions + upsertStream(s: any) { + const i = this.streams.findIndex((x) => x.id === s.id); + const withDefaults = { + id: s.id || Date.now().toString(), + label: s.label || s.name || "", + monthly: s.monthly || s.targetMonthlyAmount || 0, + category: s.category ?? "", + certainty: s.certainty ?? "Probable", + }; + if (i === -1) { + this.streams.push(withDefaults); + } else { + this.streams[i] = withDefaults; + } + }, + + removeStream(id: string) { + this.streams = this.streams.filter((s) => s.id !== id); + }, + + // Milestone actions + addMilestone(label: string, date: string) { + this.milestones.push({ + id: Date.now().toString(), + label, + date, + }); + }, + + removeMilestone(id: string) { + this.milestones = this.milestones.filter((m) => m.id !== id); + }, + + // Operating mode + setOperatingMode(mode: "min" | "target") { + this.operatingMode = mode; + }, + + // Scenario + setScenario( + scenario: "current" | "quit-jobs" | "start-production" | "custom" + ) { + this.scenario = scenario; + }, + + // Stress test + updateStress(updates: Partial) { + this.stress = { ...this.stress, ...updates }; + }, + + // Policy updates + setPolicy(relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") { + this.policy.relationship = relationship; + }, + + setRoleBands(bands: Record) { + this.policy.roleBands = bands; + }, + + setEqualWage(wage: number) { + this.equalHourlyWage = wage; + }, + + setOncostPct(pct: number) { + this.payrollOncostPct = pct; + }, + + // Overhead costs + addOverheadCost(cost: any) { + const withDefaults = { + id: cost.id || Date.now().toString(), + name: cost.name || "", + amount: cost.amount || 0, + category: cost.category ?? "", + }; + this.overheadCosts.push(withDefaults); + }, + + upsertOverheadCost(cost: any) { + const i = this.overheadCosts.findIndex((c) => c.id === cost.id); + const withDefaults = { + id: cost.id || Date.now().toString(), + name: cost.name || "", + amount: cost.amount || 0, + category: cost.category ?? "", + }; + if (i === -1) { + this.overheadCosts.push(withDefaults); + } else { + this.overheadCosts[i] = withDefaults; + } + }, + + removeOverheadCost(id: string) { + this.overheadCosts = this.overheadCosts.filter((c) => c.id !== id); + }, + + // Initialize with default data if empty - DISABLED + // NO automatic initialization - stores should start empty + initializeDefaults() { + // DISABLED: No automatic data loading + // User must explicitly choose to load demo data + return; + }, + + // Clear ALL data - no exceptions + clearAll() { + // Reset ALL state to initial empty values + this._wasCleared = true; + this.operatingMode = "min"; + this.members = []; + this.streams = []; + this.milestones = []; + this.scenario = "current"; + this.stress = { + revenueDelay: 0, + costShockPct: 0, + grantLost: false, + }; + this.policy = { + relationship: "equal-pay", + roleBands: {}, + }; + this.equalHourlyWage = 0; + this.payrollOncostPct = 0; + this.savingsTargetMonths = 0; + this.minCashCushion = 0; + this.currentCash = 0; + this.currentSavings = 0; + this.overheadCosts = []; + + // Clear ALL localStorage data + if (typeof window !== "undefined") { + // Save cleared flag first + localStorage.setItem("urgent-tools-cleared-flag", "true"); + + // Remove all known keys + const keysToRemove = [ + "coop_builder_v1", + "urgent-tools-members", + "urgent-tools-policies", + "urgent-tools-streams", + "urgent-tools-budget", + "urgent-tools-cash", + "urgent-tools-schema-version", + ]; + + keysToRemove.forEach((key) => localStorage.removeItem(key)); + + // Clear any other urgent-tools or coop keys + const allKeys = Object.keys(localStorage); + allKeys.forEach((key) => { + if (key.startsWith("urgent-tools-") || key.startsWith("coop_")) { + if (key !== "urgent-tools-cleared-flag") { + localStorage.removeItem(key); + } + } + }); + } + }, + }, + + persist: { + key: "coop_builder_v1", + storage: typeof window !== "undefined" ? localStorage : undefined, + }, +}); diff --git a/stores/members.ts b/stores/members.ts index 891d647..df49835 100644 --- a/stores/members.ts +++ b/stores/members.ts @@ -1,4 +1,6 @@ import { defineStore } from "pinia"; +import { ref, computed } from 'vue'; +import { coverage, teamCoverageStats } from "~/types/members"; export const useMembersStore = defineStore( "members", @@ -34,10 +36,16 @@ export const useMembersStore = defineStore( // Normalize a member object to ensure required structure and sane defaults function normalizeMember(raw) { + // Calculate hoursPerWeek from targetHours (monthly) if not explicitly set + const targetHours = Number(raw.capacity?.targetHours) || 0; + const hoursPerWeek = raw.hoursPerWeek ?? (targetHours > 0 ? targetHours / 4.33 : 0); + const normalized = { id: raw.id || Date.now().toString(), displayName: typeof raw.displayName === "string" ? raw.displayName : "", roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "", + role: raw.role || raw.roleFocus || "", + hoursPerWeek: hoursPerWeek, payRelationship: raw.payRelationship || "FullyPaid", capacity: { minHours: Number(raw.capacity?.minHours) || 0, @@ -49,6 +57,11 @@ export const useMembersStore = defineStore( privacyNeeds: raw.privacyNeeds || "aggregate_ok", deferredHours: Number(raw.deferredHours ?? 0), quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240), + // NEW fields for needs coverage + minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0, + targetMonthlyPay: Number(raw.targetMonthlyPay) || 0, + externalMonthlyIncome: Number(raw.externalMonthlyIncome) || 0, + monthlyPayPlanned: Number(raw.monthlyPayPlanned) || 0, ...raw, }; return normalized; @@ -187,6 +200,56 @@ export const useMembersStore = defineStore( members.value = []; } + // Coverage calculations for individual members + function getMemberCoverage(memberId) { + const member = members.value.find((m) => m.id === memberId); + if (!member) return { minPct: undefined, targetPct: undefined }; + + return coverage( + member.minMonthlyNeeds || 0, + member.targetMonthlyPay || 0, + member.monthlyPayPlanned || 0, + member.externalMonthlyIncome || 0 + ); + } + + // Team-wide coverage statistics + const teamStats = computed(() => teamCoverageStats(members.value)); + + // Pay policy configuration + const payPolicy = ref({ + relationship: 'equal-pay' as const, + notes: '', + equalBase: 0, + needsWeight: 0.5, + roleBands: {}, + hoursRate: 0, + customFormula: '' + }); + + // Setters for new fields + function setMonthlyNeeds(memberId, minNeeds, targetPay) { + const member = members.value.find((m) => m.id === memberId); + if (member) { + member.minMonthlyNeeds = Number(minNeeds) || 0; + member.targetMonthlyPay = Number(targetPay) || 0; + } + } + + function setExternalIncome(memberId, income) { + const member = members.value.find((m) => m.id === memberId); + if (member) { + member.externalMonthlyIncome = Number(income) || 0; + } + } + + function setPlannedPay(memberId, planned) { + const member = members.value.find((m) => m.id === memberId); + if (member) { + member.monthlyPayPlanned = Number(planned) || 0; + } + } + return { members, capacityTotals, @@ -194,6 +257,8 @@ export const useMembersStore = defineStore( validationDetails, isValid, schemaVersion, + payPolicy, + teamStats, // Wizard actions upsertMember, setCapacity, @@ -202,6 +267,11 @@ export const useMembersStore = defineStore( setExternalCoveragePct, setPrivacy, resetMembers, + // New coverage actions + setMonthlyNeeds, + setExternalIncome, + setPlannedPay, + getMemberCoverage, // Legacy actions addMember, updateMember, @@ -211,7 +281,7 @@ export const useMembersStore = defineStore( { persist: { key: "urgent-tools-members", - paths: ["members", "privacyFlags"], + paths: ["members", "privacyFlags", "payPolicy"], }, } ); diff --git a/stores/policies.ts b/stores/policies.ts index 1c0e477..e77c692 100644 --- a/stores/policies.ts +++ b/stores/policies.ts @@ -11,6 +11,13 @@ export const usePoliciesStore = defineStore( const payrollOncostPct = ref(0); const savingsTargetMonths = ref(0); const minCashCushionAmount = ref(0); + const operatingMode = ref<'minimum' | 'target'>('minimum'); + + // Pay policy for member needs coverage + const payPolicy = ref({ + relationship: 'equal-pay' as 'equal-pay' | 'needs-weighted' | 'role-banded' | 'hours-weighted', + roleBands: [] as Array<{ role: string; hourlyWage: number }> + }); // Deferred pay limits const deferredCapHoursPerQtr = ref(0); @@ -95,6 +102,13 @@ export const usePoliciesStore = defineStore( function setPaymentPriority(priority) { paymentPriority.value = [...priority]; } + + function setPayPolicy(relationship, roleBands = []) { + payPolicy.value = { + relationship, + roleBands: [...roleBands] + }; + } // Legacy actions function updatePolicy(key, value) { @@ -120,6 +134,8 @@ export const usePoliciesStore = defineStore( payrollOncostPct.value = 0; savingsTargetMonths.value = 0; minCashCushionAmount.value = 0; + operatingMode.value = 'minimum'; + payPolicy.value = { relationship: 'equal-pay', roleBands: [] }; deferredCapHoursPerQtr.value = 0; deferredSunsetMonths.value = 0; surplusOrder.value = [ @@ -139,6 +155,8 @@ export const usePoliciesStore = defineStore( payrollOncostPct, savingsTargetMonths, minCashCushionAmount, + operatingMode, + payPolicy, deferredCapHoursPerQtr, deferredSunsetMonths, surplusOrder, @@ -151,6 +169,7 @@ export const usePoliciesStore = defineStore( setOncostPct, setSavingsTargetMonths, setMinCashCushion, + setPayPolicy, setDeferredCap, setDeferredSunset, setVolunteerScope, @@ -171,6 +190,8 @@ export const usePoliciesStore = defineStore( "payrollOncostPct", "savingsTargetMonths", "minCashCushionAmount", + "operatingMode", + "payPolicy", "deferredCapHoursPerQtr", "deferredSunsetMonths", "surplusOrder", diff --git a/stores/streams.ts b/stores/streams.ts index e5e18f4..90ab989 100644 --- a/stores/streams.ts +++ b/stores/streams.ts @@ -87,30 +87,6 @@ export const useStreamsStore = defineStore( } } - // Initialize with fixture data if empty - async function initializeWithFixtures() { - if (streams.value.length === 0) { - const { useFixtures } = await import('~/composables/useFixtures'); - const fixtures = useFixtures(); - const { revenueStreams } = await fixtures.loadStreams(); - - revenueStreams.forEach(stream => { - upsertStream(stream); - }); - } - } - - // Load realistic demo data (for better user experience) - async function loadDemoData() { - resetStreams(); - const { useFixtures } = await import('~/composables/useFixtures'); - const fixtures = useFixtures(); - const { revenueStreams } = await fixtures.loadStreams(); - - revenueStreams.forEach(stream => { - upsertStream(stream); - }); - } // Reset function function resetStreams() { @@ -127,8 +103,6 @@ export const useStreamsStore = defineStore( // Wizard actions upsertStream, resetStreams, - initializeWithFixtures, - loadDemoData, // Legacy actions addStream, updateStream, diff --git a/tailwind.config.ts b/tailwind.config.ts index b1aeef9..115f9af 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,5 +1,13 @@ import type { Config } from "tailwindcss"; export default { + content: [ + './app.vue', + './pages/**/*.vue', + './components/**/*.{vue,js,ts}', + './composables/**/*.{js,ts}', + './layouts/**/*.vue', + './plugins/**/*.{js,ts}', + ], darkMode: "class", } satisfies Config; diff --git a/tests/coach-integration.spec.ts b/tests/coach-integration.spec.ts index f01b96a..0d87713 100644 --- a/tests/coach-integration.spec.ts +++ b/tests/coach-integration.spec.ts @@ -9,12 +9,35 @@ import WizardRevenueStep from '~/components/WizardRevenueStep.vue'; import { useOfferSuggestor } from '~/composables/useOfferSuggestor'; import { usePlanStore } from '~/stores/plan'; import { offerToStream, offersToStreams } from '~/utils/offerToStream'; -import { - membersSample, - skillsCatalogSample, - problemsCatalogSample, - sampleSelections -} from '~/sample/skillsToOffersSamples'; + +// Create inline test data to replace removed sample imports +const membersSample = [ + { id: "1", name: "Maya Chen", role: "Designer", hourly: 32, availableHrs: 20 }, + { id: "2", name: "Alex Rodriguez", role: "Developer", hourly: 35, availableHrs: 30 }, + { id: "3", name: "Jordan Kim", role: "Writer", hourly: 28, availableHrs: 15 } +]; + +const skillsCatalogSample = [ + { id: "design", label: "UI/UX Design" }, + { id: "writing", label: "Technical Writing" }, + { id: "development", label: "Web Development" } +]; + +const problemsCatalogSample = [ + { + id: "unclear-pitch", + label: "Unclear value proposition", + examples: ["Need better messaging", "Confusing product pitch"] + } +]; + +const sampleSelections = { + selectedSkillsByMember: { + "1": ["design"], + "3": ["writing"] + }, + selectedProblems: ["unclear-pitch"] +}; // Mock router vi.mock('vue-router', () => ({ @@ -159,65 +182,34 @@ describe('Coach Integration Tests', () => { }); describe('Coach Page Integration', () => { - it('loads sample data and generates offers automatically', async () => { + it('starts with empty data by default', async () => { const wrapper = mount(CoachSkillsToOffers, { global: { plugins: [pinia] } }); - // Trigger sample data loading - await wrapper.vm.loadSampleData(); - await nextTick(); - - // Wait for debounced offer generation - await new Promise(resolve => setTimeout(resolve, 350)); - - // Should have loaded sample members - expect(wrapper.vm.members).toEqual(membersSample); - - // Should have pre-selected skills and problems - expect(wrapper.vm.selectedSkills).toEqual(sampleSelections.selectedSkillsByMember); - expect(wrapper.vm.selectedProblems).toEqual(sampleSelections.selectedProblems); - - // Should have generated offers - expect(wrapper.vm.offers).toBeDefined(); - expect(wrapper.vm.offers?.length).toBeGreaterThan(0); + // Should start with empty data + expect(wrapper.vm.members).toEqual([]); + expect(wrapper.vm.availableSkills).toEqual([]); + expect(wrapper.vm.availableProblems).toEqual([]); + expect(wrapper.vm.offers).toBeNull(); }); - it('handles "Use these" action correctly', async () => { + it('handles empty state gracefully with no offers generated', async () => { const wrapper = mount(CoachSkillsToOffers, { global: { plugins: [pinia] } }); - // Load sample data and generate offers - await wrapper.vm.loadSampleData(); + // Wait for any potential async operations await nextTick(); - await new Promise(resolve => setTimeout(resolve, 350)); - - // Ensure we have offers - expect(wrapper.vm.offers?.length).toBeGreaterThan(0); + await new Promise(resolve => setTimeout(resolve, 100)); - const initialOffers = wrapper.vm.offers!; - - // Trigger "Use these" action - await wrapper.vm.useOffers(); - - // Should have added streams to plan store - expect(planStore.streams.length).toBe(initialOffers.length); - - // Verify streams are properly converted - planStore.streams.forEach((stream: any, index: number) => { - const originalOffer = initialOffers[index]; - expect(stream.id).toBe(`offer-${originalOffer.id}`); - expect(stream.name).toBe(originalOffer.name); - expect(stream.unitPrice).toBe(originalOffer.price.baseline); - expect(stream.payoutDelayDays).toBe(originalOffer.payoutDelayDays); - expect(stream.feePercent).toBe(3); - expect(stream.notes).toBe(originalOffer.whyThis.join('. ')); - }); + // Should have no offers with empty data + expect(wrapper.vm.offers).toBeNull(); + expect(wrapper.vm.canRegenerate).toBe(false); }); }); diff --git a/types/members.ts b/types/members.ts new file mode 100644 index 0000000..60dbb20 --- /dev/null +++ b/types/members.ts @@ -0,0 +1,137 @@ +export type PayRelationship = + | 'equal-pay' + | 'needs-weighted' + | 'role-banded' + | 'hours-weighted' + | 'custom-formula'; + +export interface Member { + id: string + displayName: string + roleFocus?: string + role?: string + hoursPerWeek?: number + hoursPerMonth?: number + capacity?: { + minHours?: number + targetHours?: number + maxHours?: number + } + + // Existing/planned + monthlyPayPlanned?: number + + // NEW - early-stage friendly, defaults-safe + minMonthlyNeeds?: number + targetMonthlyPay?: number + externalMonthlyIncome?: number + + // Compatibility with existing store + payRelationship?: string + riskBand?: string + externalCoveragePct?: number + privacyNeeds?: string + deferredHours?: number + quarterlyDeferredCap?: number + + // UI-only derivations + coverageMinPct?: number + coverageTargetPct?: number +} + +export interface PayPolicy { + relationship: PayRelationship + notes?: string + equalBase?: number + needsWeight?: number + roleBands?: Record + hoursRate?: number + customFormula?: string +} + +// Coverage calculation helpers +export function coverage(minNeeds = 0, target = 0, planned = 0, external = 0) { + const base = planned + external + const min = minNeeds > 0 ? Math.min(200, (base / minNeeds) * 100) : undefined + const tgt = target > 0 ? Math.min(200, (base / target) * 100) : undefined + return { minPct: min, targetPct: tgt } +} + +export function teamCoverageStats(members: Member[]) { + const vals = members + .map(m => coverage(m.minMonthlyNeeds, m.targetMonthlyPay, m.monthlyPayPlanned, m.externalMonthlyIncome).minPct) + .filter((v): v is number => typeof v === 'number') + + if (!vals.length) return { under100: 0, median: undefined, range: undefined, gini: undefined } + + const sorted = [...vals].sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length / 2)] + const range = { min: sorted[0], max: sorted[sorted.length - 1] } + + // quick Gini on coverage (0 = equal, 1 = unequal) + const mean = vals.reduce((a, b) => a + b, 0) / vals.length + let gini = 0 + if (mean > 0) { + let diffSum = 0 + for (let i = 0; i < vals.length; i++) + for (let j = 0; j < vals.length; j++) + diffSum += Math.abs(vals[i] - vals[j]) + gini = diffSum / (2 * vals.length * vals.length * mean) + } + + const under100 = vals.filter(v => v < 100).length + + return { under100, median, range, gini } +} + +// Payroll allocation based on policy +export function allocatePayroll(members: Member[], policy: PayPolicy, payrollBudget: number): Member[] { + const result = JSON.parse(JSON.stringify(members)) // Safe deep clone + + if (policy.relationship === 'equal-pay') { + const each = payrollBudget / result.length + result.forEach(m => m.monthlyPayPlanned = Math.max(0, each)) + return result + } + + if (policy.relationship === 'needs-weighted') { + const weights = result.map(m => m.minMonthlyNeeds ?? 0) + const sum = weights.reduce((a, b) => a + b, 0) || 1 + result.forEach((m, i) => { + const w = weights[i] / sum + m.monthlyPayPlanned = Math.max(0, payrollBudget * w) + }) + return result + } + + if (policy.relationship === 'role-banded' && policy.roleBands) { + const bands = result.map(m => policy.roleBands![m.role ?? ''] ?? 0) + const sum = bands.reduce((a, b) => a + b, 0) || 1 + result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (bands[i] / sum)) + return result + } + + if (policy.relationship === 'hours-weighted') { + const hours = result.map(m => m.hoursPerMonth ?? (m.hoursPerWeek ? m.hoursPerWeek * 4 : 0) ?? (m.capacity?.targetHours ?? 0)) + const sum = hours.reduce((a, b) => a + b, 0) || 1 + result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (hours[i] / sum)) + return result + } + + // fallback: equal + const each = payrollBudget / result.length + result.forEach(m => m.monthlyPayPlanned = Math.max(0, each)) + return result +} + +// Monthly payroll calculation for runway and cashflow +export function monthlyPayroll(members: Member[], mode: 'minimum' | 'target' = 'minimum'): number { + return members.reduce((sum, m) => { + const planned = m.monthlyPayPlanned ?? 0 + // In "minimum" mode cap at min needs to show a lean runway scenario + if (mode === 'minimum' && m.minMonthlyNeeds) { + return sum + Math.min(planned, m.minMonthlyNeeds) + } + return sum + planned + }, 0) +} \ No newline at end of file