refactor: remove deprecated components and streamline member coverage calculations, enhance budget management with improved payroll handling, and update UI elements for better clarity

This commit is contained in:
Jennie Robinson Faber 2025-09-06 09:48:57 +01:00
parent 983aeca2dc
commit 09d8794d72
42 changed files with 2166 additions and 2974 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -92,9 +92,6 @@ const isCoopBuilderSection = computed(
route.path === "/mix" ||
route.path === "/budget" ||
route.path === "/runway-lite" ||
route.path === "/scenarios" ||
route.path === "/cash" ||
route.path === "/session" ||
route.path === "/settings" ||
route.path === "/glossary"
);

View file

@ -239,7 +239,7 @@ const diversificationGuidance = computed(() => {
if (grantsCategory && grantsCategory.percentage >= 20) {
guidance += " You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
} else if (servicesCategory && servicesCategory.percentage >= 20 && productsCategory && productsCategory.percentage >= 20) {
guidance += " Strong foundation in both services and products — this balance helps smooth cash flow.";
guidance += " Strong foundation in both services and products — this balance helps smooth revenue timing.";
}
return guidance;

View file

@ -51,21 +51,6 @@ const coopBuilderItems = [
name: "Runway Lite",
path: "/runway-lite",
},
{
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 {

View file

@ -1,11 +1,6 @@
<template>
<UTooltip :text="tooltipText">
<UBadge
:color="badgeColor"
variant="solid"
size="sm"
class="font-medium"
>
<UBadge :color="badgeColor" variant="solid" size="sm" class="font-medium">
<UIcon :name="iconName" class="w-3 h-3 mr-1" />
{{ displayText }}
</UBadge>
@ -14,44 +9,46 @@
<script setup lang="ts">
interface Props {
coverageMinPct?: number
coverageTargetPct?: number
memberName?: string
warnIfUnder?: number
coveragePct?: number;
memberName?: string;
warnIfUnder?: number;
}
const props = withDefaults(defineProps<Props>(), {
warnIfUnder: 100,
memberName: 'member'
})
memberName: "member",
});
const coverage = computed(() => props.coverageMinPct || 0)
const coverage = computed(() => props.coveragePct || 0);
const badgeColor = computed(() => {
if (coverage.value >= 100) return 'success'
if (coverage.value >= 80) return 'warning'
return 'error'
})
if (!props.coveragePct) return "neutral";
if (coverage.value >= 100) return "success";
if (coverage.value >= 80) return "warning";
return "error";
});
const iconName = computed(() => {
if (coverage.value >= 100) return 'i-heroicons-check-circle'
if (coverage.value >= 80) return 'i-heroicons-exclamation-triangle'
return 'i-heroicons-x-circle'
})
if (!props.coveragePct) return "i-heroicons-cog-6-tooth";
if (coverage.value >= 100) return "i-heroicons-check-circle";
if (coverage.value >= 80) return "i-heroicons-exclamation-triangle";
return "i-heroicons-x-circle";
});
const displayText = computed(() => {
if (!props.coverageMinPct) return 'No needs set'
return `${Math.round(coverage.value)}% coverage`
})
if (!props.coveragePct) return "Set needs";
if (coverage.value === 0) return "No coverage";
return `${Math.round(coverage.value)}% covered`;
});
const tooltipText = computed(() => {
if (!props.coverageMinPct) {
return `${props.memberName} hasn't set their minimum needs yet`
if (!props.coveragePct) {
return `Click 'Set minimum needs' to enable coverage tracking for ${props.memberName}`;
}
const percent = Math.round(coverage.value)
const status = coverage.value >= 100 ? 'meets' : 'covers'
return `${status} ${percent}% of ${props.memberName}'s minimum needs (incl. external income)`
})
</script>
const percent = Math.round(coverage.value);
const status = coverage.value >= 100 ? "meets" : "covers";
return `Co-op pay ${status} ${percent}% of ${props.memberName}'s minimum needs`;
});
</script>

View file

@ -0,0 +1,278 @@
<template>
<UModal
v-model:open="isOpen"
title="Payroll Oncost Settings"
description="Configure payroll taxes and benefits percentage"
:dismissible="true"
>
<template #body>
<div class="space-y-6">
<!-- Explanation -->
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<div class="flex items-start">
<UIcon name="i-heroicons-information-circle" class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" />
<div class="text-sm">
<p class="text-blue-800 dark:text-blue-200 font-medium mb-2">What are payroll oncosts?</p>
<p class="text-blue-700 dark:text-blue-300">
Payroll oncosts cover taxes, benefits, and other employee-related expenses beyond base wages.
This typically includes employer payroll taxes, worker's compensation, benefits contributions, and other statutory requirements.
</p>
</div>
</div>
</div>
<!-- Current Settings Display -->
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<h4 class="font-medium text-gray-900 dark:text-white mb-3">Current Impact</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-gray-600 dark:text-gray-400">Base Payroll</div>
<div class="font-medium">{{ formatCurrency(basePayroll) }}/month</div>
</div>
<div>
<div class="text-gray-600 dark:text-gray-400">Oncosts ({{ currentOncostPct }}%)</div>
<div class="font-medium">{{ formatCurrency(currentOncostAmount) }}/month</div>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="text-gray-600 dark:text-gray-400 text-sm">Total Payroll Cost</div>
<div class="font-semibold text-lg">{{ formatCurrency(totalPayrollCost) }}/month</div>
</div>
</div>
<!-- Percentage Input -->
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Oncost Percentage
</label>
<div class="flex items-center space-x-3">
<div class="flex-1">
<UInput
v-model.number="newOncostPct"
type="number"
min="0"
max="100"
step="1"
placeholder="25"
class="text-center"
/>
</div>
<span class="text-sm text-gray-500">%</span>
</div>
<!-- Slider for easier adjustment -->
<div class="space-y-2">
<input
v-model.number="newOncostPct"
type="range"
min="0"
max="50"
step="1"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 slider"
/>
<div class="flex justify-between text-xs text-gray-500">
<span>0%</span>
<span>25%</span>
<span>50%</span>
</div>
</div>
</div>
<!-- Preview of New Settings -->
<div v-if="newOncostPct !== currentOncostPct" class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
<h4 class="font-medium text-green-800 dark:text-green-200 mb-3">Preview Changes</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-green-700 dark:text-green-300">New Oncosts ({{ newOncostPct }}%)</div>
<div class="font-medium">{{ formatCurrency(newOncostAmount) }}/month</div>
</div>
<div>
<div class="text-green-700 dark:text-green-300">New Total Cost</div>
<div class="font-medium">{{ formatCurrency(newTotalCost) }}/month</div>
</div>
</div>
<div class="mt-2 text-xs">
<span class="text-green-700 dark:text-green-300">
{{ newTotalCost > totalPayrollCost ? 'Increase' : 'Decrease' }} of
{{ formatCurrency(Math.abs(newTotalCost - totalPayrollCost)) }}/month
</span>
</div>
</div>
<!-- Common Oncost Ranges -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Common Ranges
</label>
<div class="flex flex-wrap gap-2">
<UButton
v-for="preset in commonRanges"
:key="preset.value"
size="xs"
color="gray"
variant="outline"
@click="newOncostPct = preset.value"
>
{{ preset.label }}
</UButton>
</div>
</div>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-3">
<UButton color="gray" variant="ghost" @click="handleCancel">
Cancel
</UButton>
<UButton
color="primary"
@click="handleSave"
:disabled="!isValidPercentage"
>
Update Oncost Percentage
</UButton>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { allocatePayroll as allocatePayrollImpl } from '~/types/members'
interface Props {
open: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
(e: 'save', percentage: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Modal state
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
// Get current payroll data
const coopStore = useCoopBuilderStore()
const currentOncostPct = computed(() => coopStore.payrollOncostPct || 0)
// Calculate current payroll values using the same logic as the budget store
const { allocatePayroll } = useCoopBuilder()
const basePayroll = computed(() => {
// Calculate base payroll the same way the budget store does
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0)), 0)
const hourlyWage = coopStore.equalHourlyWage || 0
const basePayrollBudget = totalHours * hourlyWage
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Use policy-driven allocation to get actual member pay amounts
const payPolicy = {
relationship: coopStore.policy.relationship,
roleBands: coopStore.policy.roleBands
}
// Convert members to the format expected by allocatePayroll
const membersForAllocation = coopStore.members.map(m => ({
...m,
displayName: m.name,
monthlyPayPlanned: m.monthlyPayPlanned || 0,
minMonthlyNeeds: m.minMonthlyNeeds || 0,
hoursPerMonth: m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0)
}))
// Use the imported allocatePayroll function
const allocatedMembers = allocatePayrollImpl(membersForAllocation, payPolicy, basePayrollBudget)
// Sum the allocated amounts for total payroll
return allocatedMembers.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
}
return 0
})
const currentOncostAmount = computed(() =>
basePayroll.value * (currentOncostPct.value / 100)
)
const totalPayrollCost = computed(() =>
basePayroll.value + currentOncostAmount.value
)
// New percentage input
const newOncostPct = ref(currentOncostPct.value)
// Computed values for preview
const newOncostAmount = computed(() => basePayroll.value * (newOncostPct.value / 100))
const newTotalCost = computed(() => basePayroll.value + newOncostAmount.value)
const isValidPercentage = computed(() =>
newOncostPct.value >= 0 && newOncostPct.value <= 100
)
// Common oncost ranges
const commonRanges = [
{ label: '0% (No oncosts)', value: 0 },
{ label: '15% (Basic)', value: 15 },
{ label: '25% (Standard)', value: 25 },
{ label: '35% (Comprehensive)', value: 35 }
]
// Reset to current value when modal opens
watch(isOpen, (open) => {
if (open) {
newOncostPct.value = currentOncostPct.value
}
})
// Handlers
function handleCancel() {
newOncostPct.value = currentOncostPct.value
isOpen.value = false
}
function handleSave() {
if (isValidPercentage.value) {
emit('save', newOncostPct.value)
isOpen.value = false
}
}
// Currency formatting
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
</script>
<style scoped>
.slider::-webkit-slider-thumb {
appearance: none;
height: 20px;
width: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
}
.slider::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: none;
}
</style>

View file

