refactor: enhance routing and state management in CoopBuilder, add migration checks on startup, and update Tailwind configuration for improved component styling

This commit is contained in:
Jennie Robinson Faber 2025-08-23 18:24:31 +01:00
parent 848386e3dd
commit 4cea1f71fe
55 changed files with 4053 additions and 1486 deletions

11
app.vue
View file

@ -79,14 +79,25 @@ const isCoopBuilderSection = computed(
route.path === "/coop-planner" || route.path === "/coop-planner" ||
route.path === "/coop-builder" || route.path === "/coop-builder" ||
route.path === "/" || route.path === "/" ||
route.path === "/dashboard" ||
route.path === "/mix" || route.path === "/mix" ||
route.path === "/budget" || route.path === "/budget" ||
route.path === "/scenarios" || route.path === "/scenarios" ||
route.path === "/cash" || route.path === "/cash" ||
route.path === "/session" ||
route.path === "/settings" ||
route.path === "/glossary" route.path === "/glossary"
); );
const isWizardSection = computed( const isWizardSection = computed(
() => route.path === "/wizards" || route.path.startsWith("/templates/") () => route.path === "/wizards" || route.path.startsWith("/templates/")
); );
// Run migrations on app startup
onMounted(() => {
const { migrate, needsMigration } = useMigrations();
if (needsMigration()) {
migrate();
}
});
</script> </script>

View file

