diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..22908e2 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Co-op Pay & Value Tool Configuration + +# Currency and localization +APP_CURRENCY=EUR +APP_LOCALE=en-CA +APP_DECIMAL_PLACES=2 + +# Application settings +APP_NAME="Urgent Tools" +APP_ENVIRONMENT=development + +# Optional overrides +# APP_DATE_FORMAT=short +# APP_NUMBER_FORMAT=standard diff --git a/app.config.ts b/app.config.ts index 1e58d65..0e900ab 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,7 +1,52 @@ export default defineAppConfig({ ui: { - primary: "violet", - gray: "neutral", + primary: "slate", + gray: "neutral", strategy: "class", + // High-contrast meter colors for accessibility + colors: { + green: { + 50: '#f0fdf4', + 500: '#10b981', + 600: '#059669', + 900: '#064e3b' + }, + yellow: { + 50: '#fefce8', + 500: '#f59e0b', + 600: '#d97706', + 900: '#78350f' + }, + red: { + 50: '#fef2f2', + 500: '#ef4444', + 600: '#dc2626', + 900: '#7f1d1d' + } + }, + // Spacious card styling + card: { + base: 'overflow-hidden', + background: 'bg-white dark:bg-gray-900', + divide: 'divide-y divide-gray-200 dark:divide-gray-800', + ring: 'ring-1 ring-gray-200 dark:ring-gray-800', + rounded: 'rounded-lg', + shadow: 'shadow', + body: { + base: '', + background: '', + padding: 'px-6 py-5 sm:p-6' + }, + header: { + base: '', + background: '', + padding: 'px-6 py-4 sm:px-6' + }, + footer: { + base: '', + background: '', + padding: 'px-6 py-4 sm:px-6' + } + } }, }); diff --git a/app.vue b/app.vue new file mode 100644 index 0000000..cf9fa5d --- /dev/null +++ b/app.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/app.vue b/app/app.vue deleted file mode 100644 index ae0d903..0000000 --- a/app/app.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/app/composables/useFixtureIO.ts b/app/composables/useFixtureIO.ts deleted file mode 100644 index 8a5ef41..0000000 --- a/app/composables/useFixtureIO.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useCounterStore } from '~/stores/counter' - -export type AppSnapshot = { - counter: { count: number } -} - -export function useFixtureIO() { - const exportAll = (): AppSnapshot => { - const counter = useCounterStore() - return { - counter: { count: counter.count } - } - } - - const importAll = (snapshot: AppSnapshot) => { - const counter = useCounterStore() - if (snapshot?.counter) { - counter.$patch({ count: snapshot.counter.count ?? 0 }) - } - } - - return { exportAll, importAll } -} - - diff --git a/app/pages/index.vue b/app/pages/index.vue deleted file mode 100644 index 8bb75c4..0000000 --- a/app/pages/index.vue +++ /dev/null @@ -1,62 +0,0 @@ - - - diff --git a/app/stores/counter.ts b/app/stores/counter.ts deleted file mode 100644 index d6afa81..0000000 --- a/app/stores/counter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineStore } from "pinia"; - -export const useCounterStore = defineStore("counter", { - state: () => ({ - count: 0, - }), - getters: { - doubleCount: (state) => state.count * 2, - }, - actions: { - increment() { - this.count += 1; - }, - }, - persist: { - paths: ["count"], - }, -}); diff --git a/app/assets/css/main.css b/assets/css/main.css similarity index 100% rename from app/assets/css/main.css rename to assets/css/main.css diff --git a/app/components/ColorModeToggle.vue b/components/ColorModeToggle.vue similarity index 100% rename from app/components/ColorModeToggle.vue rename to components/ColorModeToggle.vue diff --git a/components/ConcentrationChip.vue b/components/ConcentrationChip.vue new file mode 100644 index 0000000..34174d4 --- /dev/null +++ b/components/ConcentrationChip.vue @@ -0,0 +1,63 @@ + + + diff --git a/components/CoverageMeter.vue b/components/CoverageMeter.vue new file mode 100644 index 0000000..2ed2fde --- /dev/null +++ b/components/CoverageMeter.vue @@ -0,0 +1,101 @@ + + + diff --git a/components/GlossaryTooltip.vue b/components/GlossaryTooltip.vue new file mode 100644 index 0000000..d16c12e --- /dev/null +++ b/components/GlossaryTooltip.vue @@ -0,0 +1,54 @@ + + + diff --git a/components/PayRelationshipChip.vue b/components/PayRelationshipChip.vue new file mode 100644 index 0000000..6e4d669 --- /dev/null +++ b/components/PayRelationshipChip.vue @@ -0,0 +1,54 @@ + + + diff --git a/components/ReserveMeter.vue b/components/ReserveMeter.vue new file mode 100644 index 0000000..7252ac4 --- /dev/null +++ b/components/ReserveMeter.vue @@ -0,0 +1,102 @@ + + + diff --git a/components/RestrictionChip.vue b/components/RestrictionChip.vue new file mode 100644 index 0000000..92b09ac --- /dev/null +++ b/components/RestrictionChip.vue @@ -0,0 +1,39 @@ + + + diff --git a/components/RiskBandChip.vue b/components/RiskBandChip.vue new file mode 100644 index 0000000..bb276e9 --- /dev/null +++ b/components/RiskBandChip.vue @@ -0,0 +1,41 @@ + + + diff --git a/components/RunwayMeter.vue b/components/RunwayMeter.vue new file mode 100644 index 0000000..ebe5938 --- /dev/null +++ b/components/RunwayMeter.vue @@ -0,0 +1,91 @@ + + + diff --git a/components/WizardCostsStep.vue b/components/WizardCostsStep.vue new file mode 100644 index 0000000..87ee747 --- /dev/null +++ b/components/WizardCostsStep.vue @@ -0,0 +1,204 @@ + + + diff --git a/components/WizardMembersStep.vue b/components/WizardMembersStep.vue new file mode 100644 index 0000000..5429af1 --- /dev/null +++ b/components/WizardMembersStep.vue @@ -0,0 +1,196 @@ + + + diff --git a/components/WizardPoliciesStep.vue b/components/WizardPoliciesStep.vue new file mode 100644 index 0000000..b116717 --- /dev/null +++ b/components/WizardPoliciesStep.vue @@ -0,0 +1,264 @@ + + + diff --git a/components/WizardRevenueStep.vue b/components/WizardRevenueStep.vue new file mode 100644 index 0000000..516d69a --- /dev/null +++ b/components/WizardRevenueStep.vue @@ -0,0 +1,405 @@ + + + diff --git a/components/WizardReviewStep.vue b/components/WizardReviewStep.vue new file mode 100644 index 0000000..85d4e56 --- /dev/null +++ b/components/WizardReviewStep.vue @@ -0,0 +1,319 @@ + + + \ No newline at end of file diff --git a/composables/useConcentration.ts b/composables/useConcentration.ts new file mode 100644 index 0000000..c3f769f --- /dev/null +++ b/composables/useConcentration.ts @@ -0,0 +1,59 @@ +/** + * Returns Top source % and HHI-based traffic light (HHI hidden from UI) + * Uses Herfindahl-Hirschman Index internally but only exposes traffic light color + */ +export const useConcentration = () => { + const calculateTopSourcePct = (revenueShares: number[]): number => { + if (revenueShares.length === 0) return 0 + return Math.max(...revenueShares) + } + + const calculateHHI = (revenueShares: number[]): number => { + // HHI = sum of squared market shares (as percentages) + return revenueShares.reduce((sum, share) => sum + (share * share), 0) + } + + const getConcentrationStatus = (topSourcePct: number, hhi: number): 'green' | 'yellow' | 'red' => { + // Primary threshold based on top source % + if (topSourcePct > 50) return 'red' + if (topSourcePct > 35) return 'yellow' + + // Secondary check using HHI for more nuanced analysis + if (hhi > 2500) return 'red' // Highly concentrated + if (hhi > 1500) return 'yellow' // Moderately concentrated + + return 'green' + } + + const getConcentrationMessage = (status: 'green' | 'yellow' | 'red'): string => { + switch (status) { + case 'red': + return 'Most of your money comes from one place. Add another stream to reduce risk.' + case 'yellow': + return 'Revenue somewhat concentrated. Consider diversifying further.' + case 'green': + return 'Good revenue diversification.' + } + } + + const analyzeConcentration = (revenueStreams: Array<{ targetPct: number }>) => { + const shares = revenueStreams.map(stream => stream.targetPct || 0) + const topSourcePct = calculateTopSourcePct(shares) + const hhi = calculateHHI(shares) + const status = getConcentrationStatus(topSourcePct, hhi) + + return { + topSourcePct, + status, + message: getConcentrationMessage(status) + // Note: HHI is deliberately not exposed in return + } + } + + return { + calculateTopSourcePct, + getConcentrationStatus, + getConcentrationMessage, + analyzeConcentration + } +} diff --git a/composables/useCoverage.ts b/composables/useCoverage.ts new file mode 100644 index 0000000..58ce4e2 --- /dev/null +++ b/composables/useCoverage.ts @@ -0,0 +1,25 @@ +/** + * Computes coverage: funded paid hours ÷ target hours + */ +export const useCoverage = () => { + const calculateCoverage = (fundedPaidHours: number, targetHours: number): number => { + if (targetHours <= 0) return 0 + return (fundedPaidHours / targetHours) * 100 + } + + const getCoverageStatus = (coveragePct: number): 'green' | 'yellow' | 'red' => { + if (coveragePct >= 80) return 'green' + if (coveragePct >= 60) return 'yellow' + return 'red' + } + + const formatCoverage = (coveragePct: number): string => { + return `${Math.round(coveragePct)}%` + } + + return { + calculateCoverage, + getCoverageStatus, + formatCoverage + } +} diff --git a/composables/useDeferredMetrics.ts b/composables/useDeferredMetrics.ts new file mode 100644 index 0000000..43d74d9 --- /dev/null +++ b/composables/useDeferredMetrics.ts @@ -0,0 +1,89 @@ +/** + * Calculates deferred balance and ratio vs monthly payroll + * Formula: total deferred wage liability ÷ one month of payroll + */ +export const useDeferredMetrics = () => { + const calculateDeferredBalance = ( + members: Array<{ deferredHours: number }>, + hourlyWage: number + ): number => { + const totalDeferredHours = members.reduce((sum, member) => sum + (member.deferredHours || 0), 0) + return totalDeferredHours * hourlyWage + } + + const calculateMonthlyPayroll = ( + members: Array<{ targetHours: number }>, + hourlyWage: number, + oncostPct: number + ): number => { + const totalTargetHours = members.reduce((sum, member) => sum + (member.targetHours || 0), 0) + const grossPayroll = totalTargetHours * hourlyWage + return grossPayroll * (1 + oncostPct / 100) + } + + const calculateDeferredRatio = ( + deferredBalance: number, + monthlyPayroll: number + ): number => { + if (monthlyPayroll <= 0) return 0 + return deferredBalance / monthlyPayroll + } + + const getDeferredRatioStatus = (ratio: number): 'green' | 'yellow' | 'red' => { + if (ratio > 1.5) return 'red' // Flag red if >1.5× monthly payroll + if (ratio > 1.0) return 'yellow' + return 'green' + } + + const getDeferredRatioMessage = (status: 'green' | 'yellow' | 'red'): string => { + switch (status) { + case 'red': + return 'Deferred balance is high. Consider repaying or reducing scope.' + case 'yellow': + return 'Deferred balance is building up. Monitor closely.' + case 'green': + return 'Deferred balance is manageable.' + } + } + + const checkDeferredCap = ( + memberHours: number, + capHoursPerQtr: number, + quarterProgress: number + ): { withinCap: boolean; remainingHours: number } => { + const quarterlyLimit = capHoursPerQtr * quarterProgress + const withinCap = memberHours <= quarterlyLimit + const remainingHours = Math.max(0, quarterlyLimit - memberHours) + + return { withinCap, remainingHours } + } + + const analyzeDeferredMetrics = ( + members: Array<{ deferredHours?: number; targetHours?: number }>, + hourlyWage: number, + oncostPct: number + ) => { + const deferredBalance = calculateDeferredBalance(members, hourlyWage) + const monthlyPayroll = calculateMonthlyPayroll(members, hourlyWage, oncostPct) + const ratio = calculateDeferredRatio(deferredBalance, monthlyPayroll) + const status = getDeferredRatioStatus(ratio) + + return { + deferredBalance, + monthlyPayroll, + ratio: Number(ratio.toFixed(2)), + status, + message: getDeferredRatioMessage(status) + } + } + + return { + calculateDeferredBalance, + calculateMonthlyPayroll, + calculateDeferredRatio, + getDeferredRatioStatus, + getDeferredRatioMessage, + checkDeferredCap, + analyzeDeferredMetrics + } +} diff --git a/composables/useFixtureIO.ts b/composables/useFixtureIO.ts new file mode 100644 index 0000000..c56b190 --- /dev/null +++ b/composables/useFixtureIO.ts @@ -0,0 +1,77 @@ +import { useMembersStore } from '~/stores/members' +import { usePoliciesStore } from '~/stores/policies' +import { useStreamsStore } from '~/stores/streams' +import { useBudgetStore } from '~/stores/budget' +import { useScenariosStore } from '~/stores/scenarios' +import { useCashStore } from '~/stores/cash' +import { useSessionStore } from '~/stores/session' + +export type AppSnapshot = { + members: any[] + policies: Record + streams: any[] + budget: Record + scenarios: Record + cash: Record + session: Record +} + +export function useFixtureIO() { + const exportAll = (): AppSnapshot => { + const members = useMembersStore() + const policies = usePoliciesStore() + const streams = useStreamsStore() + const budget = useBudgetStore() + const scenarios = useScenariosStore() + const cash = useCashStore() + const session = useSessionStore() + + return { + members: members.members, + policies: { + equalHourlyWage: policies.equalHourlyWage, + payrollOncostPct: policies.payrollOncostPct, + savingsTargetMonths: policies.savingsTargetMonths, + minCashCushionAmount: policies.minCashCushionAmount, + deferredCapHoursPerQtr: policies.deferredCapHoursPerQtr, + deferredSunsetMonths: policies.deferredSunsetMonths, + surplusOrder: policies.surplusOrder, + paymentPriority: policies.paymentPriority + }, + streams: streams.streams, + budget: { + budgetLines: budget.budgetLines, + overheadCosts: budget.overheadCosts, + productionCosts: budget.productionCosts, + currentPeriod: budget.currentPeriod + }, + scenarios: { + sliders: scenarios.sliders, + activeScenario: scenarios.activeScenario + }, + cash: { + cashEvents: cash.cashEvents, + paymentQueue: cash.paymentQueue, + currentCash: cash.currentCash, + currentSavings: cash.currentSavings + }, + session: { + checklist: session.checklist, + draftAllocations: session.draftAllocations, + rationale: session.rationale, + currentSession: session.currentSession, + savedRecords: session.savedRecords + } + } + } + + 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) + } + + return { exportAll, importAll } +} + + diff --git a/composables/useFixtures.ts b/composables/useFixtures.ts new file mode 100644 index 0000000..07b6939 --- /dev/null +++ b/composables/useFixtures.ts @@ -0,0 +1,246 @@ +/** + * Composable for loading and managing fixture data + * Provides centralized access to demo data for all screens + */ +export const useFixtures = () => { + // Load fixture data (in real app, this would come from API or stores) + 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 + } + ] + } + } + + const loadStreams = async () => { + return { + 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', + 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', + 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', + 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', + 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', + effortHoursPerMonth: 12 + } + ] + } + } + + const loadFinances = async () => { + return { + currentBalances: { + cash: 5000, + savings: 8000, + totalLiquid: 13000 + }, + policies: { + equalHourlyWage: 20, + payrollOncostPct: 25, + savingsTargetMonths: 3, + minCashCushionAmount: 3000, + deferredCapHoursPerQtr: 240, + deferredSunsetMonths: 12 + }, + deferredLiabilities: { + totalDeferred: 2340, + byMember: { + 'member-1': 1700, + 'member-2': 0, + 'member-3': 640 + } + } + } + } + + const loadCosts = async () => { + return { + overheadCosts: [ + { + id: 'overhead-1', + name: 'Coworking Space', + amount: 800, + category: 'Workspace', + recurring: true + }, + { + id: 'overhead-2', + name: 'Tools & Software', + amount: 420, + category: 'Technology', + recurring: true + }, + { + id: 'overhead-3', + name: 'Business Insurance', + amount: 180, + category: 'Legal & Compliance', + recurring: true + } + ], + productionCosts: [ + { + id: 'production-1', + name: 'Development Kits', + amount: 500, + category: 'Hardware', + period: '2024-01' + } + ] + } + } + + // Calculate derived metrics from fixture data + 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 + } + } + + return { + loadMembers, + loadStreams, + loadFinances, + loadCosts, + calculateMetrics + } +} diff --git a/composables/usePayoutExposure.ts b/composables/usePayoutExposure.ts new file mode 100644 index 0000000..9c754af --- /dev/null +++ b/composables/usePayoutExposure.ts @@ -0,0 +1,58 @@ +/** + * Calculates weighted average payout delay across revenue streams + */ +export const usePayoutExposure = () => { + const calculateWeightedAverageDelay = ( + streams: Array<{ targetMonthlyAmount: number; payoutDelayDays: number }> + ): number => { + const totalRevenue = streams.reduce((sum, stream) => sum + (stream.targetMonthlyAmount || 0), 0) + + if (totalRevenue === 0) return 0 + + const weightedSum = streams.reduce((sum, stream) => { + const weight = (stream.targetMonthlyAmount || 0) / totalRevenue + return sum + (weight * (stream.payoutDelayDays || 0)) + }, 0) + + return weightedSum + } + + const getPayoutExposureStatus = (avgDelayDays: number, minCashCushion: number): 'green' | 'yellow' | 'red' => { + // Flag if weighted average >30 days and cushion <1 month + if (avgDelayDays > 30 && minCashCushion < 8000) return 'red' // Assuming ~8k = 1 month expenses + if (avgDelayDays > 21) return 'yellow' + return 'green' + } + + const getPayoutExposureMessage = (status: 'green' | 'yellow' | 'red'): string => { + switch (status) { + case 'red': + return 'Money is earned now but arrives later. Delays can create mid-month dips.' + case 'yellow': + return 'Some payout delays present. Monitor cash timing.' + case 'green': + return 'Payout timing looks manageable.' + } + } + + const analyzePayoutExposure = ( + streams: Array<{ targetMonthlyAmount: number; payoutDelayDays: number }>, + minCashCushion: number + ) => { + const avgDelayDays = calculateWeightedAverageDelay(streams) + const status = getPayoutExposureStatus(avgDelayDays, minCashCushion) + + return { + avgDelayDays: Math.round(avgDelayDays), + status, + message: getPayoutExposureMessage(status) + } + } + + return { + calculateWeightedAverageDelay, + getPayoutExposureStatus, + getPayoutExposureMessage, + analyzePayoutExposure + } +} diff --git a/composables/useReserveProgress.ts b/composables/useReserveProgress.ts new file mode 100644 index 0000000..aa7127f --- /dev/null +++ b/composables/useReserveProgress.ts @@ -0,0 +1,74 @@ +/** + * Calculates percentage of savings target met + * Formula: savings ÷ (savings_target_months × monthly burn) + */ +export const useReserveProgress = () => { + const calculateReserveProgress = ( + currentSavings: number, + savingsTargetMonths: number, + monthlyBurn: number + ): number => { + const targetAmount = savingsTargetMonths * monthlyBurn + if (targetAmount <= 0) return 100 + return Math.min((currentSavings / targetAmount) * 100, 100) + } + + const getReserveProgressStatus = (progressPct: number): 'green' | 'yellow' | 'red' => { + if (progressPct >= 80) return 'green' + if (progressPct >= 50) return 'yellow' + return 'red' + } + + const getReserveProgressMessage = (status: 'green' | 'yellow' | 'red'): string => { + switch (status) { + case 'red': + return 'Build savings to your target before increasing paid hours.' + case 'yellow': + return 'Savings progress is moderate. Continue building reserves.' + case 'green': + return 'Savings target nearly reached or exceeded.' + } + } + + const calculateTargetAmount = (savingsTargetMonths: number, monthlyBurn: number): number => { + return savingsTargetMonths * monthlyBurn + } + + const calculateShortfall = ( + currentSavings: number, + savingsTargetMonths: number, + monthlyBurn: number + ): number => { + const targetAmount = calculateTargetAmount(savingsTargetMonths, monthlyBurn) + return Math.max(0, targetAmount - currentSavings) + } + + const analyzeReserveProgress = ( + currentSavings: number, + savingsTargetMonths: number, + monthlyBurn: number + ) => { + const progressPct = calculateReserveProgress(currentSavings, savingsTargetMonths, monthlyBurn) + const status = getReserveProgressStatus(progressPct) + const targetAmount = calculateTargetAmount(savingsTargetMonths, monthlyBurn) + const shortfall = calculateShortfall(currentSavings, savingsTargetMonths, monthlyBurn) + + return { + progressPct: Math.round(progressPct), + status, + message: getReserveProgressMessage(status), + currentSavings, + targetAmount, + shortfall + } + } + + return { + calculateReserveProgress, + getReserveProgressStatus, + getReserveProgressMessage, + calculateTargetAmount, + calculateShortfall, + analyzeReserveProgress + } +} diff --git a/composables/useRunway.ts b/composables/useRunway.ts new file mode 100644 index 0000000..4561708 --- /dev/null +++ b/composables/useRunway.ts @@ -0,0 +1,27 @@ +/** + * Computes months of runway from cash, reserves, and burn rate + * Formula: (cash + savings) ÷ average monthly burn in scenario + */ +export const useRunway = () => { + const calculateRunway = (cash: number, savings: number, monthlyBurn: number): number => { + if (monthlyBurn <= 0) return Infinity + return (cash + savings) / monthlyBurn + } + + const getRunwayStatus = (months: number): 'green' | 'yellow' | 'red' => { + if (months >= 3) return 'green' + if (months >= 2) return 'yellow' + return 'red' + } + + const formatRunway = (months: number): string => { + if (months === Infinity) return '∞ months' + return `${months.toFixed(1)} months` + } + + return { + calculateRunway, + getRunwayStatus, + formatRunway + } +} diff --git a/content/fixtures/cash-events.json b/content/fixtures/cash-events.json new file mode 100644 index 0000000..4675ce4 --- /dev/null +++ b/content/fixtures/cash-events.json @@ -0,0 +1,113 @@ +{ + "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 new file mode 100644 index 0000000..4a62e18 --- /dev/null +++ b/content/fixtures/costs.json @@ -0,0 +1,38 @@ +{ + "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 new file mode 100644 index 0000000..c4ea0a6 --- /dev/null +++ b/content/fixtures/finances.json @@ -0,0 +1,40 @@ +{ + "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 new file mode 100644 index 0000000..e1f0bb7 --- /dev/null +++ b/content/fixtures/members.json @@ -0,0 +1,52 @@ +{ + "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 new file mode 100644 index 0000000..76b7f36 --- /dev/null +++ b/content/fixtures/streams.json @@ -0,0 +1,84 @@ +{ + "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/setup.global.ts b/middleware/setup.global.ts new file mode 100644 index 0000000..57c1e77 --- /dev/null +++ b/middleware/setup.global.ts @@ -0,0 +1,20 @@ +export default defineNuxtRouteMiddleware((to) => { + // Skip middleware for wizard and API routes + if (to.path === "/wizard" || to.path.startsWith("/api/")) { + return; + } + + // Use actual store state to determine whether setup is complete + const membersStore = useMembersStore(); + const policiesStore = usePoliciesStore(); + const streamsStore = useStreamsStore(); + + const setupComplete = + membersStore.isValid && + policiesStore.isValid && + streamsStore.hasValidStreams; + + if (!setupComplete) { + return navigateTo("/wizard"); + } +}); diff --git a/nuxt.config.ts b/nuxt.config.ts index 24842ef..5cfb1df 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -5,8 +5,8 @@ export default defineNuxtConfig({ compatibilityDate: "2025-07-15", devtools: { enabled: true }, - // Keep SSR on (default) with Nitro server - ssr: true, + // Disable SSR to avoid hydration mismatches during wizard work + ssr: false, // Strict TypeScript typescript: { @@ -22,5 +22,15 @@ export default defineNuxtConfig({ modules: ["@pinia/nuxt", "@nuxt/ui", "@nuxtjs/color-mode"], + // Runtime configuration for formatting + runtimeConfig: { + public: { + appCurrency: process.env.APP_CURRENCY || "EUR", + appLocale: process.env.APP_LOCALE || "en-CA", + appDecimalPlaces: process.env.APP_DECIMAL_PLACES || "2", + appName: process.env.APP_NAME || "Urgent Tools", + }, + }, + // Nuxt UI minimal theme customizations live in app.config.ts }); diff --git a/pages/budget.vue b/pages/budget.vue new file mode 100644 index 0000000..4a4264f --- /dev/null +++ b/pages/budget.vue @@ -0,0 +1,264 @@ + + + diff --git a/pages/cash.vue b/pages/cash.vue new file mode 100644 index 0000000..70b230d --- /dev/null +++ b/pages/cash.vue @@ -0,0 +1,74 @@ + + + diff --git a/pages/glossary.vue b/pages/glossary.vue new file mode 100644 index 0000000..4434fd1 --- /dev/null +++ b/pages/glossary.vue @@ -0,0 +1,180 @@ + + + diff --git a/pages/index.vue b/pages/index.vue new file mode 100644 index 0000000..27004e3 --- /dev/null +++ b/pages/index.vue @@ -0,0 +1,289 @@ + + + diff --git a/pages/mix.vue b/pages/mix.vue new file mode 100644 index 0000000..cead75a --- /dev/null +++ b/pages/mix.vue @@ -0,0 +1,273 @@ + + + diff --git a/pages/scenarios.vue b/pages/scenarios.vue new file mode 100644 index 0000000..03a9437 --- /dev/null +++ b/pages/scenarios.vue @@ -0,0 +1,367 @@ + + + diff --git a/pages/session.vue b/pages/session.vue new file mode 100644 index 0000000..cbce591 --- /dev/null +++ b/pages/session.vue @@ -0,0 +1,121 @@ + + + diff --git a/pages/settings.vue b/pages/settings.vue new file mode 100644 index 0000000..eacf6eb --- /dev/null +++ b/pages/settings.vue @@ -0,0 +1,138 @@ + + + diff --git a/pages/wizard.vue b/pages/wizard.vue new file mode 100644 index 0000000..65365a9 --- /dev/null +++ b/pages/wizard.vue @@ -0,0 +1,214 @@ + + + diff --git a/plugins/formatting.client.ts b/plugins/formatting.client.ts new file mode 100644 index 0000000..57febe0 --- /dev/null +++ b/plugins/formatting.client.ts @@ -0,0 +1,127 @@ +export default defineNuxtPlugin(() => { + const config = useRuntimeConfig() + + // Get configuration from environment + const currency = config.public.appCurrency || 'EUR' + const locale = config.public.appLocale || 'en-CA' + const decimalPlaces = parseInt(config.public.appDecimalPlaces || '2') + + // Create formatters with centralized configuration + const currencyFormatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces + }) + + const numberFormatter = new Intl.NumberFormat(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: decimalPlaces + }) + + const percentFormatter = new Intl.NumberFormat(locale, { + style: 'percent', + minimumFractionDigits: 0, + maximumFractionDigits: 1 + }) + + const dateFormatter = new Intl.DateTimeFormat(locale, { + dateStyle: 'medium' + }) + + const shortDateFormatter = new Intl.DateTimeFormat(locale, { + dateStyle: 'short' + }) + + // Helper functions + const formatters = { + /** + * Format currency amount + */ + currency: (amount: number): string => { + return currencyFormatter.format(amount) + }, + + /** + * Format number with locale-specific formatting + */ + number: (value: number): string => { + return numberFormatter.format(value) + }, + + /** + * Format percentage (expects decimal, e.g., 0.65 for 65%) + */ + percent: (value: number): string => { + return percentFormatter.format(value) + }, + + /** + * Format percentage from whole number (e.g., 65 for 65%) + */ + percentFromWhole: (value: number): string => { + return `${Math.round(value)}%` + }, + + /** + * Format date + */ + date: (date: Date | string): string => { + const dateObj = typeof date === 'string' ? new Date(date) : date + return dateFormatter.format(dateObj) + }, + + /** + * Format date in short format + */ + shortDate: (date: Date | string): string => { + const dateObj = typeof date === 'string' ? new Date(date) : date + return shortDateFormatter.format(dateObj) + }, + + /** + * Format compact number for large amounts + */ + compact: (value: number): string => { + return new Intl.NumberFormat(locale, { + notation: 'compact', + maximumFractionDigits: 1 + }).format(value) + }, + + /** + * Format currency without symbol (for inputs) + */ + currencyNumber: (amount: number): string => { + return numberFormatter.format(amount) + }, + + /** + * Get currency symbol + */ + getCurrencySymbol: (): string => { + return currencyFormatter.formatToParts(1) + .find(part => part.type === 'currency')?.value || currency + }, + + /** + * Parse currency string back to number + */ + parseCurrency: (value: string): number => { + // Remove currency symbols and formatting, parse as float + const cleaned = value.replace(/[^\d.,\-]/g, '') + .replace(/,/g, '.') // Handle comma as decimal separator + return parseFloat(cleaned) || 0 + } + } + + // Make formatters available globally + return { + provide: { + format: formatters, + currency, + locale, + decimalPlaces + } + } +}) diff --git a/app/plugins/piniaPersistedState.client.ts b/plugins/piniaPersistedState.client.ts similarity index 100% rename from app/plugins/piniaPersistedState.client.ts rename to plugins/piniaPersistedState.client.ts diff --git a/stores/budget.ts b/stores/budget.ts new file mode 100644 index 0000000..44f792e --- /dev/null +++ b/stores/budget.ts @@ -0,0 +1,131 @@ +import { defineStore } from "pinia"; + +export const useBudgetStore = defineStore( + "budget", + () => { + // Schema version for persistence + const schemaVersion = "1.0"; + + // Monthly budget lines by period (YYYY-MM) + const budgetLines = ref({}); + + // Overhead costs (recurring monthly) + const overheadCosts = ref([]); + + // Production costs (variable monthly) + const productionCosts = ref([]); + + // Current selected period + const currentPeriod = ref("2024-01"); + + // Computed current budget + const currentBudget = computed(() => { + return ( + budgetLines.value[currentPeriod.value] || { + period: currentPeriod.value, + revenueByStream: {}, + payrollCosts: { memberHours: [], oncostApplied: 0 }, + overheadCosts: [], + productionCosts: [], + savingsChange: 0, + net: 0, + } + ); + }); + + // Actions + function setBudgetLine(period, budgetData) { + budgetLines.value[period] = { + period, + ...budgetData, + }; + } + + function updateRevenue(period, streamId, type, amount) { + if (!budgetLines.value[period]) { + budgetLines.value[period] = { period, revenueByStream: {} }; + } + if (!budgetLines.value[period].revenueByStream[streamId]) { + budgetLines.value[period].revenueByStream[streamId] = {}; + } + budgetLines.value[period].revenueByStream[streamId][type] = amount; + } + + // Wizard-required actions + function addOverheadLine(cost) { + // Allow creating a blank line so the user can fill it out in the UI + const safeName = cost?.name ?? ""; + const safeAmountMonthly = + typeof cost?.amountMonthly === "number" && + !Number.isNaN(cost.amountMonthly) + ? cost.amountMonthly + : 0; + + overheadCosts.value.push({ + id: Date.now().toString(), + name: safeName, + amount: safeAmountMonthly, + category: cost?.category || "Operations", + recurring: cost?.recurring ?? true, + ...cost, + }); + } + + function removeOverheadLine(id) { + const index = overheadCosts.value.findIndex((c) => c.id === id); + if (index > -1) { + overheadCosts.value.splice(index, 1); + } + } + + function addOverheadCost(cost) { + addOverheadLine({ name: cost.name, amountMonthly: cost.amount, ...cost }); + } + + function addProductionCost(cost) { + productionCosts.value.push({ + id: Date.now().toString(), + name: cost.name, + amount: cost.amount, + category: cost.category || "Production", + period: cost.period, + ...cost, + }); + } + + function setCurrentPeriod(period) { + currentPeriod.value = period; + } + + // Reset function + function resetBudgetOverhead() { + overheadCosts.value = []; + productionCosts.value = []; + } + + return { + budgetLines, + overheadCosts, + productionCosts, + currentPeriod: readonly(currentPeriod), + currentBudget, + schemaVersion, + setBudgetLine, + updateRevenue, + // Wizard actions + addOverheadLine, + removeOverheadLine, + resetBudgetOverhead, + // Legacy actions + addOverheadCost, + addProductionCost, + setCurrentPeriod, + }; + }, + { + persist: { + key: "urgent-tools-budget", + paths: ["overheadCosts", "productionCosts", "currentPeriod"], + }, + } +); diff --git a/stores/cash.ts b/stores/cash.ts new file mode 100644 index 0000000..c60b743 --- /dev/null +++ b/stores/cash.ts @@ -0,0 +1,114 @@ +import { defineStore } from "pinia"; + +export const useCashStore = defineStore("cash", () => { + // 13-week cash flow events + const cashEvents = ref([]); + + // Payment queue - staged payments within policy + const paymentQueue = ref([]); + + // Week that first breaches minimum cushion + const firstBreachWeek = ref(null); + + // Current cash and savings balances + const currentCash = ref(5000); + const currentSavings = ref(8000); + + // Computed weekly projections + const weeklyProjections = computed(() => { + const weeks = []; + let runningBalance = currentCash.value; + + for (let week = 1; week <= 13; week++) { + const weekEvents = cashEvents.value.filter((e) => e.week === week); + const weekInflow = weekEvents + .filter((e) => e.type === "Influx") + .reduce((sum, e) => sum + e.amount, 0); + const weekOutflow = weekEvents + .filter((e) => e.type === "Outflow") + .reduce((sum, e) => sum + e.amount, 0); + + const net = weekInflow - weekOutflow; + runningBalance += net; + + weeks.push({ + number: week, + inflow: weekInflow, + outflow: weekOutflow, + net, + balance: runningBalance, + cushion: runningBalance, // Will be calculated properly later + breachesCushion: false, // Will be calculated properly later + }); + } + + return weeks; + }); + + // Actions + function addCashEvent(event) { + cashEvents.value.push({ + id: Date.now().toString(), + date: event.date, + week: event.week, + type: event.type, // Influx|Outflow + amount: event.amount, + sourceRef: event.sourceRef, + policyTag: event.policyTag, // Payroll|Tax|Vendor|SavingsSweep + ...event, + }); + } + + function updateCashEvent(id, updates) { + const event = cashEvents.value.find((e) => e.id === id); + if (event) { + Object.assign(event, updates); + } + } + + function removeCashEvent(id) { + const index = cashEvents.value.findIndex((e) => e.id === id); + if (index > -1) { + cashEvents.value.splice(index, 1); + } + } + + function addToPaymentQueue(payment) { + paymentQueue.value.push({ + id: Date.now().toString(), + amount: payment.amount, + recipient: payment.recipient, + scheduledWeek: payment.scheduledWeek, + priority: payment.priority, + canStage: payment.canStage !== false, + ...payment, + }); + } + + function stagePayment(paymentId, newWeek) { + const payment = paymentQueue.value.find((p) => p.id === paymentId); + if (payment && payment.canStage) { + payment.scheduledWeek = newWeek; + } + } + + function updateCurrentBalances(cash, savings) { + currentCash.value = cash; + currentSavings.value = savings; + } + + return { + cashEvents: readonly(cashEvents), + paymentQueue: readonly(paymentQueue), + firstBreachWeek: readonly(firstBreachWeek), + currentCash: readonly(currentCash), + currentSavings: readonly(currentSavings), + weeklyProjections, + addCashEvent, + updateCashEvent, + removeCashEvent, + addToPaymentQueue, + stagePayment, + updateCurrentBalances, + }; +}); diff --git a/stores/members.ts b/stores/members.ts new file mode 100644 index 0000000..891d647 --- /dev/null +++ b/stores/members.ts @@ -0,0 +1,217 @@ +import { defineStore } from "pinia"; + +export const useMembersStore = defineStore( + "members", + () => { + // Member list and profiles + const members = ref([]); + + // Schema version for persistence + const schemaVersion = "1.0"; + + // Capacity totals across all members + const capacityTotals = computed(() => ({ + minHours: members.value.reduce( + (sum, m) => sum + (m.capacity?.minHours || 0), + 0 + ), + targetHours: members.value.reduce( + (sum, m) => sum + (m.capacity?.targetHours || 0), + 0 + ), + maxHours: members.value.reduce( + (sum, m) => sum + (m.capacity?.maxHours || 0), + 0 + ), + })); + + // Privacy flags - aggregate vs individual visibility + const privacyFlags = ref({ + showIndividualNeeds: false, + showIndividualCapacity: false, + stewardOnlyDetails: true, + }); + + // Normalize a member object to ensure required structure and sane defaults + function normalizeMember(raw) { + const normalized = { + id: raw.id || Date.now().toString(), + displayName: typeof raw.displayName === "string" ? raw.displayName : "", + roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "", + payRelationship: raw.payRelationship || "FullyPaid", + capacity: { + minHours: Number(raw.capacity?.minHours) || 0, + targetHours: Number(raw.capacity?.targetHours) || 0, + maxHours: Number(raw.capacity?.maxHours) || 0, + }, + riskBand: raw.riskBand || "Medium", + externalCoveragePct: Number(raw.externalCoveragePct ?? 0), + privacyNeeds: raw.privacyNeeds || "aggregate_ok", + deferredHours: Number(raw.deferredHours ?? 0), + quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240), + ...raw, + }; + return normalized; + } + + // Initialize normalization for any persisted members + if (Array.isArray(members.value) && members.value.length) { + members.value = members.value.map(normalizeMember); + } + + // Validation details for debugging + const validationDetails = computed(() => + members.value.map((m) => { + const nameOk = + typeof m.displayName === "string" && m.displayName.trim().length > 0; + const relOk = Boolean(m.payRelationship); + const target = Number(m.capacity?.targetHours); + const targetOk = Number.isFinite(target) && target > 0; + const pctRaw = m.externalCoveragePct; + const pct = + pctRaw === undefined || pctRaw === null ? 0 : Number(pctRaw); + const pctOk = + pctRaw === undefined || + pctRaw === null || + (Number.isFinite(pct) && pct >= 0 && pct <= 100); + return { + id: m.id, + displayName: m.displayName, + payRelationship: m.payRelationship, + targetHours: m.capacity?.targetHours, + externalCoveragePct: m.externalCoveragePct, + checks: { nameOk, relOk, targetOk, pctOk }, + valid: nameOk && relOk && targetOk && pctOk, + }; + }) + ); + + // Validation computed (robust against NaN/empty values) + const isValid = computed(() => { + // A member is valid if core required fields pass + const isMemberValid = (m: any) => { + const nameOk = + typeof m.displayName === "string" && m.displayName.trim().length > 0; + const relOk = Boolean(m.payRelationship); + const target = Number(m.capacity?.targetHours); + const targetOk = Number.isFinite(target) && target > 0; + // External coverage is optional; when present it must be within 0..100 + const pctRaw = m.externalCoveragePct; + const pct = + pctRaw === undefined || pctRaw === null ? 0 : Number(pctRaw); + const pctOk = + pctRaw === undefined || + pctRaw === null || + (Number.isFinite(pct) && pct >= 0 && pct <= 100); + return nameOk && relOk && targetOk && pctOk; + }; + + // Require at least one valid member + return members.value.some(isMemberValid); + }); + + // Wizard-required actions + function upsertMember(member) { + const existingIndex = members.value.findIndex((m) => m.id === member.id); + if (existingIndex > -1) { + members.value[existingIndex] = normalizeMember({ + ...members.value[existingIndex], + ...member, + }); + } else { + members.value.push( + normalizeMember({ + id: member.id || Date.now().toString(), + ...member, + }) + ); + } + } + + function setCapacity(memberId, capacity) { + const member = members.value.find((m) => m.id === memberId); + if (member) { + member.capacity = { ...member.capacity, ...capacity }; + } + } + + function setPayRelationship(memberId, payRelationship) { + const member = members.value.find((m) => m.id === memberId); + if (member) { + member.payRelationship = payRelationship; + } + } + + function setRiskBand(memberId, riskBand) { + const member = members.value.find((m) => m.id === memberId); + if (member) { + member.riskBand = riskBand; + } + } + + function setExternalCoveragePct(memberId, pct) { + const member = members.value.find((m) => m.id === memberId); + if (member && pct >= 0 && pct <= 100) { + member.externalCoveragePct = pct; + } + } + + function setPrivacy(memberId, privacyNeeds) { + const member = members.value.find((m) => m.id === memberId); + if (member) { + member.privacyNeeds = privacyNeeds; + } + } + + // Actions + function addMember(member) { + upsertMember(member); + } + + function updateMember(id, updates) { + const member = members.value.find((m) => m.id === id); + if (member) { + Object.assign(member, updates); + } + } + + function removeMember(id) { + const index = members.value.findIndex((m) => m.id === id); + if (index > -1) { + members.value.splice(index, 1); + } + } + + // Reset function + function resetMembers() { + members.value = []; + } + + return { + members, + capacityTotals, + privacyFlags, + validationDetails, + isValid, + schemaVersion, + // Wizard actions + upsertMember, + setCapacity, + setPayRelationship, + setRiskBand, + setExternalCoveragePct, + setPrivacy, + resetMembers, + // Legacy actions + addMember, + updateMember, + removeMember, + }; + }, + { + persist: { + key: "urgent-tools-members", + paths: ["members", "privacyFlags"], + }, + } +); diff --git a/stores/policies.ts b/stores/policies.ts new file mode 100644 index 0000000..9ed91da --- /dev/null +++ b/stores/policies.ts @@ -0,0 +1,182 @@ +import { defineStore } from "pinia"; + +export const usePoliciesStore = defineStore( + "policies", + () => { + // Schema version for persistence + const schemaVersion = "1.0"; + + // Core policies + const equalHourlyWage = ref(0); + const payrollOncostPct = ref(25); + const savingsTargetMonths = ref(3); + const minCashCushionAmount = ref(3000); + + // Deferred pay limits + const deferredCapHoursPerQtr = ref(240); + const deferredSunsetMonths = ref(12); + + // Surplus distribution order + const surplusOrder = ref([ + "Deferred", + "Savings", + "Hardship", + "Training", + "Patronage", + "Retained", + ]); + + // Payment priority order + const paymentPriority = ref(["Payroll", "Taxes", "CriticalOps", "Vendors"]); + + // Volunteer scope - allowed flows + const volunteerScope = ref({ + allowedFlows: ["Care", "SharedLearning"], + }); + + // Validation computed + const isValid = computed(() => { + return ( + equalHourlyWage.value > 0 && + payrollOncostPct.value >= 0 && + payrollOncostPct.value <= 100 && + savingsTargetMonths.value >= 0 && + minCashCushionAmount.value >= 0 && + deferredCapHoursPerQtr.value >= 0 && + deferredSunsetMonths.value >= 0 + ); + }); + + // Wizard-required actions + function setEqualWage(amount) { + if (amount > 0) { + equalHourlyWage.value = amount; + } + } + + function setOncostPct(pct) { + if (pct >= 0 && pct <= 100) { + payrollOncostPct.value = pct; + } + } + + function setSavingsTargetMonths(months) { + if (months >= 0) { + savingsTargetMonths.value = months; + } + } + + function setMinCashCushion(amount) { + if (amount >= 0) { + minCashCushionAmount.value = amount; + } + } + + function setDeferredCap(hours) { + if (hours >= 0) { + deferredCapHoursPerQtr.value = hours; + } + } + + function setDeferredSunset(months) { + if (months >= 0) { + deferredSunsetMonths.value = months; + } + } + + function setVolunteerScope(allowedFlows) { + volunteerScope.value = { allowedFlows: [...allowedFlows] }; + } + + function setSurplusOrder(order) { + surplusOrder.value = [...order]; + } + + function setPaymentPriority(priority) { + paymentPriority.value = [...priority]; + } + + // Legacy actions + function updatePolicy(key, value) { + if (key === "equalHourlyWage") setEqualWage(value); + else if (key === "payrollOncostPct") setOncostPct(value); + else if (key === "savingsTargetMonths") setSavingsTargetMonths(value); + else if (key === "minCashCushionAmount") setMinCashCushion(value); + else if (key === "deferredCapHoursPerQtr") setDeferredCap(value); + else if (key === "deferredSunsetMonths") setDeferredSunset(value); + } + + function updateSurplusOrder(newOrder) { + setSurplusOrder(newOrder); + } + + function updatePaymentPriority(newOrder) { + setPaymentPriority(newOrder); + } + + // Reset function + function resetPolicies() { + equalHourlyWage.value = 0; + payrollOncostPct.value = 25; + savingsTargetMonths.value = 3; + minCashCushionAmount.value = 3000; + deferredCapHoursPerQtr.value = 240; + deferredSunsetMonths.value = 12; + surplusOrder.value = [ + "Deferred", + "Savings", + "Hardship", + "Training", + "Patronage", + "Retained", + ]; + paymentPriority.value = ["Payroll", "Taxes", "CriticalOps", "Vendors"]; + volunteerScope.value = { allowedFlows: ["Care", "SharedLearning"] }; + } + + return { + equalHourlyWage, + payrollOncostPct, + savingsTargetMonths, + minCashCushionAmount, + deferredCapHoursPerQtr, + deferredSunsetMonths, + surplusOrder, + paymentPriority, + volunteerScope, + isValid, + schemaVersion, + // Wizard actions + setEqualWage, + setOncostPct, + setSavingsTargetMonths, + setMinCashCushion, + setDeferredCap, + setDeferredSunset, + setVolunteerScope, + setSurplusOrder, + setPaymentPriority, + resetPolicies, + // Legacy actions + updatePolicy, + updateSurplusOrder, + updatePaymentPriority, + }; + }, + { + persist: { + key: "urgent-tools-policies", + paths: [ + "equalHourlyWage", + "payrollOncostPct", + "savingsTargetMonths", + "minCashCushionAmount", + "deferredCapHoursPerQtr", + "deferredSunsetMonths", + "surplusOrder", + "paymentPriority", + "volunteerScope", + ], + }, + } +); diff --git a/stores/scenarios.ts b/stores/scenarios.ts new file mode 100644 index 0000000..4c2a2b7 --- /dev/null +++ b/stores/scenarios.ts @@ -0,0 +1,95 @@ +import { defineStore } from "pinia"; + +export const useScenariosStore = defineStore("scenarios", () => { + // Scenario presets + const presets = ref({ + current: { + name: "Operate Current Plan", + description: "Continue with existing revenue and capacity", + settings: {}, + }, + quitDayJobs: { + name: "Quit Day Jobs", + description: "Members leave external work, increase co-op hours", + settings: { + targetHoursMultiplier: 1.5, + externalCoverageReduction: 0.8, + }, + }, + startProduction: { + name: "Start Production", + description: "Launch product development phase", + settings: { + productionCostsIncrease: 2000, + effortHoursIncrease: 100, + }, + }, + sixMonth: { + name: "6-Month Plan", + description: "Extended planning horizon", + settings: { + timeHorizonMonths: 6, + }, + }, + }); + + // What-if sliders state + const sliders = ref({ + monthlyRevenue: 12000, + paidHoursPerMonth: 320, + winRatePct: 70, + avgPayoutDelayDays: 30, + hourlyWageAdjust: 0, + }); + + // Selected scenario + const activeScenario = ref("current"); + + // Computed scenario results (will be calculated by composables) + const scenarioResults = computed(() => ({ + runway: 2.8, + monthlyCosts: 8700, + cashflowRisk: "medium", + recommendations: [], + })); + + // Actions + function setActiveScenario(scenarioKey) { + activeScenario.value = scenarioKey; + } + + function updateSlider(key, value) { + if (key in sliders.value) { + sliders.value[key] = value; + } + } + + function resetSliders() { + sliders.value = { + monthlyRevenue: 12000, + paidHoursPerMonth: 320, + winRatePct: 70, + avgPayoutDelayDays: 30, + hourlyWageAdjust: 0, + }; + } + + function saveCustomScenario(name, settings) { + presets.value[name.toLowerCase().replace(/\s+/g, "")] = { + name, + description: "Custom scenario", + settings, + }; + } + + return { + presets: readonly(presets), + sliders, + activeScenario: readonly(activeScenario), + scenarioResults, + setActiveScenario, + updateSlider, + resetSliders, + saveCustomScenario, + }; +}); diff --git a/stores/session.ts b/stores/session.ts new file mode 100644 index 0000000..f586742 --- /dev/null +++ b/stores/session.ts @@ -0,0 +1,132 @@ +import { defineStore } from "pinia"; + +export const useSessionStore = defineStore("session", () => { + // Value Accounting session checklist state + const checklist = ref({ + monthClosed: false, + contributionsLogged: false, + surplusCalculated: false, + needsReviewed: false, + }); + + // Draft distribution allocations + const draftAllocations = ref({ + deferredRepay: 0, + savings: 0, + hardship: 0, + training: 0, + patronage: 0, + retained: 0, + }); + + // Session rationale text + const rationale = ref(""); + + // Current session period + const currentSession = ref("2024-01"); + + // Saved distribution records + const savedRecords = ref([]); + + // Available amounts for distribution + const availableAmounts = ref({ + surplus: 0, + deferredOwed: 0, + savingsNeeded: 0, + }); + + // Computed total allocated + const totalAllocated = computed(() => + Object.values(draftAllocations.value).reduce( + (sum, amount) => sum + amount, + 0 + ) + ); + + // Computed checklist completion + const checklistComplete = computed(() => + Object.values(checklist.value).every(Boolean) + ); + + // Actions + function updateChecklistItem(key, value) { + if (key in checklist.value) { + checklist.value[key] = value; + } + } + + function updateAllocation(key, amount) { + if (key in draftAllocations.value) { + draftAllocations.value[key] = Number(amount) || 0; + } + } + + function resetAllocations() { + Object.keys(draftAllocations.value).forEach((key) => { + draftAllocations.value[key] = 0; + }); + } + + function updateRationale(text) { + rationale.value = text; + } + + function saveSession() { + const record = { + id: Date.now().toString(), + period: currentSession.value, + date: new Date().toISOString(), + allocations: { ...draftAllocations.value }, + rationale: rationale.value, + checklist: { ...checklist.value }, + }; + + savedRecords.value.push(record); + + // Reset for next session + resetAllocations(); + rationale.value = ""; + Object.keys(checklist.value).forEach((key) => { + checklist.value[key] = false; + }); + + return record; + } + + function loadSession(period) { + const record = savedRecords.value.find((r) => r.period === period); + if (record) { + currentSession.value = period; + Object.assign(draftAllocations.value, record.allocations); + rationale.value = record.rationale; + Object.assign(checklist.value, record.checklist); + } + } + + function setCurrentSession(period) { + currentSession.value = period; + } + + function updateAvailableAmounts(amounts) { + Object.assign(availableAmounts.value, amounts); + } + + return { + checklist, + draftAllocations, + rationale, + currentSession: readonly(currentSession), + savedRecords: readonly(savedRecords), + availableAmounts: readonly(availableAmounts), + totalAllocated, + checklistComplete, + updateChecklistItem, + updateAllocation, + resetAllocations, + updateRationale, + saveSession, + loadSession, + setCurrentSession, + updateAvailableAmounts, + }; +}); diff --git a/stores/streams.ts b/stores/streams.ts new file mode 100644 index 0000000..9f50205 --- /dev/null +++ b/stores/streams.ts @@ -0,0 +1,117 @@ +import { defineStore } from "pinia"; + +export const useStreamsStore = defineStore( + "streams", + () => { + // Schema version for persistence + const schemaVersion = "1.0"; + + // Revenue streams with all properties + const streams = ref([]); + + // Computed totals + const totalTargetPct = computed(() => + streams.value.reduce((sum, stream) => sum + (stream.targetPct || 0), 0) + ); + + const totalMonthlyAmount = computed(() => + streams.value.reduce( + (sum, stream) => sum + (stream.targetMonthlyAmount || 0), + 0 + ) + ); + + // Validation computed + const targetPctDeviation = computed(() => { + const total = totalTargetPct.value; + return Math.abs(100 - total); + }); + + const hasValidStreams = computed(() => { + return streams.value.every( + (stream) => + stream.name && + stream.category && + stream.payoutDelayDays >= 0 && + (stream.targetPct >= 0 || stream.targetMonthlyAmount >= 0) + ); + }); + + // Wizard-required actions + function upsertStream(stream) { + const existingIndex = streams.value.findIndex((s) => s.id === stream.id); + if (existingIndex > -1) { + streams.value[existingIndex] = { + ...streams.value[existingIndex], + ...stream, + }; + } else { + const newStream = { + id: stream.id || Date.now().toString(), + name: stream.name, + category: stream.category, + subcategory: stream.subcategory || "", + targetPct: stream.targetPct || 0, + targetMonthlyAmount: stream.targetMonthlyAmount || 0, + certainty: stream.certainty || "Aspirational", // Committed|Probable|Aspirational + payoutDelayDays: stream.payoutDelayDays || 0, + terms: stream.terms || "", + revenueSharePct: stream.revenueSharePct || 0, + platformFeePct: stream.platformFeePct || 0, + restrictions: stream.restrictions || "General", // Restricted|General + seasonalityWeights: + stream.seasonalityWeights || new Array(12).fill(1), + effortHoursPerMonth: stream.effortHoursPerMonth || 0, + ...stream, + }; + streams.value.push(newStream); + } + } + + // Legacy actions + function addStream(stream) { + upsertStream(stream); + } + + function updateStream(id, updates) { + const stream = streams.value.find((s) => s.id === id); + if (stream) { + Object.assign(stream, updates); + } + } + + function removeStream(id) { + const index = streams.value.findIndex((s) => s.id === id); + if (index > -1) { + streams.value.splice(index, 1); + } + } + + // Reset function + function resetStreams() { + streams.value = []; + } + + return { + streams, + totalTargetPct, + totalMonthlyAmount, + targetPctDeviation, + hasValidStreams, + schemaVersion, + // Wizard actions + upsertStream, + resetStreams, + // Legacy actions + addStream, + updateStream, + removeStream, + }; + }, + { + persist: { + key: "urgent-tools-streams", + paths: ["streams"], + }, + } +); diff --git a/stores/wizard.ts b/stores/wizard.ts new file mode 100644 index 0000000..5a6616e --- /dev/null +++ b/stores/wizard.ts @@ -0,0 +1,28 @@ +import { defineStore } from "pinia"; + +export const useWizardStore = defineStore( + "wizard", + () => { + 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"], + }, + } +);