@ -1,42 +1,14 @@
<template>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header with Export Controls -->
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-2xl font-black text-black mb-2">
Where does your money go?
</h3>
<p class="text-neutral-600">
Add costs like rent, tools, insurance, or other recurring expenses.
</p>
</div>
<div class="flex items-center gap-3">
<UButton variant="outline" color="gray" size="sm" @click="exportCosts">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export
</UButton>
</div>
</div>
<!-- Operating Mode Toggle -->
<div class="p-4 border-3 border-black rounded-xl bg-white shadow-md">
<div class="flex items-center justify-between">
<div>
<h4 class="font-bold text-sm">Operating Mode</h4>
<p class="text-xs text-gray-600 mt-1">
Choose between minimum needs or target pay for payroll calculations
</p>
</div>
<UToggle
v-model="useTargetMode"
@update:model-value="updateOperatingMode"
:ui="{ active: 'bg-success-500' }"
/>
</div>
<div class="mt-2 text-xs font-medium">
{{ useTargetMode ? '🎯 Target Mode' : '⚡ Minimum Mode' }}:
{{ useTargetMode ? 'Uses target pay allocations' : 'Uses minimum needs allocations' }}
</div>
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">
Where does your money go?
</h3>
<p class="text-neutral-600">
Add costs like rent + utilities, software licenses, insurance, lawyer
fees, accountant fees, and other recurring expenses.
</p>
</div>
<!-- Overhead Costs -->
@ -44,24 +16,12 @@
<div
v-if="overheadCosts.length > 0"
class="flex items-center justify-between">
<h4 class="text-lg font-bold text-black">Monthly Overhead</h4>
<UButton
size="sm"
@click="addOverheadCost"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-1" />
Add Cost
</UButton>
<h4 class="text-lg font-bold text-black">Overhead</h4>
</div>
<div
v-if="overheadCosts.length === 0"
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
<h4 class="font-medium text-neutral-900 mb-2">No overhead costs yet</h4>
<p class="text-sm text-neutral-500 mb-4">
Get started by adding your first overhead cost.
@ -79,48 +39,23 @@
<div
v-for="cost in overheadCosts"
:key="cost.id"
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<UFormField label="Cost Name" required>
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
<!-- Header row with name and delete button -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-4 flex-1">
<UInput
v-model="cost.name"
placeholder="Office rent"
size="xl"
class="text-lg font-medium w-full"
class="text-xl w-full font-bold flex-1"
@update:model-value="saveCost(cost)"
@blur="saveCost(cost)" />
</UFormField>
<UFormField label="Monthly Amount" required>
<UInput
v-model="cost.amount"
type="text"
placeholder="800.00"
size="xl"
class="text-lg font-bold w-full"
@update:model-value="validateAndSaveAmount($event, cost)"
@blur="saveCost(cost)">
<template #leading>
<span class="text-neutral-500"></span>
</template>
</UInput>
</UFormField>
<UFormField label="Category">
<USelect
v-model="cost.category"
:items="categoryOptions"
size="xl"
class="text-lg font-medium w-full"
@update:model-value="saveCost(cost)" />
</UFormField>
</div>
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
</div>
<UButton
size="xs"
variant="solid"
color="error"
class="ml-4"
@click="removeCost(cost.id)"
:ui="{
base: 'cursor-pointer hover:opacity-90 transition-opacity',
@ -128,6 +63,66 @@
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton>
</div>
<!-- Fields grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Category">
<USelect
v-model="cost.category"
:items="categoryOptions"
size="md"
class="text-sm font-medium w-full"
@update:model-value="saveCost(cost)" />
</UFormField>
<UFormField
:label="
cost.amountType === 'annual' ? 'Annual Amount' : 'Monthly Amount'
"
required>
<div class="flex gap-2">
<UInput
:value="
cost.amountType === 'annual' ? cost.annualAmount : cost.amount
"
type="text"
:placeholder="cost.amountType === 'annual' ? '9600' : '800'"
size="md"
class="text-sm font-medium w-full"
@update:model-value="validateAndSaveAmount($event, cost)"
@blur="saveCost(cost)">
<template #leading>
<span class="text-neutral-500">{{ currencySymbol }}</span>
</template>
</UInput>
<UButtonGroup size="md">
<UButton
:variant="cost.amountType === 'monthly' ? 'solid' : 'outline'"
color="primary"
@click="switchAmountType(cost, 'monthly')"
class="text-xs">
Monthly
</UButton>
<UButton
:variant="cost.amountType === 'annual' ? 'solid' : 'outline'"
color="primary"
@click="switchAmountType(cost, 'annual')"
class="text-xs">
Annual
</UButton>
</UButtonGroup>
</div>
<p class="text-xs text-neutral-500 mt-1">
<template v-if="cost.amountType === 'annual'">
{{ currencySymbol
}}{{ Math.round((cost.annualAmount || 0) / 12) }} per month
</template>
<template v-else>
{{ currencySymbol }}{{ (cost.amount || 0) * 12 }} per year
</template>
</p>
</UFormField>
</div>
</div>
<!-- Add Cost Button (when items exist) -->
@ -155,20 +150,27 @@ const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"];
}>();
// Store
// Store and Currency
const coop = useCoopBuilder();
const { currencySymbol } = useCurrency();
// Get the store directly for overhead costs
const store = useCoopBuilderStore();
// Computed for overhead costs (from store)
const overheadCosts = computed(() => store.overheadCosts || []);
// Computed for overhead costs (from store) with amountType defaults
const overheadCosts = computed(() =>
(store.overheadCosts || []).map((cost) => ({
...cost,
amountType: cost.amountType || "monthly",
annualAmount: cost.annualAmount || (cost.amount || 0) * 12,
}))
);
// Operating mode toggle
const useTargetMode = ref(coop.operatingMode.value === 'target');
const useTargetMode = ref(coop.operatingMode.value === "target");
function updateOperatingMode(value: boolean) {
coop.setOperatingMode(value ? 'target' : 'min');
coop.setOperatingMode(value ? "target" : "min");
emit("save-status", "saved");
}
@ -211,23 +213,66 @@ const debouncedSave = useDebounceFn((cost: any) => {
}, 300);
function saveCost(cost: any) {
if (cost.name && cost.amount >= 0) {
const hasValidAmount =
cost.amountType === "annual" ? cost.annualAmount >= 0 : cost.amount >= 0;
if (cost.name && hasValidAmount) {
debouncedSave(cost);
}
}
// Immediate save without debounce for UI responsiveness
function saveCostImmediate(cost: any) {
try {
// Use store's upsert method directly
store.upsertOverheadCost(cost);
} catch (error) {
console.error("Failed to save cost:", error);
}
}
// Validation function for amount
function validateAndSaveAmount(value: string, cost: any) {
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
cost.amount = isNaN(numValue) ? 0 : Math.max(0, numValue);
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
if (cost.amountType === "annual") {
cost.annualAmount = validValue;
cost.amount = Math.round(validValue / 12);
} else {
cost.amount = validValue;
cost.annualAmount = validValue * 12;
}
saveCost(cost);
}
// Function to switch between annual and monthly
function switchAmountType(cost: any, type: "annual" | "monthly") {
cost.amountType = type;
// Recalculate values based on new type
if (type === "annual") {
if (!cost.annualAmount) {
cost.annualAmount = (cost.amount || 0) * 12;
}
} else {
if (!cost.amount) {
cost.amount = Math.round((cost.annualAmount || 0) / 12);
}
}
// Save immediately without debounce for instant UI update
saveCostImmediate(cost);
}
function addOverheadCost() {
const newCost = {
id: Date.now().toString(),
name: "",
amount: 0,
annualAmount: 0,
amountType: "monthly",
category: "Operations",
recurring: true,
};
@ -240,24 +285,4 @@ function removeCost(id: string) {
store.removeOverheadCost(id);
emit("save-status", "saved");
}
function exportCosts() {
const exportData = {
overheadCosts: overheadCosts.value,
exportedAt: new Date().toISOString(),
section: "costs",
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coop-costs-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>

View file

@ -1,36 +1,16 @@
<template>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header with Export Controls -->
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-2xl font-black text-black mb-2">Who's on your team?</h3>
<p class="text-neutral-600">
Add everyone who'll be working in the co-op, even if they're not ready
to be paid yet.
</p>
</div>
<div class="flex items-center gap-3">
<UButton
variant="outline"
color="gray"
size="sm"
@click="exportMembers">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export
</UButton>
<UButton
v-if="members.length > 0"
@click="addMember"
size="sm"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-1" />
Add member
</UButton>
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">Who's on your team?</h3>
<p class="text-neutral-600">
Add everyone who'll be working in the co-op. Based on your pay approach,
we'll collect the right information for each person.
</p>
<!-- Debug info -->
<div class="mt-2 p-2 bg-gray-100 rounded text-xs">
Debug: Policy = {{ currentPolicy }}, Needs field shown =
{{ isNeedsWeighted }}
</div>
</div>
@ -38,7 +18,7 @@
<div class="space-y-3">
<div
v-if="members.length === 0"
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
<h4 class="font-medium text-neutral-900 mb-2">No team members yet</h4>
<p class="text-sm text-neutral-500 mb-4">
Get started by adding your first team member.
@ -52,27 +32,23 @@
<div
v-for="(member, index) in members"
:key="member.id"
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<!-- Header row with name and coverage chip -->
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
<!-- Header row with name and optional coverage chip -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex items-center gap-4 flex-1">
<UInput
v-model="member.displayName"
placeholder="Member name"
size="lg"
class="text-lg font-bold w-48"
size="xl"
class="text-xl w-full font-bold flex-1"
@update:model-value="saveMember(member)"
@blur="saveMember(member)" />
<CoverageChip
:coverage-min-pct="memberCoverage(member).minPct"
:coverage-target-pct="memberCoverage(member).targetPct"
:member-name="member.displayName || 'This member'"
/>
</div>
<UButton
size="xs"
variant="solid"
color="error"
class="ml-4"
@click="removeMember(member.id)"
:ui="{
base: 'cursor-pointer hover:opacity-90 transition-opacity',
@ -80,77 +56,36 @@
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton>
</div>
<!-- Compact grid for pay and hours -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
<UFormField label="Pay relationship" required>
<USelect
v-model="member.payRelationship"
:items="payRelationshipOptions"
<!-- Essential fields based on policy -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Hours per month" required>
<UInputNumber
v-model="member.capacity.targetHours"
:min="0"
:max="500"
:step="1"
placeholder="160"
size="md"
class="text-sm font-medium w-full"
@update:model-value="saveMember(member)" />
</UFormField>
<UFormField label="Hours/month" required>
<UInput
v-model="member.capacity.targetHours"
type="text"
placeholder="120"
size="md"
class="text-sm font-medium w-full"
@update:model-value="validateAndSaveHours($event, member)"
@blur="saveMember(member)" />
</UFormField>
<UFormField label="Role (optional)">
<UInput
v-model="member.role"
placeholder="Developer"
size="md"
class="text-sm font-medium w-full"
@update:model-value="saveMember(member)"
@blur="saveMember(member)" />
</UFormField>
</div>
<!-- Compact needs section -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 p-3 bg-gray-50 rounded-lg">
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block">Minimum needs (/mo)</label>
<UInput
<!-- Show minimum needs field when needs-weighted policy is selected -->
<UFormField
v-if="isNeedsWeighted"
:label="`Minimum needs (${getCurrencySymbol(coop.currency.value)}/month)`"
required>
<UInputNumber
v-model="member.minMonthlyNeeds"
type="text"
placeholder="2000"
size="sm"
:min="0"
:max="50000"
:step="10"
placeholder="2500"
size="md"
class="text-sm font-medium w-full"
@update:model-value="validateAndSaveAmount($event, member, 'minMonthlyNeeds')"
@blur="saveMember(member)" />
</div>
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block">Target pay (/mo)</label>
<UInput
v-model="member.targetMonthlyPay"
type="text"
placeholder="3500"
size="sm"
class="text-sm font-medium w-full"
@update:model-value="validateAndSaveAmount($event, member, 'targetMonthlyPay')"
@blur="saveMember(member)" />
</div>
<div>
<label class="text-xs font-medium text-gray-600 mb-1 block">External income (/mo)</label>
<UInput
v-model="member.externalMonthlyIncome"
type="text"
placeholder="1500"
size="sm"
class="text-sm font-medium w-full"
@update:model-value="validateAndSaveAmount($event, member, 'externalMonthlyIncome')"
@blur="saveMember(member)" />
</div>
@update:model-value="saveMember(member)" />
</UFormField>
</div>
</div>
@ -176,6 +111,7 @@
<script setup lang="ts">
import { useDebounceFn } from "@vueuse/core";
import { coverage } from "~/types/members";
import { getCurrencySymbol } from "~/utils/currency";
const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"];
@ -183,24 +119,30 @@ const emit = defineEmits<{
// Store
const coop = useCoopBuilder();
const members = computed(() =>
coop.members.value.map(m => ({
const members = computed(() =>
coop.members.value.map((m) => ({
// Map store fields to component expectations
id: m.id,
displayName: m.name,
role: m.role || '',
capacity: {
targetHours: m.hoursPerMonth || 0
targetHours: Number(m.hoursPerMonth) || 0,
},
payRelationship: 'FullyPaid', // Default since not in store yet
minMonthlyNeeds: m.minMonthlyNeeds || 0,
targetMonthlyPay: m.targetMonthlyPay || 0,
externalMonthlyIncome: m.externalMonthlyIncome || 0,
monthlyPayPlanned: m.monthlyPayPlanned || 0
payRelationship: "FullyPaid", // Default since not in store yet
minMonthlyNeeds: Number(m.minMonthlyNeeds) || 0,
monthlyPayPlanned: Number(m.monthlyPayPlanned) || 0,
}))
);
// Options
// Get current policy to determine which fields to show
const isNeedsWeighted = computed(() => {
const policy = coop.policy.value?.relationship;
return policy === "needs-weighted";
});
// Also expose policy for debugging in template
const currentPolicy = computed(() => coop.policy.value?.relationship || "none");
// Simplified options - removed pay relationship as it's now in the policies step
const payRelationshipOptions = [
{ label: "Fully Paid", value: "FullyPaid" },
{ label: "Hybrid", value: "Hybrid" },
@ -236,15 +178,12 @@ const debouncedSave = useDebounceFn((member: any) => {
// Convert component format back to store format
const memberData = {
id: member.id,
name: member.displayName || '',
role: member.role || '',
hoursPerMonth: member.capacity?.targetHours || 0,
minMonthlyNeeds: member.minMonthlyNeeds || 0,
targetMonthlyPay: member.targetMonthlyPay || 0,
externalMonthlyIncome: member.externalMonthlyIncome || 0,
monthlyPayPlanned: member.monthlyPayPlanned || 0,
name: member.displayName || "",
hoursPerMonth: Number(member.capacity?.targetHours) || 0,
minMonthlyNeeds: Number(member.minMonthlyNeeds) || 0,
monthlyPayPlanned: Number(member.monthlyPayPlanned) || 0,
};
coop.upsertMember(memberData);
emit("save-status", "saved");
} catch (error) {
@ -257,13 +196,7 @@ function saveMember(member: any) {
debouncedSave(member);
}
// Validation functions
function validateAndSaveHours(value: string, member: any) {
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
member.capacity.targetHours = isNaN(numValue) ? 0 : Math.max(0, numValue);
saveMember(member);
}
// Validation functions (simplified since UInputNumber handles numeric validation)
function validateAndSavePercentage(value: string, member: any) {
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
member.externalCoveragePct = isNaN(numValue)
@ -272,30 +205,16 @@ function validateAndSavePercentage(value: string, member: any) {
saveMember(member);
}
function validateAndSaveAmount(value: string, member: any, field: string) {
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
member[field] = isNaN(numValue) ? 0 : Math.max(0, numValue);
saveMember(member);
}
function memberCoverage(member: any) {
return coverage(
member.minMonthlyNeeds || 0,
member.targetMonthlyPay || 0,
member.monthlyPayPlanned || 0,
member.externalMonthlyIncome || 0
);
return coverage(member.minMonthlyNeeds || 0, member.monthlyPayPlanned || 0);
}
function addMember() {
const newMember = {
id: Date.now().toString(),
name: "",
role: "",
hoursPerMonth: 0,
minMonthlyNeeds: 0,
targetMonthlyPay: 0,
externalMonthlyIncome: 0,
monthlyPayPlanned: 0,
};
@ -305,24 +224,4 @@ function addMember() {
function removeMember(id: string) {
coop.removeMember(id);
}
function exportMembers() {
const exportData = {
members: members.value,
exportedAt: new Date().toISOString(),
section: "members",
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coop-members-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>

View file

@ -1,97 +1,78 @@
<template>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header with Export Controls -->
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-2xl font-black text-black mb-2">Set your wage & pay policy</h3>
<p class="text-neutral-600">
Choose how to allocate payroll among members and set the base hourly rate.
</p>
</div>
<div class="flex items-center gap-3">
<UButton variant="outline" color="neutral" size="sm" @click="exportPolicies">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export
</UButton>
</div>
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black mb-2">
How will you share money?
</h3>
<p class="text-neutral-600">
This is the foundation of your co-op's finances. Choose a pay approach
and set your hourly rate.
</p>
</div>
<!-- Pay Policy Selection -->
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<h4 class="font-bold mb-4">Pay Allocation Policy</h4>
<div class="space-y-3">
<label
v-for="option in policyOptions"
:key="option.value"
class="flex items-start gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded-lg transition-colors"
>
<input
type="radio"
:value="option.value"
v-model="selectedPolicy"
@change="updatePolicy(option.value)"
class="mt-1 w-4 h-4 text-black border-2 border-gray-300 focus:ring-2 focus:ring-black"
/>
<span class="text-sm flex-1">{{ option.label }}</span>
</label>
</div>
<!-- Role bands editor if role-banded is selected -->
<div v-if="selectedPolicy === 'role-banded'" class="mt-4 p-4 bg-gray-50 rounded-lg">
<h5 class="text-sm font-medium mb-3">Role Bands (monthly or weight)</h5>
<div class="space-y-2">
<div
v-for="member in uniqueRoles"
:key="member.role"
class="flex items-center gap-2"
>
<span class="text-sm w-32">{{ member.role || "No role" }}</span>
<UInput
v-model="roleBands[member.role || '']"
type="text"
placeholder="3000"
size="sm"
class="w-24"
@update:model-value="updateRoleBands"
/>
</div>
</div>
</div>
<UAlert
class="mt-4"
color="primary"
variant="soft"
icon="i-heroicons-information-circle"
>
<template #description>
Policies affect payroll allocation and member coverage. You can iterate later.
</template>
</UAlert>
<div class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
<h4 class="font-bold mb-2">Step 1: Choose your pay approach</h4>
<p class="text-sm text-gray-600 mb-4">
How should available money be shared among members?
</p>
<URadioGroup
v-model="selectedPolicy"
:items="policyOptions"
@update:model-value="updatePolicy"
variant="list"
size="xl"
class="flex flex-col gap-2 w-full" />
</div>
<!-- Hourly Wage Input -->
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<h4 class="font-bold mb-4">Base Hourly Wage</h4>
<div class="max-w-md">
<UInput
v-model="wageText"
type="text"
placeholder="0.00"
size="xl"
class="text-4xl font-black w-full h-20"
@update:model-value="validateAndSaveWage"
>
<template #leading>
<span class="text-neutral-500 text-3xl"></span>
</template>
</UInput>
<div class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
<h4 class="font-bold mb-2">Step 2: Set your base wage</h4>
<p class="text-sm text-gray-600 mb-4">
This hourly rate applies to all paid work in your co-op
</p>
<div class="flex gap-4 items-start">
<!-- Currency Selection -->
<UFormField label="Currency" class="w-1/2">
<USelect
v-model="selectedCurrency"
:items="currencySelectOptions"
placeholder="Select currency"
size="xl"
class="w-full"
@update:model-value="updateCurrency">
<template #leading>
<span class="text-lg">{{
getCurrencySymbol(selectedCurrency)
}}</span>
</template>
</USelect>
</UFormField>
<UFormField label="Hourly Rate" class="w-1/2">
<UInput
v-model="wageText"
type="text"
placeholder="0.00"
size="xl"
class="text-2xl font-bold w-full"
@update:model-value="validateAndSaveWage">
<template #leading>
<span class="text-neutral-500 text-xl">{{
getCurrencySymbol(selectedCurrency)
}}</span>
</template>
</UInput>
</UFormField>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { currencyOptions, getCurrencySymbol } from "~/utils/currency";
const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"];
}>();
@ -104,6 +85,7 @@ const store = useCoopBuilderStore();
const selectedPolicy = ref(coop.policy.value?.relationship || "equal-pay");
const roleBands = ref(coop.policy.value?.roleBands || {});
const wageText = ref(String(store.equalHourlyWage || ""));
const selectedCurrency = ref(coop.currency.value || "EUR");
function parseNumberInput(val: unknown): number {
if (typeof val === "number") return val;
@ -115,35 +97,42 @@ function parseNumberInput(val: unknown): number {
return 0;
}
// Pay policy options
// Simplified pay policy options
const policyOptions = [
{
value: "equal-pay",
label: "Equal pay - Everyone gets the same monthly amount",
},
{
value: "needs-weighted",
label: "Needs-weighted - Allocate based on minimum needs",
label: "Equal pay - Everyone gets the same amount",
},
{
value: "hours-weighted",
label: "Hours-weighted - Allocate based on hours worked",
label: "Hours-based - Pay proportional to hours worked",
},
{
value: "needs-weighted",
label: "Needs-based - Pay proportional to individual needs",
},
{ value: "role-banded", label: "Role-banded - Different amounts per role" },
];
// Currency options for USelect (simplified format)
const currencySelectOptions = computed(() =>
currencyOptions.map((currency) => ({
label: `${currency.name} (${currency.code})`,
value: currency.code,
}))
);
// Already initialized above with store values
const uniqueRoles = computed(() => {
const roles = new Set(coop.members.value.map((m) => m.role || ""));
return Array.from(roles).map((role) => ({ role }));
});
// Removed uniqueRoles computed - no longer needed with simplified policies
function updateCurrency(value: string) {
selectedCurrency.value = value;
coop.setCurrency(value);
emit("save-status", "saved");
}
function updatePolicy(value: string) {
selectedPolicy.value = value;
coop.setPolicy(
value as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded"
);
coop.setPolicy(value as "equal-pay" | "needs-weighted" | "hours-weighted");
// Trigger payroll reallocation after policy change
const allocatedMembers = coop.allocatePayroll();
@ -154,19 +143,7 @@ function updatePolicy(value: string) {
emit("save-status", "saved");
}
function updateRoleBands() {
coop.setRoleBands(roleBands.value);
// Trigger payroll reallocation after role bands change
if (selectedPolicy.value === "role-banded") {
const allocatedMembers = coop.allocatePayroll();
allocatedMembers.forEach((m) => {
coop.upsertMember(m);
});
}
emit("save-status", "saved");
}
// Removed updateRoleBands - no longer needed with simplified policies
// Text input for wage with validation (initialized above)
@ -188,28 +165,4 @@ function validateAndSaveWage(value: string) {
emit("save-status", "saved");
}
}
function exportPolicies() {
const exportData = {
policies: {
selectedPolicy: coop.policy.value?.relationship || selectedPolicy.value,
roleBands: coop.policy.value?.roleBands || roleBands.value,
equalHourlyWage: store.equalHourlyWage || parseFloat(wageText.value),
},
exportedAt: new Date().toISOString(),
section: "policies",
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coop-policies-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>

View file

@ -12,22 +12,11 @@
<!-- Removed Tab Navigation - showing streams directly -->
<div class="space-y-6">
<!-- Export Controls -->
<div class="flex justify-end">
<UButton
variant="outline"
color="gray"
size="sm"
@click="exportStreams">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export
</UButton>
</div>
<div class="space-y-3">
<div
v-if="streams.length === 0"
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
<h4 class="font-medium text-neutral-900 mb-2">
No revenue streams yet
</h4>
@ -47,56 +36,85 @@
<div
v-for="stream in streams"
:key="stream.id"
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<UFormField label="Category" required>
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
<!-- First row: Category and Name with delete button -->
<div class="flex gap-4 mb-4">
<UFormField label="Category" required class="flex-1">
<USelect
v-model="stream.category"
:items="categoryOptions"
size="xl"
class="text-xl font-bold w-full"
@update:model-value="saveStream(stream)" />
size="md"
class="text-sm font-medium w-full"
@update:model-value="saveCategoryChange(stream)" />
</UFormField>
<UFormField label="Revenue source name" required>
<USelectMenu
v-model="stream.name"
:items="nameOptionsByCategory[stream.category] || []"
placeholder="Select or type a source name"
creatable
searchable
size="xl"
class="text-xl font-bold w-full"
@update:model-value="saveStream(stream)" />
</UFormField>
<UFormField label="Monthly amount" required>
<UInput
v-model="stream.targetMonthlyAmount"
type="text"
placeholder="5000"
size="xl"
class="text-xl font-black w-full"
@update:model-value="validateAndSaveAmount($event, stream)"
@blur="saveStream(stream)">
<template #leading>
<span class="text-neutral-500 text-xl">$</span>
</template>
</UInput>
<UFormField label="Name" required class="flex-1">
<div class="flex gap-2">
<USelectMenu
v-model="stream.name"
:items="nameOptionsByCategory[stream.category] || []"
placeholder="Select or type a source name"
creatable
searchable
size="md"
class="text-sm font-medium w-full"
@update:model-value="saveStream(stream)" />
<UButton
size="md"
variant="solid"
color="error"
@click="removeStream(stream.id)"
:ui="{
base: 'cursor-pointer hover:opacity-90 transition-opacity',
}">
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton>
</div>
</UFormField>
</div>
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
<UButton
size="xs"
variant="solid"
color="error"
@click="removeStream(stream.id)"
:ui="{
base: 'cursor-pointer hover:opacity-90 transition-opacity',
}">
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton>
<!-- Second row: Amount with toggle -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField :label="stream.amountType === 'annual' ? 'Annual amount' : 'Monthly amount'" required>
<div class="flex gap-2">
<UInput
:value="stream.amountType === 'annual' ? stream.targetAnnualAmount : stream.targetMonthlyAmount"
type="text"
:placeholder="stream.amountType === 'annual' ? '60000' : '5000'"
size="md"
class="text-sm font-medium w-full"
@update:model-value="validateAndSaveAmount($event, stream)"
@blur="saveStream(stream)">
<template #leading>
<span class="text-neutral-500">{{ currencySymbol }}</span>
</template>
</UInput>
<UButtonGroup size="md">
<UButton
:variant="stream.amountType === 'monthly' ? 'solid' : 'outline'"
color="primary"
@click="switchAmountType(stream, 'monthly')"
class="text-xs">
Monthly
</UButton>
<UButton
:variant="stream.amountType === 'annual' ? 'solid' : 'outline'"
color="primary"
@click="switchAmountType(stream, 'annual')"
class="text-xs">
Annual
</UButton>
</UButtonGroup>
</div>
<p class="text-xs text-neutral-500 mt-1">
<template v-if="stream.amountType === 'annual'">
{{ currencySymbol }}{{ Math.round((stream.targetAnnualAmount || 0) / 12) }} per month
</template>
<template v-else>
{{ currencySymbol }}{{ (stream.targetMonthlyAmount || 0) * 12 }} per year
</template>
</p>
</UFormField>
</div>
</div>
@ -115,21 +133,6 @@
Add another stream
</UButton>
</div>
<div v-if="streams.length > 0" class="flex items-center gap-3 justify-end">
<UButton
@click="addRevenueStream"
size="sm"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-1" />
Add stream
</UButton>
</div>
</div>
</div>
</div>
@ -142,8 +145,9 @@ const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"];
}>();
// Store
// Store and Currency
const coop = useCoopBuilder();
const { currencySymbol } = useCurrency();
const streams = computed(() =>
coop.streams.value.map(s => ({
// Map store fields to component expectations
@ -151,6 +155,8 @@ const streams = computed(() =>
name: s.label,
category: s.category || 'games',
targetMonthlyAmount: s.monthly || 0,
targetAnnualAmount: (s.annual || (s.monthly || 0) * 12),
amountType: s.amountType || 'monthly',
subcategory: '',
targetPct: 0,
certainty: s.certainty || 'Aspirational',
@ -219,7 +225,12 @@ const nameOptionsByCategory: Record<string, string[]> = {
// Computed
const totalMonthlyAmount = computed(() =>
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
streams.value.reduce((sum, s) => {
const monthly = s.amountType === 'annual'
? Math.round((s.targetAnnualAmount || 0) / 12)
: (s.targetMonthlyAmount || 0);
return sum + monthly;
}, 0)
);
// Live-write with debounce
@ -228,10 +239,16 @@ const debouncedSave = useDebounceFn((stream: any) => {
try {
// Convert component format back to store format
const monthly = stream.amountType === 'annual'
? Math.round((stream.targetAnnualAmount || 0) / 12)
: (stream.targetMonthlyAmount || 0);
const streamData = {
id: stream.id,
label: stream.name || '',
monthly: stream.targetMonthlyAmount || 0,
monthly: monthly,
annual: stream.targetAnnualAmount || monthly * 12,
amountType: stream.amountType || 'monthly',
category: stream.category || 'games',
certainty: stream.certainty || 'Aspirational'
};
@ -245,23 +262,87 @@ const debouncedSave = useDebounceFn((stream: any) => {
}, 300);
function saveStream(stream: any) {
if (stream.name && stream.category && stream.targetMonthlyAmount >= 0) {
const hasValidAmount = stream.amountType === 'annual'
? stream.targetAnnualAmount >= 0
: stream.targetMonthlyAmount >= 0;
if (stream.name && stream.category && hasValidAmount) {
debouncedSave(stream);
}
}
// Save category changes immediately even without a name
function saveCategoryChange(stream: any) {
// Always save category changes immediately
saveStreamImmediate(stream);
}
// Immediate save without debounce for UI responsiveness
function saveStreamImmediate(stream: any) {
try {
// Convert component format back to store format
const monthly = stream.amountType === 'annual'
? Math.round((stream.targetAnnualAmount || 0) / 12)
: (stream.targetMonthlyAmount || 0);
const streamData = {
id: stream.id,
label: stream.name || '',
monthly: monthly,
annual: stream.targetAnnualAmount || monthly * 12,
amountType: stream.amountType || 'monthly',
category: stream.category || 'games',
certainty: stream.certainty || 'Aspirational'
};
coop.upsertStream(streamData);
} catch (error) {
console.error("Failed to save stream:", error);
}
}
// Validation function for amount
function validateAndSaveAmount(value: string, stream: any) {
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
stream.targetMonthlyAmount = isNaN(numValue) ? 0 : Math.max(0, numValue);
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
if (stream.amountType === 'annual') {
stream.targetAnnualAmount = validValue;
stream.targetMonthlyAmount = Math.round(validValue / 12);
} else {
stream.targetMonthlyAmount = validValue;
stream.targetAnnualAmount = validValue * 12;
}
saveStream(stream);
}
// Function to switch between annual and monthly
function switchAmountType(stream: any, type: 'annual' | 'monthly') {
stream.amountType = type;
// Recalculate values based on new type
if (type === 'annual') {
if (!stream.targetAnnualAmount) {
stream.targetAnnualAmount = (stream.targetMonthlyAmount || 0) * 12;
}
} else {
if (!stream.targetMonthlyAmount) {
stream.targetMonthlyAmount = Math.round((stream.targetAnnualAmount || 0) / 12);
}
}
// Save immediately without debounce for instant UI update
saveStreamImmediate(stream);
}
function addRevenueStream() {
const newStream = {
id: Date.now().toString(),
label: "",
monthly: 0,
annual: 0,
amountType: "monthly",
category: "games",
certainty: "Aspirational"
};

View file

@ -1,395 +0,0 @@
<template>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header -->
<div class="mb-8">
<h3 class="text-2xl font-black text-black dark:text-white mb-2">Review & Complete</h3>
<p class="text-neutral-600 dark:text-neutral-400">
Review your setup and complete the wizard to start using your co-op
tool.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Members Summary -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h4 class="font-medium">Members ({{ members.length }})</h4>
<UBadge :color="membersValid ? 'green' : 'red'" variant="subtle">
{{ membersValid ? "Valid" : "Incomplete" }}
</UBadge>
</div>
</template>
<div class="space-y-3">
<div
v-for="member in members"
:key="member.id"
class="flex items-center justify-between text-sm">
<div>
<span class="font-medium">{{
member.displayName || "Unnamed Member"
}}</span>
<span v-if="member.roleFocus" class="text-neutral-500 ml-1"
>({{ member.roleFocus }})</span
>
</div>
<div class="text-right text-xs text-neutral-500">
<div>{{ member.payRelationship || "No relationship set" }}</div>
<div>{{ member.capacity?.targetHours || 0 }}h/month</div>
</div>
</div>
<div class="pt-3 border-t border-neutral-100">
<div class="grid grid-cols-2 gap-4 text-xs">
<div>
<span class="text-neutral-600">Total capacity:</span>
<span class="font-medium ml-1">{{ totalCapacity }}h</span>
</div>
<div>
<span class="text-neutral-600">Avg external:</span>
<span class="font-medium ml-1">{{ avgExternal }}%</span>
</div>
</div>
</div>
</div>
</UCard>
<!-- Policies Summary -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h4 class="font-medium">Policies</h4>
<UBadge :color="policiesValid ? 'green' : 'red'" variant="subtle">
{{ policiesValid ? "Valid" : "Incomplete" }}
</UBadge>
</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-neutral-600">Equal hourly wage:</span>
<span class="font-medium"
>{{ policies.equalHourlyWage || 0 }}</span
>
</div>
<div class="flex justify-between">
<span class="text-neutral-600">Payroll on-costs:</span>
<span class="font-medium"
>{{ policies.payrollOncostPct || 0 }}%</span
>
</div>
<div class="flex justify-between">
<span class="text-neutral-600">Savings target:</span>
<span class="font-medium"
>{{ policies.savingsTargetMonths || 0 }} months</span
>
</div>
<div class="flex justify-between">
<span class="text-neutral-600">Cash cushion:</span>
<span class="font-medium"
>{{ policies.minCashCushionAmount || 0 }}</span
>
</div>
<div class="flex justify-between">
<span class="text-neutral-600">Deferred cap:</span>
<span class="font-medium"
>{{ policies.deferredCapHoursPerQtr || 0 }}h/qtr</span
>
</div>
<div class="flex justify-between">
<span class="text-neutral-600">Volunteer flows:</span>
<span class="font-medium"
>{{ policies.volunteerScope.allowedFlows.length }} types</span
>
</div>
</div>
</UCard>
<!-- Costs Summary -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h4 class="font-medium">
Overhead Costs ({{ overheadCosts.length }})
</h4>
<UBadge color="blue" variant="subtle">Optional</UBadge>
</div>
</template>
<div v-if="overheadCosts.length === 0" class="text-center py-8">
<h4 class="font-medium text-neutral-900 mb-1">
No overhead costs yet
</h4>
<p class="text-sm text-neutral-500">Optional - add costs in step 3</p>
</div>
<div v-else class="space-y-2">
<div
v-for="cost in overheadCosts.slice(0, 3)"
:key="cost.id"
class="flex justify-between text-sm">
<span class="text-neutral-700">{{ cost.name }}</span>
<span class="font-medium">{{ cost.amount || 0 }}</span>
</div>
<div v-if="overheadCosts.length > 3" class="text-xs text-neutral-500">
+{{ overheadCosts.length - 3 }} more items
</div>
<div class="pt-2 border-t border-neutral-100">
<div class="flex justify-between text-sm font-medium">
<span>Monthly total:</span>
<span>{{ totalMonthlyCosts }}</span>
</div>
</div>
</div>
</UCard>
<!-- Revenue Summary -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h4 class="font-medium">Revenue Streams ({{ streams.length }})</h4>
<UBadge :color="streamsValid ? 'green' : 'red'" variant="subtle">
{{ streamsValid ? "Valid" : "Incomplete" }}
</UBadge>
</div>
</template>
<div v-if="streams.length === 0" class="text-center py-8">
<h4 class="font-medium text-neutral-900 mb-1">
No revenue streams yet
</h4>
<p class="text-sm text-neutral-500">
Required - add streams in step 4
</p>
</div>
<div v-else class="space-y-3">
<div
v-for="stream in streams.slice(0, 3)"
:key="stream.id"
class="space-y-1">
<div class="flex justify-between text-sm">
<span class="font-medium">{{
stream.name || "Unnamed Stream"
}}</span>
<span class="text-neutral-600">{{ stream.targetPct || 0 }}%</span>
</div>
<div class="flex justify-between text-xs text-neutral-500">
<span>{{ stream.category }} {{ stream.certainty }}</span>
<span>{{ stream.targetMonthlyAmount || 0 }}/mo</span>
</div>
</div>
<div v-if="streams.length > 3" class="text-xs text-neutral-500">
+{{ streams.length - 3 }} more streams
</div>
<div class="pt-3 border-t border-neutral-100">
<div class="grid grid-cols-2 gap-4 text-xs">
<div>
<span class="text-neutral-600">Target % total:</span>
<span
class="font-medium ml-1"
:class="
totalTargetPct === 100 ? 'text-green-600' : 'text-red-600'
">
{{ totalTargetPct }}%
</span>
</div>
<div>
<span class="text-neutral-600">Monthly target:</span>
<span class="font-medium ml-1">{{ totalMonthlyTarget }}</span>
</div>
</div>
</div>
</div>
</UCard>
</div>
<!-- Team Coverage Summary -->
<div class="bg-white border-2 border-black rounded-lg p-4 mb-4">
<h4 class="font-medium text-sm mb-3">Team Coverage (min needs)</h4>
<div class="flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-2">
<UIcon
:name="teamStats.under100 === 0 ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
:class="teamStats.under100 === 0 ? 'text-green-500' : 'text-yellow-500'"
class="w-4 h-4" />
<span>
<strong>{{ teamStats.under100 }}</strong> under 100%
</span>
</div>
<div v-if="teamStats.median" class="flex items-center gap-1">
<span class="text-neutral-600">Median:</span>
<strong>{{ Math.round(teamStats.median) }}%</strong>
</div>
<div v-if="teamStats.gini !== undefined" class="flex items-center gap-1">
<span class="text-neutral-600">Gini:</span>
<strong>{{ teamStats.gini.toFixed(2) }}</strong>
</div>
</div>
<div v-if="teamStats.under100 > 0" class="mt-3 p-2 bg-yellow-50 rounded text-xs text-yellow-800">
Consider more needs-weighting or a smaller headcount to ensure everyone's minimum needs are met.
</div>
</div>
<!-- Overall Status -->
<div class="bg-neutral-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-3">Setup Status</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="flex items-center gap-2">
<UIcon
:name="
membersValid ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'
"
:class="membersValid ? 'text-green-500' : 'text-red-500'"
class="w-4 h-4" />
<span class="text-sm">Members</span>
</div>
<div class="flex items-center gap-2">
<UIcon
:name="
policiesValid
? 'i-heroicons-check-circle'
: 'i-heroicons-x-circle'
"
:class="policiesValid ? 'text-green-500' : 'text-red-500'"
class="w-4 h-4" />
<span class="text-sm">Policies</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-check-circle"
class="text-blue-500 w-4 h-4" />
<span class="text-sm">Costs (Optional)</span>
</div>
<div class="flex items-center gap-2">
<UIcon
:name="
streamsValid ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'
"
:class="streamsValid ? 'text-green-500' : 'text-red-500'"
class="w-4 h-4" />
<span class="text-sm">Revenue</span>
</div>
</div>
<div
v-if="!canComplete"
class="bg-yellow-100 border border-yellow-200 rounded-md p-3 mb-4">
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-exclamation-triangle"
class="text-yellow-600 w-4 h-4" />
<span class="text-sm font-medium text-yellow-800"
>Complete required sections to finish setup</span
>
</div>
<ul class="list-disc list-inside text-xs text-yellow-700 mt-2">
<li v-if="!membersValid">
Add at least one member with valid details
</li>
<li v-if="!policiesValid">
Set a valid hourly wage and complete policy fields
</li>
<li v-if="!streamsValid">
Add at least one revenue stream with valid details
</li>
</ul>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center pt-6 border-t">
<UButton variant="ghost" color="red" @click="$emit('reset')">
Reset All Data
</UButton>
<div class="flex gap-3">
<UButton
@click="completeSetup"
:disabled="!canComplete"
size="lg"
variant="solid"
color="primary">
Complete Setup
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
complete: [];
reset: [];
}>();
// Store
const coop = useCoopBuilder();
// Computed data
const members = computed(() => coop.members.value);
const teamStats = computed(() => coop.teamCoverageStats());
const policies = computed(() => ({
// TODO: Get actual policy data from centralized store
equalHourlyWage: 0,
payrollOncostPct: 0,
savingsTargetMonths: 0,
minCashCushionAmount: 0,
deferredCapHoursPerQtr: 0,
volunteerScope: { allowedFlows: [] },
}));
const overheadCosts = computed(() => []);
const streams = computed(() => coop.streams.value);
// Validation
const membersValid = computed(() => coop.members.value.length > 0);
const policiesValid = computed(() => true); // TODO: Add validation
const streamsValid = computed(() => coop.streams.value.length > 0);
const canComplete = computed(
() => membersValid.value && policiesValid.value && streamsValid.value
);
// Summary calculations
const totalCapacity = computed(() =>
members.value.reduce((sum, m) => sum + (m.capacity?.targetHours || 0), 0)
);
const avgExternal = computed(() => {
if (members.value.length === 0) return 0;
const total = members.value.reduce(
(sum, m) => sum + (m.externalCoveragePct || 0),
0
);
return Math.round(total / members.value.length);
});
const totalMonthlyCosts = computed(() =>
overheadCosts.value.reduce((sum, c) => sum + (c.amount || 0), 0)
);
const totalTargetPct = computed(() =>
coop.streams.value.reduce((sum, s) => sum + (s.targetPct || 0), 0)
);
const totalMonthlyTarget = computed(() =>
Math.round(
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
)
);
function completeSetup() {
if (canComplete.value) {
// Mark setup as complete in some way (could be a store flag)
emit("complete");
}
}
</script>

View file

@ -1,49 +0,0 @@
<template>
<UCard>
<template #header>
<h4 class="font-medium">Scenarios</h4>
</template>
<div class="space-y-3">
<USelect
:model-value="scenario"
:options="scenarioOptions"
@update:model-value="setScenario"
/>
<div v-if="scenario !== 'current'" class="p-3 bg-blue-50 border border-blue-200 rounded text-sm">
<div class="flex items-center gap-2 text-blue-800">
<UIcon name="i-heroicons-information-circle" class="w-4 h-4" />
<span class="font-medium">Scenario Active</span>
</div>
<p class="text-blue-700 mt-1">
{{ getScenarioDescription(scenario) }}
</p>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const { scenario, setScenario } = useCoopBuilder()
const scenarioOptions = [
{ label: 'Current', value: 'current' },
{ label: 'Quit Day Jobs', value: 'quit-jobs' },
{ label: 'Start Production', value: 'start-production' },
{ label: 'Custom', value: 'custom', disabled: true }
]
function getScenarioDescription(scenario: string): string {
switch (scenario) {
case 'quit-jobs':
return 'All external income removed. Shows runway if everyone works full-time for the co-op.'
case 'start-production':
return 'Service revenue reduced by 30%. Models transition from services to product development.'
case 'custom':
return 'Custom scenario configuration coming soon.'
default:
return ''
}
}
</script>

View file

@ -1,41 +1,137 @@
<template>
<div class="hidden" data-ui="member_coverage_panel_v1" />
<div class="hidden" data-ui="member_coverage_panel_v2" />
<UCard class="shadow-sm rounded-xl">
<template #header>
<h3 class="font-semibold">Member needs coverage</h3>
<div class="flex items-center justify-between">
<h3 class="font-semibold">Individual Member Coverage</h3>
<UTooltip text="Shows what each member needs from the co-op vs. what we can actually pay them">
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" />
</UTooltip>
</div>
</template>
<div class="space-y-6">
<div v-if="allocatedMembers.length > 0" class="space-y-4">
<div
v-for="member in allocatedMembers"
:key="member.id"
class="flex items-center gap-4"
class="space-y-2"
>
<div class="w-20 text-sm font-medium text-gray-700 truncate">
{{ member.name }}
</div>
<div class="flex-1">
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="getBarColor(coverage(member).minPct)"
:style="{ width: `${Math.min(100, (coverage(member).minPct / 200) * 100)}%` }"
/>
<!-- Member name and coverage percentage -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
<UBadge
:color="getCoverageColor(coverage(member).coveragePct)"
size="xs"
:ui="{ base: 'font-medium' }"
>
{{ Math.round(coverage(member).coveragePct || 0) }}% covered
</UBadge>
</div>
</div>
<div class="w-12 text-sm font-medium text-right">
{{ Math.round(coverage(member).minPct) }}%
</div>
</div>
<div v-if="allocatedMembers.length === 0" class="text-sm text-gray-600 text-center py-8">
Add members in Setup Members to see coverage.
<!-- Financial breakdown -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="space-y-1">
<div class="text-gray-600">Needs from co-op</div>
<div class="font-medium">{{ formatCurrency(member.minMonthlyNeeds || 0) }}</div>
</div>
<div class="space-y-1">
<div class="text-gray-600">Co-op can pay</div>
<div class="font-medium" :class="getAmountColor(member.monthlyPayPlanned, member.minMonthlyNeeds)">
{{ formatCurrency(member.monthlyPayPlanned || 0) }}
</div>
</div>
</div>
<!-- Visual progress bar -->
<div class="space-y-1">
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
<div
class="h-3 rounded-full transition-all duration-300"
:class="getBarColor(coverage(member).coveragePct)"
:style="{ width: `${Math.min(100, coverage(member).coveragePct || 0)}%` }"
/>
<!-- 100% marker line -->
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="(coverage(member).coveragePct || 0) < 100">
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" />
</div>
</div>
<div class="flex justify-between text-xs text-gray-500">
<span>0%</span>
<span>100%</span>
<span>200%+</span>
</div>
</div>
<!-- Gap/surplus indicator -->
<div v-if="getGapAmount(member) !== 0" class="flex items-center gap-1 text-xs">
<UIcon
:name="getGapAmount(member) > 0 ? 'i-heroicons-arrow-trending-down' : 'i-heroicons-arrow-trending-up'"
class="h-3 w-3"
:class="getGapAmount(member) > 0 ? 'text-red-500' : 'text-green-500'"
/>
<span :class="getGapAmount(member) > 0 ? 'text-red-600' : 'text-green-600'">
{{ getGapAmount(member) > 0 ? 'Gap: ' : 'Surplus: ' }}{{ formatCurrency(Math.abs(getGapAmount(member))) }}
</span>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-8 text-gray-500">
<UIcon name="i-heroicons-users" class="h-8 w-8 mx-auto mb-2 opacity-50" />
<p class="text-sm mb-2">No members added yet</p>
<p class="text-xs">Complete setup wizard to add team members</p>
</div>
<template #footer v-if="allocatedMembers.length > 0">
<div class="text-sm text-gray-600 text-center">
Team median {{ Math.round(stats.median) }}% {{ stats.under100 }} under 100%{{ allCovered ? ' • All covered ✓' : '' }}
<!-- Summary Stats -->
<div class="flex justify-between items-center text-sm text-gray-600 pb-3 border-b border-gray-200">
<div class="flex items-center gap-4">
<span>Median coverage: {{ Math.round(stats.median || 0) }}%</span>
<span :class="stats.under100 === 0 ? 'text-green-600' : 'text-amber-600'">
{{ stats.under100 === 0 ? 'All covered ✓' : `${stats.under100} need more` }}
</span>
</div>
<div class="text-xs">
Total payroll: {{ formatCurrency(totalPayroll) }}
</div>
</div>
<!-- Actionable Insights -->
<div class="pt-3">
<div v-if="totalGap > 0" class="text-xs">
<div class="flex items-center gap-2 text-amber-700">
<UIcon name="i-heroicons-light-bulb" class="h-3 w-3" />
<span class="font-medium">To cover everyone:</span>
</div>
<p class="mt-1 text-gray-600 pl-5">
Increase available payroll by <strong>{{ formatCurrency(totalGap) }}</strong>
through higher revenue or lower overhead costs.
</p>
</div>
<div v-else-if="totalSurplus > 0" class="text-xs">
<div class="flex items-center gap-2 text-green-700">
<UIcon name="i-heroicons-check-circle" class="h-3 w-3" />
<span class="font-medium">Healthy position:</span>
</div>
<p class="mt-1 text-gray-600 pl-5">
You have <strong>{{ formatCurrency(totalSurplus) }}</strong> surplus after covering all member needs.
Consider growth opportunities or building reserves.
</p>
</div>
<div v-else class="text-xs">
<div class="flex items-center gap-2 text-green-700">
<UIcon name="i-heroicons-scales" class="h-3 w-3" />
<span class="font-medium">Perfect balance:</span>
</div>
<p class="mt-1 text-gray-600 pl-5">
Available payroll exactly matches member needs.
</p>
</div>
</div>
</template>
</UCard>
@ -46,11 +142,61 @@ const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
const allocatedMembers = computed(() => allocatePayroll())
const stats = computed(() => teamCoverageStats())
const allCovered = computed(() => stats.value.under100 === 0)
// Calculate total payroll
const totalPayroll = computed(() =>
allocatedMembers.value.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
)
// Color functions for coverage display
function getBarColor(pct: number): string {
if (pct >= 100) return 'bg-green-500'
if (pct >= 80) return 'bg-amber-500'
return 'bg-red-500'
if (!pct || pct < 80) return 'bg-red-500'
if (pct < 100) return 'bg-amber-500'
return 'bg-green-500'
}
function getCoverageColor(pct: number): string {
if (!pct || pct < 80) return 'red'
if (pct < 100) return 'amber'
return 'green'
}
function getAmountColor(planned: number = 0, needed: number = 0): string {
if (!needed) return 'text-gray-900'
if (planned >= needed) return 'text-green-600'
if (planned >= needed * 0.8) return 'text-amber-600'
return 'text-red-600'
}
// Calculate gap between what's needed vs what can be paid
function getGapAmount(member: any): number {
const planned = member.monthlyPayPlanned || 0
const needed = member.minMonthlyNeeds || 0
return needed - planned // positive = gap, negative = surplus
}
// Calculate total gap/surplus across all members
const totalGap = computed(() => {
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
const totalPlanned = totalPayroll.value
const gap = totalNeeded - totalPlanned
return gap > 0 ? gap : 0
})
const totalSurplus = computed(() => {
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
const totalPlanned = totalPayroll.value
const surplus = totalPlanned - totalNeeded
return surplus > 0 ? surplus : 0
})
// Currency formatting
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
</script>

View file

@ -1,37 +1,165 @@
<template>
<div class="hidden" data-ui="needs_coverage_card_v1" />
<div class="hidden" data-ui="needs_coverage_card_v2" />
<UCard class="min-h-[140px] shadow-sm rounded-xl">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-user-group" class="h-5 w-5" />
<h3 class="font-semibold">Members covered</h3>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-user-group" class="h-5 w-5" />
<h3 class="font-semibold">Member Needs Coverage</h3>
</div>
<UTooltip text="Shows how well the co-op can meet each member's stated financial needs">
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" />
</UTooltip>
</div>
</template>
<div class="text-center space-y-6">
<div class="text-2xl font-semibold" :class="statusColor">
{{ pctCovered }}%
<div v-if="hasMembers" class="space-y-4">
<!-- Team Summary -->
<div class="text-center">
<div class="text-2xl font-semibold" :class="statusColor">
{{ fullyCoveredCount }} of {{ totalMembers }}
</div>
<div class="text-sm text-gray-600">
members fully covered
</div>
</div>
<div class="text-sm text-gray-600">
Median {{ median }}%
<!-- Coverage Stats -->
<div class="flex justify-between text-sm">
<div class="text-center">
<div class="font-medium">{{ median }}%</div>
<div class="text-gray-600">Median</div>
</div>
<div class="text-center">
<div class="font-medium" :class="underCoveredColor">{{ stats.under100 }}</div>
<div class="text-gray-600">Under 100%</div>
</div>
<div class="text-center">
<div class="font-medium">{{ formatCurrency(availablePayroll) }}</div>
<div class="text-gray-600">Available</div>
</div>
</div>
<div v-if="stats.under100 > 0" class="flex items-center justify-center gap-1 text-xs text-amber-600 mt-3">
<span></span>
<span>{{ stats.under100 }} under 100%</span>
<!-- Intelligent Financial Analysis -->
<div v-if="hasMembers" class="space-y-2">
<!-- Coverage gap analysis -->
<div v-if="stats.under100 > 0" class="text-xs bg-amber-50 p-3 rounded border-l-4 border-amber-400">
<div class="flex items-start gap-2">
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="font-medium text-amber-800">Coverage Gap Analysis</p>
<p class="text-amber-700">
To meet member needs, you need <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements,
but you have <strong>{{ formatCurrency(availablePayroll) }}</strong> available for payroll.
</p>
<p class="text-amber-600">
<strong>Shortfall: {{ formatCurrency(Math.max(0, totalNeeds - availablePayroll)) }}</strong>
</p>
<p class="text-xs text-amber-600 mt-2">
💡 Note: This reflects member-stated needs. Check your Budget page for detailed payroll planning.
</p>
</div>
</div>
</div>
<!-- Surplus analysis -->
<div v-else-if="availablePayroll > totalNeeds && totalNeeds > 0" class="text-xs bg-green-50 p-3 rounded border-l-4 border-green-400">
<div class="flex items-start gap-2">
<UIcon name="i-heroicons-check-circle" class="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="font-medium text-green-800">Healthy Coverage</p>
<p class="text-green-700">
You have <strong>{{ formatCurrency(availablePayroll) }}</strong> available to cover
<strong>{{ formatCurrency(totalNeeds) }}</strong> in member needs.
</p>
<p class="text-green-600">
<strong>Surplus: {{ formatCurrency(availablePayroll - totalNeeds) }}</strong>
</p>
</div>
</div>
</div>
<!-- No payroll available -->
<div v-else-if="availablePayroll === 0 && totalNeeds > 0" class="text-xs bg-red-50 p-3 rounded border-l-4 border-red-400">
<div class="flex items-start gap-2">
<UIcon name="i-heroicons-x-circle" class="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="font-medium text-red-800">No Funds for Payroll</p>
<p class="text-red-700">
Member needs total <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements,
but current revenue minus costs leaves $0 for payroll.
</p>
<p class="text-red-600">
Consider increasing revenue or reducing overhead costs.
</p>
<p class="text-xs text-red-600 mt-2">
💡 Note: This reflects member-stated needs. Your Budget page may show different payroll amounts.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-6 text-gray-500">
<UIcon name="i-heroicons-users" class="h-8 w-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">Add members in setup to see coverage</p>
</div>
</UCard>
</template>
<script setup lang="ts">
const { members, teamCoverageStats } = useCoopBuilder()
const { members, teamCoverageStats, allocatePayroll, streams } = useCoopBuilder()
const coopStore = useCoopBuilderStore()
const stats = computed(() => teamCoverageStats())
const pctCovered = computed(() => Math.round(stats.value.over100Pct || 0))
const allocatedMembers = computed(() => allocatePayroll())
const median = computed(() => Math.round(stats.value.median ?? 0))
// Team-level calculations
const hasMembers = computed(() => members.value.length > 0)
const totalMembers = computed(() => members.value.length)
const fullyCoveredCount = computed(() => totalMembers.value - stats.value.under100)
// Financial calculations
const totalNeeds = computed(() =>
allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
)
const totalRevenue = computed(() =>
streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0)
)
const overheadCosts = computed(() =>
coopStore.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0)
)
const availablePayroll = computed(() =>
Math.max(0, totalRevenue.value - overheadCosts.value)
)
// Status colors based on coverage
const statusColor = computed(() => {
if (pctCovered.value >= 100) return 'text-green-600'
if (pctCovered.value >= 80) return 'text-amber-600'
const ratio = fullyCoveredCount.value / Math.max(1, totalMembers.value)
if (ratio === 1) return 'text-green-600'
if (ratio >= 0.8) return 'text-amber-600'
return 'text-red-600'
})
const underCoveredColor = computed(() => {
if (stats.value.under100 === 0) return 'text-green-600'
if (stats.value.under100 <= Math.ceil(totalMembers.value * 0.2)) return 'text-amber-600'
return 'text-red-600'
})
// Currency formatting
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
</script>

View file

@ -60,6 +60,24 @@ export function useCoopBuilder() {
}
})
const currency = computed({
get: () => {
try {
return store.currency || 'EUR'
} catch (e) {
console.warn('Error accessing currency:', e)
return 'EUR'
}
},
set: (value: string) => {
try {
store.setCurrency(value)
} catch (e) {
console.warn('Error setting currency:', e)
}
}
})
const scenario = computed({
get: () => store.scenario,
set: (value) => store.setScenario(value)
@ -78,12 +96,6 @@ export function useCoopBuilder() {
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,
@ -154,25 +166,21 @@ export function useCoopBuilder() {
}
// Coverage calculation for a single member
function coverage(member: Member): { minPct: number; targetPct: number } {
const totalIncome = (member.monthlyPayPlanned || 0) + (member.externalMonthlyIncome || 0)
function coverage(member: Member): { coveragePct: number } {
const coopPay = member.monthlyPayPlanned || 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)
const coveragePct = member.minMonthlyNeeds > 0
? Math.min(200, (coopPay / member.minMonthlyNeeds) * 100)
: 100
return { minPct, targetPct }
return { coveragePct }
}
// Team coverage statistics
function teamCoverageStats() {
try {
const allocatedMembers = allocatePayroll() || []
const coverages = allocatedMembers.map(m => coverage(m).minPct).filter(c => !isNaN(c))
const coverages = allocatedMembers.map(m => coverage(m).coveragePct).filter(c => !isNaN(c))
if (coverages.length === 0) {
return { median: 0, under100: 0, over100Pct: 0, gini: 0 }
@ -354,6 +362,7 @@ export function useCoopBuilder() {
streams,
policy,
operatingMode,
currency,
scenario,
stress,
milestones,
@ -380,6 +389,7 @@ export function useCoopBuilder() {
setPolicy: (relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") => store.setPolicy(relationship),
setRoleBands: (bands: Record<string, number>) => store.setRoleBands(bands),
setEqualWage: (wage: number) => store.setEqualWage(wage),
setCurrency: (currency: string) => store.setCurrency(currency),
// Testing helpers
clearAll,

View file

@ -0,0 +1,37 @@
import { getCurrencySymbol } from '~/utils/currency'
export function useCurrency() {
const coop = useCoopBuilder()
const currencySymbol = computed(() => getCurrencySymbol(coop.currency.value))
const formatCurrency = (amount: number, options?: { showSymbol?: boolean; precision?: number }) => {
const { showSymbol = true, precision = 0 } = options || {}
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: precision,
maximumFractionDigits: precision
}).format(amount)
if (showSymbol) {
return `${currencySymbol.value}${formatted}`
}
return formatted
}
const formatCurrencyCompact = (amount: number) => {
if (amount >= 1000000) {
return `${currencySymbol.value}${(amount / 1000000).toFixed(1)}M`
} else if (amount >= 1000) {
return `${currencySymbol.value}${(amount / 1000).toFixed(1)}k`
}
return formatCurrency(amount)
}
return {
currencySymbol,
formatCurrency,
formatCurrencyCompact
}
}

View file

@ -2,18 +2,14 @@ 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<string, any>
streams: any[]
budget: Record<string, any>
scenarios: Record<string, any>
cash: Record<string, any>
session: Record<string, any>
}
export function useFixtureIO() {
@ -22,9 +18,7 @@ export function useFixtureIO() {
const policies = usePoliciesStore()
const streams = useStreamsStore()
const budget = useBudgetStore()
const scenarios = useScenariosStore()
const cash = useCashStore()
const session = useSessionStore()
return {
members: members.members,
@ -48,23 +42,12 @@ export function useFixtureIO() {
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
}
}
}
@ -73,9 +56,7 @@ export function useFixtureIO() {
const policies = usePoliciesStore()
const streams = useStreamsStore()
const budget = useBudgetStore()
const scenarios = useScenariosStore()
const cash = useCashStore()
const session = useSessionStore()
try {
// Import members
@ -98,10 +79,6 @@ export function useFixtureIO() {
budget.$patch(snapshot.budget)
}
// Import scenarios
if (snapshot.scenarios) {
scenarios.$patch(snapshot.scenarios)
}
// Import cash
if (snapshot.cash) {
@ -118,10 +95,6 @@ export function useFixtureIO() {
}
}
// Import session
if (snapshot.session) {
session.$patch(snapshot.session)
}
console.log('Successfully imported data snapshot')
} catch (error) {

View file

@ -1,109 +0,0 @@
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
}
}

View file

@ -0,0 +1,83 @@
/**
* Setup State Management
*
* Provides utilities to determine setup completion status and manage
* field locking based on setup state
*/
export const useSetupState = () => {
const coopStore = useCoopBuilderStore()
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const streamsStore = useStreamsStore()
// Check if setup is complete using the same logic as middleware
const isSetupComplete = computed(() => {
// 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
)
return legacyComplete || coopComplete
})
// Determine if revenue and expense fields should be locked
const areRevenueFieldsLocked = computed(() => {
return isSetupComplete.value
})
const areExpenseFieldsLocked = computed(() => {
return isSetupComplete.value
})
// Determine if member management should be in separate interface
const shouldUseSeparateMemberInterface = computed(() => {
return isSetupComplete.value
})
// Get setup completion percentage for progress display
const setupProgress = computed(() => {
let completed = 0
let total = 4 // policies, members, revenue, costs
if (policiesStore.isValid || (coopStore.equalHourlyWage > 0)) completed++
if (membersStore.members.length > 0 || coopStore.members.length > 0) completed++
if (streamsStore.hasValidStreams || coopStore.streams.length > 0) completed++
if (coopStore.overheadCosts.length > 0) completed++
return Math.round((completed / total) * 100)
})
// Navigation helpers
const goToSetup = () => {
navigateTo('/coop-planner')
}
const goToMemberManagement = () => {
navigateTo('/settings#members')
}
const goToRevenueMix = () => {
navigateTo('/mix')
}
return {
isSetupComplete,
areRevenueFieldsLocked,
areExpenseFieldsLocked,
shouldUseSeparateMemberInterface,
setupProgress,
goToSetup,
goToMemberManagement,
goToRevenueMix
}
}

260
composables/useStorSync.ts Normal file
View file

@ -0,0 +1,260 @@
/**
* Store Synchronization Composable
*
* Ensures that the legacy stores (streams, members, policies) always stay
* synchronized with the new CoopBuilderStore. This makes the setup interface
* the single source of truth while maintaining backward compatibility.
*/
export const useStoreSync = () => {
const coopStore = useCoopBuilderStore()
const streamsStore = useStreamsStore()
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
// Flags to prevent recursive syncing and duplicate watchers
let isSyncing = false
let watchersSetup = false
// Sync CoopBuilder -> Legacy Stores
const syncToLegacyStores = () => {
if (isSyncing) return
isSyncing = true
// Sync streams
streamsStore.resetStreams()
coopStore.streams.forEach((stream: any) => {
streamsStore.upsertStream({
id: stream.id,
name: stream.label,
category: stream.category || 'services',
targetMonthlyAmount: stream.monthly,
certainty: stream.certainty || 'Probable',
payoutDelayDays: 30,
terms: 'Net 30',
targetPct: 0,
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0
})
})
// Sync members
membersStore.resetMembers()
coopStore.members.forEach((member: any) => {
membersStore.upsertMember({
id: member.id,
displayName: member.name,
role: member.role || '',
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33),
minMonthlyNeeds: member.minMonthlyNeeds || 0,
monthlyPayPlanned: member.monthlyPayPlanned || 0,
targetMonthlyPay: member.targetMonthlyPay || 0,
externalMonthlyIncome: member.externalMonthlyIncome || 0
})
})
// Sync policies - using individual update calls based on store structure
policiesStore.updatePolicy('equalHourlyWage', coopStore.equalHourlyWage)
policiesStore.updatePolicy('payrollOncostPct', coopStore.payrollOncostPct)
policiesStore.updatePolicy('savingsTargetMonths', coopStore.savingsTargetMonths)
policiesStore.updatePolicy('minCashCushionAmount', coopStore.minCashCushion)
// Reset flag after sync completes
nextTick(() => {
isSyncing = false
})
}
// Sync Legacy Stores -> CoopBuilder
const syncFromLegacyStores = () => {
if (isSyncing) return
isSyncing = true
// Sync streams from legacy store
streamsStore.streams.forEach((stream: any) => {
coopStore.upsertStream({
id: stream.id,
label: stream.name,
monthly: stream.targetMonthlyAmount,
category: stream.category,
certainty: stream.certainty
})
})
// Sync members from legacy store
membersStore.members.forEach((member: any) => {
coopStore.upsertMember({
id: member.id,
name: member.displayName,
role: member.role,
hoursPerMonth: Math.round((member.hoursPerWeek || 0) * 4.33),
minMonthlyNeeds: member.minMonthlyNeeds,
monthlyPayPlanned: member.monthlyPayPlanned,
targetMonthlyPay: member.targetMonthlyPay,
externalMonthlyIncome: member.externalMonthlyIncome
})
})
// Sync policies from legacy store
if (policiesStore.isValid) {
coopStore.setEqualWage(policiesStore.equalHourlyWage)
coopStore.setOncostPct(policiesStore.payrollOncostPct)
coopStore.savingsTargetMonths = policiesStore.savingsTargetMonths
coopStore.minCashCushion = policiesStore.minCashCushionAmount
if (policiesStore.payPolicy?.relationship) {
coopStore.setPolicy(policiesStore.payPolicy.relationship as any)
}
}
// Reset flag after sync completes
nextTick(() => {
isSyncing = false
})
}
// Watch for changes in CoopBuilder and sync to legacy stores
const setupCoopBuilderWatchers = () => {
// Watch streams changes
watch(() => coopStore.streams, () => {
if (!isSyncing) {
syncToLegacyStores()
}
}, { deep: true })
// Watch members changes
watch(() => coopStore.members, () => {
if (!isSyncing) {
syncToLegacyStores()
}
}, { deep: true })
// Watch policy changes
watch(() => [
coopStore.equalHourlyWage,
coopStore.payrollOncostPct,
coopStore.savingsTargetMonths,
coopStore.minCashCushion,
coopStore.currency,
coopStore.policy.relationship
], () => {
if (!isSyncing) {
syncToLegacyStores()
}
})
}
// Watch for changes in legacy stores and sync to CoopBuilder
const setupLegacyStoreWatchers = () => {
// Watch streams store changes
watch(() => streamsStore.streams, () => {
if (!isSyncing) {
syncFromLegacyStores()
}
}, { deep: true })
// Watch members store changes
watch(() => membersStore.members, () => {
if (!isSyncing) {
syncFromLegacyStores()
}
}, { deep: true })
// Watch policies store changes
watch(() => [
policiesStore.equalHourlyWage,
policiesStore.payrollOncostPct,
policiesStore.savingsTargetMonths,
policiesStore.minCashCushionAmount,
policiesStore.payPolicy?.relationship
], () => {
if (!isSyncing) {
syncFromLegacyStores()
}
})
}
// Initialize synchronization
const initSync = async () => {
// Wait for next tick to ensure stores are mounted
await nextTick()
// Force store hydration by accessing $state
if (coopStore.$state) {
console.log('🔄 CoopBuilder store hydrated')
}
// Small delay to ensure localStorage is loaded
await new Promise(resolve => setTimeout(resolve, 10))
// Determine which store has data and sync accordingly
const coopHasData = coopStore.members.length > 0 || coopStore.streams.length > 0
const legacyHasData = streamsStore.streams.length > 0 || membersStore.members.length > 0
console.log('🔄 InitSync: CoopBuilder data:', coopHasData, 'Legacy data:', legacyHasData)
console.log('🔄 CoopBuilder members:', coopStore.members.length, 'streams:', coopStore.streams.length)
console.log('🔄 Legacy members:', membersStore.members.length, 'streams:', streamsStore.streams.length)
if (coopHasData && !legacyHasData) {
console.log('🔄 Syncing CoopBuilder → Legacy')
syncToLegacyStores()
} else if (legacyHasData && !coopHasData) {
console.log('🔄 Syncing Legacy → CoopBuilder')
syncFromLegacyStores()
} else if (coopHasData && legacyHasData) {
console.log('🔄 Both have data, keeping in sync')
// Both have data, ensure consistency by syncing from CoopBuilder (primary source)
syncToLegacyStores()
} else {
console.log('🔄 No data in either store')
}
// Set up watchers for ongoing sync (only once)
if (!watchersSetup) {
setupCoopBuilderWatchers()
setupLegacyStoreWatchers()
watchersSetup = true
}
// Return promise to allow awaiting
return Promise.resolve()
}
// Get unified streams data (prioritize CoopBuilder) - make reactive
const unifiedStreams = computed(() => {
if (coopStore.streams.length > 0) {
return coopStore.streams.map(stream => ({
...stream,
name: stream.label,
targetMonthlyAmount: stream.monthly
}))
}
return streamsStore.streams
})
// Get unified members data (prioritize CoopBuilder) - make reactive
const unifiedMembers = computed(() => {
if (coopStore.members.length > 0) {
return coopStore.members.map(member => ({
...member,
displayName: member.name,
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33)
}))
}
return membersStore.members
})
// Getter functions for backward compatibility
const getStreams = () => unifiedStreams.value
const getMembers = () => unifiedMembers.value
return {
syncToLegacyStores,
syncFromLegacyStores,
initSync,
getStreams,
getMembers,
unifiedStreams,
unifiedMembers
}
}

View file

@ -57,8 +57,27 @@
</div>
</div>
<!-- Empty State Message -->
<div v-if="activeView === 'monthly' && budgetWorksheet.revenue.length === 0 && budgetWorksheet.expenses.length === 0" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-12 text-center">
<div class="max-w-md mx-auto space-y-6">
<div class="text-6xl">📊</div>
<h3 class="text-xl font-bold text-black">No budget data found</h3>
<p class="text-gray-600">
Your budget is empty. Complete the setup wizard to add your revenue streams, team members, and expenses.
</p>
<div class="flex justify-center">
<NuxtLink
to="/coop-builder"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-black hover:bg-gray-800 border-2 border-black transition-colors"
>
Complete Setup Wizard
</NuxtLink>
</div>
</div>
</div>
<!-- Monthly View -->
<div v-if="activeView === 'monthly'" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div v-else-if="activeView === 'monthly'" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
@ -236,13 +255,38 @@
</div>
</div>
</div>
<!-- Special settings gear for payroll oncosts -->
<div v-if="item.id === 'expense-payroll-oncosts'" class="flex items-center gap-1">
<UButton
@click="showPayrollOncostModal = true"
size="xs"
variant="ghost"
icon="i-heroicons-cog-6-tooth"
:ui="{
base: 'text-gray-500 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-all',
}"
/>
<UButton
@click="removeItem('expenses', item.id)"
size="xs"
variant="ghost"
:ui="{
base: 'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
}"
>
×
</UButton>
</div>
<!-- Regular delete button for non-payroll items -->
<UButton
v-else
@click="removeItem('expenses', item.id)"
size="xs"
variant="ghost"
:ui="{
base:
'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
base: 'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
}"
>
×
@ -316,7 +360,7 @@
<!-- Add Revenue Modal -->
<UModal v-model:open="showAddRevenueModal">
<template #header>
<div class="flex items-center justify-between border-b-4 border-black pb-4">
<div class="flex items-center justify-between pb-4">
<div>
<h3 class="text-xl font-bold text-black">Add Revenue Source</h3>
<p class="mt-1 text-sm text-gray-600">
@ -334,7 +378,7 @@
</template>
<template #body>
<div class="space-y-5 py-4">
<div class="space-y-6 py-4">
<UFormGroup label="Category" required>
<USelectMenu
v-model="newRevenue.category"
@ -417,7 +461,7 @@
</template>
<template #footer>
<div class="flex justify-end gap-3 border-t-2 border-gray-200 pt-4">
<div class="flex justify-end gap-3 pt-4">
<UButton @click="showAddRevenueModal = false" variant="outline" size="md">
Cancel
</UButton>
@ -438,7 +482,7 @@
<!-- Add Expense Modal -->
<UModal v-model:open="showAddExpenseModal">
<template #header>
<div class="flex items-center justify-between border-b-4 border-black pb-4">
<div class="flex items-center justify-between pb-4">
<div>
<h3 class="text-xl font-bold text-black">Add Expense Item</h3>
<p class="mt-1 text-sm text-gray-600">Create a new expense for your budget</p>
@ -454,7 +498,7 @@
</template>
<template #body>
<div class="space-y-5 py-4">
<div class="space-y-6 py-4">
<UFormGroup label="Category" required>
<USelectMenu
v-model="newExpense.category"
@ -528,7 +572,7 @@
</template>
<template #footer>
<div class="flex justify-end gap-3 border-t-2 border-gray-200 pt-4">
<div class="flex justify-end gap-3 pt-4">
<UButton @click="showAddExpenseModal = false" variant="outline" size="md">
Cancel
</UButton>
@ -544,20 +588,102 @@
</div>
</template>
</UModal>
<!-- Payroll Oncost Settings Modal -->
<PayrollOncostModal
v-model:open="showPayrollOncostModal"
@save="handlePayrollOncostUpdate"
/>
</div>
</template>
<script setup lang="ts">
// Stores
// Stores and synchronization
const budgetStore = useBudgetStore();
const streamsStore = useStreamsStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const coopBuilderStore = useCoopBuilderStore();
const { initSync, getStreams, getMembers, unifiedStreams, unifiedMembers } = useStoreSync();
// Initialize synchronization and budget data
const initializeBudgetPage = async () => {
console.log('📊 Budget Page: Starting initialization');
// First, sync stores to ensure data is available (now async)
await initSync();
// Additional wait to ensure all reactive updates have propagated
await nextTick();
// Now check if we need to initialize budget data
const hasCoopData = coopBuilderStore.streams.length > 0 || coopBuilderStore.members.length > 0;
const hasBudgetData = budgetStore.budgetWorksheet.revenue.length > 0 || budgetStore.budgetWorksheet.expenses.length > 0;
console.log('📊 Budget Page: After sync - hasCoopData:', hasCoopData, 'hasBudgetData:', hasBudgetData);
console.log('📊 Budget Page: Coop streams:', coopBuilderStore.streams);
console.log('📊 Budget Page: Coop members:', coopBuilderStore.members);
if (hasCoopData && !hasBudgetData) {
console.log('📊 Budget Page: Initializing budget from coop data');
// Force initialization since we have coop data but no budget data
await budgetStore.forceInitializeFromWizardData();
// Refresh the page data after initialization
await nextTick();
} else if (!hasCoopData && !hasBudgetData) {
console.log('📊 Budget Page: No data found in any store');
// Try one more time to get the data after a delay
await new Promise(resolve => setTimeout(resolve, 100));
const retryCoopData = coopBuilderStore.streams.length > 0 || coopBuilderStore.members.length > 0;
if (retryCoopData) {
console.log('📊 Budget Page: Found data on retry, initializing');
await budgetStore.forceInitializeFromWizardData();
}
} else if (hasBudgetData) {
console.log('📊 Budget Page: Budget data already exists, using existing data');
}
};
// Initialize on mount
onMounted(async () => {
// Always force reinitialize when navigating to budget page
// This ensures changes from setup are reflected
await budgetStore.forceInitializeFromWizardData();
// Mark initial load as complete after initialization
await nextTick();
initialLoadComplete = true;
});
// Track if initial load is complete
let initialLoadComplete = false;
// Re-initialize when coop data changes (but not on initial load)
watch([() => coopBuilderStore.streams, () => coopBuilderStore.members], async (newVal, oldVal) => {
// Skip the initial trigger
if (!initialLoadComplete) {
return;
}
// Only reinitialize if we actually have new data
const hasNewStreams = coopBuilderStore.streams.length > 0;
const hasNewMembers = coopBuilderStore.members.length > 0;
if (hasNewStreams || hasNewMembers) {
console.log('📊 Budget Page: Coop data changed, reinitializing');
await nextTick();
await initializeBudgetPage();
}
}, { deep: true });
// Use reactive synchronized data
const syncedStreams = unifiedStreams;
const syncedMembers = unifiedMembers;
// State
const activeView = ref('monthly');
const showAddRevenueModal = ref(false);
const showAddExpenseModal = ref(false);
const showPayrollOncostModal = ref(false);
const activeTab = ref(0);
const highlightedItemId = ref<string | null>(null);
@ -656,21 +782,15 @@ const monthlyHeaders = computed(() => {
return headers;
});
// Grouped data
// Grouped data with safe fallbacks
const budgetWorksheet = computed(() => budgetStore.budgetWorksheet || { revenue: [], expenses: [] });
const groupedRevenue = computed(() => budgetStore.groupedRevenue);
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
// Initialize on mount
onMounted(async () => {
try {
// Only initialize if not already done (preserve persisted data)
await budgetStore.initializeFromWizardData();
} catch (error) {
console.error("Error initializing budget page:", error);
}
});
// Removed duplicate onMounted - initialization is now handled above
// Add revenue item
function addRevenueItem() {
@ -824,6 +944,7 @@ function resetWorksheet() {
}
}
// Export budget
function exportBudget() {
const data = {
@ -861,6 +982,15 @@ function getNetIncomeClass(amount: number): string {
return "text-gray-600";
}
// Payroll oncost handling
function handlePayrollOncostUpdate(newPercentage: number) {
// Update the coop store
coopBuilderStore.payrollOncostPct = newPercentage;
// Refresh the budget to reflect the new oncost percentage
budgetStore.refreshPayrollInBudget();
}
// SEO
useSeoMeta({
title: "Budget Worksheet - Plan Your Co-op's Financial Future",

View file

@ -1,101 +0,0 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Cash Calendar</h2>
<UBadge v-if="firstBreachWeek" color="red" variant="subtle"
>Week {{ firstBreachWeek }} cushion breach</UBadge
>
<UBadge v-else color="green" variant="subtle"
>No cushion breach projected</UBadge
>
</div>
<UCard>
<template #header>
<h3 class="text-lg font-medium">13-Week Cash Flow</h3>
</template>
<div class="space-y-4">
<div class="text-sm text-neutral-600">
Week-by-week cash inflows and outflows with minimum cushion tracking.
</div>
<div
class="grid grid-cols-7 gap-2 text-xs font-medium text-neutral-500">
<div>Week</div>
<div>Inflow</div>
<div>Outflow</div>
<div>Net</div>
<div>Balance</div>
<div>Cushion</div>
<div>Status</div>
</div>
<div
v-for="week in weeks"
:key="week.number"
class="grid grid-cols-7 gap-2 text-sm py-2 border-b border-neutral-100"
:class="{ 'bg-red-50': week.breachesCushion }">
<div class="font-medium">{{ week.number }}</div>
<div class="text-green-600">+{{ week.inflow.toLocaleString() }}</div>
<div class="text-red-600">-{{ week.outflow.toLocaleString() }}</div>
<div :class="week.net >= 0 ? 'text-green-600' : 'text-red-600'">
{{ week.net >= 0 ? "+" : "" }}{{ week.net.toLocaleString() }}
</div>
<div class="font-medium">{{ week.balance.toLocaleString() }}</div>
<div
:class="
week.breachesCushion
? 'text-red-600 font-medium'
: 'text-neutral-600'
">
{{ week.cushion.toLocaleString() }}
</div>
<div>
<UBadge v-if="week.breachesCushion" color="red" size="xs">
Breach
</UBadge>
<UBadge v-else color="green" size="xs"> OK </UBadge>
</div>
</div>
<div class="mt-4 p-3 bg-orange-50 rounded-lg">
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-exclamation-triangle"
class="text-orange-500" />
<span class="text-sm font-medium text-orange-800">
This week would drop below your minimum cushion.
</span>
</div>
</div>
</div>
</UCard>
</section>
</template>
<script setup lang="ts">
const cashStore = useCashStore();
const { weeklyProjections } = storeToRefs(cashStore);
const weeks = computed(() => {
// If no projections, show empty state
if (weeklyProjections.value.length === 0) {
return Array.from({ length: 13 }, (_, index) => ({
number: index + 1,
inflow: 0,
outflow: 0,
net: 0,
balance: 0,
cushion: 0,
breachesCushion: false,
}));
}
return weeklyProjections.value;
});
// Find first week that breaches cushion
const firstBreachWeek = computed(() => {
const breachWeek = weeks.value.find((week) => week.breachesCushion);
return breachWeek ? breachWeek.number : null;
});
</script>

View file

@ -48,7 +48,7 @@
<!-- Vertical Steps Layout -->
<div v-else class="space-y-4">
<!-- Step 1: Members -->
<!-- Step 1: Pay Policy -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
@ -68,12 +68,12 @@
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
membersValid
policiesValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
">
<UIcon
v-if="membersValid"
v-if="policiesValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>1</span>
@ -81,7 +81,7 @@
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
Add your team
Choose pay approach
</h3>
</div>
</div>
@ -95,12 +95,12 @@
<div
v-if="focusedStep === 1"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
<WizardMembersStep @save-status="handleSaveStatus" />
<WizardPoliciesStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 2: Wage -->
<!-- Step 2: Members -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
@ -120,12 +120,12 @@
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
policiesValid
membersValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
">
<UIcon
v-if="policiesValid"
v-if="membersValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>2</span>
@ -133,7 +133,7 @@
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
Set your wage
Add your team
</h3>
</div>
</div>
@ -147,7 +147,7 @@
<div
v-if="focusedStep === 2"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
<WizardPoliciesStep @save-status="handleSaveStatus" />
<WizardMembersStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
@ -170,13 +170,22 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white">
<UIcon name="i-heroicons-check" class="w-4 h-4" />
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
costsValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
">
<UIcon
v-if="costsValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>3</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
Monthly costs
Expenses
</h3>
</div>
</div>
@ -247,60 +256,6 @@
</div>
</div>
<!-- Step 5: Review -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 5"
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 5 ? 'item-selected' : '',
]">
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(5)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
canComplete
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
">
<UIcon
v-if="canComplete"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>5</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
Review & finish
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 5 }" />
</div>
</div>
<div
v-if="focusedStep === 5"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
<WizardReviewStep
@complete="completeWizard"
@reset="resetWizard" />
</div>
</div>
</div>
<!-- Progress Actions -->
<div class="flex justify-between items-center pt-8">
<button
@ -334,12 +289,15 @@
>
</div>
<button
v-if="canComplete"
class="export-btn primary"
@click="completeWizard">
Complete Setup
</button>
<UTooltip :text="incompleteSectionsText" :prevent="canComplete">
<button
class="export-btn primary"
:class="{ 'opacity-50 cursor-not-allowed': !canComplete }"
:disabled="!canComplete"
@click="canComplete ? completeWizard() : null">
Complete Setup
</button>
</UTooltip>
</div>
</div>
</div>
@ -357,11 +315,6 @@ const saveStatus = ref("");
const isResetting = ref(false);
const isCompleted = ref(false);
// Computed validation
const canComplete = computed(() => {
return coop.members.value.length > 0 && coop.streams.value.length > 0;
});
// Local validity flags for step headers
const membersValid = computed(() => {
// Valid if at least one member with a name and positive hours
@ -375,7 +328,12 @@ const membersValid = computed(() => {
const policiesValid = computed(() => {
// Placeholder policy validity; mark true when wage text or policy set exists
// Since policy not persisted yet in this store, consider valid when any member exists
return membersValid.value;
return coop.members.value.length > 0;
});
const costsValid = computed(() => {
// Costs are optional, so always mark as valid for now
return true;
});
const streamsValid = computed(() => {
@ -389,6 +347,29 @@ const streamsValid = computed(() => {
);
});
// Computed validation - all 4 steps must be valid
const canComplete = computed(() => {
return (
policiesValid.value &&
membersValid.value &&
costsValid.value &&
streamsValid.value
);
});
// Generate tooltip text for incomplete sections
const incompleteSectionsText = computed(() => {
if (canComplete.value) return "";
const incomplete = [];
if (!policiesValid.value) incomplete.push("Choose pay approach");
if (!membersValid.value) incomplete.push("Add team members");
if (!costsValid.value) incomplete.push("Add monthly costs");
if (!streamsValid.value) incomplete.push("Add revenue streams");
return `Complete these sections: ${incomplete.join(", ")}`;
});
// Save status handler
function handleSaveStatus(status: "saving" | "saved" | "error") {
saveStatus.value = status;
@ -404,12 +385,14 @@ function handleSaveStatus(status: "saving" | "saved" | "error") {
// Step management
function setFocusedStep(step: number) {
console.log("Setting focused step to:", step, "Current:", focusedStep.value);
// Toggle if clicking on already focused step
if (focusedStep.value === step) {
focusedStep.value = 0; // Close the section
} else {
focusedStep.value = step; // Open the section
}
console.log("Focused step is now:", focusedStep.value);
}
function completeWizard() {

View file

@ -43,31 +43,6 @@
</div>
</div>
</UCard>
<!-- Value Accounting Section -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Value Accounting</h3>
<UBadge color="blue" variant="subtle">January 2024</UBadge>
</div>
</template>
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-2">
Next Value Session due January 2024
</p>
<div class="flex items-center gap-4">
<UProgress :value="50" :max="100" color="blue" class="w-32" />
<span class="text-sm text-gray-600">2/4 prep steps done</span>
</div>
</div>
<UButton color="primary" @click="navigateTo('/session')">
Start Session
</UButton>
</div>
</UCard>
</div>
</template>

View file

@ -19,21 +19,6 @@
<!-- Member Coverage -->
<MemberCoveragePanel />
<!-- Advanced Tools -->
<AdvancedAccordion />
<!-- Next Session -->
<UCard class="shadow-sm rounded-xl">
<div class="flex items-center justify-between">
<div class="space-y-1">
<h3 class="font-semibold">Next Value Session</h3>
<p class="text-sm text-gray-600">Review contributions and distribute surplus</p>
</div>
<UButton color="primary" @click="navigateTo('/session')">
Start Session
</UButton>
</div>
</UCard>
</div>
</template>
@ -41,7 +26,6 @@
// Import components explicitly to avoid auto-import issues
import DashboardCoreMetrics from '~/components/dashboard/DashboardCoreMetrics.vue'
import MemberCoveragePanel from '~/components/dashboard/MemberCoveragePanel.vue'
import AdvancedAccordion from '~/components/dashboard/AdvancedAccordion.vue'
// Access composable data
const { operatingMode, setOperatingMode } = useCoopBuilder()

View file

@ -57,12 +57,6 @@ const glossaryTerms = ref([
"Month-by-month plan of money in and money out. Not exact dates.",
example: "January budget shows €12,000 revenue and €9,900 costs",
},
{
id: "cash-flow",
term: "Cash Flow",
definition: "The actual dates money moves. Shows timing risk.",
example: "Client pays Net 30, so January work arrives in February",
},
{
id: "concentration",
term: "Concentration",
@ -143,13 +137,6 @@ const glossaryTerms = ref([
definition: "Money left over after all costs are paid.",
example: "€12,000 revenue - €9,900 costs = €2,100 surplus",
},
{
id: "value-accounting",
term: "Value Accounting",
definition:
"Monthly process to review contributions and distribute surplus.",
example: "January session: review work, repay deferred pay, fund training",
},
]);
// Filter terms based on search

View file

@ -117,7 +117,7 @@
<h3 class="text-xl font-semibold mb-3">Monthly vs Annual Planning</h3>
<p class="mb-4">
[Add content about balancing monthly cash flow with annual strategic planning]
[Add content about balancing monthly budgets with annual strategic planning]
</p>
<h3 class="text-xl font-semibold mb-3">Setting Realistic Goals</h3>

View file

@ -113,8 +113,7 @@
title="Revenue Concentration Risk"
:description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
:actions="[
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') },
{ label: 'Scenarios', click: () => handleAlertNavigation('/scenarios', 'diversification') }
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') }
]" />
<!-- Cushion Breach Alert -->
@ -153,7 +152,6 @@
:description="deferredAlert.description"
:actions="[
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
{ label: 'Value Session', click: () => handleAlertNavigation('/session', 'distributions') }
]" />
<!-- Success message when no alerts -->
@ -166,224 +164,8 @@
</div>
</UCard>
<!-- Scenario Snapshots -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Scenario Snapshots</h3>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Current Scenario -->
<div class="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">{{ scenarios.current.name }}</h4>
<UBadge color="green" variant="subtle" size="xs">{{ scenarios.current.status }}</UBadge>
</div>
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.current.runway)">
{{ Math.round(scenarios.current.runway * 10) / 10 }} months
</div>
<p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.current.monthlyNet) }}/mo
</p>
</div>
<!-- Quit Jobs Scenario -->
<div class="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">{{ scenarios.quitJobs.name }}</h4>
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.quitJobs.status }}</UBadge>
</div>
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.quitJobs.runway)">
{{ Math.round(scenarios.quitJobs.runway * 10) / 10 }} months
</div>
<p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.quitJobs.monthlyNet) }}/mo
</p>
</div>
<!-- Start Production Scenario -->
<div class="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">{{ scenarios.startProduction.name }}</h4>
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.startProduction.status }}</UBadge>
</div>
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.startProduction.runway)">
{{ Math.round(scenarios.startProduction.runway * 10) / 10 }} months
</div>
<p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo
</p>
</div>
</div>
<div class="mt-4">
<UButton variant="outline" @click="navigateTo('/scenarios')">
Compare All Scenarios
</UButton>
</div>
</UCard>
<!-- Next Value Accounting Session -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Next Value Accounting Session</h3>
<UBadge color="blue" variant="subtle">January 2024</UBadge>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="font-medium mb-3">Session Preparation</h4>
<div class="space-y-2">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm">Month closed & reviewed</span>
</div>
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm">Contributions logged</span>
</div>
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
<span class="text-sm text-neutral-600">Surplus calculated</span>
</div>
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
<span class="text-sm text-neutral-600"
>Member needs reviewed</span
>
</div>
</div>
<div class="mt-4">
<UProgress value="50" :max="100" color="blue" />
<p class="text-xs text-neutral-600 mt-1">2 of 4 items complete</p>
</div>
</div>
<div>
<h4 class="font-medium mb-3">Available for Distribution</h4>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Surplus</span>
<span class="font-medium text-green-600">{{
$format.currency(metrics.finances.surplus || 0)
}}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Deferred owed</span>
<span class="font-medium text-orange-600">{{
$format.currency(
metrics.finances.deferredLiabilities.totalDeferred
)
}}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Savings gap</span>
<span class="font-medium text-blue-600">{{
$format.currency(metrics.finances.savingsGap || 0)
}}</span>
</div>
</div>
<div class="mt-4">
<UButton color="primary" @click="navigateTo('/session')">
Start Session
</UButton>
</div>
</div>
</div>
</UCard>
<!-- Advanced Planning Panel -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Advanced Planning</h3>
<UButton
variant="ghost"
size="sm"
@click="showAdvanced = !showAdvanced"
:icon="showAdvanced ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
>
{{ showAdvanced ? 'Hide' : 'Show' }} Advanced
</UButton>
</div>
</template>
<div v-show="showAdvanced" class="space-y-6">
<!-- Stress Tests -->
<div class="border rounded-lg p-4">
<h4 class="font-medium mb-3">Stress Tests</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="text-sm font-medium text-gray-700 mb-1 block">Revenue Delay (months)</label>
<UInput
v-model="stressTests.revenueDelay"
type="number"
min="0"
max="6"
size="sm"
@input="updateStressTest"
/>
</div>
<div>
<label class="text-sm font-medium text-gray-700 mb-1 block">Cost Shock (%)</label>
<UInput
v-model="stressTests.costShockPct"
type="number"
min="0"
max="100"
size="sm"
@input="updateStressTest"
/>
</div>
<div>
<label class="text-sm font-medium text-gray-700 mb-1 block">Major Grant Lost</label>
<UToggle
v-model="stressTests.grantLost"
@update:model-value="updateStressTest"
/>
</div>
</div>
<!-- Stress Test Results -->
<div v-if="hasStressTest" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
<div class="flex items-center justify-between">
<div>
<h5 class="font-medium text-yellow-800">Stress Test Results</h5>
<p class="text-sm text-yellow-700">
Runway under stress: {{ Math.round(stressedRunway * 10) / 10 }} months
({{ Math.round((stressedRunway - metrics.runway) * 10) / 10 }} month change)
</p>
</div>
<UButton size="xs" @click="applyStressTest">Apply to Plan</UButton>
</div>
</div>
</div>
<!-- Policy Sandbox -->
<div class="border rounded-lg p-4">
<h4 class="font-medium mb-3">Policy Sandbox</h4>
<p class="text-sm text-gray-600 mb-3">
Try different pay relationships without overwriting your current plan.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-700 mb-1 block">Test Pay Policy</label>
<USelect
v-model="sandboxPolicy"
:options="policyOptions"
size="sm"
@update:model-value="updateSandboxPolicy"
/>
</div>
<div v-if="sandboxRunway">
<label class="text-sm font-medium text-gray-700 mb-1 block">Projected Runway</label>
<div class="text-lg font-bold" :class="getRunwayColor(sandboxRunway)">
{{ Math.round(sandboxRunway * 10) / 10 }} months
</div>
</div>
</div>
</div>
</div>
</UCard>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@ -398,38 +180,7 @@
</div>
</UButton>
<UButton
block
variant="ghost"
class="justify-start h-auto p-4"
@click="navigateTo('/cash')">
<div class="text-left">
<div class="font-medium">Cash Calendar</div>
<div class="text-xs text-neutral-500">13-week cash flow</div>
</div>
</UButton>
<UButton
block
variant="ghost"
class="justify-start h-auto p-4"
@click="navigateTo('/scenarios')">
<div class="text-left">
<div class="font-medium">Scenarios</div>
<div class="text-xs text-neutral-500">What-if analysis</div>
</div>
</UButton>
<UButton
block
color="primary"
class="justify-start h-auto p-4"
@click="navigateTo('/session')">
<div class="text-left">
<div class="font-medium">Next Session</div>
<div class="text-xs">Value Accounting</div>
</div>
</UButton>
</div>
</section>
</template>
@ -451,27 +202,6 @@ const { getDualModeRunway, getMonthlyBurn } = useRunway();
// Cushion forecast and savings progress
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
// Scenario calculations
const { scenarios } = useScenarios();
// Advanced panel state
const showAdvanced = ref(false);
// Stress testing
const stressTests = ref({
revenueDelay: 0,
costShockPct: 0,
grantLost: false
});
// Policy sandbox
const sandboxPolicy = ref('equal-pay');
const policyOptions = [
{ label: 'Equal Pay', value: 'equal-pay' },
{ label: 'Needs Weighted', value: 'needs-weighted' },
{ label: 'Hours Weighted', value: 'hours-weighted' },
{ label: 'Role Banded', value: 'role-banded' }
];
// Calculate metrics from real store data
const metrics = computed(() => {
@ -573,21 +303,6 @@ function getRunwayColor(months: number): string {
return 'text-red-600'
}
// Calculate scenario metrics
const scenarioMetrics = computed(() => {
const baseRunway = metrics.value.runway;
return {
current: {
runway: Math.round(baseRunway * 100) / 100 || 0,
},
quitJobs: {
runway: Math.round(baseRunway * 0.7 * 100) / 100 || 0, // Shorter runway due to higher costs
},
startProduction: {
runway: Math.round(baseRunway * 0.8 * 100) / 100 || 0, // Moderate impact
},
};
});
// Cash breach description
const cashBreachDescription = computed(() => {
@ -629,135 +344,6 @@ const onImport = async () => {
const { exportAll, importAll } = useFixtureIO();
// Advanced panel computed properties and methods
const hasStressTest = computed(() => {
return stressTests.value.revenueDelay > 0 ||
stressTests.value.costShockPct > 0 ||
stressTests.value.grantLost;
});
const stressedRunway = computed(() => {
if (!hasStressTest.value) return metrics.value.runway;
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
// Apply stress test adjustments
let adjustedRevenue = streamsStore.streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0);
let adjustedCosts = getMonthlyBurn();
// Revenue delay impact (reduce revenue by delay percentage)
if (stressTests.value.revenueDelay > 0) {
adjustedRevenue *= Math.max(0, 1 - (stressTests.value.revenueDelay / 12));
}
// Cost shock impact
if (stressTests.value.costShockPct > 0) {
adjustedCosts *= (1 + stressTests.value.costShockPct / 100);
}
// Grant lost (remove largest revenue stream if it's a grant)
if (stressTests.value.grantLost) {
const grantStreams = streamsStore.streams.filter(s =>
s.category?.toLowerCase().includes('grant') ||
s.name.toLowerCase().includes('grant')
);
if (grantStreams.length > 0) {
const largestGrant = Math.max(...grantStreams.map(s => s.targetMonthlyAmount || 0));
adjustedRevenue -= largestGrant;
}
}
const netMonthly = adjustedRevenue - adjustedCosts;
const burnRate = netMonthly < 0 ? Math.abs(netMonthly) : adjustedCosts;
return burnRate > 0 ? (cash + savings) / burnRate : Infinity;
});
const sandboxRunway = computed(() => {
if (!sandboxPolicy.value || sandboxPolicy.value === policiesStore.payPolicy?.relationship) {
return null;
}
// Calculate runway with sandbox policy
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
// Create sandbox policy object
const testPolicy = {
relationship: sandboxPolicy.value,
equalHourlyWage: policiesStore.equalHourlyWage,
roleBands: policiesStore.payPolicy?.roleBands || []
};
// Use scenario calculation with sandbox policy
const { calculateScenarioRunway } = useScenarios();
const result = calculateScenarioRunway(membersStore.members, streamsStore.streams);
// Apply simple adjustment based on policy type
let policyMultiplier = 1;
switch (sandboxPolicy.value) {
case 'needs-weighted':
policyMultiplier = 0.9; // Slightly higher costs
break;
case 'role-banded':
policyMultiplier = 0.85; // Higher costs due to senior roles
break;
case 'hours-weighted':
policyMultiplier = 0.95; // Moderate increase
break;
}
return result.runway * policyMultiplier;
});
function updateStressTest() {
// Reactive computed will handle updates automatically
}
function updateSandboxPolicy() {
// Reactive computed will handle updates automatically
}
function applyStressTest() {
// Apply stress test adjustments to the actual plan
if (stressTests.value.revenueDelay > 0) {
// Reduce all stream targets by delay impact
streamsStore.streams.forEach(stream => {
const reduction = (stressTests.value.revenueDelay / 12) * (stream.targetMonthlyAmount || 0);
streamsStore.updateStream(stream.id, {
targetMonthlyAmount: Math.max(0, (stream.targetMonthlyAmount || 0) - reduction)
});
});
}
if (stressTests.value.costShockPct > 0) {
// Increase overhead costs
const shockMultiplier = 1 + (stressTests.value.costShockPct / 100);
budgetStore.overheadCosts.forEach(cost => {
budgetStore.updateOverheadCost(cost.id, {
amount: (cost.amount || 0) * shockMultiplier
});
});
}
if (stressTests.value.grantLost) {
// Remove or reduce grant streams
const grantStreams = streamsStore.streams.filter(s =>
s.category?.toLowerCase().includes('grant') ||
s.name.toLowerCase().includes('grant')
);
if (grantStreams.length > 0) {
const largestGrant = grantStreams.reduce((prev, current) =>
(prev.targetMonthlyAmount || 0) > (current.targetMonthlyAmount || 0) ? prev : current
);
streamsStore.updateStream(largestGrant.id, { targetMonthlyAmount: 0 });
}
}
// Reset stress tests
stressTests.value = { revenueDelay: 0, costShockPct: 0, grantLost: false };
};
// Deferred alert logic
const deferredAlert = computed(() => {

View file

@ -1,7 +1,17 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Revenue Mix Planner</h2>
<div>
<h2 class="text-2xl font-semibold">Revenue Mix Planner</h2>
<div v-if="isSetupComplete" class="flex items-center gap-2 mt-1">
<UBadge color="green" variant="subtle" size="xs">
Synchronized with Setup
</UBadge>
<UButton variant="ghost" size="xs" @click="goToSetup">
Edit in Setup
</UButton>
</div>
</div>
<UButton color="primary" @click="sendToBudget">
Send to Budget & Scenarios
</UButton>
@ -169,9 +179,19 @@
<script setup lang="ts">
const { $format } = useNuxtApp();
// Use real store data instead of fixtures
// Use synchronized store data - setup is the source of truth
const { initSync, getStreams, unifiedStreams } = useStoreSync();
const { isSetupComplete, goToSetup } = useSetupState();
const streamsStore = useStreamsStore();
const { streams } = storeToRefs(streamsStore);
const coopStore = useCoopBuilderStore();
// Initialize synchronization on mount
onMounted(async () => {
await initSync();
});
// Use reactive synchronized streams data
const streams = unifiedStreams;
const columns = [
{ id: "name", key: "name", label: "Stream" },
@ -184,8 +204,15 @@ const columns = [
{ id: "actions", key: "actions", label: "" },
];
const totalTargetPct = computed(() => streamsStore.totalTargetPct);
const totalMonthlyAmount = computed(() => streamsStore.totalMonthlyAmount);
const totalTargetPct = computed(() => {
// Calculate from the unified streams data
return streams.value.reduce((sum, stream) => sum + (stream.targetPct || 0), 0);
});
const totalMonthlyAmount = computed(() => {
// Calculate from the unified streams data
return streams.value.reduce((sum, stream) => sum + (stream.targetMonthlyAmount || stream.monthly || 0), 0);
});
// Calculate concentration metrics
const topSourcePct = computed(() => {
@ -244,16 +271,41 @@ function getRowActions(row: any) {
}
function updateStream(id: string, field: string, value: any) {
const stream = streams.value.find((s) => s.id === id);
if (stream) {
stream[field] = Number(value) || value;
streamsStore.upsertStream(stream);
// Update the primary CoopBuilder store (source of truth)
const coopStream = coopStore.streams.find((s) => s.id === id);
if (coopStream) {
if (field === 'targetMonthlyAmount') {
coopStream.monthly = Number(value) || 0;
} else {
coopStream[field] = Number(value) || value;
}
coopStore.upsertStream(coopStream);
}
// Also update the legacy store for backward compatibility
const legacyStream = streams.value.find((s) => s.id === id);
if (legacyStream) {
legacyStream[field] = Number(value) || value;
streamsStore.upsertStream(legacyStream);
}
}
function addStream() {
const newStream = {
id: Date.now().toString(),
const newStreamId = Date.now().toString();
// Add to CoopBuilder store first (primary source)
const coopStream = {
id: newStreamId,
label: "",
monthly: 0,
category: "games",
certainty: "Aspirational",
};
coopStore.upsertStream(coopStream);
// Add to legacy store for compatibility
const legacyStream = {
id: newStreamId,
name: "",
category: "games",
subcategory: "",
@ -268,7 +320,7 @@ function addStream() {
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0,
};
streamsStore.upsertStream(newStream);
streamsStore.upsertStream(legacyStream);
}
function editStream(row: any) {
@ -282,6 +334,8 @@ function duplicateStream(row: any) {
}
function removeStream(row: any) {
// Remove from both stores to maintain sync
coopStore.removeStream(row.id);
streamsStore.removeStream(row.id);
}

View file

@ -1,575 +0,0 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Scenarios & Runway</h2>
<UButton
variant="outline"
color="red"
size="sm"
@click="restartWizard"
:disabled="isResetting">
<UIcon name="i-heroicons-arrow-path" class="mr-1" />
Restart Setup (Testing)
</UButton>
</div>
<!-- 6-Month Preset Card -->
<UCard class="bg-blue-50 border-blue-200">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-blue-900">
6-Month Plan Analysis
</h3>
<UBadge color="info" variant="solid">Recommended</UBadge>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-blue-600 mb-2">
{{ sixMonthScenario.runway }} months
</div>
<div class="text-sm text-neutral-600 mb-3">Extended runway</div>
<UProgress value="91" color="info" />
</div>
<div>
<h4 class="font-medium mb-3">Key Changes</h4>
<ul class="text-sm text-neutral-600 space-y-1">
<li> Diversify revenue mix</li>
<li> Build 6-month savings buffer</li>
<li> Gradual capacity scaling</li>
<li> Risk mitigation focus</li>
</ul>
</div>
<div>
<h4 class="font-medium mb-3">Feasibility Gates</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm">Savings target achievable</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm">Cash floor maintained</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-exclamation-triangle"
class="text-yellow-500" />
<span class="text-sm">Requires 2 new streams</span>
</div>
</div>
</div>
</div>
</UCard>
<!-- Scenario Comparison -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<UCard class="border-green-200 bg-green-50">
<div class="text-center space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-sm">Operate Current</h4>
<UBadge color="success" variant="solid" size="xs">Active</UBadge>
</div>
<div class="text-2xl font-bold text-orange-600">
{{ currentScenario.runway }} months
</div>
<div class="text-xs text-neutral-600">Baseline scenario</div>
<UButton size="xs" variant="ghost" @click="setScenario('current')">
<UIcon name="i-heroicons-play" class="mr-1" />
Continue
</UButton>
</div>
</UCard>
<UCard>
<div class="text-center space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-sm">Quit Day Jobs</h4>
<UBadge color="error" variant="subtle" size="xs">High Risk</UBadge>
</div>
<div class="text-2xl font-bold text-red-600">
{{ quitDayJobsScenario.runway }} months
</div>
<div class="text-xs text-neutral-600">Full-time co-op work</div>
<UButton
size="xs"
variant="ghost"
@click="setScenario('quitDayJobs')">
<UIcon name="i-heroicons-briefcase" class="mr-1" />
Analyze
</UButton>
</div>
</UCard>
<UCard>
<div class="text-center space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-sm">Start Production</h4>
<UBadge color="warning" variant="subtle" size="xs"
>Medium Risk</UBadge
>
</div>
<div class="text-2xl font-bold text-yellow-600">
{{ startProductionScenario.runway }} months
</div>
<div class="text-xs text-neutral-600">Launch development</div>
<UButton
size="xs"
variant="ghost"
@click="setScenario('startProduction')">
<UIcon name="i-heroicons-rocket-launch" class="mr-1" />
Analyze
</UButton>
</div>
</UCard>
<UCard class="border-blue-200">
<div class="text-center space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-sm">6-Month Plan</h4>
<UBadge color="info" variant="solid" size="xs">Planned</UBadge>
</div>
<div class="text-2xl font-bold text-blue-600">
{{ sixMonthScenario.runway }} months
</div>
<div class="text-xs text-neutral-600">Extended planning</div>
<UButton size="xs" color="primary" @click="setScenario('sixMonth')">
<UIcon name="i-heroicons-calendar" class="mr-1" />
Plan
</UButton>
</div>
</UCard>
</div>
<!-- Feasibility Analysis -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Feasibility Analysis</h3>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="font-medium mb-3">Gate Checks</h4>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm">Savings Target Reached</span>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-x-circle" class="text-red-500" />
<span class="text-sm text-neutral-600">5,200 short</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Cash Floor Maintained</span>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
<span class="text-sm text-neutral-600">Week 4+</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Revenue Diversification</span>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-exclamation-triangle"
class="text-yellow-500" />
<span class="text-sm text-neutral-600"
>Top: {{ topSourcePct }}%</span
>
</div>
</div>
</div>
</div>
<div>
<h4 class="font-medium mb-3">Key Dates</h4>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-neutral-600">Savings gate clear:</span>
<span class="text-sm font-medium">{{
keyDates.savingsGate || "Not projected"
}}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-neutral-600">First cash breach:</span>
<span class="text-sm font-medium text-red-600">{{
keyDates.firstBreach || "None projected"
}}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-neutral-600">Deferred cap reset:</span>
<span class="text-sm font-medium">{{
keyDates.deferredReset || "Not scheduled"
}}</span>
</div>
</div>
</div>
</div>
</UCard>
<!-- What-If Sliders -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">What-If Analysis</h3>
<UButton size="sm" variant="ghost" @click="resetSliders">
Reset
</UButton>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label for="revenue-slider" class="block text-sm font-medium mb-2">
<GlossaryTooltip
term="Monthly Revenue"
term-id="revenue"
definition="Total money earned from all streams in one month." />:
{{ $format.currency(revenue) }}
</label>
<div class="flex items-center gap-3">
<URange
id="revenue-slider"
v-model="revenue"
:min="5000"
:max="20000"
:step="500"
:aria-label="`Monthly revenue: ${$format.currency(revenue)}`"
class="flex-1" />
<UInput
v-model="revenue"
type="number"
:min="5000"
:max="20000"
:step="500"
class="w-24"
size="xs"
aria-label="Monthly revenue input" />
</div>
</div>
<div>
<label for="hours-slider" class="block text-sm font-medium mb-2">
Paid Hours: {{ paidHours }}h/month
</label>
<div class="flex items-center gap-3">
<URange
id="hours-slider"
v-model="paidHours"
:min="100"
:max="600"
:step="20"
:aria-label="`Paid hours: ${paidHours} per month`"
class="flex-1" />
<UInput
v-model="paidHours"
type="number"
:min="100"
:max="600"
:step="20"
class="w-20"
size="xs"
aria-label="Paid hours input" />
</div>
</div>
<div>
<label for="winrate-slider" class="block text-sm font-medium mb-2">
Win Rate: {{ winRate }}%
</label>
<div class="flex items-center gap-3">
<URange
id="winrate-slider"
v-model="winRate"
:min="40"
:max="95"
:step="5"
:aria-label="`Win rate: ${winRate} percent`"
class="flex-1" />
<UInput
v-model="winRate"
type="number"
:min="40"
:max="95"
:step="5"
class="w-16"
size="xs"
aria-label="Win rate input" />
</div>
</div>
</div>
<div class="space-y-4">
<div class="bg-neutral-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-3">Impact on Runway</h4>
<div class="text-center">
<div
class="text-2xl font-bold"
:class="getRunwayColor(calculatedRunway)">
{{ calculatedRunway }} months
</div>
<UProgress
:value="Math.min(calculatedRunway * 10, 100)"
:max="100"
:color="getProgressColor(calculatedRunway)"
class="mt-2" />
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Monthly burn:</span>
<span class="font-medium"
>{{ monthlyBurn.toLocaleString() }}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Coverage ratio:</span>
<span class="font-medium"
>{{ Math.round((paidHours / 400) * 100) }}%</span
>
</div>
</div>
</div>
</div>
</UCard>
</section>
</template>
<script setup lang="ts">
const { $format } = useNuxtApp();
const route = useRoute();
const router = useRouter();
const scenariosStore = useScenariosStore();
// Restart wizard functionality
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const cashStore = useCashStore();
const sessionStore = useSessionStore();
const coopBuilderStore = useCoopBuilderStore();
const isResetting = ref(false);
// Get initial values from stores
const initialRevenue = computed(() => streamsStore.totalMonthlyAmount || 0);
const initialHours = computed(
() => membersStore.capacityTotals.targetHours || 0
);
const revenue = ref(initialRevenue.value || 0);
const paidHours = ref(initialHours.value || 0);
const winRate = ref(0);
// Watch for store changes and update sliders
watch(initialRevenue, (newVal) => {
if (newVal > 0) revenue.value = newVal;
});
watch(initialHours, (newVal) => {
if (newVal > 0) paidHours.value = newVal;
});
// Calculate dynamic metrics from real store data
const monthlyBurn = computed(() => {
const hourlyWage = policiesStore.equalHourlyWage || 0;
const oncostPct = policiesStore.payrollOncostPct || 0;
const payroll = paidHours.value * hourlyWage * (1 + oncostPct / 100);
const overhead =
budgetStore.overheadCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
0
) || 0;
const production =
budgetStore.productionCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
0
) || 0;
return payroll + overhead + production;
});
const calculatedRunway = computed(() => {
const totalCash = cashStore.currentCash + cashStore.currentSavings;
const adjustedRevenue = revenue.value * (winRate.value / 100);
const netPerMonth = adjustedRevenue - monthlyBurn.value;
if (netPerMonth >= 0)
return monthlyBurn.value > 0
? Math.round((totalCash / monthlyBurn.value) * 100) / 100
: 0;
return Math.max(
0,
Math.round((totalCash / Math.abs(netPerMonth)) * 100) / 100
);
});
// Scenario calculations based on real data
const totalCash = computed(
() => cashStore.currentCash + cashStore.currentSavings
);
const baseRunway = computed(() => {
const baseBurn = monthlyBurn.value;
return baseBurn > 0
? Math.round((totalCash.value / baseBurn) * 100) / 100
: 0;
});
const currentScenario = computed(() => ({
runway: baseRunway.value || 0,
}));
const quitDayJobsScenario = computed(() => ({
runway:
monthlyBurn.value > 0
? Math.max(
0,
Math.round((totalCash.value / (monthlyBurn.value * 1.8)) * 100) / 100
)
: 0, // Higher burn rate
}));
const startProductionScenario = computed(() => ({
runway:
monthlyBurn.value > 0
? Math.max(
0,
Math.round((totalCash.value / (monthlyBurn.value * 1.4)) * 100) / 100
)
: 0, // Medium higher burn
}));
const sixMonthScenario = computed(() => ({
runway:
monthlyBurn.value > 0
? Math.max(
0,
Math.round((totalCash.value / (monthlyBurn.value * 0.6)) * 100) / 100
)
: 0, // Lower burn with optimization
}));
// Calculate concentration from real data
const topSourcePct = computed(() => {
if (streamsStore.streams.length === 0) return 0;
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
const total = amounts.reduce((sum, amt) => sum + amt, 0);
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
});
// Calculate key dates from real data
const keyDates = computed(() => {
const currentDate = new Date();
// Calculate savings gate clear date based on current savings and target
const savingsNeeded =
(policiesStore.savingsTargetMonths || 0) * monthlyBurn.value;
const currentSavings = cashStore.currentSavings;
const monthlyNet = revenue.value - monthlyBurn.value;
let savingsGate = null;
if (savingsNeeded > 0 && currentSavings < savingsNeeded && monthlyNet > 0) {
const monthsToTarget = Math.ceil(
(savingsNeeded - currentSavings) / monthlyNet
);
const targetDate = new Date(currentDate);
targetDate.setMonth(targetDate.getMonth() + monthsToTarget);
savingsGate = targetDate.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
}
// First cash breach from cash store projections
const firstBreachWeek = cashStore.firstBreachWeek;
let firstBreach = null;
if (firstBreachWeek) {
const breachDate = new Date(currentDate);
breachDate.setDate(breachDate.getDate() + firstBreachWeek * 7);
firstBreach = `Week ${firstBreachWeek} (${breachDate.toLocaleDateString(
"en-US",
{ month: "short", day: "numeric" }
)})`;
}
// Deferred cap reset - quarterly (every 3 months)
let deferredReset = null;
if (policiesStore.deferredCapHoursPerQtr > 0) {
const nextQuarter = new Date(currentDate);
const currentMonth = nextQuarter.getMonth();
const quarterStartMonth = Math.floor(currentMonth / 3) * 3;
nextQuarter.setMonth(quarterStartMonth + 3, 1);
deferredReset = nextQuarter.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
return {
savingsGate,
firstBreach,
deferredReset,
};
});
function getRunwayColor(months: number) {
if (months >= 6) return "text-green-600";
if (months >= 3) return "text-blue-600";
if (months >= 2) return "text-yellow-600";
return "text-red-600";
}
function getProgressColor(months: number) {
if (months >= 6) return "success";
if (months >= 3) return "info";
if (months >= 2) return "warning";
return "error";
}
function setScenario(scenario: string) {
scenariosStore.setActiveScenario(scenario);
router.replace({ query: { ...route.query, scenario } });
}
function resetSliders() {
revenue.value = initialRevenue.value || 0;
paidHours.value = initialHours.value || 0;
winRate.value = 0;
}
async function restartWizard() {
isResetting.value = true;
// Clear all localStorage persistence
if (typeof localStorage !== "undefined") {
localStorage.removeItem("urgent-tools-members");
localStorage.removeItem("urgent-tools-policies");
localStorage.removeItem("urgent-tools-streams");
localStorage.removeItem("urgent-tools-budget");
localStorage.removeItem("urgent-tools-cash");
localStorage.removeItem("urgent-tools-session");
localStorage.removeItem("urgent-tools-scenarios");
}
// Reset all stores
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
sessionStore.resetSession();
// Reset wizard state
coopBuilderStore.reset();
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
// Navigate to coop planner
await navigateTo("/coop-planner");
}
onMounted(() => {
const q = route.query.scenario;
if (typeof q === "string") {
scenariosStore.setActiveScenario(q);
}
});
</script>

View file

@ -1,173 +0,0 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Value Accounting Session</h2>
<UBadge color="primary" variant="subtle">January 2024</UBadge>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<UCard>
<template #header>
<h3 class="text-lg font-medium">Checklist</h3>
</template>
<div class="space-y-3">
<div class="flex items-center gap-3">
<UCheckbox v-model="checklist.monthClosed" />
<span class="text-sm">Month closed & reviewed</span>
</div>
<div class="flex items-center gap-3">
<UCheckbox v-model="checklist.contributionsLogged" />
<span class="text-sm">Contributions logged</span>
</div>
<div class="flex items-center gap-3">
<UCheckbox v-model="checklist.surplusCalculated" />
<span class="text-sm">Surplus calculated</span>
</div>
<div class="flex items-center gap-3">
<UCheckbox v-model="checklist.needsReviewed" />
<span class="text-sm">Member needs reviewed</span>
</div>
</div>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Available</h3>
</template>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Surplus</span>
<span class="font-medium text-green-600"
>{{ availableAmounts.surplus.toLocaleString() }}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Deferred owed</span>
<span class="font-medium text-orange-600"
>{{ availableAmounts.deferredOwed.toLocaleString() }}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Savings gap</span>
<span class="font-medium text-blue-600"
>{{ availableAmounts.savingsNeeded.toLocaleString() }}</span
>
</div>
</div>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Distribution</h3>
</template>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium mb-1">Deferred Repay</label>
<UInput
v-model.number="draftAllocations.deferredRepay"
type="number"
size="sm" />
</div>
<div>
<label class="block text-xs font-medium mb-1">Savings</label>
<UInput
v-model.number="draftAllocations.savings"
type="number"
size="sm" />
</div>
<div>
<label class="block text-xs font-medium mb-1">Training</label>
<UInput
v-model.number="draftAllocations.training"
type="number"
size="sm" />
</div>
<div>
<label class="block text-xs font-medium mb-1">Retained</label>
<UInput
v-model.number="draftAllocations.retained"
type="number"
size="sm"
readonly />
</div>
</div>
</UCard>
</div>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Decision Record</h3>
</template>
<div class="space-y-4">
<UTextarea
v-model="rationale"
placeholder="Brief rationale for this month's distribution decisions..."
rows="3" />
<div class="flex justify-end gap-3">
<UButton variant="ghost"> Save Draft </UButton>
<UButton color="primary" :disabled="!allChecklistComplete">
Complete Session
</UButton>
</div>
</div>
</UCard>
</section>
</template>
<script setup lang="ts">
// Use stores
const sessionStore = useSessionStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const budgetStore = useBudgetStore();
const streamsStore = useStreamsStore();
// Use store refs
const { checklist, draftAllocations, rationale, availableAmounts } =
storeToRefs(sessionStore);
const allChecklistComplete = computed(() => {
return Object.values(checklist.value).every(Boolean);
});
// Calculate available amounts from real data
const calculatedAvailableAmounts = computed(() => {
// Calculate surplus from budget metrics
const totalRevenue = streamsStore.totalMonthlyAmount || 0;
const totalHours = membersStore.capacityTotals.targetHours || 0;
const hourlyWage = policiesStore.equalHourlyWage || 0;
const oncostPct = policiesStore.payrollOncostPct || 0;
const totalPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
const totalOverhead = budgetStore.overheadCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
0
);
const surplus = Math.max(0, totalRevenue - totalPayroll - totalOverhead);
// Calculate deferred owed
const deferredOwed = membersStore.members.reduce((sum, member) => {
return sum + (member.deferredHours || 0) * hourlyWage;
}, 0);
// Calculate savings gap
const savingsTarget =
(policiesStore.savingsTargetMonths || 0) * (totalPayroll + totalOverhead);
const savingsNeeded = Math.max(0, savingsTarget);
return {
surplus,
deferredOwed,
savingsNeeded,
};
});
// Update store available amounts when calculated values change
watch(
calculatedAvailableAmounts,
(newAmounts) => {
sessionStore.updateAvailableAmounts(newAmounts);
},
{ immediate: true }
);
</script>

View file

@ -121,6 +121,53 @@
</UCard>
</div>
<!-- Member Management Section -->
<UCard id="members">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Team Members</h3>
<div class="flex items-center gap-2">
<UBadge v-if="isSetupComplete" color="green" variant="subtle" size="xs">
Synchronized with Setup
</UBadge>
<UButton variant="ghost" size="xs" @click="goToSetup">
Edit in Setup
</UButton>
</div>
</div>
</template>
<div class="space-y-4">
<div v-if="members.length === 0" class="text-center py-8 text-neutral-500">
<p class="mb-4">No team members found.</p>
<UButton @click="goToSetup" variant="outline">
Add Members in Setup
</UButton>
</div>
<div v-else class="space-y-3">
<div
v-for="member in members"
:key="member.id"
class="p-4 border border-neutral-200 rounded-lg bg-neutral-50">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium">{{ member.displayName || member.name }}</h4>
<div class="text-sm text-neutral-600 space-y-1">
<div v-if="member.role">Role: {{ member.role }}</div>
<div>Monthly Target: {{ $format.currency(member.monthlyPayPlanned || 0) }}</div>
<div v-if="member.minMonthlyNeeds">Min Needs: {{ $format.currency(member.minMonthlyNeeds) }}</div>
</div>
</div>
<div class="text-right text-sm text-neutral-600">
<div>{{ Math.round((member.hoursPerMonth || member.hoursPerWeek * 4.33)) }} hrs/month</div>
</div>
</div>
</div>
</div>
</div>
</UCard>
<div class="flex justify-end">
<UButton color="primary"> Save Policies </UButton>
</div>
@ -128,13 +175,37 @@
</template>
<script setup lang="ts">
const policies = ref({
hourlyWage: 20,
payrollOncost: 25,
savingsTargetMonths: 3,
minCashCushion: 3000,
deferredCapHours: 240,
deferredSunsetMonths: 12,
// Store sync and setup state
const { initSync, getMembers, unifiedMembers } = useStoreSync();
const { isSetupComplete, goToSetup } = useSetupState();
const coopStore = useCoopBuilderStore();
const { $format } = useNuxtApp();
// Initialize synchronization on mount
onMounted(async () => {
await initSync();
});
// Get reactive synchronized member data
const members = unifiedMembers;
// Get synchronized policy data from setup
const policies = computed({
get: () => ({
hourlyWage: coopStore.equalHourlyWage,
payrollOncost: coopStore.payrollOncostPct,
savingsTargetMonths: coopStore.savingsTargetMonths,
minCashCushion: coopStore.minCashCushion,
deferredCapHours: 240, // These fields might not be in coop store yet
deferredSunsetMonths: 12,
}),
set: (newPolicies) => {
// Update the CoopBuilder store when policies change
coopStore.setEqualWage(newPolicies.hourlyWage);
coopStore.setOncostPct(newPolicies.payrollOncost);
coopStore.savingsTargetMonths = newPolicies.savingsTargetMonths;
coopStore.minCashCushion = newPolicies.minCashCushion;
}
});
const distributionOrder = ref([

View file

@ -319,15 +319,22 @@ export const useBudgetStore = defineStore(
if (!isInitialized.value) return;
const coopStore = useCoopBuilderStore();
const payrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll");
const basePayrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-base");
const oncostIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-oncosts");
if (payrollIndex === -1) return; // No existing payroll entry
// If no split payroll entries exist, look for legacy single entry
const legacyIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll");
if (basePayrollIndex === -1 && legacyIndex === -1) return; // No existing payroll entries
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
const hourlyWage = coopStore.equalHourlyWage || 0;
const oncostPct = coopStore.payrollOncostPct || 0;
const basePayrollBudget = totalHours * hourlyWage;
// Declare today once for the entire function
const today = new Date();
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Use policy-driven allocation
const payPolicy = {
@ -340,44 +347,92 @@ export const useBudgetStore = defineStore(
displayName: m.name,
monthlyPayPlanned: m.monthlyPayPlanned || 0,
minMonthlyNeeds: m.minMonthlyNeeds || 0,
targetMonthlyPay: m.targetMonthlyPay || 0,
role: m.role || '',
hoursPerMonth: m.hoursPerMonth || 0
}));
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
// Sum with operating mode consideration
// Sum the allocated payroll amounts
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
const planned = m.monthlyPayPlanned || 0;
if (coopStore.operatingMode === 'min' && m.minMonthlyNeeds) {
return sum + Math.min(planned, m.minMonthlyNeeds);
}
return sum + planned;
return sum + (m.monthlyPayPlanned || 0);
}, 0);
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
// Update monthly values for base payroll
// Update monthly values
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")}`;
budgetWorksheet.value.expenses[payrollIndex].monthlyValues[monthKey] = monthlyPayroll;
if (basePayrollIndex !== -1) {
// Update base payroll entry
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")}`;
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = totalAllocatedPayroll;
}
// Update annual values for base payroll
budgetWorksheet.value.expenses[basePayrollIndex].values = {
year1: { best: totalAllocatedPayroll * 12, worst: totalAllocatedPayroll * 8, mostLikely: totalAllocatedPayroll * 12 },
year2: { best: totalAllocatedPayroll * 14, worst: totalAllocatedPayroll * 10, mostLikely: totalAllocatedPayroll * 13 },
year3: { best: totalAllocatedPayroll * 16, worst: totalAllocatedPayroll * 12, mostLikely: totalAllocatedPayroll * 15 }
};
}
// Update annual values
budgetWorksheet.value.expenses[payrollIndex].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 }
};
if (oncostIndex !== -1) {
// Update oncost entry
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
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")}`;
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = oncostAmount;
}
// Update name with current percentage
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
// Update annual values for oncosts
budgetWorksheet.value.expenses[oncostIndex].values = {
year1: { best: oncostAmount * 12, worst: oncostAmount * 8, mostLikely: oncostAmount * 12 },
year2: { best: oncostAmount * 14, worst: oncostAmount * 10, mostLikely: oncostAmount * 13 },
year3: { best: oncostAmount * 16, worst: oncostAmount * 12, mostLikely: oncostAmount * 15 }
};
}
// Handle legacy single payroll entry (update to combined amount for backwards compatibility)
if (legacyIndex !== -1 && basePayrollIndex === -1) {
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
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")}`;
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = monthlyPayroll;
}
budgetWorksheet.value.expenses[legacyIndex].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 }
};
}
}
}
// Force reinitialize - always reload from wizard data
async function forceInitializeFromWizardData() {
console.log("=== FORCE BUDGET INITIALIZATION ===");
isInitialized.value = false;
budgetWorksheet.value.revenue = [];
budgetWorksheet.value.expenses = [];
await initializeFromWizardData();
}
// Initialize worksheet from wizard data
async function initializeFromWizardData() {
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
console.log("=== BUDGET INITIALIZATION DEBUG ===");
console.log("Is already initialized:", isInitialized.value);
console.log("Current revenue items:", budgetWorksheet.value.revenue.length);
console.log("Current expense items:", budgetWorksheet.value.expenses.length);
// Check if we have actual budget data (not just initialized flag)
if (isInitialized.value && (budgetWorksheet.value.revenue.length > 0 || budgetWorksheet.value.expenses.length > 0)) {
console.log("Already initialized with data, skipping...");
return;
}
@ -388,15 +443,19 @@ export const useBudgetStore = defineStore(
// Use the new coopBuilder store instead of the old stores
const coopStore = useCoopBuilderStore();
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("CoopStore Data:");
console.log("- Streams:", coopStore.streams.length, coopStore.streams);
console.log("- Members:", coopStore.members.length, coopStore.members);
console.log("- Equal wage:", coopStore.equalHourlyWage || "No wage set");
console.log("- Overhead costs:", coopStore.overheadCosts.length, coopStore.overheadCosts);
// Clear existing data
budgetWorksheet.value.revenue = [];
budgetWorksheet.value.expenses = [];
// Declare today once for the entire function
const today = new Date();
// 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
@ -423,7 +482,6 @@ export const useBudgetStore = defineStore(
// Create monthly values - split the annual target evenly across 12 months
const monthlyValues: Record<string, number> = {};
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(
@ -470,8 +528,16 @@ export const useBudgetStore = defineStore(
const hourlyWage = coopStore.equalHourlyWage || 0;
const oncostPct = coopStore.payrollOncostPct || 0;
console.log("=== PAYROLL CALCULATION DEBUG ===");
console.log("Total hours:", totalHours);
console.log("Hourly wage:", hourlyWage);
console.log("Oncost %:", oncostPct);
console.log("Operating mode:", coopStore.operatingMode);
console.log("Policy relationship:", coopStore.policy.relationship);
// Calculate total payroll budget using policy allocation
const basePayrollBudget = totalHours * hourlyWage;
console.log("Base payroll budget:", basePayrollBudget);
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Use policy-driven allocation to get actual member pay amounts
@ -487,30 +553,28 @@ export const useBudgetStore = defineStore(
// Ensure all required fields exist
monthlyPayPlanned: m.monthlyPayPlanned || 0,
minMonthlyNeeds: m.minMonthlyNeeds || 0,
targetMonthlyPay: m.targetMonthlyPay || 0,
role: m.role || '',
hoursPerMonth: m.hoursPerMonth || 0
}));
// Allocate payroll based on policy
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
console.log("Allocated members:", allocatedMembers.map(m => ({ name: m.name, planned: m.monthlyPayPlanned, needs: m.minMonthlyNeeds })));
// Sum the allocated amounts for total payroll, respecting operating mode
// Sum the allocated amounts for total payroll
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
const planned = m.monthlyPayPlanned || 0;
// In "minimum" mode, cap at min needs to show a lean runway scenario
if (coopStore.operatingMode === 'min' && m.minMonthlyNeeds) {
return sum + Math.min(planned, m.minMonthlyNeeds);
}
console.log(`Member ${m.name}: planned ${planned}`);
return sum + planned;
}, 0);
console.log("Total allocated payroll:", totalAllocatedPayroll);
// Apply oncosts to the policy-allocated total
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
console.log("Monthly payroll with oncosts:", monthlyPayroll);
// Create monthly values for payroll
const monthlyValues: Record<string, number> = {};
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(
@ -519,31 +583,77 @@ export const useBudgetStore = defineStore(
monthlyValues[monthKey] = monthlyPayroll;
}
console.log("Creating split payroll budget items with monthly values:", Object.keys(monthlyValues).length, "months");
// Create base payroll monthly values (without oncosts)
const baseMonthlyValues: Record<string, number> = {};
const oncostMonthlyValues: Record<string, number> = {};
// Reuse the today variable from above
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")}`;
baseMonthlyValues[monthKey] = totalAllocatedPayroll;
oncostMonthlyValues[monthKey] = totalAllocatedPayroll * (oncostPct / 100);
}
// Add base payroll item
budgetWorksheet.value.expenses.push({
id: "expense-payroll",
id: "expense-payroll-base",
name: "Payroll",
mainCategory: "Salaries & Benefits",
subcategory: "Base wages and benefits",
source: "wizard",
monthlyValues,
monthlyValues: baseMonthlyValues,
values: {
year1: {
best: monthlyPayroll * 12,
worst: monthlyPayroll * 8,
mostLikely: monthlyPayroll * 12,
best: totalAllocatedPayroll * 12,
worst: totalAllocatedPayroll * 8,
mostLikely: totalAllocatedPayroll * 12,
},
year2: {
best: monthlyPayroll * 14,
worst: monthlyPayroll * 10,
mostLikely: monthlyPayroll * 13,
best: totalAllocatedPayroll * 14,
worst: totalAllocatedPayroll * 10,
mostLikely: totalAllocatedPayroll * 13,
},
year3: {
best: monthlyPayroll * 16,
worst: monthlyPayroll * 12,
mostLikely: monthlyPayroll * 15,
best: totalAllocatedPayroll * 16,
worst: totalAllocatedPayroll * 12,
mostLikely: totalAllocatedPayroll * 15,
},
},
});
// Add payroll oncosts item (if oncost percentage > 0)
if (oncostPct > 0) {
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
budgetWorksheet.value.expenses.push({
id: "expense-payroll-oncosts",
name: `Payroll Taxes & Benefits (${oncostPct}%)`,
mainCategory: "Salaries & Benefits",
subcategory: "Payroll taxes and benefits",
source: "wizard",
monthlyValues: oncostMonthlyValues,
values: {
year1: {
best: oncostAmount * 12,
worst: oncostAmount * 8,
mostLikely: oncostAmount * 12,
},
year2: {
best: oncostAmount * 14,
worst: oncostAmount * 10,
mostLikely: oncostAmount * 13,
},
year3: {
best: oncostAmount * 16,
worst: oncostAmount * 12,
mostLikely: oncostAmount * 15,
},
},
});
}
}
// Add overhead costs from wizard
@ -563,7 +673,6 @@ export const useBudgetStore = defineStore(
// Create monthly values for overhead costs
const monthlyValues: Record<string, number> = {};
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(
@ -696,7 +805,6 @@ export const useBudgetStore = defineStore(
if (!item.monthlyValues) {
console.log("Migrating item to monthly values:", item.name);
item.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(
@ -856,6 +964,7 @@ export const useBudgetStore = defineStore(
groupedExpenses,
isInitialized,
initializeFromWizardData,
forceInitializeFromWizardData,
refreshPayrollInBudget,
updateBudgetValue,
updateMonthlyValue,

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
export const useCashStore = defineStore("cash", () => {
// 13-week cash flow events
// 13-week calendar events
const cashEvents = ref([]);
// Payment queue - staged payments within policy

View file

@ -4,17 +4,17 @@ export const useCoopBuilderStore = defineStore("coop", {
state: () => ({
operatingMode: "min" as "min" | "target",
// Currency preference
currency: "EUR" as string,
// 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;
}>,
@ -22,6 +22,8 @@ export const useCoopBuilderStore = defineStore("coop", {
id: string;
label: string;
monthly: number;
annual?: number;
amountType?: 'monthly' | 'annual';
category?: string;
certainty?: string;
}>,
@ -35,7 +37,6 @@ export const useCoopBuilderStore = defineStore("coop", {
// Scenario and stress test state
scenario: "current" as
| "current"
| "quit-jobs"
| "start-production"
| "custom",
stress: {
@ -46,8 +47,7 @@ export const useCoopBuilderStore = defineStore("coop", {
// Policy settings
policy: {
relationship: "equal-pay" as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded",
roleBands: {} as Record<string, number>,
relationship: "equal-pay" as "equal-pay" | "needs-weighted" | "hours-weighted",
},
equalHourlyWage: 50,
payrollOncostPct: 25,
@ -63,6 +63,8 @@ export const useCoopBuilderStore = defineStore("coop", {
id?: string;
name: string;
amount: number;
annualAmount?: number;
amountType?: 'monthly' | 'annual';
category?: string;
}>,
}),
@ -110,10 +112,19 @@ export const useCoopBuilderStore = defineStore("coop", {
// Stream actions
upsertStream(s: any) {
const i = this.streams.findIndex((x) => x.id === s.id);
// Calculate monthly value based on amount type
let monthlyValue = s.monthly || s.targetMonthlyAmount || 0;
if (s.amountType === 'annual' && s.annual) {
monthlyValue = Math.round(s.annual / 12);
}
const withDefaults = {
id: s.id || Date.now().toString(),
label: s.label || s.name || "",
monthly: s.monthly || s.targetMonthlyAmount || 0,
monthly: monthlyValue,
annual: s.annual || s.targetAnnualAmount || monthlyValue * 12,
amountType: s.amountType || 'monthly',
category: s.category ?? "",
certainty: s.certainty ?? "Probable",
};
@ -148,7 +159,7 @@ export const useCoopBuilderStore = defineStore("coop", {
// Scenario
setScenario(
scenario: "current" | "quit-jobs" | "start-production" | "custom"
scenario: "current" | "start-production" | "custom"
) {
this.scenario = scenario;
},
@ -159,14 +170,10 @@ export const useCoopBuilderStore = defineStore("coop", {
},
// Policy updates
setPolicy(relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") {
setPolicy(relationship: "equal-pay" | "needs-weighted" | "hours-weighted") {
this.policy.relationship = relationship;
},
setRoleBands(bands: Record<string, number>) {
this.policy.roleBands = bands;
},
setEqualWage(wage: number) {
this.equalHourlyWage = wage;
},
@ -175,12 +182,24 @@ export const useCoopBuilderStore = defineStore("coop", {
this.payrollOncostPct = pct;
},
setCurrency(currency: string) {
this.currency = currency;
},
// Overhead costs
addOverheadCost(cost: any) {
// Calculate monthly value based on amount type
let monthlyValue = cost.amount || 0;
if (cost.amountType === 'annual' && cost.annualAmount) {
monthlyValue = Math.round(cost.annualAmount / 12);
}
const withDefaults = {
id: cost.id || Date.now().toString(),
name: cost.name || "",
amount: cost.amount || 0,
amount: monthlyValue,
annualAmount: cost.annualAmount || monthlyValue * 12,
amountType: cost.amountType || 'monthly',
category: cost.category ?? "",
};
this.overheadCosts.push(withDefaults);
@ -188,10 +207,19 @@ export const useCoopBuilderStore = defineStore("coop", {
upsertOverheadCost(cost: any) {
const i = this.overheadCosts.findIndex((c) => c.id === cost.id);
// Calculate monthly value based on amount type
let monthlyValue = cost.amount || 0;
if (cost.amountType === 'annual' && cost.annualAmount) {
monthlyValue = Math.round(cost.annualAmount / 12);
}
const withDefaults = {
id: cost.id || Date.now().toString(),
name: cost.name || "",
amount: cost.amount || 0,
amount: monthlyValue,
annualAmount: cost.annualAmount || monthlyValue * 12,
amountType: cost.amountType || 'monthly',
category: cost.category ?? "",
};
if (i === -1) {
@ -218,6 +246,7 @@ export const useCoopBuilderStore = defineStore("coop", {
// Reset ALL state to initial empty values
this._wasCleared = true;
this.operatingMode = "min";
this.currency = "EUR";
this.members = [];
this.streams = [];
this.milestones = [];
@ -229,7 +258,6 @@ export const useCoopBuilderStore = defineStore("coop", {
};
this.policy = {
relationship: "equal-pay",
roleBands: {},
};
this.equalHourlyWage = 0;
this.payrollOncostPct = 0;

View file

@ -43,8 +43,6 @@ export const useMembersStore = defineStore(
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: {
@ -57,10 +55,8 @@ export const useMembersStore = defineStore(
privacyNeeds: raw.privacyNeeds || "aggregate_ok",
deferredHours: Number(raw.deferredHours ?? 0),
quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240),
// NEW fields for needs coverage
// Simplified - only minimum needs for allocation
minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0,
targetMonthlyPay: Number(raw.targetMonthlyPay) || 0,
externalMonthlyIncome: Number(raw.externalMonthlyIncome) || 0,
monthlyPayPlanned: Number(raw.monthlyPayPlanned) || 0,
...raw,
};
@ -203,13 +199,11 @@ export const useMembersStore = defineStore(
// Coverage calculations for individual members
function getMemberCoverage(memberId) {
const member = members.value.find((m) => m.id === memberId);
if (!member) return { minPct: undefined, targetPct: undefined };
if (!member) return { coveragePct: undefined };
return coverage(
member.minMonthlyNeeds || 0,
member.targetMonthlyPay || 0,
member.monthlyPayPlanned || 0,
member.externalMonthlyIncome || 0
member.monthlyPayPlanned || 0
);
}
@ -222,26 +216,19 @@ export const useMembersStore = defineStore(
notes: '',
equalBase: 0,
needsWeight: 0.5,
roleBands: {},
hoursRate: 0,
customFormula: ''
});
// Setters for new fields
function setMonthlyNeeds(memberId, minNeeds, targetPay) {
// Setter for minimum needs only
function setMonthlyNeeds(memberId, minNeeds) {
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;
}
}
// Removed setExternalIncome - no longer needed
function setPlannedPay(memberId, planned) {
const member = members.value.find((m) => m.id === memberId);
@ -269,7 +256,6 @@ export const useMembersStore = defineStore(
resetMembers,
// New coverage actions
setMonthlyNeeds,
setExternalIncome,
setPlannedPay,
getMemberCoverage,
// Legacy actions

View file

@ -1,95 +0,0 @@
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: 0,
paidHoursPerMonth: 0,
winRatePct: 0,
avgPayoutDelayDays: 0,
hourlyWageAdjust: 0,
});
// Selected scenario
const activeScenario = ref("current");
// Computed scenario results (will be calculated by composables)
const scenarioResults = computed(() => ({
runway: 0,
monthlyCosts: 0,
cashflowRisk: "low",
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: 0,
paidHoursPerMonth: 0,
winRatePct: 0,
avgPayoutDelayDays: 0,
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,
};
});

View file

@ -1,159 +0,0 @@
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 - use current month/year
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
const currentSession = ref(`${currentYear}-${currentMonth}`);
// 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);
}
function resetSession() {
// Reset checklist
Object.keys(checklist.value).forEach((key) => {
checklist.value[key] = false;
});
// Reset allocations
Object.keys(draftAllocations.value).forEach((key) => {
draftAllocations.value[key] = 0;
});
// Reset other values
rationale.value = "";
savedRecords.value = [];
// Reset available amounts
availableAmounts.value = {
surplus: 0,
deferredOwed: 0,
savingsNeeded: 0,
};
}
return {
checklist,
draftAllocations,
rationale,
currentSession: readonly(currentSession),
savedRecords: readonly(savedRecords),
availableAmounts: readonly(availableAmounts),
totalAllocated,
checklistComplete,
updateChecklistItem,
updateAllocation,
resetAllocations,
updateRationale,
saveSession,
loadSession,
setCurrentSession,
updateAvailableAmounts,
resetSession,
};
});

View file

@ -48,8 +48,8 @@ export const useStreamsStore = defineStore(
} else {
const newStream = {
id: stream.id || Date.now().toString(),
name: stream.name,
category: stream.category,
name: stream.name,
subcategory: stream.subcategory || "",
targetPct: stream.targetPct || 0,
targetMonthlyAmount: stream.targetMonthlyAmount || 0,

View file

@ -1,15 +1,12 @@
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?: {
@ -21,10 +18,8 @@ export interface Member {
// Existing/planned
monthlyPayPlanned?: number
// NEW - early-stage friendly, defaults-safe
// Simplified - only minimum needs for needs-weighted allocation
minMonthlyNeeds?: number
targetMonthlyPay?: number
externalMonthlyIncome?: number
// Compatibility with existing store
payRelationship?: string
@ -35,8 +30,7 @@ export interface Member {
quarterlyDeferredCap?: number
// UI-only derivations
coverageMinPct?: number
coverageTargetPct?: number
coveragePct?: number
}
export interface PayPolicy {
@ -44,22 +38,19 @@ export interface PayPolicy {
notes?: string
equalBase?: number
needsWeight?: number
roleBands?: Record<string, number>
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 }
// Simplified coverage calculation
export function coverage(minNeeds = 0, planned = 0) {
const coveragePct = minNeeds > 0 ? Math.min(200, (planned / minNeeds) * 100) : undefined
return { coveragePct }
}
export function teamCoverageStats(members: Member[]) {
const vals = members
.map(m => coverage(m.minMonthlyNeeds, m.targetMonthlyPay, m.monthlyPayPlanned, m.externalMonthlyIncome).minPct)
.map(m => coverage(m.minMonthlyNeeds, m.monthlyPayPlanned).coveragePct)
.filter((v): v is number => typeof v === 'number')
if (!vals.length) return { under100: 0, median: undefined, range: undefined, gini: undefined }
@ -104,12 +95,7 @@ export function allocatePayroll(members: Member[], policy: PayPolicy, payrollBud
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
}
// Removed role-banded allocation - no longer supported
if (policy.relationship === 'hours-weighted') {
const hours = result.map(m => m.hoursPerMonth ?? (m.hoursPerWeek ? m.hoursPerWeek * 4 : 0) ?? (m.capacity?.targetHours ?? 0))
@ -124,7 +110,7 @@ export function allocatePayroll(members: Member[], policy: PayPolicy, payrollBud
return result
}
// Monthly payroll calculation for runway and cashflow
// Monthly payroll calculation for runway and budgets
export function monthlyPayroll(members: Member[], mode: 'minimum' | 'target' = 'minimum'): number {
return members.reduce((sum, m) => {
const planned = m.monthlyPayPlanned ?? 0

23
utils/currency.ts Normal file
View file

@ -0,0 +1,23 @@
export interface CurrencyOption {
code: string;
symbol: string;
name: string;
}
export const currencyOptions: CurrencyOption[] = [
{ code: 'USD', symbol: '$', name: 'US Dollar' },
{ code: 'EUR', symbol: '€', name: 'Euro' },
{ code: 'GBP', symbol: '£', name: 'British Pound' },
{ code: 'CAD', symbol: 'C$', name: 'Canadian Dollar' },
{ code: 'AUD', symbol: 'A$', name: 'Australian Dollar' },
{ code: 'CHF', symbol: 'CHF', name: 'Swiss Franc' },
{ code: 'JPY', symbol: '¥', name: 'Japanese Yen' },
{ code: 'SEK', symbol: 'kr', name: 'Swedish Krona' },
{ code: 'NOK', symbol: 'kr', name: 'Norwegian Krone' },
{ code: 'DKK', symbol: 'kr', name: 'Danish Krone' },
];
export function getCurrencySymbol(currencyCode: string): string {
const currency = currencyOptions.find(c => c.code === currencyCode);
return currency?.symbol || currencyCode;
}

View file

@ -0,0 +1,94 @@
/**
* Test Data Consistency Utilities
*
* Functions to verify that data is consistent across all interfaces
* after setup completion and synchronization
*/
export const testDataConsistency = () => {
const coopStore = useCoopBuilderStore()
const streamsStore = useStreamsStore()
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const issues: string[] = []
// Test streams consistency
if (coopStore.streams.length > 0 && streamsStore.streams.length > 0) {
if (coopStore.streams.length !== streamsStore.streams.length) {
issues.push(`Stream count mismatch: CoopBuilder(${coopStore.streams.length}) vs Legacy(${streamsStore.streams.length})`)
}
// Check if stream data matches
coopStore.streams.forEach(coopStream => {
const legacyStream = streamsStore.streams.find(s => s.id === coopStream.id)
if (!legacyStream) {
issues.push(`CoopBuilder stream "${coopStream.label}" not found in legacy store`)
} else {
if (legacyStream.name !== coopStream.label) {
issues.push(`Stream name mismatch for ${coopStream.id}: "${legacyStream.name}" vs "${coopStream.label}"`)
}
if (legacyStream.targetMonthlyAmount !== coopStream.monthly) {
issues.push(`Stream amount mismatch for ${coopStream.id}: ${legacyStream.targetMonthlyAmount} vs ${coopStream.monthly}`)
}
}
})
}
// Test members consistency
if (coopStore.members.length > 0 && membersStore.members.length > 0) {
if (coopStore.members.length !== membersStore.members.length) {
issues.push(`Member count mismatch: CoopBuilder(${coopStore.members.length}) vs Legacy(${membersStore.members.length})`)
}
// Check if member data matches
coopStore.members.forEach(coopMember => {
const legacyMember = membersStore.members.find(m => m.id === coopMember.id)
if (!legacyMember) {
issues.push(`CoopBuilder member "${coopMember.name}" not found in legacy store`)
} else {
if (legacyMember.displayName !== coopMember.name) {
issues.push(`Member name mismatch for ${coopMember.id}: "${legacyMember.displayName}" vs "${coopMember.name}"`)
}
}
})
}
// Test policies consistency
if (coopStore.equalHourlyWage > 0 && policiesStore.equalHourlyWage > 0) {
if (coopStore.equalHourlyWage !== policiesStore.equalHourlyWage) {
issues.push(`Hourly wage mismatch: CoopBuilder(${coopStore.equalHourlyWage}) vs Legacy(${policiesStore.equalHourlyWage})`)
}
if (coopStore.payrollOncostPct !== policiesStore.payrollOncostPct) {
issues.push(`Oncost percentage mismatch: CoopBuilder(${coopStore.payrollOncostPct}) vs Legacy(${policiesStore.payrollOncostPct})`)
}
}
return {
isConsistent: issues.length === 0,
issues,
summary: issues.length === 0
? 'All data is consistent across interfaces'
: `${issues.length} consistency issues found`
}
}
export const logDataConsistency = () => {
const result = testDataConsistency()
console.group('🔄 Data Consistency Check')
console.log('Status:', result.isConsistent ? '✅ Consistent' : '❌ Issues Found')
console.log('Summary:', result.summary)
if (result.issues.length > 0) {
console.group('Issues:')
result.issues.forEach((issue, index) => {
console.log(`${index + 1}. ${issue}`)
})
console.groupEnd()
}
console.groupEnd()
return result
}