@ -26,6 +26,11 @@
const route = useRoute(); const route = useRoute();
const coopBuilderItems = [ const coopBuilderItems = [
{
id: "dashboard",
name: "Dashboard",
path: "/dashboard",
},
{ {
id: "coop-builder", id: "coop-builder",
name: "Setup Wizard", name: "Setup Wizard",
@ -36,6 +41,26 @@ const coopBuilderItems = [
name: "Budget", name: "Budget",
path: "/budget", path: "/budget",
}, },
{
id: "mix",
name: "Revenue Mix",
path: "/mix",
},
{
id: "scenarios",
name: "Scenarios",
path: "/scenarios",
},
{
id: "cash",
name: "Cash Flow",
path: "/cash",
},
{
id: "session",
name: "Value Session",
path: "/session",
},
]; ];
function isActive(path: string): boolean { function isActive(path: string): boolean {

View file

@ -0,0 +1,57 @@
<template>
<UTooltip :text="tooltipText">
<UBadge
:color="badgeColor"
variant="solid"
size="sm"
class="font-medium"
>
<UIcon :name="iconName" class="w-3 h-3 mr-1" />
{{ displayText }}
</UBadge>
</UTooltip>
</template>
<script setup lang="ts">
interface Props {
coverageMinPct?: number
coverageTargetPct?: number
memberName?: string
warnIfUnder?: number
}
const props = withDefaults(defineProps<Props>(), {
warnIfUnder: 100,
memberName: 'member'
})
const coverage = computed(() => props.coverageMinPct || 0)
const badgeColor = computed(() => {
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'
})
const displayText = computed(() => {
if (!props.coverageMinPct) return 'No needs set'
return `${Math.round(coverage.value)}% coverage`
})
const tooltipText = computed(() => {
if (!props.coverageMinPct) {
return `${props.memberName} hasn't set their minimum needs yet`
}
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>

View file

@ -0,0 +1,211 @@
<template>
<div class="space-y-4">
<!-- Runway summary -->
<div class="grid grid-cols-2 gap-4 p-3 bg-gray-50 rounded-lg text-sm">
<div>
<span class="text-gray-600">Min mode runway:</span>
<div class="font-bold text-lg">{{ minRunwayMonths }} months</div>
<div class="text-xs text-gray-500">Until {{ formatDate(minRunwayEndDate) }}</div>
</div>
<div>
<span class="text-gray-600">Target mode runway:</span>
<div class="font-bold text-lg">{{ targetRunwayMonths }} months</div>
<div class="text-xs text-gray-500">Until {{ formatDate(targetRunwayEndDate) }}</div>
</div>
</div>
<!-- Milestones -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">Milestones</h4>
<UButton
size="xs"
variant="ghost"
@click="addMilestone"
>
<UIcon name="i-heroicons-plus" class="w-3 h-3 mr-1" />
Add
</UButton>
</div>
<div v-if="milestones.length === 0" class="text-xs text-gray-500 italic p-2">
No milestones set. Add key dates to track runway coverage.
</div>
<div v-for="milestone in milestonesWithStatus" :key="milestone.id"
class="flex items-center justify-between p-2 border border-gray-200 rounded text-sm">
<div class="flex items-center gap-2">
<UIcon
:name="milestone.status === 'safe' ? 'i-heroicons-check-circle' :
milestone.status === 'warning' ? 'i-heroicons-exclamation-triangle' :
'i-heroicons-x-circle'"
:class="milestone.status === 'safe' ? 'text-green-500' :
milestone.status === 'warning' ? 'text-yellow-500' :
'text-red-500'"
class="w-4 h-4"
/>
<div>
<div class="font-medium">{{ milestone.label }}</div>
<div class="text-xs text-gray-500">{{ formatDate(milestone.date) }}</div>
</div>
</div>
<div class="text-right">
<div class="text-xs text-gray-600">
{{ milestone.monthsFromNow > 0 ? `+${milestone.monthsFromNow}` : milestone.monthsFromNow }}mo
</div>
<UButton
size="xs"
variant="ghost"
color="red"
@click="removeMilestone(milestone.id)"
>
<UIcon name="i-heroicons-trash" class="w-3 h-3" />
</UButton>
</div>
</div>
</div>
<!-- Add milestone form -->
<div v-if="showAddForm" class="p-3 border border-gray-200 rounded-lg space-y-2">
<UInput
v-model="newMilestone.label"
placeholder="Milestone name (e.g., 'Prototype release')"
size="sm"
/>
<UInput
v-model="newMilestone.date"
type="date"
size="sm"
/>
<div class="flex gap-2">
<UButton size="xs" @click="saveMilestone">Save</UButton>
<UButton size="xs" variant="ghost" @click="cancelAdd">Cancel</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Milestone {
id: string
label: string
date: string // YYYY-MM-DD
}
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const cashStore = useCashStore()
const { getDualModeRunway, formatRunway, getRunwayStatus } = useRunway()
// Runway calculations using the integrated composable
const runwayData = computed(() => {
const cash = cashStore.currentCash || 50000 // Mock fallback
const savings = cashStore.currentSavings || 15000 // Mock fallback
return getDualModeRunway(cash, savings)
})
const minRunwayMonths = computed(() => Math.floor(runwayData.value.minimum))
const targetRunwayMonths = computed(() => Math.floor(runwayData.value.target))
const minRunwayEndDate = computed(() => {
const date = new Date()
date.setMonth(date.getMonth() + minRunwayMonths.value)
return date
})
const targetRunwayEndDate = computed(() => {
const date = new Date()
date.setMonth(date.getMonth() + targetRunwayMonths.value)
return date
})
// Milestones management - store in localStorage
const milestones = ref<Milestone[]>([])
// Initialize milestones from localStorage
onMounted(() => {
const stored = localStorage.getItem('urgent-tools-milestones')
if (stored) {
try {
milestones.value = JSON.parse(stored)
} catch (e) {
console.warn('Failed to parse stored milestones')
milestones.value = []
}
} else {
// Default milestones for new users
milestones.value = [
{ id: '1', label: 'Prototype release', date: '2025-12-15' },
{ id: '2', label: 'Series A funding', date: '2026-03-01' }
]
saveMilestones()
}
})
// Save milestones to localStorage
function saveMilestones() {
localStorage.setItem('urgent-tools-milestones', JSON.stringify(milestones.value))
}
const showAddForm = ref(false)
const newMilestone = ref({ label: '', date: '' })
const milestonesWithStatus = computed(() => {
const currentMode = policiesStore.operatingMode || 'minimum'
const runwayMonths = currentMode === 'target' ? targetRunwayMonths.value : minRunwayMonths.value
const runwayEndDate = currentMode === 'target' ? targetRunwayEndDate.value : minRunwayEndDate.value
return milestones.value.map(milestone => {
const milestoneDate = new Date(milestone.date)
const now = new Date()
const monthsFromNow = Math.round((milestoneDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30))
let status: 'safe' | 'warning' | 'danger'
if (milestoneDate <= runwayEndDate) {
status = 'safe'
} else if (monthsFromNow <= runwayMonths + 2) {
status = 'warning'
} else {
status = 'danger'
}
return {
...milestone,
monthsFromNow,
status
}
}).sort((a, b) => a.monthsFromNow - b.monthsFromNow)
})
function formatDate(date: Date | string) {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
}
function addMilestone() {
showAddForm.value = true
}
function saveMilestone() {
if (newMilestone.value.label && newMilestone.value.date) {
milestones.value.push({
id: Date.now().toString(),
...newMilestone.value
})
saveMilestones() // Persist to localStorage
newMilestone.value = { label: '', date: '' }
showAddForm.value = false
}
}
function cancelAdd() {
newMilestone.value = { label: '', date: '' }
showAddForm.value = false
}
function removeMilestone(id: string) {
milestones.value = milestones.value.filter(m => m.id !== id)
saveMilestones() // Persist to localStorage
}
</script>

View file

@ -0,0 +1,71 @@
<template>
<div class="space-y-3">
<div v-for="member in membersWithCoverage" :key="member.id" class="space-y-1">
<div class="flex justify-between text-xs font-medium text-gray-700">
<span>{{ member.displayName || 'Unnamed' }}</span>
<span>{{ Math.round(member.coverageMinPct || 0) }}%</span>
</div>
<div class="relative h-6 bg-gray-100 rounded overflow-hidden">
<!-- Min coverage bar -->
<div
class="absolute top-0 left-0 h-full transition-all duration-300"
:class="getBarColor(member.coverageMinPct)"
:style="{ width: `${Math.min(100, member.coverageMinPct || 0)}%` }"
/>
<!-- Target coverage tick/ghost -->
<div
v-if="member.coverageTargetPct"
class="absolute top-0 h-full w-0.5 bg-gray-400 opacity-50"
:style="{ left: `${Math.min(100, member.coverageTargetPct)}%` }"
>
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-400 rounded-full opacity-50" />
</div>
<!-- 100% line -->
<div class="absolute top-0 left-0 h-full w-full pointer-events-none">
<div class="absolute top-0 h-full w-px bg-gray-600" style="left: 100%" />
</div>
</div>
</div>
<!-- Summary stats -->
<div class="pt-3 border-t border-gray-200 text-xs text-gray-600">
<div class="flex justify-between">
<span>Team median: {{ Math.round(teamStats.median || 0) }}%</span>
<span v-if="teamStats.under100 > 0" class="text-yellow-600 font-medium">
{{ teamStats.under100 }} under minimum
</span>
<span v-else class="text-green-600 font-medium">
All covered
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
const membersStore = useMembersStore()
const { members, teamStats } = storeToRefs(membersStore)
const membersWithCoverage = computed(() => {
return members.value.map(member => {
const coverage = membersStore.getMemberCoverage(member.id)
return {
...member,
coverageMinPct: coverage.minPct,
coverageTargetPct: coverage.targetPct
}
})
})
function getBarColor(coverage: number | undefined) {
const pct = coverage || 0
if (pct >= 100) return 'bg-green-500'
if (pct >= 80) return 'bg-yellow-500'
return 'bg-red-500'
}
</script>

View file

@ -0,0 +1,93 @@
<template>
<div class="space-y-3">
<!-- Revenue streams table -->
<div class="space-y-2">
<div v-for="stream in sortedStreams" :key="stream.id"
class="flex justify-between items-center text-sm">
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded"
:style="{ backgroundColor: getStreamColor(stream.id) }"
/>
<span class="font-medium">{{ stream.name }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-gray-600">{{ formatCurrency(stream.targetMonthlyAmount || 0) }}</span>
<span class="font-medium min-w-[40px] text-right">{{ stream.percentage }}%</span>
</div>
</div>
</div>
<!-- Concentration warning -->
<div v-if="concentrationWarning"
class="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="flex items-start gap-2">
<UIcon name="i-heroicons-exclamation-triangle" class="w-4 h-4 text-yellow-600 mt-0.5" />
<div class="text-sm text-yellow-800">
<span class="font-medium">{{ concentrationWarning.stream }}</span>
= {{ concentrationWarning.percentage }}% of total consider balancing
</div>
</div>
</div>
<!-- Totals -->
<div class="pt-2 border-t border-gray-200 text-xs text-gray-600">
<div class="flex justify-between">
<span>Total monthly target:</span>
<span class="font-medium">{{ formatCurrency(totalMonthly) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
const streamsStore = useStreamsStore()
const { streams } = storeToRefs(streamsStore)
const totalMonthly = computed(() => {
return streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
})
const sortedStreams = computed(() => {
const withPercentages = streams.value
.map(stream => {
const amount = stream.targetMonthlyAmount || 0
const percentage = totalMonthly.value > 0
? Math.round((amount / totalMonthly.value) * 100)
: 0
return { ...stream, percentage }
})
.filter(s => s.percentage > 0)
return withPercentages.sort((a, b) => b.percentage - a.percentage)
})
const concentrationWarning = computed(() => {
const topStream = sortedStreams.value[0]
if (topStream && topStream.percentage >= 50) {
return {
stream: topStream.name,
percentage: topStream.percentage
}
}
return null
})
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#84cc16']
function getStreamColor(streamId: string) {
const index = streams.value.findIndex(s => s.id === streamId)
return colors[index % colors.length]
}
function formatCurrency(amount: number) {
return new Intl.NumberFormat('en-EU', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount)
}
</script>

View file

@ -18,6 +18,27 @@
</div> </div>
</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>
</div>
<!-- Overhead Costs --> <!-- Overhead Costs -->
<div class="space-y-4"> <div class="space-y-4">
<div <div
@ -130,14 +151,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { storeToRefs } from "pinia";
const emit = defineEmits<{ const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"]; "save-status": [status: "saving" | "saved" | "error"];
}>(); }>();
// Store // Store
const budgetStore = useBudgetStore(); const coop = useCoopBuilder();
const { overheadCosts } = storeToRefs(budgetStore);
// Get the store directly for overhead costs
const store = useCoopBuilderStore();
// Computed for overhead costs (from store)
const overheadCosts = computed(() => store.overheadCosts || []);
// Operating mode toggle
const useTargetMode = ref(coop.operatingMode.value === 'target');
function updateOperatingMode(value: boolean) {
coop.setOperatingMode(value ? 'target' : 'min');
emit("save-status", "saved");
}
// Category options // Category options
const categoryOptions = [ const categoryOptions = [
@ -168,13 +201,8 @@ const debouncedSave = useDebounceFn((cost: any) => {
emit("save-status", "saving"); emit("save-status", "saving");
try { try {
// Find and update existing cost // Use store's upsert method
const existingCost = overheadCosts.value.find((c) => c.id === cost.id); store.upsertOverheadCost(cost);
if (existingCost) {
// Store will handle reactivity through the ref
Object.assign(existingCost, cost);
}
emit("save-status", "saved"); emit("save-status", "saved");
} catch (error) { } catch (error) {
console.error("Failed to save cost:", error); console.error("Failed to save cost:", error);
@ -204,15 +232,13 @@ function addOverheadCost() {
recurring: true, recurring: true,
}; };
budgetStore.addOverheadLine({ store.addOverheadCost(newCost);
name: newCost.name, emit("save-status", "saved");
amountMonthly: newCost.amount,
category: newCost.category,
});
} }
function removeCost(id: string) { function removeCost(id: string) {
budgetStore.removeOverheadLine(id); store.removeOverheadCost(id);
emit("save-status", "saved");
} }
function exportCosts() { function exportCosts() {

View file

@ -53,58 +53,22 @@
v-for="(member, index) in members" v-for="(member, index) in members"
:key="member.id" :key="member.id"
class="p-6 border-3 border-black rounded-xl bg-white shadow-md"> class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> <!-- Header row with name and coverage chip -->
<UFormField label="Name" required class="md:col-span-2"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<UInput <UInput
v-model="member.displayName" v-model="member.displayName"
placeholder="Alex Chen" placeholder="Member name"
size="xl" size="lg"
class="text-lg font-medium w-full" class="text-lg font-bold w-48"
@update:model-value="saveMember(member)" @update:model-value="saveMember(member)"
@blur="saveMember(member)" /> @blur="saveMember(member)" />
</UFormField> <CoverageChip
:coverage-min-pct="memberCoverage(member).minPct"
<UFormField label="Pay relationship" required> :coverage-target-pct="memberCoverage(member).targetPct"
<USelect :member-name="member.displayName || 'This member'"
v-model="member.payRelationship" />
:items="payRelationshipOptions" </div>
size="xl"
class="text-lg 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="xl"
class="text-xl font-bold w-full"
@update:model-value="validateAndSaveHours($event, member)"
@blur="saveMember(member)" />
</UFormField>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
<UFormField label="External income coverage %" class="md:col-span-1">
<UInput
v-model="member.externalCoveragePct"
type="text"
placeholder="50"
size="xl"
class="text-lg font-medium w-full"
@update:model-value="validateAndSavePercentage($event, member)"
@blur="saveMember(member)" />
<template #help>
<span class="text-xs text-neutral-500"
>% of needs covered by other income</span
>
</template>
</UFormField>
</div>
<!-- Actions -->
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
<UButton <UButton
size="xs" size="xs"
variant="solid" variant="solid"
@ -116,6 +80,78 @@
<UIcon name="i-heroicons-trash" class="w-4 h-4" /> <UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton> </UButton>
</div> </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"
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
v-model="member.minMonthlyNeeds"
type="text"
placeholder="2000"
size="sm"
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>
</div>
</div> </div>
<!-- Add Member --> <!-- Add Member -->
@ -139,14 +175,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { storeToRefs } from "pinia"; import { coverage } from "~/types/members";
const emit = defineEmits<{ const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"]; "save-status": [status: "saving" | "saved" | "error"];
}>(); }>();
// Store // Store
const membersStore = useMembersStore(); const coop = useCoopBuilder();
const { members } = storeToRefs(membersStore); 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
},
payRelationship: 'FullyPaid', // Default since not in store yet
minMonthlyNeeds: m.minMonthlyNeeds || 0,
targetMonthlyPay: m.targetMonthlyPay || 0,
externalMonthlyIncome: m.externalMonthlyIncome || 0,
monthlyPayPlanned: m.monthlyPayPlanned || 0
}))
);
// Options // Options
const payRelationshipOptions = [ const payRelationshipOptions = [
@ -181,7 +233,19 @@ const debouncedSave = useDebounceFn((member: any) => {
emit("save-status", "saving"); emit("save-status", "saving");
try { try {
membersStore.upsertMember(member); // 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,
};
coop.upsertMember(memberData);
emit("save-status", "saved"); emit("save-status", "saved");
} catch (error) { } catch (error) {
console.error("Failed to save member:", error); console.error("Failed to save member:", error);
@ -208,29 +272,38 @@ function validateAndSavePercentage(value: string, member: any) {
saveMember(member); 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
);
}
function addMember() { function addMember() {
const newMember = { const newMember = {
id: Date.now().toString(), id: Date.now().toString(),
displayName: "", name: "",
roleFocus: "", // Hidden but kept for compatibility role: "",
payRelationship: "FullyPaid", hoursPerMonth: 0,
capacity: { minMonthlyNeeds: 0,
minHours: 0, targetMonthlyPay: 0,
targetHours: 0, externalMonthlyIncome: 0,
maxHours: 0, monthlyPayPlanned: 0,
},
riskBand: "Medium", // Hidden but kept with default
externalCoveragePct: 50,
privacyNeeds: "aggregate_ok",
deferredHours: 0,
quarterlyDeferredCap: 240,
}; };
membersStore.upsertMember(newMember); coop.upsertMember(newMember);
} }
function removeMember(id: string) { function removeMember(id: string) {
membersStore.removeMember(id); coop.removeMember(id);
} }
function exportMembers() { function exportMembers() {

View file

@ -4,10 +4,10 @@
<div class="flex items-center justify-between mb-8"> <div class="flex items-center justify-between mb-8">
<div> <div>
<h3 class="text-2xl font-black text-black mb-2"> <h3 class="text-2xl font-black text-black mb-2">
What's your equal hourly wage? Set your wage & pay policy
</h3> </h3>
<p class="text-neutral-600"> <p class="text-neutral-600">
Set the hourly rate that all co-op members will earn for their work. Choose how to allocate payroll among members and set the base hourly rate.
</p> </p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -22,18 +22,68 @@
</div> </div>
</div> </div>
<div class="max-w-md"> <!-- Pay Policy Selection -->
<UInput <div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
v-model="wageText" <h4 class="font-bold mb-4">Pay Allocation Policy</h4>
type="text" <div class="space-y-3">
placeholder="0.00" <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">
size="xl" <input
class="text-4xl font-black w-full h-20" type="radio"
@update:model-value="validateAndSaveWage"> :value="option.value"
<template #leading> v-model="selectedPolicy"
<span class="text-neutral-500 text-3xl">$</span> @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> </template>
</UInput> </UAlert>
</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>
</div> </div>
</div> </div>
</template> </template>
@ -44,7 +94,13 @@ const emit = defineEmits<{
}>(); }>();
// Store // Store
const policiesStore = usePoliciesStore(); const coop = useCoopBuilder();
const store = useCoopBuilderStore();
// Initialize from store
const selectedPolicy = ref(coop.policy.value?.relationship || 'equal-pay')
const roleBands = ref(coop.policy.value?.roleBands || {})
const wageText = ref(String(store.equalHourlyWage || ''))
function parseNumberInput(val: unknown): number { function parseNumberInput(val: unknown): number {
if (typeof val === "number") return val; if (typeof val === "number") return val;
@ -56,20 +112,49 @@ function parseNumberInput(val: unknown): number {
return 0; return 0;
} }
// Text input for wage with validation // Pay policy options
const wageText = ref( const policyOptions = [
policiesStore.equalHourlyWage > 0 { value: 'equal-pay', label: 'Equal pay - Everyone gets the same monthly amount' },
? policiesStore.equalHourlyWage.toString() { value: 'needs-weighted', label: 'Needs-weighted - Allocate based on minimum needs' },
: "" { value: 'hours-weighted', label: 'Hours-weighted - Allocate based on hours worked' },
); { value: 'role-banded', label: 'Role-banded - Different amounts per role' }
]
// Watch for store changes to update text field // Already initialized above with store values
watch(
() => policiesStore.equalHourlyWage, const uniqueRoles = computed(() => {
(newWage) => { const roles = new Set(coop.members.value.map(m => m.role || ''))
wageText.value = newWage > 0 ? newWage.toString() : ""; return Array.from(roles).map(role => ({ role }))
})
function updatePolicy(value: string) {
selectedPolicy.value = value
coop.setPolicy(value as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded")
// Trigger payroll reallocation after policy change
const allocatedMembers = coop.allocatePayroll()
allocatedMembers.forEach(m => {
coop.upsertMember(m)
})
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");
}
// Text input for wage with validation (initialized above)
function validateAndSaveWage(value: string) { function validateAndSaveWage(value: string) {
const cleanValue = value.replace(/[^\d.]/g, ""); const cleanValue = value.replace(/[^\d.]/g, "");
@ -78,56 +163,24 @@ function validateAndSaveWage(value: string) {
wageText.value = cleanValue; wageText.value = cleanValue;
if (!isNaN(numValue) && numValue >= 0) { if (!isNaN(numValue) && numValue >= 0) {
policiesStore.setEqualWage(numValue); coop.setEqualWage(numValue)
// Set sensible defaults when wage is set // Trigger payroll reallocation after wage change
if (numValue > 0) { const allocatedMembers = coop.allocatePayroll()
setDefaults(); allocatedMembers.forEach(m => {
emit("save-status", "saved"); coop.upsertMember(m)
} })
emit("save-status", "saved");
} }
} }
// Set reasonable defaults for hidden fields
function setDefaults() {
if (policiesStore.payrollOncostPct === 0) {
policiesStore.setOncostPct(25); // 25% on-costs
}
if (policiesStore.savingsTargetMonths === 0) {
policiesStore.setSavingsTargetMonths(3); // 3 months savings
}
if (policiesStore.minCashCushionAmount === 0) {
policiesStore.setMinCashCushion(3000); // 3k cushion
}
if (policiesStore.deferredCapHoursPerQtr === 0) {
policiesStore.setDeferredCap(240); // 240 hours quarterly cap
}
if (policiesStore.deferredSunsetMonths === 0) {
policiesStore.setDeferredSunset(12); // 12 month sunset
}
// Set default volunteer flows
if (policiesStore.volunteerScope.allowedFlows.length === 0) {
policiesStore.setVolunteerScope(["Care", "SharedLearning"]);
}
}
// Set defaults on mount if needed
onMounted(() => {
if (policiesStore.equalHourlyWage > 0) {
setDefaults();
}
});
function exportPolicies() { function exportPolicies() {
const exportData = { const exportData = {
policies: { policies: {
equalHourlyWage: policiesStore.equalHourlyWage, selectedPolicy: coop.policy.value?.relationship || selectedPolicy.value,
payrollOncostPct: policiesStore.payrollOncostPct, roleBands: coop.policy.value?.roleBands || roleBands.value,
savingsTargetMonths: policiesStore.savingsTargetMonths, equalHourlyWage: store.equalHourlyWage || parseFloat(wageText.value),
minCashCushionAmount: policiesStore.minCashCushionAmount,
deferredCapHoursPerQtr: policiesStore.deferredCapHoursPerQtr,
deferredSunsetMonths: policiesStore.deferredSunsetMonths,
volunteerScope: policiesStore.volunteerScope,
}, },
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
section: "policies", section: "policies",

View file

@ -137,15 +137,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { storeToRefs } from "pinia";
const emit = defineEmits<{ const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"]; "save-status": [status: "saving" | "saved" | "error"];
}>(); }>();
// Store // Store
const streamsStore = useStreamsStore(); const coop = useCoopBuilder();
const { streams } = storeToRefs(streamsStore); const streams = computed(() =>
coop.streams.value.map(s => ({
// Map store fields to component expectations
id: s.id,
name: s.label,
category: s.category || 'games',
targetMonthlyAmount: s.monthly || 0,
subcategory: '',
targetPct: 0,
certainty: s.certainty || 'Aspirational',
payoutDelayDays: 30,
terms: 'Net 30',
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0,
}))
);
// Original category options // Original category options
const categoryOptions = [ const categoryOptions = [
@ -210,18 +227,16 @@ const debouncedSave = useDebounceFn((stream: any) => {
emit("save-status", "saving"); emit("save-status", "saving");
try { try {
// Set sensible defaults for hidden fields // Convert component format back to store format
stream.targetPct = 0; // Will be calculated automatically later const streamData = {
stream.certainty = "Aspirational"; id: stream.id,
stream.payoutDelayDays = 30; // Default 30 days label: stream.name || '',
stream.terms = "Net 30"; monthly: stream.targetMonthlyAmount || 0,
stream.revenueSharePct = 0; category: stream.category || 'games',
stream.platformFeePct = 0; certainty: stream.certainty || 'Aspirational'
stream.restrictions = "General"; };
stream.seasonalityWeights = new Array(12).fill(1);
stream.effortHoursPerMonth = 0;
streamsStore.upsertStream(stream); coop.upsertStream(streamData);
emit("save-status", "saved"); emit("save-status", "saved");
} catch (error) { } catch (error) {
console.error("Failed to save stream:", error); console.error("Failed to save stream:", error);
@ -245,26 +260,17 @@ function validateAndSaveAmount(value: string, stream: any) {
function addRevenueStream() { function addRevenueStream() {
const newStream = { const newStream = {
id: Date.now().toString(), id: Date.now().toString(),
name: "", label: "",
monthly: 0,
category: "games", category: "games",
subcategory: "", certainty: "Aspirational"
targetPct: 0,
targetMonthlyAmount: 0,
certainty: "Aspirational",
payoutDelayDays: 30,
terms: "Net 30",
revenueSharePct: 0,
platformFeePct: 0,
restrictions: "General",
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: 0,
}; };
streamsStore.upsertStream(newStream); coop.upsertStream(newStream);
} }
function removeStream(id: string) { function removeStream(id: string) {
streamsStore.removeStream(id); coop.removeStream(id);
} }
function exportStreams() { function exportStreams() {

View file

@ -208,6 +208,34 @@
</UCard> </UCard>
</div> </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 --> <!-- Overall Status -->
<div class="bg-neutral-50 rounded-lg p-4"> <div class="bg-neutral-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-3">Setup Status</h4> <h4 class="font-medium text-sm mb-3">Setup Status</h4>
@ -304,29 +332,28 @@ const emit = defineEmits<{
reset: []; reset: [];
}>(); }>();
// Stores // Store
const membersStore = useMembersStore(); const coop = useCoopBuilder();
const policiesStore = usePoliciesStore();
const budgetStore = useBudgetStore();
const streamsStore = useStreamsStore();
// Computed data // Computed data
const members = computed(() => membersStore.members); const members = computed(() => coop.members.value);
const teamStats = computed(() => coop.teamCoverageStats());
const policies = computed(() => ({ const policies = computed(() => ({
equalHourlyWage: policiesStore.equalHourlyWage, // TODO: Get actual policy data from centralized store
payrollOncostPct: policiesStore.payrollOncostPct, equalHourlyWage: 0,
savingsTargetMonths: policiesStore.savingsTargetMonths, payrollOncostPct: 0,
minCashCushionAmount: policiesStore.minCashCushionAmount, savingsTargetMonths: 0,
deferredCapHoursPerQtr: policiesStore.deferredCapHoursPerQtr, minCashCushionAmount: 0,
volunteerScope: policiesStore.volunteerScope, deferredCapHoursPerQtr: 0,
volunteerScope: { allowedFlows: [] },
})); }));
const overheadCosts = computed(() => budgetStore.overheadCosts); const overheadCosts = computed(() => []);
const streams = computed(() => streamsStore.streams); const streams = computed(() => coop.streams.value);
// Validation // Validation
const membersValid = computed(() => membersStore.isValid); const membersValid = computed(() => coop.members.value.length > 0);
const policiesValid = computed(() => policiesStore.isValid); const policiesValid = computed(() => true); // TODO: Add validation
const streamsValid = computed(() => streamsStore.hasValidStreams); const streamsValid = computed(() => coop.streams.value.length > 0);
const canComplete = computed( const canComplete = computed(
() => membersValid.value && policiesValid.value && streamsValid.value () => membersValid.value && policiesValid.value && streamsValid.value
); );
@ -349,7 +376,9 @@ const totalMonthlyCosts = computed(() =>
overheadCosts.value.reduce((sum, c) => sum + (c.amount || 0), 0) overheadCosts.value.reduce((sum, c) => sum + (c.amount || 0), 0)
); );
const totalTargetPct = computed(() => streamsStore.totalTargetPct); const totalTargetPct = computed(() =>
coop.streams.value.reduce((sum, s) => sum + (s.targetPct || 0), 0)
);
const totalMonthlyTarget = computed(() => const totalMonthlyTarget = computed(() =>
Math.round( Math.round(
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0) streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)

View file

@ -0,0 +1,98 @@
<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h4 class="font-medium">Milestones</h4>
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-plus"
@click="showAddForm = true"
>
Add
</UButton>
</div>
</template>
<div class="space-y-3">
<div v-if="milestoneStatuses.length === 0" class="text-sm text-gray-500 italic py-2">
No milestones set. Add key dates to track progress.
</div>
<div
v-for="milestone in milestoneStatuses"
:key="milestone.id"
class="flex items-center justify-between p-2 border border-gray-200 rounded"
>
<div class="flex items-center gap-2">
<UIcon
:name="milestone.willReach ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
:class="milestone.willReach ? 'text-green-500' : 'text-amber-500'"
class="w-4 h-4"
/>
<div>
<div class="text-sm font-medium">{{ milestone.label }}</div>
<div class="text-xs text-gray-500">
{{ formatDate(milestone.date) }}
</div>
</div>
</div>
<UButton
size="xs"
variant="ghost"
color="red"
icon="i-heroicons-trash"
@click="removeMilestone(milestone.id)"
/>
</div>
</div>
<!-- Add milestone modal -->
<UModal v-model="showAddForm">
<div class="p-4">
<h3 class="text-lg font-semibold mb-4">Add Milestone</h3>
<div class="space-y-3">
<UInput
v-model="newMilestone.label"
placeholder="Milestone name (e.g., 'Product launch')"
/>
<UInput
v-model="newMilestone.date"
type="date"
/>
<div class="flex gap-2">
<UButton @click="saveMilestone">Save</UButton>
<UButton variant="ghost" @click="cancelAdd">Cancel</UButton>
</div>
</div>
</div>
</UModal>
</UCard>
</template>
<script setup lang="ts">
const { milestoneStatus, addMilestone, removeMilestone } = useCoopBuilder()
const showAddForm = ref(false)
const newMilestone = ref({ label: '', date: '' })
const milestoneStatuses = computed(() => milestoneStatus())
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
}
function saveMilestone() {
if (newMilestone.value.label && newMilestone.value.date) {
addMilestone(newMilestone.value.label, newMilestone.value.date)
newMilestone.value = { label: '', date: '' }
showAddForm.value = false
}
}
function cancelAdd() {
newMilestone.value = { label: '', date: '' }
showAddForm.value = false
}
</script>

View file

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

@ -0,0 +1,93 @@
<template>
<UCard>
<template #header>
<h4 class="font-medium">Stress Test</h4>
</template>
<div class="space-y-4">
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">
Revenue Delay: {{ stress.revenueDelay }} months
</label>
<input
type="range"
:value="stress.revenueDelay"
min="0"
max="6"
step="1"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
@input="(e) => updateStress({ revenueDelay: Number(e.target.value) })"
/>
</div>
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">
Cost Shock: +{{ stress.costShockPct }}%
</label>
<input
type="range"
:value="stress.costShockPct"
min="0"
max="30"
step="5"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
@input="(e) => updateStress({ costShockPct: Number(e.target.value) })"
/>
</div>
<div>
<UCheckbox
:model-value="stress.grantLost"
label="Major Grant Lost"
@update:model-value="(val) => updateStress({ grantLost: val })"
/>
</div>
<div v-if="isStressActive" class="p-3 bg-orange-50 border border-orange-200 rounded">
<div class="text-sm">
<div class="flex items-center gap-2 text-orange-800 mb-1">
<UIcon name="i-heroicons-exclamation-triangle" class="w-4 h-4" />
<span class="font-medium">Stress Test Active</span>
</div>
<div class="text-orange-700">
Projected runway: <span class="font-semibold">{{ displayStressedRunway }}</span>
<span v-if="runwayChange !== 0" class="ml-2">
({{ runwayChange > 0 ? '+' : '' }}{{ runwayChange }} months)
</span>
</div>
</div>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const { stress, updateStress, runwayMonths } = useCoopBuilder()
const isStressActive = computed(() =>
stress.value.revenueDelay > 0 ||
stress.value.costShockPct > 0 ||
stress.value.grantLost
)
const stressedRunway = computed(() => runwayMonths(undefined, { useStress: true }))
const normalRunway = computed(() => runwayMonths())
const displayStressedRunway = computed(() => {
const months = stressedRunway.value
if (!isFinite(months)) return '∞'
if (months < 1) return '<1 month'
return `${Math.round(months)} months`
})
const runwayChange = computed(() => {
const normal = normalRunway.value
const stressed = stressedRunway.value
if (!isFinite(normal) && !isFinite(stressed)) return 0
if (!isFinite(normal)) return -99 // Very large negative change
if (!isFinite(stressed)) return 0
return Math.round(stressed - normal)
})
</script>

View file

@ -0,0 +1,186 @@
<template>
<div class="hidden" data-ui="advanced_accordion_v1" />
<UAccordion :items="accordionItems" :multiple="false" class="shadow-sm rounded-xl">
<template #advanced>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Scenarios Panel -->
<div class="space-y-6">
<h4 class="font-semibold">Scenarios</h4>
<USelect
v-model="scenario"
:options="scenarioOptions"
placeholder="Select scenario"
/>
</div>
<!-- Stress Test Panel -->
<div class="space-y-6">
<h4 class="font-semibold">Stress Test</h4>
<div class="space-y-2">
<div>
<label class="text-xs text-gray-600">Revenue Delay (months)</label>
<URange
v-model="stress.revenueDelay"
:min="0"
:max="6"
:step="1"
class="mt-1"
/>
<div class="text-xs text-gray-500">{{ stress.revenueDelay }} months</div>
</div>
<div>
<label class="text-xs text-gray-600">Cost Shock (%)</label>
<URange
v-model="stress.costShockPct"
:min="0"
:max="30"
:step="1"
class="mt-1"
/>
<div class="text-xs text-gray-500">{{ stress.costShockPct }}%</div>
</div>
<UCheckbox
v-model="stress.grantLost"
label="Grant lost"
/>
<div class="text-sm text-gray-600 pt-2 border-t">
Projected runway: {{ projectedRunway }}
</div>
</div>
</div>
<!-- Milestones Panel -->
<div class="space-y-6">
<div class="flex items-center justify-between">
<h4 class="font-semibold">Milestones</h4>
<UButton
size="xs"
variant="outline"
@click="showMilestoneModal = true"
>
+ Add
</UButton>
</div>
<div class="space-y-2">
<div
v-for="milestone in milestoneStatus()"
:key="milestone.id"
class="flex items-center justify-between text-sm"
>
<div class="flex items-center gap-2">
<span>{{ milestone.willReach ? '✅' : '⚠️' }}</span>
<span>{{ milestone.label }}</span>
</div>
<span class="text-xs text-gray-600">{{ formatDate(milestone.date) }}</span>
</div>
<div v-if="milestones.length === 0" class="text-sm text-gray-600 italic">
No milestones yet
</div>
</div>
</div>
</div>
</template>
</UAccordion>
<!-- Milestone Modal -->
<UModal v-model="showMilestoneModal">
<UCard>
<template #header>
<h3 class="text-lg font-medium">Add Milestone</h3>
</template>
<div class="space-y-4">
<UFormGroup label="Label">
<UInput v-model="newMilestone.label" placeholder="e.g. Product launch" />
</UFormGroup>
<UFormGroup label="Date">
<UInput v-model="newMilestone.date" type="date" />
</UFormGroup>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="showMilestoneModal = false">
Cancel
</UButton>
<UButton @click="addNewMilestone" :disabled="!newMilestone.label || !newMilestone.date">
Add Milestone
</UButton>
</div>
</template>
</UCard>
</UModal>
</template>
<script setup lang="ts">
const {
scenario,
setScenario,
stress,
updateStress,
milestones,
milestoneStatus,
addMilestone,
runwayMonths
} = useCoopBuilder()
// Accordion setup
const accordionItems = [{
label: 'Advanced Planning',
icon: 'i-heroicons-wrench-screwdriver',
slot: 'advanced',
defaultOpen: false
}]
// Scenarios
const scenarioOptions = [
{ label: 'Current', value: 'current' },
{ label: 'Quit Day Jobs', value: 'quit-jobs' },
{ label: 'Start Production', value: 'start-production' },
{ label: 'Custom', value: 'custom' }
]
// Stress test with live preview
const projectedRunway = computed(() => {
const months = runwayMonths(undefined, { useStress: true })
if (!isFinite(months)) return '∞'
if (months < 1) return '<1m'
return `${Math.round(months)}m`
})
// Milestones modal
const showMilestoneModal = ref(false)
const newMilestone = reactive({
label: '',
date: ''
})
function addNewMilestone() {
if (!newMilestone.label || !newMilestone.date) return
addMilestone(newMilestone.label, newMilestone.date)
newMilestone.label = ''
newMilestone.date = ''
showMilestoneModal.value = false
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
// Watch scenario changes
watch(scenario, (newValue) => {
setScenario(newValue)
})
// Watch stress changes
watch(stress, (newValue) => {
updateStress(newValue)
}, { deep: true })
</script>

View file

@ -0,0 +1,15 @@
<template>
<div class="hidden" data-ui="dashboard_core_metrics_v1" />
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<RunwayCard />
<NeedsCoverageCard />
<RevenueMixCard />
</div>
</template>
<script setup lang="ts">
// Import child components explicitly
import RunwayCard from './RunwayCard.vue'
import NeedsCoverageCard from './NeedsCoverageCard.vue'
import RevenueMixCard from './RevenueMixCard.vue'
</script>

View file

@ -0,0 +1,56 @@
<template>
<div class="hidden" data-ui="member_coverage_panel_v1" />
<UCard class="shadow-sm rounded-xl">
<template #header>
<h3 class="font-semibold">Member needs coverage</h3>
</template>
<div class="space-y-6">
<div
v-for="member in allocatedMembers"
:key="member.id"
class="flex items-center gap-4"
>
<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)}%` }"
/>
</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.
</div>
</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 ✓' : '' }}
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
const allocatedMembers = computed(() => allocatePayroll())
const stats = computed(() => teamCoverageStats())
const allCovered = computed(() => stats.value.under100 === 0)
function getBarColor(pct: number): string {
if (pct >= 100) return 'bg-green-500'
if (pct >= 80) return 'bg-amber-500'
return 'bg-red-500'
}
</script>

View file

@ -0,0 +1,37 @@
<template>
<div class="hidden" data-ui="needs_coverage_card_v1" />
<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>
</template>
<div class="text-center space-y-6">
<div class="text-2xl font-semibold" :class="statusColor">
{{ pctCovered }}%
</div>
<div class="text-sm text-gray-600">
Median {{ median }}%
</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>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const { members, teamCoverageStats } = useCoopBuilder()
const stats = computed(() => teamCoverageStats())
const pctCovered = computed(() => Math.round(stats.value.over100Pct || 0))
const median = computed(() => Math.round(stats.value.median ?? 0))
const statusColor = computed(() => {
if (pctCovered.value >= 100) return 'text-green-600'
if (pctCovered.value >= 80) return 'text-amber-600'
return 'text-red-600'
})
</script>

View file

@ -0,0 +1,56 @@
<template>
<div class="hidden" data-ui="revenue_mix_card_v1" />
<UCard class="min-h-[140px] shadow-sm rounded-xl">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-chart-pie" class="h-5 w-5" />
<h3 class="font-semibold">Revenue Mix</h3>
</div>
</template>
<div class="space-y-6">
<div v-if="mix.length === 0" class="text-sm text-gray-600 text-center py-8">
Add revenue streams to see mix.
</div>
<div v-else>
<!-- Revenue bars -->
<div v-for="s in mix.slice(0, 3)" :key="s.label" class="mb-2">
<div class="flex justify-between text-xs">
<span class="truncate">{{ s.label }}</span>
<span>{{ Math.round(s.pct * 100) }}%</span>
</div>
<div class="h-2 bg-gray-200 rounded">
<div
class="h-2 rounded"
:class="getBarColor(mix.indexOf(s))"
:style="{ width: (s.pct * 100) + '%' }"
/>
</div>
</div>
<!-- Subtext with concentration warning -->
<div class="text-sm text-gray-600 text-center">
Top stream {{ Math.round(topPct * 100) }}%
<span v-if="topPct > 0.5" class="text-amber-600"></span>
</div>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const { revenueMix, concentrationPct } = useCoopBuilder()
const mix = computed(() => revenueMix())
const topPct = computed(() => concentrationPct())
function getBarColor(index: number): string {
const colors = [
'bg-blue-500',
'bg-green-500',
'bg-amber-500'
]
return colors[index % colors.length]
}
</script>

View file

@ -0,0 +1,62 @@
<template>
<div class="hidden" data-ui="runway_card_v1" />
<UCard class="min-h-[140px] shadow-sm rounded-xl" :class="borderColor">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="statusDotColor" />
<h3 class="font-semibold">Runway</h3>
</div>
<UBadge
:color="operatingMode === 'target' ? 'blue' : 'gray'"
size="xs"
>
{{ operatingMode === 'target' ? 'Target Mode' : 'Min Mode' }}
</UBadge>
</div>
</template>
<div class="text-center space-y-6">
<div class="text-2xl font-semibold" :class="statusColor">
{{ displayRunway }}
</div>
<div class="text-sm text-gray-600">
at current spending
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const { runwayMonths, operatingMode } = useCoopBuilder()
const runway = computed(() => runwayMonths())
const displayRunway = computed(() => {
const months = runway.value
if (!isFinite(months)) return '∞'
if (months < 1) return '<1 month'
return `${Math.round(months)} months`
})
const statusColor = computed(() => {
const months = runway.value
if (!isFinite(months) || months >= 6) return 'text-green-600'
if (months >= 3) return 'text-amber-600'
return 'text-red-600'
})
const statusDotColor = computed(() => {
const months = runway.value
if (!isFinite(months) || months >= 6) return 'bg-green-500'
if (months >= 3) return 'bg-amber-500'
return 'bg-red-500'
})
const borderColor = computed(() => {
const months = runway.value
if (!isFinite(months) || months >= 6) return 'ring-1 ring-green-200'
if (months >= 3) return 'ring-1 ring-amber-200'
return 'ring-1 ring-red-200'
})
</script>

View file

@ -0,0 +1,41 @@
<template>
<div class="flex items-center gap-2">
<div class="flex-1 h-3 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="barColor"
:style="{ width: `${Math.min(100, displayPct)}%` }"
/>
</div>
<span class="text-sm font-medium min-w-[3rem] text-right" :class="textColor">
{{ Math.round(valuePct) }}%
</span>
</div>
</template>
<script setup lang="ts">
interface Props {
valuePct: number
targetPct?: number
}
const props = withDefaults(defineProps<Props>(), {
targetPct: 100
})
// Display percentage (cap at 200% for visual purposes)
const displayPct = computed(() => Math.min(200, props.valuePct))
// Color based on coverage thresholds
const barColor = computed(() => {
if (props.valuePct >= 100) return 'bg-green-500'
if (props.valuePct >= 80) return 'bg-amber-500'
return 'bg-red-500'
})
const textColor = computed(() => {
if (props.valuePct >= 100) return 'text-green-600'
if (props.valuePct >= 80) return 'text-amber-600'
return 'text-red-600'
})
</script>

View file

@ -0,0 +1,36 @@
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Mode:</span>
<UButtonGroup>
<UButton
:variant="modelValue === 'minimum' ? 'solid' : 'ghost'"
color="gray"
size="xs"
@click="$emit('update:modelValue', 'minimum')"
>
Min Mode
</UButton>
<UButton
:variant="modelValue === 'target' ? 'solid' : 'ghost'"
color="primary"
size="xs"
@click="$emit('update:modelValue', 'target')"
>
Target Mode
</UButton>
</UButtonGroup>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: 'minimum' | 'target'
}
interface Emits {
(e: 'update:modelValue', value: 'minimum' | 'target'): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>

View file

@ -0,0 +1,364 @@
import { allocatePayroll as allocatePayrollImpl, monthlyPayroll, type Member, type PayPolicy } from '~/types/members'
export function useCoopBuilder() {
// Use the centralized Pinia store
const store = useCoopBuilderStore()
// Initialize store (but don't auto-load demo data)
onMounted(() => {
// Give the persistence plugin time to hydrate
nextTick(() => {
// Just ensure store is initialized but don't load demo data
// store.initializeDefaults() is disabled to prevent auto demo data
})
})
// Core computed values with error handling
const members = computed(() => {
try {
return store.members || []
} catch (e) {
console.warn('Error accessing members:', e)
return []
}
})
const streams = computed(() => {
try {
return store.streams || []
} catch (e) {
console.warn('Error accessing streams:', e)
return []
}
})
const policy = computed(() => {
try {
return store.policy || { relationship: 'equal-pay', roleBands: {} }
} catch (e) {
console.warn('Error accessing policy:', e)
return { relationship: 'equal-pay', roleBands: {} }
}
})
const operatingMode = computed({
get: () => {
try {
return store.operatingMode || 'min'
} catch (e) {
console.warn('Error accessing operating mode:', e)
return 'min' as 'min' | 'target'
}
},
set: (value: 'min' | 'target') => {
try {
store.setOperatingMode(value)
} catch (e) {
console.warn('Error setting operating mode:', e)
}
}
})
const scenario = computed({
get: () => store.scenario,
set: (value) => store.setScenario(value)
})
const stress = computed({
get: () => store.stress,
set: (value) => store.updateStress(value)
})
const milestones = computed(() => store.milestones)
// Helper: Get scenario-transformed data
function getScenarioData() {
const baseMembers = [...members.value]
const baseStreams = [...streams.value]
switch (scenario.value) {
case 'quit-jobs':
return {
members: baseMembers.map(m => ({ ...m, externalMonthlyIncome: 0 })),
streams: baseStreams
}
case 'start-production':
return {
members: baseMembers,
streams: baseStreams.map(s => {
// Reduce service revenue by 30%
if (s.category?.toLowerCase().includes('service') || s.label.toLowerCase().includes('service')) {
return { ...s, monthly: (s.monthly || 0) * 0.7 }
}
return s
})
}
default:
return { members: baseMembers, streams: baseStreams }
}
}
// Helper: Apply stress test to scenario data
function getStressedData(baseData?: { members: Member[]; streams: any[] }) {
const data = baseData || getScenarioData()
const { revenueDelay, costShockPct, grantLost } = stress.value
if (revenueDelay === 0 && costShockPct === 0 && !grantLost) {
return data
}
let adjustedStreams = [...data.streams]
// Apply revenue delay (reduce revenue by delay percentage)
if (revenueDelay > 0) {
adjustedStreams = adjustedStreams.map(s => ({
...s,
monthly: (s.monthly || 0) * Math.max(0, 1 - (revenueDelay / 12))
}))
}
// Grant lost - remove largest grant
if (grantLost) {
const grantStreams = adjustedStreams.filter(s =>
s.category?.toLowerCase().includes('grant') ||
s.label.toLowerCase().includes('grant')
)
if (grantStreams.length > 0) {
const largestGrant = grantStreams.reduce((prev, current) =>
(prev.monthly || 0) > (current.monthly || 0) ? prev : current
)
adjustedStreams = adjustedStreams.map(s =>
s.id === largestGrant.id ? { ...s, monthly: 0 } : s
)
}
}
return {
members: data.members,
streams: adjustedStreams
}
}
// Payroll allocation
function allocatePayroll() {
const { members: scenarioMembers } = getScenarioData()
const payPolicy = policy.value
const totalRevenue = streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0)
const overheadCosts = store.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0)
const availableForPayroll = Math.max(0, totalRevenue - overheadCosts)
return allocatePayrollImpl(scenarioMembers, payPolicy as PayPolicy, availableForPayroll)
}
// Coverage calculation for a single member
function coverage(member: Member): { minPct: number; targetPct: number } {
const totalIncome = (member.monthlyPayPlanned || 0) + (member.externalMonthlyIncome || 0)
const minPct = member.minMonthlyNeeds > 0
? Math.min(200, (totalIncome / member.minMonthlyNeeds) * 100)
: 100
const targetPct = member.targetMonthlyPay > 0
? Math.min(200, (totalIncome / member.targetMonthlyPay) * 100)
: 100
return { minPct, targetPct }
}
// Team coverage statistics
function teamCoverageStats() {
try {
const allocatedMembers = allocatePayroll() || []
const coverages = allocatedMembers.map(m => coverage(m).minPct).filter(c => !isNaN(c))
if (coverages.length === 0) {
return { median: 0, under100: 0, over100Pct: 0, gini: 0 }
}
const sorted = [...coverages].sort((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)] || 0
const under100 = coverages.filter(c => c < 100).length
const over100Pct = coverages.length > 0
? Math.round(((coverages.length - under100) / coverages.length) * 100)
: 0
// Simple Gini coefficient approximation
const mean = coverages.reduce((sum, c) => sum + c, 0) / coverages.length
const gini = coverages.length > 1 && mean > 0
? coverages.reduce((sum, c) => sum + Math.abs(c - mean), 0) / (2 * coverages.length * mean)
: 0
return { median, under100, over100Pct, gini }
} catch (e) {
console.warn('Error calculating team coverage stats:', e)
return { median: 0, under100: 0, over100Pct: 0, gini: 0 }
}
}
// Revenue mix
function revenueMix() {
try {
const { streams: scenarioStreams } = getStressedData() || { streams: [] }
const validStreams = scenarioStreams.filter(s => s && typeof s === 'object')
const total = validStreams.reduce((sum, s) => sum + (s.monthly || 0), 0)
return validStreams
.filter(s => (s.monthly || 0) > 0)
.map(s => ({
label: s.label || 'Unnamed Stream',
monthly: s.monthly || 0,
pct: total > 0 ? (s.monthly || 0) / total : 0
}))
.sort((a, b) => b.monthly - a.monthly)
} catch (e) {
console.warn('Error calculating revenue mix:', e)
return []
}
}
// Concentration percentage (highest stream)
function concentrationPct(): number {
try {
const mix = revenueMix()
return mix.length > 0 ? (mix[0].pct || 0) : 0
} catch (e) {
console.warn('Error calculating concentration:', e)
return 0
}
}
// Runway calculation
function runwayMonths(mode?: 'min' | 'target', opts?: { useStress?: boolean }): number {
try {
const inputMode = mode || operatingMode.value
// Map to internal store format for compatibility
const currentMode = inputMode === 'min' ? 'minimum' : inputMode === 'target' ? 'target' : 'minimum'
const { members: scenarioMembers, streams: scenarioStreams } = opts?.useStress
? getStressedData()
: getScenarioData()
// Calculate monthly payroll
const payrollCost = monthlyPayroll(scenarioMembers || [], currentMode) || 0
const oncostPct = store.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + Math.max(0, oncostPct) / 100)
// Calculate revenue and costs
const totalRevenue = (scenarioStreams || []).reduce((sum, s) => sum + (s.monthly || 0), 0)
const overheadCost = (store.overheadCosts || []).reduce((sum, cost) => sum + (cost.amount || 0), 0)
// Apply cost shock if in stress mode
const adjustedOverhead = opts?.useStress && stress.value.costShockPct > 0
? overheadCost * (1 + Math.max(0, stress.value.costShockPct) / 100)
: overheadCost
// Monthly net and burn
const monthlyNet = totalRevenue - totalPayroll - adjustedOverhead
const monthlyBurn = totalPayroll + adjustedOverhead
// Cash reserves with safe defaults
const cash = Math.max(0, store.currentCash || 50000)
const savings = Math.max(0, store.currentSavings || 15000)
const totalLiquid = cash + savings
// Runway calculation
if (monthlyNet >= 0) {
return Infinity // Sustainable
}
return monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0
} catch (e) {
console.warn('Error calculating runway:', e)
return 0
}
}
// Milestone status
function milestoneStatus(mode?: 'min' | 'target') {
const currentMode = mode || operatingMode.value
const runway = runwayMonths(currentMode)
const runwayEndDate = new Date()
runwayEndDate.setMonth(runwayEndDate.getMonth() + Math.floor(runway))
return milestones.value.map(milestone => ({
...milestone,
willReach: new Date(milestone.date) <= runwayEndDate
}))
}
// Actions
function setOperatingMode(mode: 'min' | 'target') {
store.setOperatingMode(mode)
}
function setScenario(newScenario: 'current' | 'quit-jobs' | 'start-production' | 'custom') {
store.setScenario(newScenario)
}
function updateStress(newStress: Partial<typeof stress.value>) {
store.updateStress(newStress)
}
function addMilestone(label: string, date: string) {
store.addMilestone(label, date)
}
function removeMilestone(id: string) {
store.removeMilestone(id)
}
// Testing helpers
function clearAll() {
store.clearAll()
}
function loadDefaults() {
store.loadDefaultData()
}
// Reset helper function
function reset() {
store.clearAll()
store.loadDefaultData()
}
return {
// State
members,
streams,
policy,
operatingMode,
scenario,
stress,
milestones,
// Computed
allocatePayroll,
coverage,
teamCoverageStats,
revenueMix,
concentrationPct,
runwayMonths,
milestoneStatus,
// Actions
setOperatingMode,
setScenario,
updateStress,
addMilestone,
removeMilestone,
upsertMember: (member: any) => store.upsertMember(member),
removeMember: (id: string) => store.removeMember(id),
upsertStream: (stream: any) => store.upsertStream(stream),
removeStream: (id: string) => store.removeStream(id),
setPolicy: (relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") => store.setPolicy(relationship),
setRoleBands: (bands: Record<string, number>) => store.setRoleBands(bands),
setEqualWage: (wage: number) => store.setEqualWage(wage),
// Testing helpers
clearAll,
loadDefaults,
reset
}
}

View file

@ -0,0 +1,92 @@
import { monthlyPayroll } from '~/types/members'
export function useCushionForecast() {
const cashStore = useCashStore()
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const budgetStore = useBudgetStore()
// Savings progress calculation
const savingsProgress = computed(() => {
const current = cashStore.currentSavings || 0
const targetMonths = policiesStore.savingsTargetMonths || 3
const monthlyBurn = getMonthlyBurn()
const target = targetMonths * monthlyBurn
return {
current,
target,
targetMonths,
progressPct: target > 0 ? Math.min(100, (current / target) * 100) : 0,
gap: Math.max(0, target - current),
status: getProgressStatus(current, target)
}
})
// 13-week cushion breach forecast
const cushionForecast = computed(() => {
const minCushion = policiesStore.minCashCushionAmount || 5000
const currentBalance = cashStore.currentCash || 0
const monthlyBurn = getMonthlyBurn()
const weeklyBurn = monthlyBurn / 4.33 // Convert monthly to weekly
const weeks = []
let runningBalance = currentBalance
for (let week = 1; week <= 13; week++) {
// Simple projection: subtract weekly burn
runningBalance -= weeklyBurn
const breachesCushion = runningBalance < minCushion
weeks.push({
week,
balance: runningBalance,
breachesCushion,
cushionAmount: minCushion
})
}
const firstBreachWeek = weeks.find(w => w.breachesCushion)?.week
const breachesWithin13Weeks = Boolean(firstBreachWeek)
return {
weeks,
firstBreachWeek,
breachesWithin13Weeks,
minCushion,
weeksUntilBreach: firstBreachWeek || null
}
})
function getMonthlyBurn() {
const operatingMode = policiesStore.operatingMode || 'minimum'
const payrollCost = monthlyPayroll(membersStore.members, operatingMode)
const oncostPct = policiesStore.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + oncostPct / 100)
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
return totalPayroll + overheadCost
}
function getProgressStatus(current: number, target: number): 'green' | 'yellow' | 'red' {
if (target === 0) return 'green'
const pct = (current / target) * 100
if (pct >= 80) return 'green'
if (pct >= 50) return 'yellow'
return 'red'
}
// Alert conditions (matching CLAUDE.md)
const alerts = computed(() => ({
savingsBelowTarget: savingsProgress.value.current < savingsProgress.value.target,
cushionBreach: cushionForecast.value.breachesWithin13Weeks,
}))
return {
savingsProgress,
cushionForecast,
alerts,
getMonthlyBurn
}
}

View file

@ -33,10 +33,13 @@ export function useFixtureIO() {
payrollOncostPct: policies.payrollOncostPct, payrollOncostPct: policies.payrollOncostPct,
savingsTargetMonths: policies.savingsTargetMonths, savingsTargetMonths: policies.savingsTargetMonths,
minCashCushionAmount: policies.minCashCushionAmount, minCashCushionAmount: policies.minCashCushionAmount,
operatingMode: policies.operatingMode,
payPolicy: policies.payPolicy,
deferredCapHoursPerQtr: policies.deferredCapHoursPerQtr, deferredCapHoursPerQtr: policies.deferredCapHoursPerQtr,
deferredSunsetMonths: policies.deferredSunsetMonths, deferredSunsetMonths: policies.deferredSunsetMonths,
surplusOrder: policies.surplusOrder, surplusOrder: policies.surplusOrder,
paymentPriority: policies.paymentPriority paymentPriority: policies.paymentPriority,
volunteerScope: policies.volunteerScope
}, },
streams: streams.streams, streams: streams.streams,
budget: { budget: {
@ -66,9 +69,65 @@ export function useFixtureIO() {
} }
const importAll = (snapshot: AppSnapshot) => { const importAll = (snapshot: AppSnapshot) => {
// TODO: Implement import functionality for all stores const members = useMembersStore()
// This will patch each store with the snapshot data const policies = usePoliciesStore()
console.log('Import functionality to be implemented', snapshot) const streams = useStreamsStore()
const budget = useBudgetStore()
const scenarios = useScenariosStore()
const cash = useCashStore()
const session = useSessionStore()
try {
// Import members
if (snapshot.members && Array.isArray(snapshot.members)) {
members.$patch({ members: snapshot.members })
}
// Import policies
if (snapshot.policies) {
policies.$patch(snapshot.policies)
}
// Import streams
if (snapshot.streams && Array.isArray(snapshot.streams)) {
streams.$patch({ streams: snapshot.streams })
}
// Import budget
if (snapshot.budget) {
budget.$patch(snapshot.budget)
}
// Import scenarios
if (snapshot.scenarios) {
scenarios.$patch(snapshot.scenarios)
}
// Import cash
if (snapshot.cash) {
cash.updateCurrentBalances(
snapshot.cash.currentCash || 0,
snapshot.cash.currentSavings || 0
)
// Handle cash events and payment queue if present
if (snapshot.cash.cashEvents) {
cash.$patch({ cashEvents: snapshot.cash.cashEvents })
}
if (snapshot.cash.paymentQueue) {
cash.$patch({ paymentQueue: snapshot.cash.paymentQueue })
}
}
// Import session
if (snapshot.session) {
session.$patch(snapshot.session)
}
console.log('Successfully imported data snapshot')
} catch (error) {
console.error('Failed to import snapshot:', error)
throw error
}
} }
return { exportAll, importAll } return { exportAll, importAll }

View file

@ -1,238 +1,73 @@
/** /**
* Composable for loading and managing fixture data * DISABLED: All fixture data removed to prevent automatic demo data
* Provides centralized access to demo data for all screens * This composable previously loaded sample data but is now empty
*/ */
export const useFixtures = () => { export const useFixtures = () => {
// Load fixture data (in real app, this would come from API or stores) // All sample data functions now return empty data
const loadMembers = async () => { const loadMembers = async () => {
// In production, this would fetch from content/fixtures/members.json
// For now, return inline data that matches the fixture structure
return { return {
members: [ members: []
{
id: 'member-1',
displayName: 'Alex Chen',
roleFocus: 'Technical Lead',
payRelationship: 'Hybrid',
capacity: { minHours: 20, targetHours: 120, maxHours: 160 },
riskBand: 'Medium',
externalCoveragePct: 60,
privacyNeeds: 'aggregate_ok',
deferredHours: 85,
quarterlyDeferredCap: 240
},
{
id: 'member-2',
displayName: 'Jordan Silva',
roleFocus: 'Design & UX',
payRelationship: 'FullyPaid',
capacity: { minHours: 30, targetHours: 140, maxHours: 180 },
riskBand: 'Low',
externalCoveragePct: 20,
privacyNeeds: 'aggregate_ok',
deferredHours: 0,
quarterlyDeferredCap: 240
},
{
id: 'member-3',
displayName: 'Sam Rodriguez',
roleFocus: 'Operations & Growth',
payRelationship: 'Supplemental',
capacity: { minHours: 10, targetHours: 60, maxHours: 100 },
riskBand: 'High',
externalCoveragePct: 85,
privacyNeeds: 'steward_only',
deferredHours: 32,
quarterlyDeferredCap: 120
}
]
} }
} }
const loadStreams = async () => { const loadStreams = async () => {
return { return {
revenueStreams: [ revenueStreams: []
{
id: 'stream-1',
name: 'Client Services',
category: 'Services',
subcategory: 'Development',
targetPct: 65,
targetMonthlyAmount: 13000,
certainty: 'Committed',
payoutDelayDays: 30,
terms: 'Net 30',
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
effortHoursPerMonth: 180
},
{
id: 'stream-2',
name: 'Platform Sales',
category: 'Product',
subcategory: 'Digital Tools',
targetPct: 20,
targetMonthlyAmount: 4000,
certainty: 'Probable',
payoutDelayDays: 14,
terms: 'Platform payout',
revenueSharePct: 0,
platformFeePct: 5,
restrictions: 'General',
effortHoursPerMonth: 40
},
{
id: 'stream-3',
name: 'Innovation Grant',
category: 'Grant',
subcategory: 'Government',
targetPct: 10,
targetMonthlyAmount: 2000,
certainty: 'Committed',
payoutDelayDays: 45,
terms: 'Quarterly disbursement',
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'Restricted',
effortHoursPerMonth: 8
},
{
id: 'stream-4',
name: 'Community Donations',
category: 'Donation',
subcategory: 'Individual',
targetPct: 3,
targetMonthlyAmount: 600,
certainty: 'Aspirational',
payoutDelayDays: 3,
terms: 'Immediate',
revenueSharePct: 0,
platformFeePct: 2.9,
restrictions: 'General',
effortHoursPerMonth: 5
},
{
id: 'stream-5',
name: 'Consulting & Training',
category: 'Other',
subcategory: 'Professional Services',
targetPct: 2,
targetMonthlyAmount: 400,
certainty: 'Probable',
payoutDelayDays: 21,
terms: 'Net 21',
revenueSharePct: 0,
platformFeePct: 0,
restrictions: 'General',
effortHoursPerMonth: 12
}
]
} }
} }
const loadFinances = async () => { const loadFinances = async () => {
return { return {
currentBalances: { currentBalances: {
cash: 5000, cash: 0,
savings: 8000, savings: 0,
totalLiquid: 13000 totalLiquid: 0
}, },
policies: { policies: {
equalHourlyWage: 20, equalHourlyWage: 0,
payrollOncostPct: 25, payrollOncostPct: 0,
savingsTargetMonths: 3, savingsTargetMonths: 0,
minCashCushionAmount: 3000, minCashCushionAmount: 0,
deferredCapHoursPerQtr: 240, deferredCapHoursPerQtr: 0,
deferredSunsetMonths: 12 deferredSunsetMonths: 0
}, },
deferredLiabilities: { deferredLiabilities: {
totalDeferred: 2340, totalDeferred: 0,
byMember: { byMember: {}
'member-1': 1700,
'member-2': 0,
'member-3': 640
}
} }
} }
} }
const loadCosts = async () => { const loadCosts = async () => {
return { return {
overheadCosts: [ overheadCosts: [],
{ productionCosts: []
id: 'overhead-1',
name: 'Coworking Space',
amount: 0,
category: 'Workspace',
recurring: true
},
{
id: 'overhead-2',
name: 'Tools & Software',
amount: 0,
category: 'Technology',
recurring: true
},
{
id: 'overhead-3',
name: 'Business Insurance',
amount: 0,
category: 'Legal & Compliance',
recurring: true
}
],
productionCosts: [
{
id: 'production-1',
name: 'Development Kits',
amount: 0,
category: 'Hardware',
period: '2024-01'
}
]
} }
} }
// Calculate derived metrics from fixture data // Return empty metrics
const calculateMetrics = async () => { const calculateMetrics = async () => {
const [members, streams, finances, costs] = await Promise.all([
loadMembers(),
loadStreams(),
loadFinances(),
loadCosts()
])
const totalTargetHours = members.members.reduce((sum, member) =>
sum + member.capacity.targetHours, 0
)
const totalTargetRevenue = streams.revenueStreams.reduce((sum, stream) =>
sum + stream.targetMonthlyAmount, 0
)
const totalOverheadCosts = costs.overheadCosts.reduce((sum, cost) =>
sum + cost.amount, 0
)
const monthlyPayroll = totalTargetHours * finances.policies.equalHourlyWage *
(1 + finances.policies.payrollOncostPct / 100)
const monthlyBurn = monthlyPayroll + totalOverheadCosts +
costs.productionCosts.reduce((sum, cost) => sum + cost.amount, 0)
const runway = finances.currentBalances.totalLiquid / monthlyBurn
return { return {
totalTargetHours, totalTargetHours: 0,
totalTargetRevenue, totalTargetRevenue: 0,
monthlyPayroll, monthlyPayroll: 0,
monthlyBurn, monthlyBurn: 0,
runway, runway: 0,
members: members.members, members: [],
streams: streams.revenueStreams, streams: [],
finances: finances, finances: {
costs: costs currentBalances: { cash: 0, savings: 0, totalLiquid: 0 },
policies: {
equalHourlyWage: 0,
payrollOncostPct: 0,
savingsTargetMonths: 0,
minCashCushionAmount: 0,
deferredCapHoursPerQtr: 0,
deferredSunsetMonths: 0
},
deferredLiabilities: { totalDeferred: 0, byMember: {} }
},
costs: { overheadCosts: [], productionCosts: [] }
} }
} }

View file

@ -0,0 +1,142 @@
export function useMigrations() {
const CURRENT_SCHEMA_VERSION = "1.1"
const SCHEMA_KEY = "urgent-tools-schema-version"
// Get stored schema version
function getStoredVersion(): string {
if (process.client) {
return localStorage.getItem(SCHEMA_KEY) || "1.0"
}
return "1.0"
}
// Set schema version
function setVersion(version: string) {
if (process.client) {
localStorage.setItem(SCHEMA_KEY, version)
}
}
// Migration functions
const migrations = {
"1.0": () => {
// Initial schema - no migration needed
},
"1.1": () => {
// Add new member needs fields
const membersData = localStorage.getItem("urgent-tools-members")
if (membersData) {
try {
const parsed = JSON.parse(membersData)
if (Array.isArray(parsed.members)) {
// Add default values for new fields
parsed.members = parsed.members.map((member: any) => ({
...member,
minMonthlyNeeds: member.minMonthlyNeeds || 0,
targetMonthlyPay: member.targetMonthlyPay || 0,
externalMonthlyIncome: member.externalMonthlyIncome || 0,
monthlyPayPlanned: member.monthlyPayPlanned || 0,
}))
localStorage.setItem("urgent-tools-members", JSON.stringify(parsed))
}
} catch (error) {
console.warn("Failed to migrate members data:", error)
}
}
// Add new policy fields
const policiesData = localStorage.getItem("urgent-tools-policies")
if (policiesData) {
try {
const parsed = JSON.parse(policiesData)
parsed.operatingMode = parsed.operatingMode || 'minimum'
parsed.payPolicy = parsed.payPolicy || {
relationship: 'equal-pay',
roleBands: []
}
localStorage.setItem("urgent-tools-policies", JSON.stringify(parsed))
} catch (error) {
console.warn("Failed to migrate policies data:", error)
}
}
// DISABLED: No automatic cash balance initialization
// The app should start completely empty - no demo data
//
// Previously this would auto-initialize cash balances, but this
// created unwanted demo data. Users must explicitly set up their data.
//
// const cashData = localStorage.getItem("urgent-tools-cash")
// if (!cashData) {
// localStorage.setItem("urgent-tools-cash", JSON.stringify({
// currentCash: 50000,
// currentSavings: 15000,
// cashEvents: [],
// paymentQueue: []
// }))
// }
}
}
// Run all necessary migrations
function migrate() {
if (!process.client) return
const currentVersion = getStoredVersion()
if (currentVersion === CURRENT_SCHEMA_VERSION) {
return // Already up to date
}
console.log(`Migrating from schema version ${currentVersion} to ${CURRENT_SCHEMA_VERSION}`)
// Run migrations in order
const versions = Object.keys(migrations).sort()
const currentIndex = versions.indexOf(currentVersion)
if (currentIndex === -1) {
console.warn(`Unknown schema version: ${currentVersion}, running all migrations`)
// Run all migrations
versions.forEach(version => {
try {
migrations[version as keyof typeof migrations]()
console.log(`Applied migration: ${version}`)
} catch (error) {
console.error(`Migration ${version} failed:`, error)
}
})
} else {
// Run migrations from current version + 1 to latest
for (let i = currentIndex + 1; i < versions.length; i++) {
const version = versions[i]
try {
migrations[version as keyof typeof migrations]()
console.log(`Applied migration: ${version}`)
} catch (error) {
console.error(`Migration ${version} failed:`, error)
}
}
}
// Update stored version
setVersion(CURRENT_SCHEMA_VERSION)
console.log(`Migration complete. Schema version: ${CURRENT_SCHEMA_VERSION}`)
}
// Check if migration is needed
function needsMigration(): boolean {
// Don't run migrations if data was intentionally cleared
if (process.client && localStorage.getItem('urgent-tools-cleared-flag') === 'true') {
return false
}
return getStoredVersion() !== CURRENT_SCHEMA_VERSION
}
return {
migrate,
needsMigration,
currentVersion: CURRENT_SCHEMA_VERSION,
storedVersion: getStoredVersion()
}
}

View file

@ -0,0 +1,52 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { allocatePayroll } from '~/types/members'
export function usePayrollAllocation() {
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const budgetStore = useBudgetStore()
const { members, payPolicy } = storeToRefs(membersStore)
const { equalHourlyWage, payrollOncostPct } = storeToRefs(policiesStore)
const { capacityTotals } = storeToRefs(membersStore)
// Calculate base payroll budget from hours and wage
const basePayrollBudget = computed(() => {
const totalHours = capacityTotals.value.targetHours || 0
const wage = equalHourlyWage.value || 0
return totalHours * wage
})
// Allocate payroll to members based on policy
const allocatedMembers = computed(() => {
if (members.value.length === 0 || basePayrollBudget.value === 0) {
return members.value
}
return allocatePayroll(
members.value,
payPolicy.value,
basePayrollBudget.value
)
})
// Total payroll with oncosts
const totalPayrollWithOncosts = computed(() => {
return basePayrollBudget.value * (1 + payrollOncostPct.value / 100)
})
// Update member planned pay when allocation changes
watchEffect(() => {
allocatedMembers.value.forEach(member => {
membersStore.setPlannedPay(member.id, member.monthlyPayPlanned || 0)
})
})
return {
basePayrollBudget,
allocatedMembers,
totalPayrollWithOncosts,
payPolicy
}
}

View file

@ -1,13 +1,49 @@
import { monthlyPayroll } from '~/types/members'
/** /**
* Computes months of runway from cash, reserves, and burn rate * Computes months of runway from cash, reserves, and burn rate
* Formula: (cash + savings) ÷ average monthly burn in scenario * Formula: (cash + savings) ÷ average monthly burn in scenario
*/ */
export const useRunway = () => { export const useRunway = () => {
const membersStore = useMembersStore()
const policiesStore = usePoliciesStore()
const budgetStore = useBudgetStore()
const calculateRunway = (cash: number, savings: number, monthlyBurn: number): number => { const calculateRunway = (cash: number, savings: number, monthlyBurn: number): number => {
if (monthlyBurn <= 0) return Infinity if (monthlyBurn <= 0) return Infinity
return (cash + savings) / monthlyBurn return (cash + savings) / monthlyBurn
} }
// Calculate monthly burn based on operating mode
const getMonthlyBurn = (mode?: 'minimum' | 'target') => {
const operatingMode = mode || policiesStore.operatingMode || 'minimum'
// Get payroll costs based on mode
const payrollCost = monthlyPayroll(membersStore.members, operatingMode)
// Add oncosts
const oncostPct = policiesStore.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + oncostPct / 100)
// Add overhead costs
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
return totalPayroll + overheadCost
}
// Calculate runway for both modes
const getDualModeRunway = (cash: number, savings: number) => {
const minBurn = getMonthlyBurn('minimum')
const targetBurn = getMonthlyBurn('target')
return {
minimum: calculateRunway(cash, savings, minBurn),
target: calculateRunway(cash, savings, targetBurn),
minBurn,
targetBurn
}
}
const getRunwayStatus = (months: number): 'green' | 'yellow' | 'red' => { const getRunwayStatus = (months: number): 'green' | 'yellow' | 'red' => {
if (months >= 3) return 'green' if (months >= 3) return 'green'
if (months >= 2) return 'yellow' if (months >= 2) return 'yellow'
@ -22,6 +58,8 @@ export const useRunway = () => {
return { return {
calculateRunway, calculateRunway,
getRunwayStatus, getRunwayStatus,
formatRunway formatRunway,
getMonthlyBurn,
getDualModeRunway
} }
} }

109
composables/useScenarios.ts Normal file
View file

@ -0,0 +1,109 @@
import { monthlyPayroll } from '~/types/members'
export function useScenarios() {
const membersStore = useMembersStore()
const streamsStore = useStreamsStore()
const policiesStore = usePoliciesStore()
const budgetStore = useBudgetStore()
const cashStore = useCashStore()
// Base runway calculation
function calculateScenarioRunway(
members: any[],
streams: any[],
operatingMode: 'minimum' | 'target' = 'minimum'
) {
// Calculate payroll for scenario
const payrollCost = monthlyPayroll(members, operatingMode)
const oncostPct = policiesStore.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + oncostPct / 100)
// Calculate revenue
const totalRevenue = streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
// Add overhead
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
// Net monthly
const monthlyNet = totalRevenue - totalPayroll - overheadCost
// Cash + savings
const cash = cashStore.currentCash || 50000
const savings = cashStore.currentSavings || 15000
const totalLiquid = cash + savings
// Runway calculation
const monthlyBurn = totalPayroll + overheadCost
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : Infinity
return {
runway: Math.max(0, runway),
monthlyNet,
monthlyBurn,
totalRevenue,
totalPayroll
}
}
// Scenario transformations per CLAUDE.md
const scenarioTransforms = {
current: () => ({
members: [...membersStore.members],
streams: [...streamsStore.streams]
}),
quitJobs: () => ({
// Set external income to 0 for members who have day jobs
members: membersStore.members.map(m => ({
...m,
externalMonthlyIncome: 0 // Assume everyone quits their day job
})),
streams: [...streamsStore.streams]
}),
startProduction: () => ({
members: [...membersStore.members],
// Reduce service revenue, increase production costs
streams: streamsStore.streams.map(s => {
// Reduce service contracts by 30%
if (s.category?.toLowerCase().includes('service') || s.name.toLowerCase().includes('service')) {
return { ...s, targetMonthlyAmount: (s.targetMonthlyAmount || 0) * 0.7 }
}
return s
})
})
}
// Calculate all scenarios
const scenarios = computed(() => {
const currentMode = policiesStore.operatingMode || 'minimum'
const current = scenarioTransforms.current()
const quitJobs = scenarioTransforms.quitJobs()
const startProduction = scenarioTransforms.startProduction()
return {
current: {
name: 'Operate Current',
status: 'Active',
...calculateScenarioRunway(current.members, current.streams, currentMode)
},
quitJobs: {
name: 'Quit Day Jobs',
status: 'Scenario',
...calculateScenarioRunway(quitJobs.members, quitJobs.streams, currentMode)
},
startProduction: {
name: 'Start Production',
status: 'Scenario',
...calculateScenarioRunway(startProduction.members, startProduction.streams, currentMode)
}
}
})
return {
scenarios,
calculateScenarioRunway,
scenarioTransforms
}
}

View file

@ -1,113 +0,0 @@
{
"cashEvents": [
{
"id": "event-1",
"date": "2024-01-08",
"week": 1,
"type": "Influx",
"amount": 2600,
"sourceRef": "stream-1",
"policyTag": "Revenue",
"description": "Client Services - Project Alpha payment"
},
{
"id": "event-2",
"date": "2024-01-12",
"week": 2,
"type": "Influx",
"amount": 400,
"sourceRef": "stream-2",
"policyTag": "Revenue",
"description": "Platform Sales - December payout"
},
{
"id": "event-3",
"date": "2024-01-15",
"week": 3,
"type": "Outflow",
"amount": 2200,
"sourceRef": "payroll-jan-1",
"policyTag": "Payroll",
"description": "Payroll - First half January"
},
{
"id": "event-4",
"date": "2024-01-22",
"week": 4,
"type": "Influx",
"amount": 4000,
"sourceRef": "stream-1",
"policyTag": "Revenue",
"description": "Client Services - Large project milestone"
},
{
"id": "event-5",
"date": "2024-01-29",
"week": 5,
"type": "Outflow",
"amount": 2200,
"sourceRef": "payroll-jan-2",
"policyTag": "Payroll",
"description": "Payroll - Second half January"
},
{
"id": "event-6",
"date": "2024-02-05",
"week": 6,
"type": "Outflow",
"amount": 1400,
"sourceRef": "overhead-monthly",
"policyTag": "CriticalOps",
"description": "Monthly overhead costs"
},
{
"id": "event-7",
"date": "2024-02-12",
"week": 7,
"type": "Influx",
"amount": 1000,
"sourceRef": "stream-2",
"policyTag": "Revenue",
"description": "Platform Sales - Reduced month"
},
{
"id": "event-8",
"date": "2024-02-19",
"week": 8,
"type": "Outflow",
"amount": 2200,
"sourceRef": "payroll-feb-1",
"policyTag": "Payroll",
"description": "Payroll - First half February"
}
],
"paymentQueue": [
{
"id": "payment-1",
"amount": 500,
"recipient": "Development Kits Supplier",
"scheduledWeek": 9,
"priority": "Vendors",
"canStage": true,
"description": "Hardware purchase for Q1 development"
},
{
"id": "payment-2",
"amount": 1700,
"recipient": "Alex Chen - Deferred Pay",
"scheduledWeek": 10,
"priority": "Payroll",
"canStage": false,
"description": "Deferred wage repayment"
},
{
"id": "payment-3",
"amount": 800,
"recipient": "Tax Authority",
"scheduledWeek": 12,
"priority": "Taxes",
"canStage": false,
"description": "Quarterly tax payment"
}
]
}

View file

@ -1,38 +0,0 @@
{
"overheadCosts": [
{
"id": "overhead-1",
"name": "Coworking Space",
"amount": 800,
"category": "Workspace",
"recurring": true,
"description": "Shared workspace membership for 3 desks"
},
{
"id": "overhead-2",
"name": "Tools & Software",
"amount": 420,
"category": "Technology",
"recurring": true,
"description": "Development tools, design software, project management"
},
{
"id": "overhead-3",
"name": "Business Insurance",
"amount": 180,
"category": "Legal & Compliance",
"recurring": true,
"description": "Professional liability and general business insurance"
}
],
"productionCosts": [
{
"id": "production-1",
"name": "Development Kits",
"amount": 500,
"category": "Hardware",
"period": "2024-01",
"description": "Testing devices and development hardware"
}
]
}

View file

@ -1,40 +0,0 @@
{
"currentBalances": {
"cash": 5000,
"savings": 8000,
"totalLiquid": 13000
},
"policies": {
"equalHourlyWage": 20,
"payrollOncostPct": 25,
"savingsTargetMonths": 3,
"minCashCushionAmount": 3000,
"deferredCapHoursPerQtr": 240,
"deferredSunsetMonths": 12,
"surplusOrder": [
"Deferred",
"Savings",
"Hardship",
"Training",
"Patronage",
"Retained"
],
"paymentPriority": [
"Payroll",
"Taxes",
"CriticalOps",
"Vendors"
],
"volunteerScope": {
"allowedFlows": ["Care", "SharedLearning"]
}
},
"deferredLiabilities": {
"totalDeferred": 2340,
"byMember": {
"member-1": 1700,
"member-2": 0,
"member-3": 640
}
}
}

View file

@ -1,52 +0,0 @@
{
"members": [
{
"id": "member-1",
"displayName": "Alex Chen",
"roleFocus": "Technical Lead",
"payRelationship": "Hybrid",
"capacity": {
"minHours": 20,
"targetHours": 120,
"maxHours": 160
},
"riskBand": "Medium",
"externalCoveragePct": 60,
"privacyNeeds": "aggregate_ok",
"deferredHours": 85,
"quarterlyDeferredCap": 240
},
{
"id": "member-2",
"displayName": "Jordan Silva",
"roleFocus": "Design & UX",
"payRelationship": "FullyPaid",
"capacity": {
"minHours": 30,
"targetHours": 140,
"maxHours": 180
},
"riskBand": "Low",
"externalCoveragePct": 20,
"privacyNeeds": "aggregate_ok",
"deferredHours": 0,
"quarterlyDeferredCap": 240
},
{
"id": "member-3",
"displayName": "Sam Rodriguez",
"roleFocus": "Operations & Growth",
"payRelationship": "Supplemental",
"capacity": {
"minHours": 10,
"targetHours": 60,
"maxHours": 100
},
"riskBand": "High",
"externalCoveragePct": 85,
"privacyNeeds": "steward_only",
"deferredHours": 32,
"quarterlyDeferredCap": 120
}
]
}

View file

@ -1,84 +0,0 @@
{
"revenueStreams": [
{
"id": "stream-1",
"name": "Client Services",
"category": "Services",
"subcategory": "Development",
"targetPct": 65,
"targetMonthlyAmount": 7800,
"certainty": "Committed",
"payoutDelayDays": 30,
"terms": "Net 30",
"revenueSharePct": 0,
"platformFeePct": 0,
"restrictions": "General",
"seasonalityWeights": [1.0, 1.0, 1.1, 1.1, 0.9, 0.8, 0.7, 0.8, 1.1, 1.2, 1.1, 1.0],
"effortHoursPerMonth": 180
},
{
"id": "stream-2",
"name": "Platform Sales",
"category": "Product",
"subcategory": "Digital Tools",
"targetPct": 20,
"targetMonthlyAmount": 2400,
"certainty": "Probable",
"payoutDelayDays": 14,
"terms": "Platform payout",
"revenueSharePct": 0,
"platformFeePct": 5,
"restrictions": "General",
"seasonalityWeights": [0.8, 0.9, 1.0, 1.1, 1.2, 1.1, 1.0, 0.9, 1.1, 1.2, 1.3, 1.1],
"effortHoursPerMonth": 40
},
{
"id": "stream-3",
"name": "Innovation Grant",
"category": "Grant",
"subcategory": "Government",
"targetPct": 10,
"targetMonthlyAmount": 1200,
"certainty": "Committed",
"payoutDelayDays": 45,
"terms": "Quarterly disbursement",
"revenueSharePct": 0,
"platformFeePct": 0,
"restrictions": "Restricted",
"seasonalityWeights": [1.0, 1.0, 1.0, 1.5, 1.0, 1.0, 0.5, 1.0, 1.0, 1.5, 1.0, 1.0],
"effortHoursPerMonth": 8
},
{
"id": "stream-4",
"name": "Community Donations",
"category": "Donation",
"subcategory": "Individual",
"targetPct": 3,
"targetMonthlyAmount": 360,
"certainty": "Aspirational",
"payoutDelayDays": 3,
"terms": "Immediate",
"revenueSharePct": 0,
"platformFeePct": 2.9,
"restrictions": "General",
"seasonalityWeights": [0.8, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.3, 1.4],
"effortHoursPerMonth": 5
},
{
"id": "stream-5",
"name": "Consulting & Training",
"category": "Other",
"subcategory": "Professional Services",
"targetPct": 2,
"targetMonthlyAmount": 240,
"certainty": "Probable",
"payoutDelayDays": 21,
"terms": "Net 21",
"revenueSharePct": 0,
"platformFeePct": 0,
"restrictions": "General",
"seasonalityWeights": [1.2, 1.1, 1.0, 0.9, 0.8, 0.7, 0.8, 0.9, 1.2, 1.3, 1.2, 1.0],
"effortHoursPerMonth": 12
}
]
}

View file

@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware((to) => {
// Redirect root path to dashboard
if (to.path === '/') {
return navigateTo('/dashboard')
}
})

View file

@ -1,24 +1,51 @@
export default defineNuxtRouteMiddleware((to) => { export default defineNuxtRouteMiddleware((to) => {
// Skip middleware for coop-planner, wizards, templates, and API routes // Allowlist: routes that should always be accessible before setup
if ( if (
to.path === "/coop-planner" || to.path === "/coop-planner" ||
to.path === "/coop-builder" ||
to.path === "/wizards" || to.path === "/wizards" ||
to.path.startsWith("/templates") || to.path.startsWith("/templates") ||
to.path.startsWith("/coach") ||
to.path.startsWith("/api/") to.path.startsWith("/api/")
) { ) {
return; return;
} }
// Only guard core dashboards until setup is complete
const protectedRoutes = new Set([
"/dashboard",
"/dashboard-simple",
"/budget",
"/mix",
"/cash",
]);
if (!protectedRoutes.has(to.path)) {
return;
}
// Use actual store state to determine whether setup is complete // Use actual store state to determine whether setup is complete
const membersStore = useMembersStore(); const membersStore = useMembersStore();
const policiesStore = usePoliciesStore(); const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore(); const streamsStore = useStreamsStore();
const coopStore = useCoopBuilderStore?.();
const setupComplete = // Legacy stores OR new coop builder store (either is enough)
const legacyComplete =
membersStore.isValid && membersStore.isValid &&
policiesStore.isValid && policiesStore.isValid &&
streamsStore.hasValidStreams; streamsStore.hasValidStreams;
const coopComplete = Boolean(
coopStore &&
Array.isArray(coopStore.members) &&
coopStore.members.length > 0 &&
Array.isArray(coopStore.streams) &&
coopStore.streams.length > 0
);
const setupComplete = legacyComplete || coopComplete;
if (!setupComplete) { if (!setupComplete) {
return navigateTo("/coop-planner"); return navigateTo("/coop-planner");
} }

View file

@ -357,30 +357,15 @@
</UModal> </UModal>
<!-- Helper Modal --> <!-- Helper Modal -->
<UModal v-model:open="showHelperModal" :ui="{ wrapper: 'sm:max-w-lg' }"> <UModal
<template #header> v-model:open="showHelperModal"
<div class="flex items-center justify-between"> :ui="{ wrapper: 'sm:max-w-lg', footer: 'justify-end' }"
<div> title="Quick Entry Tools"
<h3 class="text-lg font-semibold text-gray-900 modal-header"> :description="selectedItemDetails?.label || 'Budget item'"
Quick Entry Tools >
</h3>
<p class="mt-1 text-sm text-gray-500 modal-header">
{{ selectedItemDetails?.label || "Budget item" }}
</p>
</div>
<UButton
@click="showHelperModal = false"
variant="ghost"
size="sm"
icon="i-heroicons-x-mark-20-solid"
class="-mr-2"
/>
</div>
</template>
<template #body> <template #body>
<div class="isolate"> <div class="isolate">
<UTabs :items="helperTabs" class="w-full"> <UTabs v-model="activeHelperTab" :items="helperTabs" class="w-full">
<template #content="{ item }"> <template #content="{ item }">
<!-- Annual Distribution Content --> <!-- Annual Distribution Content -->
<div v-if="item.key === 'annual'" class="pt-4 space-y-4"> <div v-if="item.key === 'annual'" class="pt-4 space-y-4">
@ -399,18 +384,6 @@
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
This will divide the amount equally across all 12 months This will divide the amount equally across all 12 months
</p> </p>
<div class="flex justify-end gap-3 pt-2">
<UButton @click="showHelperModal = false" variant="ghost" color="neutral">
Cancel
</UButton>
<UButton
@click="distributeAnnualAmount"
:disabled="!helperConfig.annualAmount || helperConfig.annualAmount <= 0"
color="primary"
>
Distribute Amount
</UButton>
</div>
</div> </div>
<!-- Monthly Amount Content --> <!-- Monthly Amount Content -->
@ -430,25 +403,31 @@
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
This will set the same value for all months This will set the same value for all months
</p> </p>
<div class="flex justify-end gap-3 pt-2">
<UButton @click="showHelperModal = false" variant="ghost" color="neutral">
Cancel
</UButton>
<UButton
@click="setAllMonths"
:disabled="
!helperConfig.monthlyAmount || helperConfig.monthlyAmount <= 0
"
color="primary"
>
Apply to All Months
</UButton>
</div>
</div> </div>
</template> </template>
</UTabs> </UTabs>
</div> </div>
</template> </template>
<template #footer="{ close }">
<UButton @click="close" variant="outline" color="neutral"> Cancel </UButton>
<UButton
v-if="activeHelperTab === 0"
@click="distributeAnnualAmount"
:disabled="!helperConfig.annualAmount || helperConfig.annualAmount <= 0"
color="primary"
>
Distribute Amount
</UButton>
<UButton
v-else
@click="setAllMonths"
:disabled="!helperConfig.monthlyAmount || helperConfig.monthlyAmount <= 0"
color="primary"
>
Apply to All Months
</UButton>
</template>
</UModal> </UModal>
<!-- Add Expense Modal --> <!-- Add Expense Modal -->
@ -525,8 +504,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// Store // Stores
const budgetStore = useBudgetStore(); const budgetStore = useBudgetStore();
const streamsStore = useStreamsStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
// State // State
const showAddRevenueModal = ref(false); const showAddRevenueModal = ref(false);
@ -567,6 +549,7 @@ const selectedItemDetails = computed(() => {
}); });
// Helper tabs configuration // Helper tabs configuration
const activeHelperTab = ref(0); // UTabs uses index, not key
const helperTabs = [ const helperTabs = [
{ {
key: "annual", key: "annual",
@ -641,9 +624,9 @@ const allBudgetItems = computed(() => {
// Initialize on mount // Initialize on mount
onMounted(async () => { onMounted(async () => {
try { try {
if (!budgetStore.isInitialized) { // Always re-initialize to get latest wizard data
await budgetStore.initializeFromWizardData(); budgetStore.isInitialized = false;
} await budgetStore.initializeFromWizardData();
} catch (error) { } catch (error) {
console.error("Error initializing budget page:", error); console.error("Error initializing budget page:", error);
} }
@ -807,7 +790,9 @@ function resetWorksheet() {
if (confirm("Are you sure you want to reset all budget data? This cannot be undone.")) { if (confirm("Are you sure you want to reset all budget data? This cannot be undone.")) {
budgetStore.resetBudgetWorksheet(); budgetStore.resetBudgetWorksheet();
budgetStore.isInitialized = false; budgetStore.isInitialized = false;
budgetStore.initializeFromWizardData(); nextTick(() => {
budgetStore.initializeFromWizardData();
});
} }
} }

View file

@ -20,18 +20,6 @@
> >
Skip coach Streams Skip coach Streams
</button> </button>
<button
@click="loadSampleData"
class="px-4 py-2 text-sm bg-blue-50 border-2 border-blue-200 rounded-lg text-blue-700 hover:bg-blue-100 hover:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
:aria-label="'Load sample data to see example offers'"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Load sample data
</div>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -384,74 +372,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching"; import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { // REMOVED: All sample data imports to prevent demo data
membersSample,
skillsCatalogSample,
problemsCatalogSample,
sampleSelections
} from "~/sample/skillsToOffersSamples";
// Store integration // Store integration
const planStore = usePlanStore(); const planStore = usePlanStore();
// Initialize with default data // Initialize with empty data
const members = ref<Member[]>([ const members = ref<Member[]>([]);
{ id: "1", name: "Alex Chen", role: "Game Designer", hourly: 75, availableHrs: 30 },
{ id: "2", name: "Jordan Smith", role: "Developer", hourly: 80, availableHrs: 35 },
{ id: "3", name: "Sam Rodriguez", role: "Artist", hourly: 70, availableHrs: 25 }
]);
const availableSkills = ref<SkillTag[]>([ const availableSkills = ref<SkillTag[]>([]);
{ id: "unity", label: "Unity Development" },
{ id: "art", label: "2D/3D Art" },
{ id: "design", label: "Game Design" },
{ id: "audio", label: "Audio Design" },
{ id: "writing", label: "Narrative Writing" },
{ id: "marketing", label: "Marketing" },
{ id: "business", label: "Business Strategy" },
{ id: "web", label: "Web Development" },
{ id: "mobile", label: "Mobile Development" },
{ id: "consulting", label: "Technical Consulting" }
]);
const availableProblems = ref<ProblemTag[]>([ const availableProblems = ref<ProblemTag[]>([]);
{
id: "indie-games",
label: "Indie game development",
examples: [
"Small studios needing extra development capacity",
"Solo developers wanting art/audio support",
"Teams needing game design consultation"
]
},
{
id: "corporate-training",
label: "Corporate training games",
examples: [
"Companies wanting engaging employee training",
"HR departments needing onboarding tools",
"Safety training for industrial workers"
]
},
{
id: "educational",
label: "Educational technology",
examples: [
"Schools needing interactive learning tools",
"Universities wanting research simulations",
"Non-profits creating awareness campaigns"
]
},
{
id: "prototypes",
label: "Rapid prototyping",
examples: [
"Startups validating game concepts",
"Publishers testing market fit",
"Researchers creating proof-of-concepts"
]
}
]);
// Set members in store on component mount // Set members in store on component mount
onMounted(() => { onMounted(() => {
@ -512,25 +443,6 @@ function updateLanguageToCoopTerms(text: string): string {
.replace(/productivity/gi, 'shared capacity'); .replace(/productivity/gi, 'shared capacity');
} }
// Sample data loading
function loadSampleData() {
// Replace data with samples
members.value = [...membersSample];
availableSkills.value = [...skillsCatalogSample];
availableProblems.value = [...problemsCatalogSample];
// Set pre-selected skills and problems
selectedSkills.value = { ...sampleSelections.selectedSkillsByMember };
selectedProblems.value = [...sampleSelections.selectedProblems];
// Update store with new members
planStore.setMembers(members.value);
// Trigger offer generation immediately
nextTick(() => {
debouncedGenerateOffers();
});
}
// Debounced offer generation // Debounced offer generation
const debouncedGenerateOffers = useDebounceFn(async () => { const debouncedGenerateOffers = useDebounceFn(async () => {

View file

@ -6,8 +6,7 @@
<!-- Header --> <!-- Header -->
<div class="mb-10 text-center"> <div class="mb-10 text-center">
<h1 <h1
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4" class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4">
>
Co-op Builder Co-op Builder
</h1> </h1>
</div> </div>
@ -18,16 +17,15 @@
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div> <div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div <div
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8" class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
>
<div <div
class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4" class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4">
> <UIcon
<UIcon name="i-heroicons-check" class="w-8 h-8 text-white dark:text-black" /> name="i-heroicons-check"
class="w-8 h-8 text-white dark:text-black" />
</div> </div>
<h2 <h2
class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide" class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide">
>
You're all set! You're all set!
</h2> </h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-6"> <p class="text-neutral-600 dark:text-neutral-400 mb-6">
@ -35,7 +33,10 @@
</p> </p>
<div class="flex justify-center gap-4"> <div class="flex justify-center gap-4">
<button class="export-btn" @click="restartWizard" :disabled="isResetting"> <button
class="export-btn"
@click="restartWizard"
:disabled="isResetting">
Start Over Start Over
</button> </button>
<button class="export-btn primary" @click="navigateTo('/budget')"> <button class="export-btn primary" @click="navigateTo('/budget')">
@ -52,40 +53,34 @@
<!-- Dithered shadow for selected state --> <!-- Dithered shadow for selected state -->
<div <div
v-if="focusedStep === 1" v-if="focusedStep === 1"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 1 ? 'item-selected' : '', focusedStep === 1 ? 'item-selected' : '',
]" ]">
>
<div <div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(1)" @click="setFocusedStep(1)">
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2" class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class=" :class="
membersStore.isValid membersValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white' ? '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' : 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
" ">
>
<UIcon <UIcon
v-if="membersStore.isValid" v-if="membersValid"
name="i-heroicons-check" name="i-heroicons-check"
class="w-4 h-4" class="w-4 h-4" />
/>
<span v-else>1</span> <span v-else>1</span>
</div> </div>
<div> <div>
<h3 <h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide" class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
>
Add your team Add your team
</h3> </h3>
</div> </div>
@ -93,15 +88,13 @@
<UIcon <UIcon
name="i-heroicons-chevron-down" name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold" class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 1 }" :class="{ 'rotate-180': focusedStep === 1 }" />
/>
</div> </div>
</div> </div>
<div <div
v-if="focusedStep === 1" v-if="focusedStep === 1"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white" class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
>
<WizardMembersStep @save-status="handleSaveStatus" /> <WizardMembersStep @save-status="handleSaveStatus" />
</div> </div>
</div> </div>
@ -112,40 +105,34 @@
<!-- Dithered shadow for selected state --> <!-- Dithered shadow for selected state -->
<div <div
v-if="focusedStep === 2" v-if="focusedStep === 2"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 2 ? 'item-selected' : '', focusedStep === 2 ? 'item-selected' : '',
]" ]">
>
<div <div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(2)" @click="setFocusedStep(2)">
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2" class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class=" :class="
policiesStore.isValid policiesValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white' ? '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' : 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
" ">
>
<UIcon <UIcon
v-if="policiesStore.isValid" v-if="policiesValid"
name="i-heroicons-check" name="i-heroicons-check"
class="w-4 h-4" class="w-4 h-4" />
/>
<span v-else>2</span> <span v-else>2</span>
</div> </div>
<div> <div>
<h3 <h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide" class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
>
Set your wage Set your wage
</h3> </h3>
</div> </div>
@ -153,15 +140,13 @@
<UIcon <UIcon
name="i-heroicons-chevron-down" name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold" class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 2 }" :class="{ 'rotate-180': focusedStep === 2 }" />
/>
</div> </div>
</div> </div>
<div <div
v-if="focusedStep === 2" v-if="focusedStep === 2"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white" class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
>
<WizardPoliciesStep @save-status="handleSaveStatus" /> <WizardPoliciesStep @save-status="handleSaveStatus" />
</div> </div>
</div> </div>
@ -172,30 +157,25 @@
<!-- Dithered shadow for selected state --> <!-- Dithered shadow for selected state -->
<div <div
v-if="focusedStep === 3" v-if="focusedStep === 3"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 3 ? 'item-selected' : '', focusedStep === 3 ? 'item-selected' : '',
]" ]">
>
<div <div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(3)" @click="setFocusedStep(3)">
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white" class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white">
>
<UIcon name="i-heroicons-check" class="w-4 h-4" /> <UIcon name="i-heroicons-check" class="w-4 h-4" />
</div> </div>
<div> <div>
<h3 <h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide" class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
>
Monthly costs Monthly costs
</h3> </h3>
</div> </div>
@ -203,15 +183,13 @@
<UIcon <UIcon
name="i-heroicons-chevron-down" name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold" class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 3 }" :class="{ 'rotate-180': focusedStep === 3 }" />
/>
</div> </div>
</div> </div>
<div <div
v-if="focusedStep === 3" v-if="focusedStep === 3"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white" class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
>
<WizardCostsStep @save-status="handleSaveStatus" /> <WizardCostsStep @save-status="handleSaveStatus" />
</div> </div>
</div> </div>
@ -222,40 +200,34 @@
<!-- Dithered shadow for selected state --> <!-- Dithered shadow for selected state -->
<div <div
v-if="focusedStep === 4" v-if="focusedStep === 4"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 4 ? 'item-selected' : '', focusedStep === 4 ? 'item-selected' : '',
]" ]">
>
<div <div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(4)" @click="setFocusedStep(4)">
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2" class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class=" :class="
streamsStore.hasValidStreams streamsValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white' ? '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' : 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
" ">
>
<UIcon <UIcon
v-if="streamsStore.hasValidStreams" v-if="streamsValid"
name="i-heroicons-check" name="i-heroicons-check"
class="w-4 h-4" class="w-4 h-4" />
/>
<span v-else>4</span> <span v-else>4</span>
</div> </div>
<div> <div>
<h3 <h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide" class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
>
Revenue streams Revenue streams
</h3> </h3>
</div> </div>
@ -263,15 +235,13 @@
<UIcon <UIcon
name="i-heroicons-chevron-down" name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold" class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 4 }" :class="{ 'rotate-180': focusedStep === 4 }" />
/>
</div> </div>
</div> </div>
<div <div
v-if="focusedStep === 4" v-if="focusedStep === 4"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white" class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
>
<WizardRevenueStep @save-status="handleSaveStatus" /> <WizardRevenueStep @save-status="handleSaveStatus" />
</div> </div>
</div> </div>
@ -282,19 +252,16 @@
<!-- Dithered shadow for selected state --> <!-- Dithered shadow for selected state -->
<div <div
v-if="focusedStep === 5" v-if="focusedStep === 5"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<div <div
:class="[ :class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden', 'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 5 ? 'item-selected' : '', focusedStep === 5 ? 'item-selected' : '',
]" ]">
>
<div <div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(5)" @click="setFocusedStep(5)">
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
@ -303,15 +270,16 @@
canComplete canComplete
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white' ? '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' : 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
" ">
> <UIcon
<UIcon v-if="canComplete" name="i-heroicons-check" class="w-4 h-4" /> v-if="canComplete"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>5</span> <span v-else>5</span>
</div> </div>
<div> <div>
<h3 <h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide" class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
>
Review & finish Review & finish
</h3> </h3>
</div> </div>
@ -319,52 +287,57 @@
<UIcon <UIcon
name="i-heroicons-chevron-down" name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold" class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 5 }" :class="{ 'rotate-180': focusedStep === 5 }" />
/>
</div> </div>
</div> </div>
<div <div
v-if="focusedStep === 5" v-if="focusedStep === 5"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white" class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
> <WizardReviewStep
<WizardReviewStep @complete="completeWizard" @reset="resetWizard" /> @complete="completeWizard"
@reset="resetWizard" />
</div> </div>
</div> </div>
</div> </div>
<!-- Progress Actions --> <!-- Progress Actions -->
<div class="flex justify-between items-center pt-8"> <div class="flex justify-between items-center pt-8">
<button class="export-btn" @click="resetWizard" :disabled="isResetting"> <button
class="export-btn"
@click="resetWizard"
:disabled="isResetting">
Start Over Start Over
</button> </button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Save status --> <!-- Save status -->
<div <div
class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide" class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide">
>
<UIcon <UIcon
v-if="saveStatus === 'saving'" v-if="saveStatus === 'saving'"
name="i-heroicons-arrow-path" name="i-heroicons-arrow-path"
class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400" class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400" />
/>
<UIcon <UIcon
v-if="saveStatus === 'saved'" v-if="saveStatus === 'saved'"
name="i-heroicons-check-circle" name="i-heroicons-check-circle"
class="w-4 h-4 text-black dark:text-white" class="w-4 h-4 text-black dark:text-white" />
/>
<span <span
v-if="saveStatus === 'saving'" v-if="saveStatus === 'saving'"
class="text-neutral-500 dark:text-neutral-400" class="text-neutral-500 dark:text-neutral-400"
>Saving...</span >Saving...</span
> >
<span v-if="saveStatus === 'saved'" class="text-black dark:text-white" <span
v-if="saveStatus === 'saved'"
class="text-black dark:text-white"
>Saved</span >Saved</span
> >
</div> </div>
<button v-if="canComplete" class="export-btn primary" @click="completeWizard"> <button
v-if="canComplete"
class="export-btn primary"
@click="completeWizard">
Complete Setup Complete Setup
</button> </button>
</div> </div>
@ -375,12 +348,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// Stores // Store
const membersStore = useMembersStore(); const coop = useCoopBuilder();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const coopBuilderStore = useCoopBuilderStore();
// UI state // UI state
const focusedStep = ref(1); const focusedStep = ref(1);
@ -389,9 +358,36 @@ const isResetting = ref(false);
const isCompleted = ref(false); const isCompleted = ref(false);
// Computed validation // Computed validation
const canComplete = computed( const canComplete = computed(() => {
() => membersStore.isValid && policiesStore.isValid && streamsStore.hasValidStreams return coop.members.value.length > 0 && coop.streams.value.length > 0;
); });
// Local validity flags for step headers
const membersValid = computed(() => {
// Valid if at least one member with a name and positive hours
return coop.members.value.some((m: any) => {
const hasName = typeof m.name === "string" && m.name.trim().length > 0;
const hours = Number((m as any).hoursPerMonth ?? 0);
return hasName && Number.isFinite(hours) && hours > 0;
});
});
const policiesValid = computed(() => {
// Placeholder policy validity; mark true when wage text or policy set exists
// Since policy not persisted yet in this store, consider valid when any member exists
return membersValid.value;
});
const streamsValid = computed(() => {
// Valid if all streams have name, category, and non-negative monthly
return (
coop.streams.value.length > 0 &&
coop.streams.value.every((s: any) => {
const monthly = Number((s as any).monthly ?? 0);
return (s.label || "").toString().trim().length > 0 && monthly >= 0;
})
);
});
// Save status handler // Save status handler
function handleSaveStatus(status: "saving" | "saved" | "error") { function handleSaveStatus(status: "saving" | "saved" | "error") {
@ -424,14 +420,8 @@ function completeWizard() {
async function resetWizard() { async function resetWizard() {
isResetting.value = true; isResetting.value = true;
// Reset all stores // Reset centralized store
membersStore.resetMembers(); coop.reset();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
// Reset coop builder state
coopBuilderStore.reset();
saveStatus.value = ""; saveStatus.value = "";
// Small delay for UX // Small delay for UX
@ -446,12 +436,8 @@ async function restartWizard() {
isCompleted.value = false; isCompleted.value = false;
focusedStep.value = 1; focusedStep.value = 1;
// Reset all stores and coop builder state // Reset centralized store
membersStore.resetMembers(); coop.reset();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
coopBuilderStore.reset();
saveStatus.value = ""; saveStatus.value = "";
// Small delay for UX // Small delay for UX

112
pages/dashboard-simple.vue Normal file
View file

@ -0,0 +1,112 @@
<template>
<div class="space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Dashboard</h1>
<div class="text-sm text-gray-600">
Mode: {{ currentMode }}
</div>
</div>
<!-- Simple Core Metrics -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Core Metrics</h3>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="text-center">
<div class="text-3xl font-bold text-green-600">{{ runwayDisplay }}</div>
<div class="text-sm text-gray-600">Runway</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-blue-600">{{ coverageDisplay }}</div>
<div class="text-sm text-gray-600">Coverage</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-purple-600">{{ streamCount }}</div>
<div class="text-sm text-gray-600">Revenue Streams</div>
</div>
</div>
</UCard>
<!-- Simple Member List -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Members ({{ memberCount }})</h3>
</template>
<div class="space-y-2">
<div v-for="(member, index) in membersList" :key="index" class="flex items-center justify-between p-2 border border-gray-200 rounded">
<span class="font-medium">{{ member.name }}</span>
<span class="text-sm text-gray-600">{{ member.relationship }}</span>
</div>
<div v-if="memberCount === 0" class="text-sm text-gray-500 italic p-4">
No members configured yet.
</div>
</div>
</UCard>
<!-- Value Accounting Section -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Value Accounting</h3>
<UBadge color="blue" variant="subtle">January 2024</UBadge>
</div>
</template>
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-2">
Next Value Session due January 2024
</p>
<div class="flex items-center gap-4">
<UProgress :value="50" :max="100" color="blue" class="w-32" />
<span class="text-sm text-gray-600">2/4 prep steps done</span>
</div>
</div>
<UButton color="primary" @click="navigateTo('/session')">
Start Session
</UButton>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
// Simple reactive data without complex computations
const currentMode = ref('minimum')
const runwayDisplay = ref('∞')
const coverageDisplay = ref('100%')
const streamCount = ref(0)
const memberCount = ref(0)
const membersList = ref([])
// Try to initialize with store data
onMounted(async () => {
try {
// Simple store access without composable
const membersStore = useMembersStore()
const streamsStore = useStreamsStore()
const policiesStore = usePoliciesStore()
// Update reactive values
currentMode.value = policiesStore.operatingMode || 'minimum'
memberCount.value = membersStore.members?.length || 0
streamCount.value = streamsStore.streams?.length || 0
// Simple member list
membersList.value = membersStore.members?.map(m => ({
name: m.displayName || 'Unknown',
relationship: m.payRelationship || 'Unknown'
})) || []
console.log('Dashboard initialized:', {
mode: currentMode.value,
members: memberCount.value,
streams: streamCount.value
})
} catch (error) {
console.error('Error initializing simple dashboard:', error)
}
})
</script>

48
pages/dashboard.vue Normal file
View file

@ -0,0 +1,48 @@
<template>
<div class="max-w-6xl mx-auto px-4 py-6 space-y-8" data-ui="dashboard_v1">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Dashboard</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600">Min</span>
<UToggle
:model-value="operatingMode === 'target'"
@update:model-value="(value) => setOperatingMode(value ? 'target' : 'min')"
/>
<span class="text-sm text-gray-600">Target</span>
</div>
</div>
<!-- Core Metrics -->
<DashboardCoreMetrics />
<!-- Member Coverage -->
<MemberCoveragePanel />
<!-- Advanced Tools -->
<AdvancedAccordion />
<!-- Next Session -->
<UCard class="shadow-sm rounded-xl">
<div class="flex items-center justify-between">
<div class="space-y-1">
<h3 class="font-semibold">Next Value Session</h3>
<p class="text-sm text-gray-600">Review contributions and distribute surplus</p>
</div>
<UButton color="primary" @click="navigateTo('/session')">
Start Session
</UButton>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
// Import components explicitly to avoid auto-import issues
import DashboardCoreMetrics from '~/components/dashboard/DashboardCoreMetrics.vue'
import MemberCoveragePanel from '~/components/dashboard/MemberCoveragePanel.vue'
import AdvancedAccordion from '~/components/dashboard/AdvancedAccordion.vue'
// Access composable data
const { operatingMode, setOperatingMode } = useCoopBuilder()
</script>

View file

@ -1,7 +1,20 @@
<template> <template>
<section class="py-8 space-y-6"> <section class="py-8 space-y-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Dashboard</h2> <div>
<h2 class="text-2xl font-semibold">Dashboard</h2>
<div class="flex items-center gap-2 mt-1">
<UBadge
:color="policiesStore.operatingMode === 'target' ? 'primary' : 'gray'"
size="xs"
>
{{ policiesStore.operatingMode === 'target' ? '🎯 Target Mode' : '⚡ Min Mode' }}
</UBadge>
<span class="text-xs text-gray-500">
Runway: {{ Math.round(metrics.runway) }}mo
</span>
</div>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<UButton <UButton
icon="i-heroicons-arrow-down-tray" icon="i-heroicons-arrow-down-tray"
@ -29,10 +42,10 @@
description="Funded hours vs target capacity across all members." /> description="Funded hours vs target capacity across all members." />
<ReserveMeter <ReserveMeter
:current-savings="metrics.finances.currentBalances.savings" :current-savings="savingsProgress.current"
:savings-target-months="metrics.finances.policies.savingsTargetMonths" :savings-target-months="savingsProgress.targetMonths"
:monthly-burn="metrics.monthlyBurn" :monthly-burn="getMonthlyBurn()"
description="Build savings to your target before increasing paid hours." /> :description="`${savingsProgress.progressPct.toFixed(0)}% of savings target reached. ${savingsProgress.gap > 0 ? 'Gap: ' + $format.currency(savingsProgress.gap) : 'Target achieved!'}`" />
<UCard> <UCard>
<div class="text-center space-y-3"> <div class="text-center space-y-3">
@ -58,43 +71,98 @@
</UCard> </UCard>
</div> </div>
<!-- Quick Wins Dashboard Components -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Needs Coverage Bars -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Member Coverage</h3>
</template>
<NeedsCoverageBars />
</UCard>
<!-- Revenue Mix -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Revenue Mix</h3>
</template>
<RevenueMixTable />
</UCard>
<!-- Milestone-Runway Overlay -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Runway vs Milestones</h3>
</template>
<MilestoneRunwayOverlay />
</UCard>
</div>
<!-- Alerts Section --> <!-- Alerts Section -->
<UCard> <UCard>
<template #header> <template #header>
<h3 class="text-lg font-medium">Alerts</h3> <h3 class="text-lg font-medium">Alerts</h3>
</template> </template>
<div class="space-y-3"> <div class="space-y-3">
<!-- Concentration Risk Alert -->
<UAlert <UAlert
v-if="topSourcePct > 50"
color="red" color="red"
variant="subtle" variant="subtle"
icon="i-heroicons-exclamation-triangle" icon="i-heroicons-exclamation-triangle"
title="Revenue Concentration Risk" title="Revenue Concentration Risk"
description="Most of your money comes from one place. Add another stream to reduce risk." :description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
:actions="[{ label: 'Plan Mix', click: () => navigateTo('/mix') }]" /> :actions="[
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') },
{ label: 'Scenarios', click: () => handleAlertNavigation('/scenarios', 'diversification') }
]" />
<!-- Cushion Breach Alert -->
<UAlert <UAlert
v-if="alerts.cushionBreach"
color="orange" color="orange"
variant="subtle" variant="subtle"
icon="i-heroicons-calendar" icon="i-heroicons-calendar"
title="Cash Cushion Breach Forecast" title="Cash Cushion Breach Forecast"
:description="cashBreachDescription" :description="`Projected to breach minimum cushion in week ${cushionForecast.firstBreachWeek || 'unknown'}`"
:actions="[ :actions="[
{ label: 'View Calendar', click: () => navigateTo('/cash') }, { label: 'View Calendar', click: () => handleAlertNavigation('/cash', 'breach-forecast') },
{ label: 'Adjust Budget', click: () => handleAlertNavigation('/budget', 'expenses') }
]" /> ]" />
<!-- Savings Below Target Alert -->
<UAlert <UAlert
v-if="alerts.savingsBelowTarget"
color="yellow" color="yellow"
variant="subtle" variant="subtle"
icon="i-heroicons-banknotes" icon="i-heroicons-banknotes"
title="Savings Below Target" title="Savings Below Target"
description="Build savings to your target before increasing paid hours." :description="`${savingsProgress.progressPct.toFixed(0)}% of target reached. Build savings before increasing paid hours.`"
:actions="[ :actions="[
{ label: 'View Progress', click: () => navigateTo('/budget') }, { label: 'View Progress', click: () => handleAlertNavigation('/budget', 'savings') },
{ label: 'Adjust Policies', click: () => handleAlertNavigation('/coop-builder', 'policies') }
]" /> ]" />
<!-- Over-Deferred Member Alert -->
<UAlert <UAlert
color="amber" v-if="deferredAlert.show"
color="purple"
variant="subtle" variant="subtle"
icon="i-heroicons-clock" icon="i-heroicons-user-group"
title="Over-Deferred Member" title="Member Over-Deferred"
description="Alex has reached 85% of quarterly deferred cap." /> :description="deferredAlert.description"
:actions="[
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
{ label: 'Value Session', click: () => handleAlertNavigation('/session', 'distributions') }
]" />
<!-- Success message when no alerts -->
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
class="text-center py-8 text-gray-500">
<UIcon name="i-heroicons-check-circle" class="w-8 h-8 mx-auto mb-2 text-green-500" />
<p class="font-medium">All systems looking good!</p>
<p class="text-sm">No critical alerts at this time.</p>
</div>
</div> </div>
</UCard> </UCard>
@ -104,37 +172,46 @@
<h3 class="text-lg font-medium">Scenario Snapshots</h3> <h3 class="text-lg font-medium">Scenario Snapshots</h3>
</template> </template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <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="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">Operate Current</h4> <h4 class="font-medium text-sm">{{ scenarios.current.name }}</h4>
<UBadge color="green" variant="subtle" size="xs">Active</UBadge> <UBadge color="green" variant="subtle" size="xs">{{ scenarios.current.status }}</UBadge>
</div> </div>
<div class="text-2xl font-bold text-orange-600 mb-1"> <div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.current.runway)">
{{ scenarioMetrics.current.runway }} months {{ Math.round(scenarios.current.runway * 10) / 10 }} months
</div> </div>
<p class="text-xs text-neutral-600">Continue existing plan</p> <p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.current.monthlyNet) }}/mo
</p>
</div> </div>
<!-- Quit Jobs Scenario -->
<div class="p-4 border border-neutral-200 rounded-lg"> <div class="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">Quit Day Jobs</h4> <h4 class="font-medium text-sm">{{ scenarios.quitJobs.name }}</h4>
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge> <UBadge color="gray" variant="subtle" size="xs">{{ scenarios.quitJobs.status }}</UBadge>
</div> </div>
<div class="text-2xl font-bold text-red-600 mb-1"> <div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.quitJobs.runway)">
{{ scenarioMetrics.quitJobs.runway }} months {{ Math.round(scenarios.quitJobs.runway * 10) / 10 }} months
</div> </div>
<p class="text-xs text-neutral-600">Full-time co-op work</p> <p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.quitJobs.monthlyNet) }}/mo
</p>
</div> </div>
<!-- Start Production Scenario -->
<div class="p-4 border border-neutral-200 rounded-lg"> <div class="p-4 border border-neutral-200 rounded-lg">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-sm">Start Production</h4> <h4 class="font-medium text-sm">{{ scenarios.startProduction.name }}</h4>
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge> <UBadge color="gray" variant="subtle" size="xs">{{ scenarios.startProduction.status }}</UBadge>
</div> </div>
<div class="text-2xl font-bold text-yellow-600 mb-1"> <div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.startProduction.runway)">
{{ scenarioMetrics.startProduction.runway }} months {{ Math.round(scenarios.startProduction.runway * 10) / 10 }} months
</div> </div>
<p class="text-xs text-neutral-600">Launch development</p> <p class="text-xs text-neutral-600">
Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo
</p>
</div> </div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
@ -214,6 +291,100 @@
</div> </div>
</UCard> </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 --> <!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<UButton <UButton
@ -274,6 +445,34 @@ const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore(); const budgetStore = useBudgetStore();
const cashStore = useCashStore(); const cashStore = useCashStore();
// Runway composable with operating mode integration
const { getDualModeRunway, getMonthlyBurn } = useRunway();
// Cushion forecast and savings progress
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
// Scenario calculations
const { scenarios } = useScenarios();
// Advanced panel state
const showAdvanced = ref(false);
// Stress testing
const stressTests = ref({
revenueDelay: 0,
costShockPct: 0,
grantLost: false
});
// Policy sandbox
const sandboxPolicy = ref('equal-pay');
const policyOptions = [
{ label: 'Equal Pay', value: 'equal-pay' },
{ label: 'Needs Weighted', value: 'needs-weighted' },
{ label: 'Hours Weighted', value: 'hours-weighted' },
{ label: 'Role Banded', value: 'role-banded' }
];
// Calculate metrics from real store data // Calculate metrics from real store data
const metrics = computed(() => { const metrics = computed(() => {
const totalTargetHours = membersStore.members.reduce( const totalTargetHours = membersStore.members.reduce(
@ -291,24 +490,26 @@ const metrics = computed(() => {
0 0
); );
const monthlyPayroll = // Use integrated runway calculations that respect operating mode
totalTargetHours * const currentMode = policiesStore.operatingMode || 'minimum';
policiesStore.equalHourlyWage * const monthlyBurn = getMonthlyBurn(currentMode);
(1 + policiesStore.payrollOncostPct / 100);
const monthlyBurn = monthlyPayroll + totalOverheadCosts; // Use actual cash store values with fallback
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
const totalLiquid = cash + savings;
// Use actual cash store values // Get dual-mode runway data
const totalLiquid = cashStore.currentCash + cashStore.currentSavings; const runwayData = getDualModeRunway(cash, savings);
const runway = currentMode === 'target' ? runwayData.target : runwayData.minimum;
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0;
return { return {
totalTargetHours, totalTargetHours,
totalTargetRevenue, totalTargetRevenue,
monthlyPayroll, monthlyPayroll: runwayData.minBurn, // Use actual calculated payroll
monthlyBurn, monthlyBurn,
runway, runway,
runwayData, // Include dual-mode data
finances: { finances: {
currentBalances: { currentBalances: {
cash: cashStore.currentCash, cash: cashStore.currentCash,
@ -346,6 +547,14 @@ const topSourcePct = computed(() => {
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0; return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
}); });
const topStreamName = computed(() => {
if (streamsStore.streams.length === 0) return 'No streams';
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
const maxAmount = Math.max(...amounts);
const topStream = streamsStore.streams.find(s => (s.targetMonthlyAmount || 0) === maxAmount);
return topStream?.name || 'Unknown stream';
});
const concentrationStatus = computed(() => { const concentrationStatus = computed(() => {
if (topSourcePct.value > 50) return "red"; if (topSourcePct.value > 50) return "red";
if (topSourcePct.value > 35) return "yellow"; if (topSourcePct.value > 35) return "yellow";
@ -358,6 +567,12 @@ const concentrationColor = computed(() => {
return "text-green-600"; return "text-green-600";
}); });
function getRunwayColor(months: number): string {
if (months >= 6) return 'text-green-600'
if (months >= 3) return 'text-yellow-600'
return 'text-red-600'
}
// Calculate scenario metrics // Calculate scenario metrics
const scenarioMetrics = computed(() => { const scenarioMetrics = computed(() => {
const baseRunway = metrics.value.runway; const baseRunway = metrics.value.runway;
@ -413,4 +628,169 @@ const onImport = async () => {
}; };
const { exportAll, importAll } = useFixtureIO(); const { exportAll, importAll } = useFixtureIO();
// Advanced panel computed properties and methods
const hasStressTest = computed(() => {
return stressTests.value.revenueDelay > 0 ||
stressTests.value.costShockPct > 0 ||
stressTests.value.grantLost;
});
const stressedRunway = computed(() => {
if (!hasStressTest.value) return metrics.value.runway;
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
// Apply stress test adjustments
let adjustedRevenue = streamsStore.streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0);
let adjustedCosts = getMonthlyBurn();
// Revenue delay impact (reduce revenue by delay percentage)
if (stressTests.value.revenueDelay > 0) {
adjustedRevenue *= Math.max(0, 1 - (stressTests.value.revenueDelay / 12));
}
// Cost shock impact
if (stressTests.value.costShockPct > 0) {
adjustedCosts *= (1 + stressTests.value.costShockPct / 100);
}
// Grant lost (remove largest revenue stream if it's a grant)
if (stressTests.value.grantLost) {
const grantStreams = streamsStore.streams.filter(s =>
s.category?.toLowerCase().includes('grant') ||
s.name.toLowerCase().includes('grant')
);
if (grantStreams.length > 0) {
const largestGrant = Math.max(...grantStreams.map(s => s.targetMonthlyAmount || 0));
adjustedRevenue -= largestGrant;
}
}
const netMonthly = adjustedRevenue - adjustedCosts;
const burnRate = netMonthly < 0 ? Math.abs(netMonthly) : adjustedCosts;
return burnRate > 0 ? (cash + savings) / burnRate : Infinity;
});
const sandboxRunway = computed(() => {
if (!sandboxPolicy.value || sandboxPolicy.value === policiesStore.payPolicy?.relationship) {
return null;
}
// Calculate runway with sandbox policy
const cash = cashStore.currentCash || 50000;
const savings = cashStore.currentSavings || 15000;
// Create sandbox policy object
const testPolicy = {
relationship: sandboxPolicy.value,
equalHourlyWage: policiesStore.equalHourlyWage,
roleBands: policiesStore.payPolicy?.roleBands || []
};
// Use scenario calculation with sandbox policy
const { calculateScenarioRunway } = useScenarios();
const result = calculateScenarioRunway(membersStore.members, streamsStore.streams);
// Apply simple adjustment based on policy type
let policyMultiplier = 1;
switch (sandboxPolicy.value) {
case 'needs-weighted':
policyMultiplier = 0.9; // Slightly higher costs
break;
case 'role-banded':
policyMultiplier = 0.85; // Higher costs due to senior roles
break;
case 'hours-weighted':
policyMultiplier = 0.95; // Moderate increase
break;
}
return result.runway * policyMultiplier;
});
function updateStressTest() {
// Reactive computed will handle updates automatically
}
function updateSandboxPolicy() {
// Reactive computed will handle updates automatically
}
function applyStressTest() {
// Apply stress test adjustments to the actual plan
if (stressTests.value.revenueDelay > 0) {
// Reduce all stream targets by delay impact
streamsStore.streams.forEach(stream => {
const reduction = (stressTests.value.revenueDelay / 12) * (stream.targetMonthlyAmount || 0);
streamsStore.updateStream(stream.id, {
targetMonthlyAmount: Math.max(0, (stream.targetMonthlyAmount || 0) - reduction)
});
});
}
if (stressTests.value.costShockPct > 0) {
// Increase overhead costs
const shockMultiplier = 1 + (stressTests.value.costShockPct / 100);
budgetStore.overheadCosts.forEach(cost => {
budgetStore.updateOverheadCost(cost.id, {
amount: (cost.amount || 0) * shockMultiplier
});
});
}
if (stressTests.value.grantLost) {
// Remove or reduce grant streams
const grantStreams = streamsStore.streams.filter(s =>
s.category?.toLowerCase().includes('grant') ||
s.name.toLowerCase().includes('grant')
);
if (grantStreams.length > 0) {
const largestGrant = grantStreams.reduce((prev, current) =>
(prev.targetMonthlyAmount || 0) > (current.targetMonthlyAmount || 0) ? prev : current
);
streamsStore.updateStream(largestGrant.id, { targetMonthlyAmount: 0 });
}
}
// Reset stress tests
stressTests.value = { revenueDelay: 0, costShockPct: 0, grantLost: false };
};
// Deferred alert logic
const deferredAlert = computed(() => {
const maxDeferredRatio = 1.5; // From CLAUDE.md - flag if >1.5× monthly payroll
const monthlyPayrollCost = getMonthlyBurn() * 0.7; // Estimate payroll as 70% of burn
const totalDeferred = membersStore.members.reduce(
(sum, m) => sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
0
);
const deferredRatio = monthlyPayrollCost > 0 ? totalDeferred / monthlyPayrollCost : 0;
const show = deferredRatio > maxDeferredRatio;
const overDeferredMembers = membersStore.members.filter(m => {
const memberDeferred = (m.deferredHours || 0) * policiesStore.equalHourlyWage;
const memberMonthlyPay = m.monthlyPayPlanned || 0;
return memberDeferred > memberMonthlyPay * 2; // Member has >2 months of pay deferred
});
return {
show,
description: show
? `${overDeferredMembers.length} member(s) over deferred cap. Total: ${(deferredRatio * 100).toFixed(0)}% of monthly payroll.`
: ''
};
});
// Alert navigation with context
function handleAlertNavigation(path: string, section?: string) {
// Store alert context for target page to highlight relevant section
if (section) {
localStorage.setItem('urgent-tools-alert-context', JSON.stringify({ section, timestamp: Date.now() }));
}
navigateTo(path);
};
</script> </script>

View file

@ -1,7 +1,6 @@
import { defineNuxtPlugin } from "#app"; import { defineNuxtPlugin } from '#app'
import { createPersistedState } from "pinia-plugin-persistedstate"; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
// Register persisted state plugin for Pinia on client nuxtApp.$pinia.use(piniaPluginPersistedstate)
nuxtApp.$pinia.use(createPersistedState()); })
});

View file

@ -1,40 +0,0 @@
import type { Member, SkillTag, ProblemTag } from "~/types/coaching";
import { skillsCatalog, problemsCatalog } from "~/data/skillsProblems";
export const membersSample: Member[] = [
{
id: "sample-1",
name: "Maya Chen",
role: "Design Lead",
hourly: 32,
availableHrs: 40
},
{
id: "sample-2",
name: "Alex Rivera",
role: "Developer",
hourly: 45,
availableHrs: 30
},
{
id: "sample-3",
name: "Jordan Blake",
role: "Content Writer",
hourly: 28,
availableHrs: 20
}
];
export const skillsCatalogSample: SkillTag[] = skillsCatalog;
export const problemsCatalogSample: ProblemTag[] = problemsCatalog;
// Pre-selected sample data for quick demos
export const sampleSelections = {
selectedSkillsByMember: {
"sample-1": ["design", "facilitation"], // Maya: Design + Facilitation
"sample-2": ["dev", "pm"], // Alex: Dev + PM
"sample-3": ["writing", "marketing"] // Jordan: Writing + Marketing
},
selectedProblems: ["unclear-pitch", "need-landing-store-page"]
};

View file

@ -1,4 +1,5 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { allocatePayroll } from "~/types/members";
export const useBudgetStore = defineStore( export const useBudgetStore = defineStore(
"budget", "budget",
@ -69,8 +70,26 @@ export const useBudgetStore = defineStore(
"Other Expenses", "Other Expenses",
]); ]);
// Define budget item type
interface BudgetItem {
id: string;
name: string;
mainCategory: string;
subcategory: string;
source: string;
monthlyValues: Record<string, number>;
values: {
year1: { best: number; worst: number; mostLikely: number };
year2: { best: number; worst: number; mostLikely: number };
year3: { best: number; worst: number; mostLikely: number };
};
}
// NEW: Budget worksheet structure (starts empty, populated from wizard data) // NEW: Budget worksheet structure (starts empty, populated from wizard data)
const budgetWorksheet = ref({ const budgetWorksheet = ref<{
revenue: BudgetItem[];
expenses: BudgetItem[];
}>({
revenue: [], revenue: [],
expenses: [], expenses: [],
}); });
@ -271,6 +290,30 @@ export const useBudgetStore = defineStore(
currentPeriod.value = period; currentPeriod.value = period;
} }
// Helper function to map stream category to budget category
function mapStreamToBudgetCategory(streamCategory) {
const categoryLower = (streamCategory || "").toLowerCase();
// More comprehensive category mapping
if (categoryLower.includes("game") || categoryLower.includes("product")) {
return "Games & Products";
} else if (categoryLower.includes("service") || categoryLower.includes("consulting")) {
return "Services & Contracts";
} else if (categoryLower.includes("grant") || categoryLower.includes("funding")) {
return "Grants & Funding";
} else if (categoryLower.includes("community") || categoryLower.includes("donation")) {
return "Community Support";
} else if (categoryLower.includes("partnership")) {
return "Partnerships";
} else if (categoryLower.includes("investment")) {
return "Investment Income";
} else if (categoryLower.includes("other")) {
return "Services & Contracts"; // Map "Other" to services as fallback
} else {
return "Games & Products"; // Default fallback
}
}
// Initialize worksheet from wizard data // Initialize worksheet from wizard data
async function initializeFromWizardData() { async function initializeFromWizardData() {
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) { if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
@ -280,340 +323,232 @@ export const useBudgetStore = defineStore(
console.log("Initializing budget from wizard data..."); console.log("Initializing budget from wizard data...");
// Import stores dynamically to avoid circular deps try {
const { useStreamsStore } = await import("./streams"); // Use the new coopBuilder store instead of the old stores
const { useMembersStore } = await import("./members"); const coopStore = useCoopBuilderStore();
const { usePoliciesStore } = await import("./policies");
const streamsStore = useStreamsStore(); console.log("Streams:", coopStore.streams.length, "streams");
const membersStore = useMembersStore(); console.log("Members:", coopStore.members.length, "members");
const policiesStore = usePoliciesStore(); console.log("Equal wage:", coopStore.equalHourlyWage || "No wage set");
console.log("Overhead costs:", coopStore.overheadCosts.length, "costs");
console.log("Streams:", streamsStore.streams.length, "streams"); // Clear existing data
console.log("Members capacity:", membersStore.capacityTotals); budgetWorksheet.value.revenue = [];
console.log("Policies wage:", policiesStore.equalHourlyWage); budgetWorksheet.value.expenses = [];
// Clear existing data // Add revenue streams from wizard (but don't auto-load fixtures)
budgetWorksheet.value.revenue = []; // Note: We don't auto-load fixtures anymore, but wizard data should still work
budgetWorksheet.value.expenses = [];
// Add revenue streams from wizard coopStore.streams.forEach((stream) => {
if (streamsStore.streams.length === 0) { const monthlyAmount = stream.monthly || 0;
console.log("No wizard streams found, adding sample data"); console.log(
// Initialize with minimal demo if no wizard data exists "Adding stream:",
await streamsStore.initializeWithFixtures(); stream.label,
} "category:",
stream.category,
"amount:",
monthlyAmount
);
streamsStore.streams.forEach((stream) => { // Use the helper function for category mapping
const monthlyAmount = stream.targetMonthlyAmount || 0; const mappedCategory = mapStreamToBudgetCategory(stream.category);
console.log(
"Adding stream:",
stream.name,
"category:",
stream.category,
"subcategory:",
stream.subcategory,
"amount:",
monthlyAmount
);
console.log("Full stream object:", stream);
// Simple category mapping - just map the key categories we know exist console.log(
let mappedCategory = "Games & Products"; // Default "Mapped category from",
const categoryLower = (stream.category || "").toLowerCase(); stream.category,
if (categoryLower === "games" || categoryLower === "product") "to",
mappedCategory = "Games & Products"; mappedCategory
else if (categoryLower === "services" || categoryLower === "service") );
mappedCategory = "Services & Contracts";
else if (categoryLower === "grants" || categoryLower === "grant")
mappedCategory = "Grants & Funding";
else if (categoryLower === "community")
mappedCategory = "Community Support";
else if (
categoryLower === "partnerships" ||
categoryLower === "partnership"
)
mappedCategory = "Partnerships";
else if (categoryLower === "investment")
mappedCategory = "Investment Income";
console.log( // Create monthly values - split the annual target evenly across 12 months
"Mapped category from", const monthlyValues: Record<string, number> = {};
stream.category,
"to",
mappedCategory
);
// Create monthly values - split the annual target evenly across 12 months
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
monthlyValues[monthKey] = monthlyAmount;
}
console.log(
"Created monthly values for",
stream.name,
":",
monthlyValues
);
budgetWorksheet.value.revenue.push({
id: `revenue-${stream.id}`,
name: stream.name,
mainCategory: mappedCategory,
subcategory: stream.subcategory || "Direct sales", // Use actual subcategory from stream
source: "wizard",
monthlyValues,
values: {
year1: {
best: monthlyAmount * 12,
worst: monthlyAmount * 6,
mostLikely: monthlyAmount * 10,
},
year2: {
best: monthlyAmount * 15,
worst: monthlyAmount * 8,
mostLikely: monthlyAmount * 12,
},
year3: {
best: monthlyAmount * 18,
worst: monthlyAmount * 10,
mostLikely: monthlyAmount * 15,
},
},
});
});
// Add payroll from wizard data
const totalHours = membersStore.capacityTotals.targetHours || 0;
const hourlyWage = policiesStore.equalHourlyWage || 0;
const oncostPct = policiesStore.payrollOncostPct || 0;
if (totalHours > 0 && hourlyWage > 0) {
const monthlyPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
// Create monthly values for payroll
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
monthlyValues[monthKey] = monthlyPayroll;
}
budgetWorksheet.value.expenses.push({
id: "expense-payroll",
name: "Payroll",
mainCategory: "Salaries & Benefits",
subcategory: "Base wages and benefits",
source: "wizard",
monthlyValues,
values: {
year1: {
best: monthlyPayroll * 12,
worst: monthlyPayroll * 8,
mostLikely: monthlyPayroll * 12,
},
year2: {
best: monthlyPayroll * 14,
worst: monthlyPayroll * 10,
mostLikely: monthlyPayroll * 13,
},
year3: {
best: monthlyPayroll * 16,
worst: monthlyPayroll * 12,
mostLikely: monthlyPayroll * 15,
},
},
});
}
// Add overhead costs from wizard
overheadCosts.value.forEach((cost) => {
if (cost.amount > 0) {
const annualAmount = cost.amount * 12;
// Map overhead cost categories to expense categories
let expenseCategory = "Other Expenses"; // Default
if (cost.category === "Operations")
expenseCategory = "Office & Operations";
else if (cost.category === "Technology")
expenseCategory = "Equipment & Technology";
else if (cost.category === "Legal")
expenseCategory = "Legal & Professional";
else if (cost.category === "Marketing")
expenseCategory = "Marketing & Outreach";
// Create monthly values for overhead costs
const monthlyValues = {};
const today = new Date(); const today = new Date();
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String( const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1 date.getMonth() + 1
).padStart(2, "0")}`; ).padStart(2, "0")}`;
monthlyValues[monthKey] = cost.amount; monthlyValues[monthKey] = monthlyAmount;
} }
console.log(
"Created monthly values for",
stream.label,
":",
monthlyValues
);
budgetWorksheet.value.expenses.push({ budgetWorksheet.value.revenue.push({
id: `expense-${cost.id}`, id: `revenue-${stream.id}`,
name: cost.name, name: stream.label,
mainCategory: expenseCategory, mainCategory: mappedCategory,
subcategory: cost.name, // Use the cost name as subcategory subcategory: "Direct sales", // Default subcategory for coopStore streams
source: "wizard", source: "wizard",
monthlyValues, monthlyValues,
values: { values: {
year1: { year1: {
best: annualAmount, best: monthlyAmount * 12,
worst: annualAmount * 0.8, worst: monthlyAmount * 6,
mostLikely: annualAmount, mostLikely: monthlyAmount * 10,
}, },
year2: { year2: {
best: annualAmount * 1.1, best: monthlyAmount * 15,
worst: annualAmount * 0.9, worst: monthlyAmount * 8,
mostLikely: annualAmount * 1.05, mostLikely: monthlyAmount * 12,
}, },
year3: { year3: {
best: annualAmount * 1.2, best: monthlyAmount * 18,
worst: annualAmount, worst: monthlyAmount * 10,
mostLikely: annualAmount * 1.1, mostLikely: monthlyAmount * 15,
}, },
}, },
}); });
} });
});
// Add production costs from wizard // Add payroll from wizard data using the allocatePayroll function
productionCosts.value.forEach((cost) => { const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
if (cost.amount > 0) { const hourlyWage = coopStore.equalHourlyWage || 0;
const annualAmount = cost.amount * 12; const oncostPct = coopStore.payrollOncostPct || 0;
// Create monthly values for production costs
const monthlyValues = {}; // Calculate total payroll budget (before oncosts)
const basePayrollBudget = totalHours * hourlyWage;
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Calculate total with oncosts
const monthlyPayroll = basePayrollBudget * (1 + oncostPct / 100);
// Create monthly values for payroll
const monthlyValues: Record<string, number> = {};
const today = new Date(); const today = new Date();
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String( const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1 date.getMonth() + 1
).padStart(2, "0")}`; ).padStart(2, "0")}`;
monthlyValues[monthKey] = cost.amount; monthlyValues[monthKey] = monthlyPayroll;
} }
budgetWorksheet.value.expenses.push({ budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`, id: "expense-payroll",
name: cost.name, name: "Payroll",
mainCategory: "Development Costs", mainCategory: "Salaries & Benefits",
subcategory: cost.name, // Use the cost name as subcategory subcategory: "Base wages and benefits",
source: "wizard", source: "wizard",
monthlyValues, monthlyValues,
values: { values: {
year1: { year1: {
best: annualAmount, best: monthlyPayroll * 12,
worst: annualAmount * 0.7, worst: monthlyPayroll * 8,
mostLikely: annualAmount * 0.9, mostLikely: monthlyPayroll * 12,
}, },
year2: { year2: {
best: annualAmount * 1.2, best: monthlyPayroll * 14,
worst: annualAmount * 0.8, worst: monthlyPayroll * 10,
mostLikely: annualAmount, mostLikely: monthlyPayroll * 13,
}, },
year3: { year3: {
best: annualAmount * 1.3, best: monthlyPayroll * 16,
worst: annualAmount * 0.9, worst: monthlyPayroll * 12,
mostLikely: annualAmount * 1.1, mostLikely: monthlyPayroll * 15,
}, },
}, },
}); });
} }
});
// If still no data after initialization, add a sample row // Add overhead costs from wizard
if (budgetWorksheet.value.revenue.length === 0) { coopStore.overheadCosts.forEach((cost) => {
console.log("Adding sample revenue line"); if (cost.amount && cost.amount > 0) {
// Create monthly values for sample revenue const annualAmount = cost.amount * 12;
const monthlyValues = {}; // Map overhead cost categories to expense categories
const today = new Date(); let expenseCategory = "Other Expenses"; // Default
for (let i = 0; i < 12; i++) { if (cost.category === "Operations")
const date = new Date(today.getFullYear(), today.getMonth() + i, 1); expenseCategory = "Office & Operations";
const monthKey = `${date.getFullYear()}-${String( else if (cost.category === "Tools")
date.getMonth() + 1 expenseCategory = "Equipment & Technology";
).padStart(2, "0")}`; else if (cost.category === "Professional")
monthlyValues[monthKey] = 667; // ~8000/12 expenseCategory = "Legal & Professional";
} else if (cost.category === "Marketing")
expenseCategory = "Marketing & Outreach";
budgetWorksheet.value.revenue.push({ // Create monthly values for overhead costs
id: "revenue-sample", const monthlyValues: Record<string, number> = {};
name: "Sample Revenue", const today = new Date();
mainCategory: "Games & Products", for (let i = 0; i < 12; i++) {
subcategory: "Direct sales", const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
source: "user", const monthKey = `${date.getFullYear()}-${String(
monthlyValues, date.getMonth() + 1
values: { ).padStart(2, "0")}`;
year1: { best: 10000, worst: 5000, mostLikely: 8000 }, monthlyValues[monthKey] = cost.amount;
year2: { best: 12000, worst: 6000, mostLikely: 10000 }, }
year3: { best: 15000, worst: 8000, mostLikely: 12000 },
}, budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`,
name: cost.name,
mainCategory: expenseCategory,
subcategory: cost.name, // Use the cost name as subcategory
source: "wizard",
monthlyValues,
values: {
year1: {
best: annualAmount,
worst: annualAmount * 0.8,
mostLikely: annualAmount,
},
year2: {
best: annualAmount * 1.1,
worst: annualAmount * 0.9,
mostLikely: annualAmount * 1.05,
},
year3: {
best: annualAmount * 1.2,
worst: annualAmount,
mostLikely: annualAmount * 1.1,
},
},
});
}
}); });
}
if (budgetWorksheet.value.expenses.length === 0) { // Production costs are handled within overhead costs in the new architecture
console.log("Adding sample expense line");
// Create monthly values for sample expense
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
monthlyValues[monthKey] = 67; // ~800/12
}
budgetWorksheet.value.expenses.push({ // DISABLED: No sample data - budget should start empty
id: "expense-sample", // if (budgetWorksheet.value.revenue.length === 0) {
name: "Sample Expense", // console.log("Adding sample revenue line");
mainCategory: "Other Expenses", // // ... sample revenue creation code removed
subcategory: "Miscellaneous", // }
source: "user",
monthlyValues, // DISABLED: No sample data - expenses should start empty
values: { // if (budgetWorksheet.value.expenses.length === 0) {
year1: { best: 1000, worst: 500, mostLikely: 800 }, // console.log("Adding sample expense line");
year2: { best: 1200, worst: 600, mostLikely: 1000 }, // // ... sample expense creation code removed
year3: { best: 1500, worst: 800, mostLikely: 1200 }, // }
},
// Debug: Log all revenue items and their categories
console.log("Final revenue items:");
budgetWorksheet.value.revenue.forEach((item) => {
console.log(
`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`
);
}); });
}
// Debug: Log all revenue items and their categories console.log("Final expense items:");
console.log("Final revenue items:"); budgetWorksheet.value.expenses.forEach((item) => {
budgetWorksheet.value.revenue.forEach((item) => { console.log(
console.log( `- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`
`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})` );
); });
});
console.log("Final expense items:"); // Ensure all items have monthlyValues and new structure - migrate existing items
budgetWorksheet.value.expenses.forEach((item) => { [
console.log( ...budgetWorksheet.value.revenue,
`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})` ...budgetWorksheet.value.expenses,
); ].forEach((item) => {
}); // Migrate to new structure if needed
if (item.category && !item.mainCategory) {
console.log("Migrating item structure for:", item.name);
item.mainCategory = item.category; // Old category becomes mainCategory
// Ensure all items have monthlyValues and new structure - migrate existing items // Set appropriate subcategory based on the main category and item name
[ if (item.category === "Games & Products") {
...budgetWorksheet.value.revenue,
...budgetWorksheet.value.expenses,
].forEach((item) => {
// Migrate to new structure if needed
if (item.category && !item.mainCategory) {
console.log("Migrating item structure for:", item.name);
item.mainCategory = item.category; // Old category becomes mainCategory
// Set appropriate subcategory based on the main category and item name
if (item.category === "Games & Products") {
const gameSubcategories = [ const gameSubcategories = [
"Direct sales", "Direct sales",
"Platform revenue share", "Platform revenue share",
@ -688,14 +623,22 @@ export const useBudgetStore = defineStore(
} }
}); });
console.log( console.log(
"Initialization complete. Revenue items:", "Initialization complete. Revenue items:",
budgetWorksheet.value.revenue.length, budgetWorksheet.value.revenue.length,
"Expense items:", "Expense items:",
budgetWorksheet.value.expenses.length budgetWorksheet.value.expenses.length
); );
isInitialized.value = true; isInitialized.value = true;
} catch (error) {
console.error("Error initializing budget from wizard data:", error);
// DISABLED: No fallback sample data - budget should remain empty on errors
// Budget initialization complete (without automatic fallback data)
isInitialized.value = true;
}
} }
// NEW: Budget worksheet functions // NEW: Budget worksheet functions

View file

@ -10,7 +10,7 @@ export const useCashStore = defineStore("cash", () => {
// Week that first breaches minimum cushion // Week that first breaches minimum cushion
const firstBreachWeek = ref(null); const firstBreachWeek = ref(null);
// Current cash and savings balances - start with zeros // Current cash and savings balances - start empty
const currentCash = ref(0); const currentCash = ref(0);
const currentSavings = ref(0); const currentSavings = ref(0);
@ -111,4 +111,14 @@ export const useCashStore = defineStore("cash", () => {
stagePayment, stagePayment,
updateCurrentBalances, updateCurrentBalances,
}; };
}, {
persist: {
key: "urgent-tools-cash",
paths: [
"currentCash",
"currentSavings",
"cashEvents",
"paymentQueue"
],
},
}); });

View file

@ -1,28 +0,0 @@
import { defineStore } from "pinia";
export const useCoopBuilderStore = defineStore(
"coop-builder",
() => {
const currentStep = ref(1);
function setStep(step: number) {
currentStep.value = Math.min(Math.max(1, step), 5);
}
function reset() {
currentStep.value = 1;
}
return {
currentStep,
setStep,
reset,
};
},
{
persist: {
key: "urgent-tools-wizard",
paths: ["currentStep"],
},
}
);

277
stores/coopBuilder.ts Normal file
View file

@ -0,0 +1,277 @@
import { defineStore } from "pinia";
export const useCoopBuilderStore = defineStore("coop", {
state: () => ({
operatingMode: "min" as "min" | "target",
// Flag to track if data was intentionally cleared
_wasCleared: false,
members: [] as Array<{
id: string;
name: string;
role?: string;
hoursPerMonth?: number;
minMonthlyNeeds: number;
targetMonthlyPay: number;
externalMonthlyIncome: number;
monthlyPayPlanned: number;
}>,
streams: [] as Array<{
id: string;
label: string;
monthly: number;
category?: string;
certainty?: string;
}>,
milestones: [] as Array<{
id: string;
label: string;
date: string;
}>,
// Scenario and stress test state
scenario: "current" as
| "current"
| "quit-jobs"
| "start-production"
| "custom",
stress: {
revenueDelay: 0,
costShockPct: 0,
grantLost: false,
},
// Policy settings
policy: {
relationship: "equal-pay" as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded",
roleBands: {} as Record<string, number>,
},
equalHourlyWage: 50,
payrollOncostPct: 25,
savingsTargetMonths: 6,
minCashCushion: 10000,
// Cash reserves
currentCash: 50000,
currentSavings: 15000,
// Overhead costs
overheadCosts: [] as Array<{
id?: string;
name: string;
amount: number;
category?: string;
}>,
}),
getters: {
totalRevenue: (state) => {
return state.streams.reduce((sum, s) => sum + (s.monthly || 0), 0);
},
totalOverhead: (state) => {
return state.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0);
},
totalLiquid: (state) => {
return state.currentCash + state.currentSavings;
},
},
actions: {
// Member actions
upsertMember(m: any) {
const i = this.members.findIndex((x) => x.id === m.id);
// Ensure all keys exist (prevents undefined stripping)
const withDefaults = {
id: m.id || Date.now().toString(),
name: m.name || m.displayName || "",
role: m.role ?? "",
hoursPerMonth: m.hoursPerMonth ?? 0,
minMonthlyNeeds: m.minMonthlyNeeds ?? 0,
targetMonthlyPay: m.targetMonthlyPay ?? 0,
externalMonthlyIncome: m.externalMonthlyIncome ?? 0,
monthlyPayPlanned: m.monthlyPayPlanned ?? 0,
};
if (i === -1) {
this.members.push(withDefaults);
} else {
this.members[i] = withDefaults;
}
},
removeMember(id: string) {
this.members = this.members.filter((m) => m.id !== id);
},
// Stream actions
upsertStream(s: any) {
const i = this.streams.findIndex((x) => x.id === s.id);
const withDefaults = {
id: s.id || Date.now().toString(),
label: s.label || s.name || "",
monthly: s.monthly || s.targetMonthlyAmount || 0,
category: s.category ?? "",
certainty: s.certainty ?? "Probable",
};
if (i === -1) {
this.streams.push(withDefaults);
} else {
this.streams[i] = withDefaults;
}
},
removeStream(id: string) {
this.streams = this.streams.filter((s) => s.id !== id);
},
// Milestone actions
addMilestone(label: string, date: string) {
this.milestones.push({
id: Date.now().toString(),
label,
date,
});
},
removeMilestone(id: string) {
this.milestones = this.milestones.filter((m) => m.id !== id);
},
// Operating mode
setOperatingMode(mode: "min" | "target") {
this.operatingMode = mode;
},
// Scenario
setScenario(
scenario: "current" | "quit-jobs" | "start-production" | "custom"
) {
this.scenario = scenario;
},
// Stress test
updateStress(updates: Partial<typeof this.stress>) {
this.stress = { ...this.stress, ...updates };
},
// Policy updates
setPolicy(relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") {
this.policy.relationship = relationship;
},
setRoleBands(bands: Record<string, number>) {
this.policy.roleBands = bands;
},
setEqualWage(wage: number) {
this.equalHourlyWage = wage;
},
setOncostPct(pct: number) {
this.payrollOncostPct = pct;
},
// Overhead costs
addOverheadCost(cost: any) {
const withDefaults = {
id: cost.id || Date.now().toString(),
name: cost.name || "",
amount: cost.amount || 0,
category: cost.category ?? "",
};
this.overheadCosts.push(withDefaults);
},
upsertOverheadCost(cost: any) {
const i = this.overheadCosts.findIndex((c) => c.id === cost.id);
const withDefaults = {
id: cost.id || Date.now().toString(),
name: cost.name || "",
amount: cost.amount || 0,
category: cost.category ?? "",
};
if (i === -1) {
this.overheadCosts.push(withDefaults);
} else {
this.overheadCosts[i] = withDefaults;
}
},
removeOverheadCost(id: string) {
this.overheadCosts = this.overheadCosts.filter((c) => c.id !== id);
},
// Initialize with default data if empty - DISABLED
// NO automatic initialization - stores should start empty
initializeDefaults() {
// DISABLED: No automatic data loading
// User must explicitly choose to load demo data
return;
},
// Clear ALL data - no exceptions
clearAll() {
// Reset ALL state to initial empty values
this._wasCleared = true;
this.operatingMode = "min";
this.members = [];
this.streams = [];
this.milestones = [];
this.scenario = "current";
this.stress = {
revenueDelay: 0,
costShockPct: 0,
grantLost: false,
};
this.policy = {
relationship: "equal-pay",
roleBands: {},
};
this.equalHourlyWage = 0;
this.payrollOncostPct = 0;
this.savingsTargetMonths = 0;
this.minCashCushion = 0;
this.currentCash = 0;
this.currentSavings = 0;
this.overheadCosts = [];
// Clear ALL localStorage data
if (typeof window !== "undefined") {
// Save cleared flag first
localStorage.setItem("urgent-tools-cleared-flag", "true");
// Remove all known keys
const keysToRemove = [
"coop_builder_v1",
"urgent-tools-members",
"urgent-tools-policies",
"urgent-tools-streams",
"urgent-tools-budget",
"urgent-tools-cash",
"urgent-tools-schema-version",
];
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Clear any other urgent-tools or coop keys
const allKeys = Object.keys(localStorage);
allKeys.forEach((key) => {
if (key.startsWith("urgent-tools-") || key.startsWith("coop_")) {
if (key !== "urgent-tools-cleared-flag") {
localStorage.removeItem(key);
}
}
});
}
},
},
persist: {
key: "coop_builder_v1",
storage: typeof window !== "undefined" ? localStorage : undefined,
},
});

View file

@ -1,4 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from 'vue';
import { coverage, teamCoverageStats } from "~/types/members";
export const useMembersStore = defineStore( export const useMembersStore = defineStore(
"members", "members",
@ -34,10 +36,16 @@ export const useMembersStore = defineStore(
// Normalize a member object to ensure required structure and sane defaults // Normalize a member object to ensure required structure and sane defaults
function normalizeMember(raw) { function normalizeMember(raw) {
// Calculate hoursPerWeek from targetHours (monthly) if not explicitly set
const targetHours = Number(raw.capacity?.targetHours) || 0;
const hoursPerWeek = raw.hoursPerWeek ?? (targetHours > 0 ? targetHours / 4.33 : 0);
const normalized = { const normalized = {
id: raw.id || Date.now().toString(), id: raw.id || Date.now().toString(),
displayName: typeof raw.displayName === "string" ? raw.displayName : "", displayName: typeof raw.displayName === "string" ? raw.displayName : "",
roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "", roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "",
role: raw.role || raw.roleFocus || "",
hoursPerWeek: hoursPerWeek,
payRelationship: raw.payRelationship || "FullyPaid", payRelationship: raw.payRelationship || "FullyPaid",
capacity: { capacity: {
minHours: Number(raw.capacity?.minHours) || 0, minHours: Number(raw.capacity?.minHours) || 0,
@ -49,6 +57,11 @@ export const useMembersStore = defineStore(
privacyNeeds: raw.privacyNeeds || "aggregate_ok", privacyNeeds: raw.privacyNeeds || "aggregate_ok",
deferredHours: Number(raw.deferredHours ?? 0), deferredHours: Number(raw.deferredHours ?? 0),
quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240), quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240),
// NEW fields for needs coverage
minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0,
targetMonthlyPay: Number(raw.targetMonthlyPay) || 0,
externalMonthlyIncome: Number(raw.externalMonthlyIncome) || 0,
monthlyPayPlanned: Number(raw.monthlyPayPlanned) || 0,
...raw, ...raw,
}; };
return normalized; return normalized;
@ -187,6 +200,56 @@ export const useMembersStore = defineStore(
members.value = []; members.value = [];
} }
// Coverage calculations for individual members
function getMemberCoverage(memberId) {
const member = members.value.find((m) => m.id === memberId);
if (!member) return { minPct: undefined, targetPct: undefined };
return coverage(
member.minMonthlyNeeds || 0,
member.targetMonthlyPay || 0,
member.monthlyPayPlanned || 0,
member.externalMonthlyIncome || 0
);
}
// Team-wide coverage statistics
const teamStats = computed(() => teamCoverageStats(members.value));
// Pay policy configuration
const payPolicy = ref({
relationship: 'equal-pay' as const,
notes: '',
equalBase: 0,
needsWeight: 0.5,
roleBands: {},
hoursRate: 0,
customFormula: ''
});
// Setters for new fields
function setMonthlyNeeds(memberId, minNeeds, targetPay) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.minMonthlyNeeds = Number(minNeeds) || 0;
member.targetMonthlyPay = Number(targetPay) || 0;
}
}
function setExternalIncome(memberId, income) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.externalMonthlyIncome = Number(income) || 0;
}
}
function setPlannedPay(memberId, planned) {
const member = members.value.find((m) => m.id === memberId);
if (member) {
member.monthlyPayPlanned = Number(planned) || 0;
}
}
return { return {
members, members,
capacityTotals, capacityTotals,
@ -194,6 +257,8 @@ export const useMembersStore = defineStore(
validationDetails, validationDetails,
isValid, isValid,
schemaVersion, schemaVersion,
payPolicy,
teamStats,
// Wizard actions // Wizard actions
upsertMember, upsertMember,
setCapacity, setCapacity,
@ -202,6 +267,11 @@ export const useMembersStore = defineStore(
setExternalCoveragePct, setExternalCoveragePct,
setPrivacy, setPrivacy,
resetMembers, resetMembers,
// New coverage actions
setMonthlyNeeds,
setExternalIncome,
setPlannedPay,
getMemberCoverage,
// Legacy actions // Legacy actions
addMember, addMember,
updateMember, updateMember,
@ -211,7 +281,7 @@ export const useMembersStore = defineStore(
{ {
persist: { persist: {
key: "urgent-tools-members", key: "urgent-tools-members",
paths: ["members", "privacyFlags"], paths: ["members", "privacyFlags", "payPolicy"],
}, },
} }
); );

View file

@ -11,6 +11,13 @@ export const usePoliciesStore = defineStore(
const payrollOncostPct = ref(0); const payrollOncostPct = ref(0);
const savingsTargetMonths = ref(0); const savingsTargetMonths = ref(0);
const minCashCushionAmount = ref(0); const minCashCushionAmount = ref(0);
const operatingMode = ref<'minimum' | 'target'>('minimum');
// Pay policy for member needs coverage
const payPolicy = ref({
relationship: 'equal-pay' as 'equal-pay' | 'needs-weighted' | 'role-banded' | 'hours-weighted',
roleBands: [] as Array<{ role: string; hourlyWage: number }>
});
// Deferred pay limits // Deferred pay limits
const deferredCapHoursPerQtr = ref(0); const deferredCapHoursPerQtr = ref(0);
@ -96,6 +103,13 @@ export const usePoliciesStore = defineStore(
paymentPriority.value = [...priority]; paymentPriority.value = [...priority];
} }
function setPayPolicy(relationship, roleBands = []) {
payPolicy.value = {
relationship,
roleBands: [...roleBands]
};
}
// Legacy actions // Legacy actions
function updatePolicy(key, value) { function updatePolicy(key, value) {
if (key === "equalHourlyWage") setEqualWage(value); if (key === "equalHourlyWage") setEqualWage(value);
@ -120,6 +134,8 @@ export const usePoliciesStore = defineStore(
payrollOncostPct.value = 0; payrollOncostPct.value = 0;
savingsTargetMonths.value = 0; savingsTargetMonths.value = 0;
minCashCushionAmount.value = 0; minCashCushionAmount.value = 0;
operatingMode.value = 'minimum';
payPolicy.value = { relationship: 'equal-pay', roleBands: [] };
deferredCapHoursPerQtr.value = 0; deferredCapHoursPerQtr.value = 0;
deferredSunsetMonths.value = 0; deferredSunsetMonths.value = 0;
surplusOrder.value = [ surplusOrder.value = [
@ -139,6 +155,8 @@ export const usePoliciesStore = defineStore(
payrollOncostPct, payrollOncostPct,
savingsTargetMonths, savingsTargetMonths,
minCashCushionAmount, minCashCushionAmount,
operatingMode,
payPolicy,
deferredCapHoursPerQtr, deferredCapHoursPerQtr,
deferredSunsetMonths, deferredSunsetMonths,
surplusOrder, surplusOrder,
@ -151,6 +169,7 @@ export const usePoliciesStore = defineStore(
setOncostPct, setOncostPct,
setSavingsTargetMonths, setSavingsTargetMonths,
setMinCashCushion, setMinCashCushion,
setPayPolicy,
setDeferredCap, setDeferredCap,
setDeferredSunset, setDeferredSunset,
setVolunteerScope, setVolunteerScope,
@ -171,6 +190,8 @@ export const usePoliciesStore = defineStore(
"payrollOncostPct", "payrollOncostPct",
"savingsTargetMonths", "savingsTargetMonths",
"minCashCushionAmount", "minCashCushionAmount",
"operatingMode",
"payPolicy",
"deferredCapHoursPerQtr", "deferredCapHoursPerQtr",
"deferredSunsetMonths", "deferredSunsetMonths",
"surplusOrder", "surplusOrder",

View file

@ -87,30 +87,6 @@ export const useStreamsStore = defineStore(
} }
} }
// Initialize with fixture data if empty
async function initializeWithFixtures() {
if (streams.value.length === 0) {
const { useFixtures } = await import('~/composables/useFixtures');
const fixtures = useFixtures();
const { revenueStreams } = await fixtures.loadStreams();
revenueStreams.forEach(stream => {
upsertStream(stream);
});
}
}
// Load realistic demo data (for better user experience)
async function loadDemoData() {
resetStreams();
const { useFixtures } = await import('~/composables/useFixtures');
const fixtures = useFixtures();
const { revenueStreams } = await fixtures.loadStreams();
revenueStreams.forEach(stream => {
upsertStream(stream);
});
}
// Reset function // Reset function
function resetStreams() { function resetStreams() {
@ -127,8 +103,6 @@ export const useStreamsStore = defineStore(
// Wizard actions // Wizard actions
upsertStream, upsertStream,
resetStreams, resetStreams,
initializeWithFixtures,
loadDemoData,
// Legacy actions // Legacy actions
addStream, addStream,
updateStream, updateStream,

View file

@ -1,5 +1,13 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
export default { export default {
content: [
'./app.vue',
'./pages/**/*.vue',
'./components/**/*.{vue,js,ts}',
'./composables/**/*.{js,ts}',
'./layouts/**/*.vue',
'./plugins/**/*.{js,ts}',
],
darkMode: "class", darkMode: "class",
} satisfies Config; } satisfies Config;

View file

@ -9,12 +9,35 @@ import WizardRevenueStep from '~/components/WizardRevenueStep.vue';
import { useOfferSuggestor } from '~/composables/useOfferSuggestor'; import { useOfferSuggestor } from '~/composables/useOfferSuggestor';
import { usePlanStore } from '~/stores/plan'; import { usePlanStore } from '~/stores/plan';
import { offerToStream, offersToStreams } from '~/utils/offerToStream'; import { offerToStream, offersToStreams } from '~/utils/offerToStream';
import {
membersSample, // Create inline test data to replace removed sample imports
skillsCatalogSample, const membersSample = [
problemsCatalogSample, { id: "1", name: "Maya Chen", role: "Designer", hourly: 32, availableHrs: 20 },
sampleSelections { id: "2", name: "Alex Rodriguez", role: "Developer", hourly: 35, availableHrs: 30 },
} from '~/sample/skillsToOffersSamples'; { id: "3", name: "Jordan Kim", role: "Writer", hourly: 28, availableHrs: 15 }
];
const skillsCatalogSample = [
{ id: "design", label: "UI/UX Design" },
{ id: "writing", label: "Technical Writing" },
{ id: "development", label: "Web Development" }
];
const problemsCatalogSample = [
{
id: "unclear-pitch",
label: "Unclear value proposition",
examples: ["Need better messaging", "Confusing product pitch"]
}
];
const sampleSelections = {
selectedSkillsByMember: {
"1": ["design"],
"3": ["writing"]
},
selectedProblems: ["unclear-pitch"]
};
// Mock router // Mock router
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
@ -159,65 +182,34 @@ describe('Coach Integration Tests', () => {
}); });
describe('Coach Page Integration', () => { describe('Coach Page Integration', () => {
it('loads sample data and generates offers automatically', async () => { it('starts with empty data by default', async () => {
const wrapper = mount(CoachSkillsToOffers, { const wrapper = mount(CoachSkillsToOffers, {
global: { global: {
plugins: [pinia] plugins: [pinia]
} }
}); });
// Trigger sample data loading // Should start with empty data
await wrapper.vm.loadSampleData(); expect(wrapper.vm.members).toEqual([]);
await nextTick(); expect(wrapper.vm.availableSkills).toEqual([]);
expect(wrapper.vm.availableProblems).toEqual([]);
// Wait for debounced offer generation expect(wrapper.vm.offers).toBeNull();
await new Promise(resolve => setTimeout(resolve, 350));
// Should have loaded sample members
expect(wrapper.vm.members).toEqual(membersSample);
// Should have pre-selected skills and problems
expect(wrapper.vm.selectedSkills).toEqual(sampleSelections.selectedSkillsByMember);
expect(wrapper.vm.selectedProblems).toEqual(sampleSelections.selectedProblems);
// Should have generated offers
expect(wrapper.vm.offers).toBeDefined();
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
}); });
it('handles "Use these" action correctly', async () => { it('handles empty state gracefully with no offers generated', async () => {
const wrapper = mount(CoachSkillsToOffers, { const wrapper = mount(CoachSkillsToOffers, {
global: { global: {
plugins: [pinia] plugins: [pinia]
} }
}); });
// Load sample data and generate offers // Wait for any potential async operations
await wrapper.vm.loadSampleData();
await nextTick(); await nextTick();
await new Promise(resolve => setTimeout(resolve, 350)); await new Promise(resolve => setTimeout(resolve, 100));
// Ensure we have offers // Should have no offers with empty data
expect(wrapper.vm.offers?.length).toBeGreaterThan(0); expect(wrapper.vm.offers).toBeNull();
expect(wrapper.vm.canRegenerate).toBe(false);
const initialOffers = wrapper.vm.offers!;
// Trigger "Use these" action
await wrapper.vm.useOffers();
// Should have added streams to plan store
expect(planStore.streams.length).toBe(initialOffers.length);
// Verify streams are properly converted
planStore.streams.forEach((stream: any, index: number) => {
const originalOffer = initialOffers[index];
expect(stream.id).toBe(`offer-${originalOffer.id}`);
expect(stream.name).toBe(originalOffer.name);
expect(stream.unitPrice).toBe(originalOffer.price.baseline);
expect(stream.payoutDelayDays).toBe(originalOffer.payoutDelayDays);
expect(stream.feePercent).toBe(3);
expect(stream.notes).toBe(originalOffer.whyThis.join('. '));
});
}); });
}); });

137
types/members.ts Normal file
View file

@ -0,0 +1,137 @@
export type PayRelationship =
| 'equal-pay'
| 'needs-weighted'
| 'role-banded'
| 'hours-weighted'
| 'custom-formula';
export interface Member {
id: string
displayName: string
roleFocus?: string
role?: string
hoursPerWeek?: number
hoursPerMonth?: number
capacity?: {
minHours?: number
targetHours?: number
maxHours?: number
}
// Existing/planned
monthlyPayPlanned?: number
// NEW - early-stage friendly, defaults-safe
minMonthlyNeeds?: number
targetMonthlyPay?: number
externalMonthlyIncome?: number
// Compatibility with existing store
payRelationship?: string
riskBand?: string
externalCoveragePct?: number
privacyNeeds?: string
deferredHours?: number
quarterlyDeferredCap?: number
// UI-only derivations
coverageMinPct?: number
coverageTargetPct?: number
}
export interface PayPolicy {
relationship: PayRelationship
notes?: string
equalBase?: number
needsWeight?: number
roleBands?: Record<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 }
}
export function teamCoverageStats(members: Member[]) {
const vals = members
.map(m => coverage(m.minMonthlyNeeds, m.targetMonthlyPay, m.monthlyPayPlanned, m.externalMonthlyIncome).minPct)
.filter((v): v is number => typeof v === 'number')
if (!vals.length) return { under100: 0, median: undefined, range: undefined, gini: undefined }
const sorted = [...vals].sort((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)]
const range = { min: sorted[0], max: sorted[sorted.length - 1] }
// quick Gini on coverage (0 = equal, 1 = unequal)
const mean = vals.reduce((a, b) => a + b, 0) / vals.length
let gini = 0
if (mean > 0) {
let diffSum = 0
for (let i = 0; i < vals.length; i++)
for (let j = 0; j < vals.length; j++)
diffSum += Math.abs(vals[i] - vals[j])
gini = diffSum / (2 * vals.length * vals.length * mean)
}
const under100 = vals.filter(v => v < 100).length
return { under100, median, range, gini }
}
// Payroll allocation based on policy
export function allocatePayroll(members: Member[], policy: PayPolicy, payrollBudget: number): Member[] {
const result = JSON.parse(JSON.stringify(members)) // Safe deep clone
if (policy.relationship === 'equal-pay') {
const each = payrollBudget / result.length
result.forEach(m => m.monthlyPayPlanned = Math.max(0, each))
return result
}
if (policy.relationship === 'needs-weighted') {
const weights = result.map(m => m.minMonthlyNeeds ?? 0)
const sum = weights.reduce((a, b) => a + b, 0) || 1
result.forEach((m, i) => {
const w = weights[i] / sum
m.monthlyPayPlanned = Math.max(0, payrollBudget * w)
})
return result
}
if (policy.relationship === 'role-banded' && policy.roleBands) {
const bands = result.map(m => policy.roleBands![m.role ?? ''] ?? 0)
const sum = bands.reduce((a, b) => a + b, 0) || 1
result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (bands[i] / sum))
return result
}
if (policy.relationship === 'hours-weighted') {
const hours = result.map(m => m.hoursPerMonth ?? (m.hoursPerWeek ? m.hoursPerWeek * 4 : 0) ?? (m.capacity?.targetHours ?? 0))
const sum = hours.reduce((a, b) => a + b, 0) || 1
result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (hours[i] / sum))
return result
}
// fallback: equal
const each = payrollBudget / result.length
result.forEach(m => m.monthlyPayPlanned = Math.max(0, each))
return result
}
// Monthly payroll calculation for runway and cashflow
export function monthlyPayroll(members: Member[], mode: 'minimum' | 'target' = 'minimum'): number {
return members.reduce((sum, m) => {
const planned = m.monthlyPayPlanned ?? 0
// In "minimum" mode cap at min needs to show a lean runway scenario
if (mode === 'minimum' && m.minMonthlyNeeds) {
return sum + Math.min(planned, m.minMonthlyNeeds)
}
return sum + planned
}, 0)
}