refactor: enhance routing and state management in CoopBuilder, add migration checks on startup, and update Tailwind configuration for improved component styling
This commit is contained in:
parent
848386e3dd
commit
4cea1f71fe
55 changed files with 4053 additions and 1486 deletions
11
app.vue
11
app.vue
|
|
@ -79,14 +79,25 @@ const isCoopBuilderSection = computed(
|
|||
route.path === "/coop-planner" ||
|
||||
route.path === "/coop-builder" ||
|
||||
route.path === "/" ||
|
||||
route.path === "/dashboard" ||
|
||||
route.path === "/mix" ||
|
||||
route.path === "/budget" ||
|
||||
route.path === "/scenarios" ||
|
||||
route.path === "/cash" ||
|
||||
route.path === "/session" ||
|
||||
route.path === "/settings" ||
|
||||
route.path === "/glossary"
|
||||
);
|
||||
|
||||
const isWizardSection = computed(
|
||||
() => route.path === "/wizards" || route.path.startsWith("/templates/")
|
||||
);
|
||||
|
||||
// Run migrations on app startup
|
||||
onMounted(() => {
|
||||
const { migrate, needsMigration } = useMigrations();
|
||||
if (needsMigration()) {
|
||||
migrate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@
|
|||
const route = useRoute();
|
||||
|
||||
const coopBuilderItems = [
|
||||
{
|
||||
id: "dashboard",
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
id: "coop-builder",
|
||||
name: "Setup Wizard",
|
||||
|
|
@ -36,6 +41,26 @@ const coopBuilderItems = [
|
|||
name: "Budget",
|
||||
path: "/budget",
|
||||
},
|
||||
{
|
||||
id: "mix",
|
||||
name: "Revenue Mix",
|
||||
path: "/mix",
|
||||
},
|
||||
{
|
||||
id: "scenarios",
|
||||
name: "Scenarios",
|
||||
path: "/scenarios",
|
||||
},
|
||||
{
|
||||
id: "cash",
|
||||
name: "Cash Flow",
|
||||
path: "/cash",
|
||||
},
|
||||
{
|
||||
id: "session",
|
||||
name: "Value Session",
|
||||
path: "/session",
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
|
|
|
|||
57
components/CoverageChip.vue
Normal file
57
components/CoverageChip.vue
Normal 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>
|
||||
211
components/MilestoneRunwayOverlay.vue
Normal file
211
components/MilestoneRunwayOverlay.vue
Normal 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>
|
||||
71
components/NeedsCoverageBars.vue
Normal file
71
components/NeedsCoverageBars.vue
Normal 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>
|
||||
93
components/RevenueMixTable.vue
Normal file
93
components/RevenueMixTable.vue
Normal 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>
|
||||
|
|
@ -18,6 +18,27 @@
|
|||
</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 -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
|
|
@ -130,14 +151,26 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
const emit = defineEmits<{
|
||||
"save-status": [status: "saving" | "saved" | "error"];
|
||||
}>();
|
||||
|
||||
// Store
|
||||
const budgetStore = useBudgetStore();
|
||||
const { overheadCosts } = storeToRefs(budgetStore);
|
||||
const coop = useCoopBuilder();
|
||||
|
||||
// Get the store directly for overhead costs
|
||||
const store = useCoopBuilderStore();
|
||||
|
||||
// Computed for overhead costs (from store)
|
||||
const overheadCosts = computed(() => store.overheadCosts || []);
|
||||
|
||||
// Operating mode toggle
|
||||
const useTargetMode = ref(coop.operatingMode.value === 'target');
|
||||
|
||||
function updateOperatingMode(value: boolean) {
|
||||
coop.setOperatingMode(value ? 'target' : 'min');
|
||||
emit("save-status", "saved");
|
||||
}
|
||||
|
||||
// Category options
|
||||
const categoryOptions = [
|
||||
|
|
@ -168,13 +201,8 @@ const debouncedSave = useDebounceFn((cost: any) => {
|
|||
emit("save-status", "saving");
|
||||
|
||||
try {
|
||||
// Find and update existing cost
|
||||
const existingCost = overheadCosts.value.find((c) => c.id === cost.id);
|
||||
if (existingCost) {
|
||||
// Store will handle reactivity through the ref
|
||||
Object.assign(existingCost, cost);
|
||||
}
|
||||
|
||||
// Use store's upsert method
|
||||
store.upsertOverheadCost(cost);
|
||||
emit("save-status", "saved");
|
||||
} catch (error) {
|
||||
console.error("Failed to save cost:", error);
|
||||
|
|
@ -204,15 +232,13 @@ function addOverheadCost() {
|
|||
recurring: true,
|
||||
};
|
||||
|
||||
budgetStore.addOverheadLine({
|
||||
name: newCost.name,
|
||||
amountMonthly: newCost.amount,
|
||||
category: newCost.category,
|
||||
});
|
||||
store.addOverheadCost(newCost);
|
||||
emit("save-status", "saved");
|
||||
}
|
||||
|
||||
function removeCost(id: string) {
|
||||
budgetStore.removeOverheadLine(id);
|
||||
store.removeOverheadCost(id);
|
||||
emit("save-status", "saved");
|
||||
}
|
||||
|
||||
function exportCosts() {
|
||||
|
|
|
|||
|
|
@ -53,58 +53,22 @@
|
|||
v-for="(member, index) in members"
|
||||
:key="member.id"
|
||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<UFormField label="Name" required class="md:col-span-2">
|
||||
<!-- Header row with name and coverage chip -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<UInput
|
||||
v-model="member.displayName"
|
||||
placeholder="Alex Chen"
|
||||
size="xl"
|
||||
class="text-lg font-medium w-full"
|
||||
placeholder="Member name"
|
||||
size="lg"
|
||||
class="text-lg font-bold w-48"
|
||||
@update:model-value="saveMember(member)"
|
||||
@blur="saveMember(member)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Pay relationship" required>
|
||||
<USelect
|
||||
v-model="member.payRelationship"
|
||||
:items="payRelationshipOptions"
|
||||
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>
|
||||
<CoverageChip
|
||||
:coverage-min-pct="memberCoverage(member).minPct"
|
||||
:coverage-target-pct="memberCoverage(member).targetPct"
|
||||
:member-name="member.displayName || 'This member'"
|
||||
/>
|
||||
</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
|
||||
size="xs"
|
||||
variant="solid"
|
||||
|
|
@ -116,6 +80,78 @@
|
|||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Compact grid for pay and hours -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||
<UFormField label="Pay relationship" required>
|
||||
<USelect
|
||||
v-model="member.payRelationship"
|
||||
:items="payRelationshipOptions"
|
||||
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>
|
||||
|
||||
<!-- Add Member -->
|
||||
|
|
@ -139,14 +175,30 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { coverage } from "~/types/members";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"save-status": [status: "saving" | "saved" | "error"];
|
||||
}>();
|
||||
|
||||
// Store
|
||||
const membersStore = useMembersStore();
|
||||
const { members } = storeToRefs(membersStore);
|
||||
const coop = useCoopBuilder();
|
||||
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
|
||||
const payRelationshipOptions = [
|
||||
|
|
@ -181,7 +233,19 @@ const debouncedSave = useDebounceFn((member: any) => {
|
|||
emit("save-status", "saving");
|
||||
|
||||
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");
|
||||
} catch (error) {
|
||||
console.error("Failed to save member:", error);
|
||||
|
|
@ -208,29 +272,38 @@ function validateAndSavePercentage(value: string, member: any) {
|
|||
saveMember(member);
|
||||
}
|
||||
|
||||
function validateAndSaveAmount(value: string, member: any, field: string) {
|
||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||
member[field] = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||
saveMember(member);
|
||||
}
|
||||
|
||||
function memberCoverage(member: any) {
|
||||
return coverage(
|
||||
member.minMonthlyNeeds || 0,
|
||||
member.targetMonthlyPay || 0,
|
||||
member.monthlyPayPlanned || 0,
|
||||
member.externalMonthlyIncome || 0
|
||||
);
|
||||
}
|
||||
|
||||
function addMember() {
|
||||
const newMember = {
|
||||
id: Date.now().toString(),
|
||||
displayName: "",
|
||||
roleFocus: "", // Hidden but kept for compatibility
|
||||
payRelationship: "FullyPaid",
|
||||
capacity: {
|
||||
minHours: 0,
|
||||
targetHours: 0,
|
||||
maxHours: 0,
|
||||
},
|
||||
riskBand: "Medium", // Hidden but kept with default
|
||||
externalCoveragePct: 50,
|
||||
privacyNeeds: "aggregate_ok",
|
||||
deferredHours: 0,
|
||||
quarterlyDeferredCap: 240,
|
||||
name: "",
|
||||
role: "",
|
||||
hoursPerMonth: 0,
|
||||
minMonthlyNeeds: 0,
|
||||
targetMonthlyPay: 0,
|
||||
externalMonthlyIncome: 0,
|
||||
monthlyPayPlanned: 0,
|
||||
};
|
||||
|
||||
membersStore.upsertMember(newMember);
|
||||
coop.upsertMember(newMember);
|
||||
}
|
||||
|
||||
function removeMember(id: string) {
|
||||
membersStore.removeMember(id);
|
||||
coop.removeMember(id);
|
||||
}
|
||||
|
||||
function exportMembers() {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black mb-2">
|
||||
What's your equal hourly wage?
|
||||
Set your wage & pay policy
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
@ -22,6 +22,55 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pay Policy Selection -->
|
||||
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<h4 class="font-bold mb-4">Pay Allocation Policy</h4>
|
||||
<div class="space-y-3">
|
||||
<label v-for="option in policyOptions" :key="option.value" class="flex items-start gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded-lg transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
v-model="selectedPolicy"
|
||||
@change="updatePolicy(option.value)"
|
||||
class="mt-1 w-4 h-4 text-black border-2 border-gray-300 focus:ring-2 focus:ring-black"
|
||||
/>
|
||||
<span class="text-sm flex-1">{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Role bands editor if role-banded is selected -->
|
||||
<div v-if="selectedPolicy === 'role-banded'" class="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<h5 class="text-sm font-medium mb-3">Role Bands (monthly € or weight)</h5>
|
||||
<div class="space-y-2">
|
||||
<div v-for="member in uniqueRoles" :key="member.role" class="flex items-center gap-2">
|
||||
<span class="text-sm w-32">{{ member.role || 'No role' }}</span>
|
||||
<UInput
|
||||
v-model="roleBands[member.role || '']"
|
||||
type="text"
|
||||
placeholder="3000"
|
||||
size="sm"
|
||||
class="w-24"
|
||||
@update:model-value="updateRoleBands"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
class="mt-4"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-information-circle"
|
||||
>
|
||||
<template #description>
|
||||
Policies affect payroll allocation and member coverage. You can iterate later.
|
||||
</template>
|
||||
</UAlert>
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
|
|
@ -31,11 +80,12 @@
|
|||
class="text-4xl font-black w-full h-20"
|
||||
@update:model-value="validateAndSaveWage">
|
||||
<template #leading>
|
||||
<span class="text-neutral-500 text-3xl">$</span>
|
||||
<span class="text-neutral-500 text-3xl">€</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -44,7 +94,13 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
// 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 {
|
||||
if (typeof val === "number") return val;
|
||||
|
|
@ -56,20 +112,49 @@ function parseNumberInput(val: unknown): number {
|
|||
return 0;
|
||||
}
|
||||
|
||||
// Text input for wage with validation
|
||||
const wageText = ref(
|
||||
policiesStore.equalHourlyWage > 0
|
||||
? policiesStore.equalHourlyWage.toString()
|
||||
: ""
|
||||
);
|
||||
// Pay policy options
|
||||
const policyOptions = [
|
||||
{ value: 'equal-pay', label: 'Equal pay - Everyone gets the same monthly amount' },
|
||||
{ value: 'needs-weighted', label: 'Needs-weighted - Allocate based on minimum needs' },
|
||||
{ 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
|
||||
watch(
|
||||
() => policiesStore.equalHourlyWage,
|
||||
(newWage) => {
|
||||
wageText.value = newWage > 0 ? newWage.toString() : "";
|
||||
// Already initialized above with store values
|
||||
|
||||
const uniqueRoles = computed(() => {
|
||||
const roles = new Set(coop.members.value.map(m => m.role || ''))
|
||||
return Array.from(roles).map(role => ({ role }))
|
||||
})
|
||||
|
||||
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) {
|
||||
const cleanValue = value.replace(/[^\d.]/g, "");
|
||||
|
|
@ -78,56 +163,24 @@ function validateAndSaveWage(value: string) {
|
|||
wageText.value = cleanValue;
|
||||
|
||||
if (!isNaN(numValue) && numValue >= 0) {
|
||||
policiesStore.setEqualWage(numValue);
|
||||
coop.setEqualWage(numValue)
|
||||
|
||||
// Trigger payroll reallocation after wage change
|
||||
const allocatedMembers = coop.allocatePayroll()
|
||||
allocatedMembers.forEach(m => {
|
||||
coop.upsertMember(m)
|
||||
})
|
||||
|
||||
// Set sensible defaults when wage is set
|
||||
if (numValue > 0) {
|
||||
setDefaults();
|
||||
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() {
|
||||
const exportData = {
|
||||
policies: {
|
||||
equalHourlyWage: policiesStore.equalHourlyWage,
|
||||
payrollOncostPct: policiesStore.payrollOncostPct,
|
||||
savingsTargetMonths: policiesStore.savingsTargetMonths,
|
||||
minCashCushionAmount: policiesStore.minCashCushionAmount,
|
||||
deferredCapHoursPerQtr: policiesStore.deferredCapHoursPerQtr,
|
||||
deferredSunsetMonths: policiesStore.deferredSunsetMonths,
|
||||
volunteerScope: policiesStore.volunteerScope,
|
||||
selectedPolicy: coop.policy.value?.relationship || selectedPolicy.value,
|
||||
roleBands: coop.policy.value?.roleBands || roleBands.value,
|
||||
equalHourlyWage: store.equalHourlyWage || parseFloat(wageText.value),
|
||||
},
|
||||
exportedAt: new Date().toISOString(),
|
||||
section: "policies",
|
||||
|
|
|
|||
|
|
@ -137,15 +137,32 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"save-status": [status: "saving" | "saved" | "error"];
|
||||
}>();
|
||||
|
||||
// Store
|
||||
const streamsStore = useStreamsStore();
|
||||
const { streams } = storeToRefs(streamsStore);
|
||||
const coop = useCoopBuilder();
|
||||
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
|
||||
const categoryOptions = [
|
||||
|
|
@ -210,18 +227,16 @@ const debouncedSave = useDebounceFn((stream: any) => {
|
|||
emit("save-status", "saving");
|
||||
|
||||
try {
|
||||
// Set sensible defaults for hidden fields
|
||||
stream.targetPct = 0; // Will be calculated automatically later
|
||||
stream.certainty = "Aspirational";
|
||||
stream.payoutDelayDays = 30; // Default 30 days
|
||||
stream.terms = "Net 30";
|
||||
stream.revenueSharePct = 0;
|
||||
stream.platformFeePct = 0;
|
||||
stream.restrictions = "General";
|
||||
stream.seasonalityWeights = new Array(12).fill(1);
|
||||
stream.effortHoursPerMonth = 0;
|
||||
// Convert component format back to store format
|
||||
const streamData = {
|
||||
id: stream.id,
|
||||
label: stream.name || '',
|
||||
monthly: stream.targetMonthlyAmount || 0,
|
||||
category: stream.category || 'games',
|
||||
certainty: stream.certainty || 'Aspirational'
|
||||
};
|
||||
|
||||
streamsStore.upsertStream(stream);
|
||||
coop.upsertStream(streamData);
|
||||
emit("save-status", "saved");
|
||||
} catch (error) {
|
||||
console.error("Failed to save stream:", error);
|
||||
|
|
@ -245,26 +260,17 @@ function validateAndSaveAmount(value: string, stream: any) {
|
|||
function addRevenueStream() {
|
||||
const newStream = {
|
||||
id: Date.now().toString(),
|
||||
name: "",
|
||||
label: "",
|
||||
monthly: 0,
|
||||
category: "games",
|
||||
subcategory: "",
|
||||
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,
|
||||
certainty: "Aspirational"
|
||||
};
|
||||
|
||||
streamsStore.upsertStream(newStream);
|
||||
coop.upsertStream(newStream);
|
||||
}
|
||||
|
||||
function removeStream(id: string) {
|
||||
streamsStore.removeStream(id);
|
||||
coop.removeStream(id);
|
||||
}
|
||||
|
||||
function exportStreams() {
|
||||
|
|
|
|||
|
|
@ -208,6 +208,34 @@
|
|||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Team Coverage Summary -->
|
||||
<div class="bg-white border-2 border-black rounded-lg p-4 mb-4">
|
||||
<h4 class="font-medium text-sm mb-3">Team Coverage (min needs)</h4>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
:name="teamStats.under100 === 0 ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
|
||||
:class="teamStats.under100 === 0 ? 'text-green-500' : 'text-yellow-500'"
|
||||
class="w-4 h-4" />
|
||||
<span>
|
||||
<strong>{{ teamStats.under100 }}</strong> under 100%
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="teamStats.median" class="flex items-center gap-1">
|
||||
<span class="text-neutral-600">Median:</span>
|
||||
<strong>{{ Math.round(teamStats.median) }}%</strong>
|
||||
</div>
|
||||
<div v-if="teamStats.gini !== undefined" class="flex items-center gap-1">
|
||||
<span class="text-neutral-600">Gini:</span>
|
||||
<strong>{{ teamStats.gini.toFixed(2) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="teamStats.under100 > 0" class="mt-3 p-2 bg-yellow-50 rounded text-xs text-yellow-800">
|
||||
Consider more needs-weighting or a smaller headcount to ensure everyone's minimum needs are met.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status -->
|
||||
<div class="bg-neutral-50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-sm mb-3">Setup Status</h4>
|
||||
|
|
@ -304,29 +332,28 @@ const emit = defineEmits<{
|
|||
reset: [];
|
||||
}>();
|
||||
|
||||
// Stores
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
// Store
|
||||
const coop = useCoopBuilder();
|
||||
|
||||
// Computed data
|
||||
const members = computed(() => membersStore.members);
|
||||
const members = computed(() => coop.members.value);
|
||||
const teamStats = computed(() => coop.teamCoverageStats());
|
||||
const policies = computed(() => ({
|
||||
equalHourlyWage: policiesStore.equalHourlyWage,
|
||||
payrollOncostPct: policiesStore.payrollOncostPct,
|
||||
savingsTargetMonths: policiesStore.savingsTargetMonths,
|
||||
minCashCushionAmount: policiesStore.minCashCushionAmount,
|
||||
deferredCapHoursPerQtr: policiesStore.deferredCapHoursPerQtr,
|
||||
volunteerScope: policiesStore.volunteerScope,
|
||||
// TODO: Get actual policy data from centralized store
|
||||
equalHourlyWage: 0,
|
||||
payrollOncostPct: 0,
|
||||
savingsTargetMonths: 0,
|
||||
minCashCushionAmount: 0,
|
||||
deferredCapHoursPerQtr: 0,
|
||||
volunteerScope: { allowedFlows: [] },
|
||||
}));
|
||||
const overheadCosts = computed(() => budgetStore.overheadCosts);
|
||||
const streams = computed(() => streamsStore.streams);
|
||||
const overheadCosts = computed(() => []);
|
||||
const streams = computed(() => coop.streams.value);
|
||||
|
||||
// Validation
|
||||
const membersValid = computed(() => membersStore.isValid);
|
||||
const policiesValid = computed(() => policiesStore.isValid);
|
||||
const streamsValid = computed(() => streamsStore.hasValidStreams);
|
||||
const membersValid = computed(() => coop.members.value.length > 0);
|
||||
const policiesValid = computed(() => true); // TODO: Add validation
|
||||
const streamsValid = computed(() => coop.streams.value.length > 0);
|
||||
const canComplete = computed(
|
||||
() => membersValid.value && policiesValid.value && streamsValid.value
|
||||
);
|
||||
|
|
@ -349,7 +376,9 @@ const totalMonthlyCosts = computed(() =>
|
|||
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(() =>
|
||||
Math.round(
|
||||
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
|
||||
|
|
|
|||
98
components/advanced/MilestonesPanel.vue
Normal file
98
components/advanced/MilestonesPanel.vue
Normal 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>
|
||||
49
components/advanced/ScenariosPanel.vue
Normal file
49
components/advanced/ScenariosPanel.vue
Normal 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>
|
||||
93
components/advanced/StressTestPanel.vue
Normal file
93
components/advanced/StressTestPanel.vue
Normal 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>
|
||||
186
components/dashboard/AdvancedAccordion.vue
Normal file
186
components/dashboard/AdvancedAccordion.vue
Normal 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>
|
||||
15
components/dashboard/DashboardCoreMetrics.vue
Normal file
15
components/dashboard/DashboardCoreMetrics.vue
Normal 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>
|
||||
56
components/dashboard/MemberCoveragePanel.vue
Normal file
56
components/dashboard/MemberCoveragePanel.vue
Normal 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>
|
||||
37
components/dashboard/NeedsCoverageCard.vue
Normal file
37
components/dashboard/NeedsCoverageCard.vue
Normal 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>
|
||||
56
components/dashboard/RevenueMixCard.vue
Normal file
56
components/dashboard/RevenueMixCard.vue
Normal 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>
|
||||
62
components/dashboard/RunwayCard.vue
Normal file
62
components/dashboard/RunwayCard.vue
Normal 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>
|
||||
41
components/shared/CoverageBar.vue
Normal file
41
components/shared/CoverageBar.vue
Normal 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>
|
||||
36
components/shared/OperatingModeToggle.vue
Normal file
36
components/shared/OperatingModeToggle.vue
Normal 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>
|
||||
364
composables/useCoopBuilder.ts
Normal file
364
composables/useCoopBuilder.ts
Normal 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
|
||||
}
|
||||
}
|
||||
92
composables/useCushionForecast.ts
Normal file
92
composables/useCushionForecast.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -33,10 +33,13 @@ export function useFixtureIO() {
|
|||
payrollOncostPct: policies.payrollOncostPct,
|
||||
savingsTargetMonths: policies.savingsTargetMonths,
|
||||
minCashCushionAmount: policies.minCashCushionAmount,
|
||||
operatingMode: policies.operatingMode,
|
||||
payPolicy: policies.payPolicy,
|
||||
deferredCapHoursPerQtr: policies.deferredCapHoursPerQtr,
|
||||
deferredSunsetMonths: policies.deferredSunsetMonths,
|
||||
surplusOrder: policies.surplusOrder,
|
||||
paymentPriority: policies.paymentPriority
|
||||
paymentPriority: policies.paymentPriority,
|
||||
volunteerScope: policies.volunteerScope
|
||||
},
|
||||
streams: streams.streams,
|
||||
budget: {
|
||||
|
|
@ -66,9 +69,65 @@ export function useFixtureIO() {
|
|||
}
|
||||
|
||||
const importAll = (snapshot: AppSnapshot) => {
|
||||
// TODO: Implement import functionality for all stores
|
||||
// This will patch each store with the snapshot data
|
||||
console.log('Import functionality to be implemented', snapshot)
|
||||
const members = useMembersStore()
|
||||
const policies = usePoliciesStore()
|
||||
const streams = useStreamsStore()
|
||||
const budget = useBudgetStore()
|
||||
const scenarios = useScenariosStore()
|
||||
const cash = useCashStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
try {
|
||||
// Import members
|
||||
if (snapshot.members && Array.isArray(snapshot.members)) {
|
||||
members.$patch({ members: snapshot.members })
|
||||
}
|
||||
|
||||
// Import policies
|
||||
if (snapshot.policies) {
|
||||
policies.$patch(snapshot.policies)
|
||||
}
|
||||
|
||||
// Import streams
|
||||
if (snapshot.streams && Array.isArray(snapshot.streams)) {
|
||||
streams.$patch({ streams: snapshot.streams })
|
||||
}
|
||||
|
||||
// Import budget
|
||||
if (snapshot.budget) {
|
||||
budget.$patch(snapshot.budget)
|
||||
}
|
||||
|
||||
// Import scenarios
|
||||
if (snapshot.scenarios) {
|
||||
scenarios.$patch(snapshot.scenarios)
|
||||
}
|
||||
|
||||
// Import cash
|
||||
if (snapshot.cash) {
|
||||
cash.updateCurrentBalances(
|
||||
snapshot.cash.currentCash || 0,
|
||||
snapshot.cash.currentSavings || 0
|
||||
)
|
||||
// Handle cash events and payment queue if present
|
||||
if (snapshot.cash.cashEvents) {
|
||||
cash.$patch({ cashEvents: snapshot.cash.cashEvents })
|
||||
}
|
||||
if (snapshot.cash.paymentQueue) {
|
||||
cash.$patch({ paymentQueue: snapshot.cash.paymentQueue })
|
||||
}
|
||||
}
|
||||
|
||||
// Import session
|
||||
if (snapshot.session) {
|
||||
session.$patch(snapshot.session)
|
||||
}
|
||||
|
||||
console.log('Successfully imported data snapshot')
|
||||
} catch (error) {
|
||||
console.error('Failed to import snapshot:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return { exportAll, importAll }
|
||||
|
|
|
|||
|
|
@ -1,238 +1,73 @@
|
|||
/**
|
||||
* Composable for loading and managing fixture data
|
||||
* Provides centralized access to demo data for all screens
|
||||
* DISABLED: All fixture data removed to prevent automatic demo data
|
||||
* This composable previously loaded sample data but is now empty
|
||||
*/
|
||||
export const useFixtures = () => {
|
||||
// Load fixture data (in real app, this would come from API or stores)
|
||||
// All sample data functions now return empty data
|
||||
const loadMembers = async () => {
|
||||
// In production, this would fetch from content/fixtures/members.json
|
||||
// For now, return inline data that matches the fixture structure
|
||||
return {
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
displayName: 'Alex Chen',
|
||||
roleFocus: 'Technical Lead',
|
||||
payRelationship: 'Hybrid',
|
||||
capacity: { minHours: 20, targetHours: 120, maxHours: 160 },
|
||||
riskBand: 'Medium',
|
||||
externalCoveragePct: 60,
|
||||
privacyNeeds: 'aggregate_ok',
|
||||
deferredHours: 85,
|
||||
quarterlyDeferredCap: 240
|
||||
},
|
||||
{
|
||||
id: 'member-2',
|
||||
displayName: 'Jordan Silva',
|
||||
roleFocus: 'Design & UX',
|
||||
payRelationship: 'FullyPaid',
|
||||
capacity: { minHours: 30, targetHours: 140, maxHours: 180 },
|
||||
riskBand: 'Low',
|
||||
externalCoveragePct: 20,
|
||||
privacyNeeds: 'aggregate_ok',
|
||||
deferredHours: 0,
|
||||
quarterlyDeferredCap: 240
|
||||
},
|
||||
{
|
||||
id: 'member-3',
|
||||
displayName: 'Sam Rodriguez',
|
||||
roleFocus: 'Operations & Growth',
|
||||
payRelationship: 'Supplemental',
|
||||
capacity: { minHours: 10, targetHours: 60, maxHours: 100 },
|
||||
riskBand: 'High',
|
||||
externalCoveragePct: 85,
|
||||
privacyNeeds: 'steward_only',
|
||||
deferredHours: 32,
|
||||
quarterlyDeferredCap: 120
|
||||
}
|
||||
]
|
||||
members: []
|
||||
}
|
||||
}
|
||||
|
||||
const loadStreams = async () => {
|
||||
return {
|
||||
revenueStreams: [
|
||||
{
|
||||
id: 'stream-1',
|
||||
name: 'Client Services',
|
||||
category: 'Services',
|
||||
subcategory: 'Development',
|
||||
targetPct: 65,
|
||||
targetMonthlyAmount: 13000,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 30,
|
||||
terms: 'Net 30',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 180
|
||||
},
|
||||
{
|
||||
id: 'stream-2',
|
||||
name: 'Platform Sales',
|
||||
category: 'Product',
|
||||
subcategory: 'Digital Tools',
|
||||
targetPct: 20,
|
||||
targetMonthlyAmount: 4000,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 14,
|
||||
terms: 'Platform payout',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 5,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 40
|
||||
},
|
||||
{
|
||||
id: 'stream-3',
|
||||
name: 'Innovation Grant',
|
||||
category: 'Grant',
|
||||
subcategory: 'Government',
|
||||
targetPct: 10,
|
||||
targetMonthlyAmount: 2000,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 45,
|
||||
terms: 'Quarterly disbursement',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'Restricted',
|
||||
effortHoursPerMonth: 8
|
||||
},
|
||||
{
|
||||
id: 'stream-4',
|
||||
name: 'Community Donations',
|
||||
category: 'Donation',
|
||||
subcategory: 'Individual',
|
||||
targetPct: 3,
|
||||
targetMonthlyAmount: 600,
|
||||
certainty: 'Aspirational',
|
||||
payoutDelayDays: 3,
|
||||
terms: 'Immediate',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 2.9,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 5
|
||||
},
|
||||
{
|
||||
id: 'stream-5',
|
||||
name: 'Consulting & Training',
|
||||
category: 'Other',
|
||||
subcategory: 'Professional Services',
|
||||
targetPct: 2,
|
||||
targetMonthlyAmount: 400,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 21,
|
||||
terms: 'Net 21',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 12
|
||||
}
|
||||
]
|
||||
revenueStreams: []
|
||||
}
|
||||
}
|
||||
|
||||
const loadFinances = async () => {
|
||||
return {
|
||||
currentBalances: {
|
||||
cash: 5000,
|
||||
savings: 8000,
|
||||
totalLiquid: 13000
|
||||
cash: 0,
|
||||
savings: 0,
|
||||
totalLiquid: 0
|
||||
},
|
||||
policies: {
|
||||
equalHourlyWage: 20,
|
||||
payrollOncostPct: 25,
|
||||
savingsTargetMonths: 3,
|
||||
minCashCushionAmount: 3000,
|
||||
deferredCapHoursPerQtr: 240,
|
||||
deferredSunsetMonths: 12
|
||||
equalHourlyWage: 0,
|
||||
payrollOncostPct: 0,
|
||||
savingsTargetMonths: 0,
|
||||
minCashCushionAmount: 0,
|
||||
deferredCapHoursPerQtr: 0,
|
||||
deferredSunsetMonths: 0
|
||||
},
|
||||
deferredLiabilities: {
|
||||
totalDeferred: 2340,
|
||||
byMember: {
|
||||
'member-1': 1700,
|
||||
'member-2': 0,
|
||||
'member-3': 640
|
||||
}
|
||||
totalDeferred: 0,
|
||||
byMember: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadCosts = async () => {
|
||||
return {
|
||||
overheadCosts: [
|
||||
{
|
||||
id: 'overhead-1',
|
||||
name: 'Coworking Space',
|
||||
amount: 0,
|
||||
category: 'Workspace',
|
||||
recurring: true
|
||||
},
|
||||
{
|
||||
id: 'overhead-2',
|
||||
name: 'Tools & Software',
|
||||
amount: 0,
|
||||
category: 'Technology',
|
||||
recurring: true
|
||||
},
|
||||
{
|
||||
id: 'overhead-3',
|
||||
name: 'Business Insurance',
|
||||
amount: 0,
|
||||
category: 'Legal & Compliance',
|
||||
recurring: true
|
||||
}
|
||||
],
|
||||
productionCosts: [
|
||||
{
|
||||
id: 'production-1',
|
||||
name: 'Development Kits',
|
||||
amount: 0,
|
||||
category: 'Hardware',
|
||||
period: '2024-01'
|
||||
}
|
||||
]
|
||||
overheadCosts: [],
|
||||
productionCosts: []
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate derived metrics from fixture data
|
||||
// Return empty metrics
|
||||
const calculateMetrics = async () => {
|
||||
const [members, streams, finances, costs] = await Promise.all([
|
||||
loadMembers(),
|
||||
loadStreams(),
|
||||
loadFinances(),
|
||||
loadCosts()
|
||||
])
|
||||
|
||||
const totalTargetHours = members.members.reduce((sum, member) =>
|
||||
sum + member.capacity.targetHours, 0
|
||||
)
|
||||
|
||||
const totalTargetRevenue = streams.revenueStreams.reduce((sum, stream) =>
|
||||
sum + stream.targetMonthlyAmount, 0
|
||||
)
|
||||
|
||||
const totalOverheadCosts = costs.overheadCosts.reduce((sum, cost) =>
|
||||
sum + cost.amount, 0
|
||||
)
|
||||
|
||||
const monthlyPayroll = totalTargetHours * finances.policies.equalHourlyWage *
|
||||
(1 + finances.policies.payrollOncostPct / 100)
|
||||
|
||||
const monthlyBurn = monthlyPayroll + totalOverheadCosts +
|
||||
costs.productionCosts.reduce((sum, cost) => sum + cost.amount, 0)
|
||||
|
||||
const runway = finances.currentBalances.totalLiquid / monthlyBurn
|
||||
|
||||
return {
|
||||
totalTargetHours,
|
||||
totalTargetRevenue,
|
||||
monthlyPayroll,
|
||||
monthlyBurn,
|
||||
runway,
|
||||
members: members.members,
|
||||
streams: streams.revenueStreams,
|
||||
finances: finances,
|
||||
costs: costs
|
||||
totalTargetHours: 0,
|
||||
totalTargetRevenue: 0,
|
||||
monthlyPayroll: 0,
|
||||
monthlyBurn: 0,
|
||||
runway: 0,
|
||||
members: [],
|
||||
streams: [],
|
||||
finances: {
|
||||
currentBalances: { cash: 0, savings: 0, totalLiquid: 0 },
|
||||
policies: {
|
||||
equalHourlyWage: 0,
|
||||
payrollOncostPct: 0,
|
||||
savingsTargetMonths: 0,
|
||||
minCashCushionAmount: 0,
|
||||
deferredCapHoursPerQtr: 0,
|
||||
deferredSunsetMonths: 0
|
||||
},
|
||||
deferredLiabilities: { totalDeferred: 0, byMember: {} }
|
||||
},
|
||||
costs: { overheadCosts: [], productionCosts: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
142
composables/useMigrations.ts
Normal file
142
composables/useMigrations.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
52
composables/usePayrollAllocation.ts
Normal file
52
composables/usePayrollAllocation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,49 @@
|
|||
import { monthlyPayroll } from '~/types/members'
|
||||
|
||||
/**
|
||||
* Computes months of runway from cash, reserves, and burn rate
|
||||
* Formula: (cash + savings) ÷ average monthly burn in scenario
|
||||
*/
|
||||
export const useRunway = () => {
|
||||
const membersStore = useMembersStore()
|
||||
const policiesStore = usePoliciesStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
|
||||
const calculateRunway = (cash: number, savings: number, monthlyBurn: number): number => {
|
||||
if (monthlyBurn <= 0) return Infinity
|
||||
return (cash + savings) / monthlyBurn
|
||||
}
|
||||
|
||||
// Calculate monthly burn based on operating mode
|
||||
const getMonthlyBurn = (mode?: 'minimum' | 'target') => {
|
||||
const operatingMode = mode || policiesStore.operatingMode || 'minimum'
|
||||
|
||||
// Get payroll costs based on mode
|
||||
const payrollCost = monthlyPayroll(membersStore.members, operatingMode)
|
||||
|
||||
// Add oncosts
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0
|
||||
const totalPayroll = payrollCost * (1 + oncostPct / 100)
|
||||
|
||||
// Add overhead costs
|
||||
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
|
||||
|
||||
return totalPayroll + overheadCost
|
||||
}
|
||||
|
||||
// Calculate runway for both modes
|
||||
const getDualModeRunway = (cash: number, savings: number) => {
|
||||
const minBurn = getMonthlyBurn('minimum')
|
||||
const targetBurn = getMonthlyBurn('target')
|
||||
|
||||
return {
|
||||
minimum: calculateRunway(cash, savings, minBurn),
|
||||
target: calculateRunway(cash, savings, targetBurn),
|
||||
minBurn,
|
||||
targetBurn
|
||||
}
|
||||
}
|
||||
|
||||
const getRunwayStatus = (months: number): 'green' | 'yellow' | 'red' => {
|
||||
if (months >= 3) return 'green'
|
||||
if (months >= 2) return 'yellow'
|
||||
|
|
@ -22,6 +58,8 @@ export const useRunway = () => {
|
|||
return {
|
||||
calculateRunway,
|
||||
getRunwayStatus,
|
||||
formatRunway
|
||||
formatRunway,
|
||||
getMonthlyBurn,
|
||||
getDualModeRunway
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
composables/useScenarios.ts
Normal file
109
composables/useScenarios.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
6
middleware/redirect-dashboard.global.ts
Normal file
6
middleware/redirect-dashboard.global.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default defineNuxtRouteMiddleware((to) => {
|
||||
// Redirect root path to dashboard
|
||||
if (to.path === '/') {
|
||||
return navigateTo('/dashboard')
|
||||
}
|
||||
})
|
||||
|
|
@ -1,24 +1,51 @@
|
|||
export default defineNuxtRouteMiddleware((to) => {
|
||||
// Skip middleware for coop-planner, wizards, templates, and API routes
|
||||
// Allowlist: routes that should always be accessible before setup
|
||||
if (
|
||||
to.path === "/coop-planner" ||
|
||||
to.path === "/coop-builder" ||
|
||||
to.path === "/wizards" ||
|
||||
to.path.startsWith("/templates") ||
|
||||
to.path.startsWith("/coach") ||
|
||||
to.path.startsWith("/api/")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only guard core dashboards until setup is complete
|
||||
const protectedRoutes = new Set([
|
||||
"/dashboard",
|
||||
"/dashboard-simple",
|
||||
"/budget",
|
||||
"/mix",
|
||||
"/cash",
|
||||
]);
|
||||
|
||||
if (!protectedRoutes.has(to.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use actual store state to determine whether setup is complete
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const coopStore = useCoopBuilderStore?.();
|
||||
|
||||
const setupComplete =
|
||||
// Legacy stores OR new coop builder store (either is enough)
|
||||
const legacyComplete =
|
||||
membersStore.isValid &&
|
||||
policiesStore.isValid &&
|
||||
streamsStore.hasValidStreams;
|
||||
|
||||
const coopComplete = Boolean(
|
||||
coopStore &&
|
||||
Array.isArray(coopStore.members) &&
|
||||
coopStore.members.length > 0 &&
|
||||
Array.isArray(coopStore.streams) &&
|
||||
coopStore.streams.length > 0
|
||||
);
|
||||
|
||||
const setupComplete = legacyComplete || coopComplete;
|
||||
|
||||
if (!setupComplete) {
|
||||
return navigateTo("/coop-planner");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -357,30 +357,15 @@
|
|||
</UModal>
|
||||
|
||||
<!-- Helper Modal -->
|
||||
<UModal v-model:open="showHelperModal" :ui="{ wrapper: 'sm:max-w-lg' }">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 modal-header">
|
||||
Quick Entry Tools
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 modal-header">
|
||||
{{ selectedItemDetails?.label || "Budget item" }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
@click="showHelperModal = false"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
class="-mr-2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UModal
|
||||
v-model:open="showHelperModal"
|
||||
:ui="{ wrapper: 'sm:max-w-lg', footer: 'justify-end' }"
|
||||
title="Quick Entry Tools"
|
||||
:description="selectedItemDetails?.label || 'Budget item'"
|
||||
>
|
||||
<template #body>
|
||||
<div class="isolate">
|
||||
<UTabs :items="helperTabs" class="w-full">
|
||||
<UTabs v-model="activeHelperTab" :items="helperTabs" class="w-full">
|
||||
<template #content="{ item }">
|
||||
<!-- Annual Distribution Content -->
|
||||
<div v-if="item.key === 'annual'" class="pt-4 space-y-4">
|
||||
|
|
@ -399,18 +384,6 @@
|
|||
<p class="text-sm text-gray-600">
|
||||
This will divide the amount equally across all 12 months
|
||||
</p>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<UButton @click="showHelperModal = false" variant="ghost" color="neutral">
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="distributeAnnualAmount"
|
||||
:disabled="!helperConfig.annualAmount || helperConfig.annualAmount <= 0"
|
||||
color="primary"
|
||||
>
|
||||
Distribute Amount
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Amount Content -->
|
||||
|
|
@ -430,25 +403,31 @@
|
|||
<p class="text-sm text-gray-600">
|
||||
This will set the same value for all months
|
||||
</p>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<UButton @click="showHelperModal = false" variant="ghost" color="neutral">
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="setAllMonths"
|
||||
:disabled="
|
||||
!helperConfig.monthlyAmount || helperConfig.monthlyAmount <= 0
|
||||
"
|
||||
color="primary"
|
||||
>
|
||||
Apply to All Months
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ close }">
|
||||
<UButton @click="close" variant="outline" color="neutral"> Cancel </UButton>
|
||||
<UButton
|
||||
v-if="activeHelperTab === 0"
|
||||
@click="distributeAnnualAmount"
|
||||
:disabled="!helperConfig.annualAmount || helperConfig.annualAmount <= 0"
|
||||
color="primary"
|
||||
>
|
||||
Distribute Amount
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
@click="setAllMonths"
|
||||
:disabled="!helperConfig.monthlyAmount || helperConfig.monthlyAmount <= 0"
|
||||
color="primary"
|
||||
>
|
||||
Apply to All Months
|
||||
</UButton>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- Add Expense Modal -->
|
||||
|
|
@ -525,8 +504,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Store
|
||||
// Stores
|
||||
const budgetStore = useBudgetStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
|
||||
// State
|
||||
const showAddRevenueModal = ref(false);
|
||||
|
|
@ -567,6 +549,7 @@ const selectedItemDetails = computed(() => {
|
|||
});
|
||||
|
||||
// Helper tabs configuration
|
||||
const activeHelperTab = ref(0); // UTabs uses index, not key
|
||||
const helperTabs = [
|
||||
{
|
||||
key: "annual",
|
||||
|
|
@ -641,9 +624,9 @@ const allBudgetItems = computed(() => {
|
|||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!budgetStore.isInitialized) {
|
||||
// Always re-initialize to get latest wizard data
|
||||
budgetStore.isInitialized = false;
|
||||
await budgetStore.initializeFromWizardData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing budget page:", error);
|
||||
}
|
||||
|
|
@ -807,7 +790,9 @@ function resetWorksheet() {
|
|||
if (confirm("Are you sure you want to reset all budget data? This cannot be undone.")) {
|
||||
budgetStore.resetBudgetWorksheet();
|
||||
budgetStore.isInitialized = false;
|
||||
nextTick(() => {
|
||||
budgetStore.initializeFromWizardData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,18 +20,6 @@
|
|||
>
|
||||
Skip coach → Streams
|
||||
</button>
|
||||
<button
|
||||
@click="loadSampleData"
|
||||
class="px-4 py-2 text-sm bg-blue-50 border-2 border-blue-200 rounded-lg text-blue-700 hover:bg-blue-100 hover:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
:aria-label="'Load sample data to see example offers'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Load sample data
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -384,74 +372,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import {
|
||||
membersSample,
|
||||
skillsCatalogSample,
|
||||
problemsCatalogSample,
|
||||
sampleSelections
|
||||
} from "~/sample/skillsToOffersSamples";
|
||||
// REMOVED: All sample data imports to prevent demo data
|
||||
|
||||
// Store integration
|
||||
const planStore = usePlanStore();
|
||||
|
||||
// Initialize with default data
|
||||
const members = ref<Member[]>([
|
||||
{ id: "1", name: "Alex Chen", role: "Game Designer", hourly: 75, availableHrs: 30 },
|
||||
{ id: "2", name: "Jordan Smith", role: "Developer", hourly: 80, availableHrs: 35 },
|
||||
{ id: "3", name: "Sam Rodriguez", role: "Artist", hourly: 70, availableHrs: 25 }
|
||||
]);
|
||||
// Initialize with empty data
|
||||
const members = ref<Member[]>([]);
|
||||
|
||||
const availableSkills = ref<SkillTag[]>([
|
||||
{ id: "unity", label: "Unity Development" },
|
||||
{ id: "art", label: "2D/3D Art" },
|
||||
{ id: "design", label: "Game Design" },
|
||||
{ id: "audio", label: "Audio Design" },
|
||||
{ id: "writing", label: "Narrative Writing" },
|
||||
{ id: "marketing", label: "Marketing" },
|
||||
{ id: "business", label: "Business Strategy" },
|
||||
{ id: "web", label: "Web Development" },
|
||||
{ id: "mobile", label: "Mobile Development" },
|
||||
{ id: "consulting", label: "Technical Consulting" }
|
||||
]);
|
||||
const availableSkills = ref<SkillTag[]>([]);
|
||||
|
||||
const availableProblems = ref<ProblemTag[]>([
|
||||
{
|
||||
id: "indie-games",
|
||||
label: "Indie game development",
|
||||
examples: [
|
||||
"Small studios needing extra development capacity",
|
||||
"Solo developers wanting art/audio support",
|
||||
"Teams needing game design consultation"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "corporate-training",
|
||||
label: "Corporate training games",
|
||||
examples: [
|
||||
"Companies wanting engaging employee training",
|
||||
"HR departments needing onboarding tools",
|
||||
"Safety training for industrial workers"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "educational",
|
||||
label: "Educational technology",
|
||||
examples: [
|
||||
"Schools needing interactive learning tools",
|
||||
"Universities wanting research simulations",
|
||||
"Non-profits creating awareness campaigns"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "prototypes",
|
||||
label: "Rapid prototyping",
|
||||
examples: [
|
||||
"Startups validating game concepts",
|
||||
"Publishers testing market fit",
|
||||
"Researchers creating proof-of-concepts"
|
||||
]
|
||||
}
|
||||
]);
|
||||
const availableProblems = ref<ProblemTag[]>([]);
|
||||
|
||||
// Set members in store on component mount
|
||||
onMounted(() => {
|
||||
|
|
@ -512,25 +443,6 @@ function updateLanguageToCoopTerms(text: string): string {
|
|||
.replace(/productivity/gi, 'shared capacity');
|
||||
}
|
||||
|
||||
// Sample data loading
|
||||
function loadSampleData() {
|
||||
// Replace data with samples
|
||||
members.value = [...membersSample];
|
||||
availableSkills.value = [...skillsCatalogSample];
|
||||
availableProblems.value = [...problemsCatalogSample];
|
||||
|
||||
// Set pre-selected skills and problems
|
||||
selectedSkills.value = { ...sampleSelections.selectedSkillsByMember };
|
||||
selectedProblems.value = [...sampleSelections.selectedProblems];
|
||||
|
||||
// Update store with new members
|
||||
planStore.setMembers(members.value);
|
||||
|
||||
// Trigger offer generation immediately
|
||||
nextTick(() => {
|
||||
debouncedGenerateOffers();
|
||||
});
|
||||
}
|
||||
|
||||
// Debounced offer generation
|
||||
const debouncedGenerateOffers = useDebounceFn(async () => {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
<!-- Header -->
|
||||
<div class="mb-10 text-center">
|
||||
<h1
|
||||
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4"
|
||||
>
|
||||
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4">
|
||||
Co-op Builder
|
||||
</h1>
|
||||
</div>
|
||||
|
|
@ -18,16 +17,15 @@
|
|||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8"
|
||||
>
|
||||
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
|
||||
<div
|
||||
class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<UIcon name="i-heroicons-check" class="w-8 h-8 text-white dark:text-black" />
|
||||
class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4">
|
||||
<UIcon
|
||||
name="i-heroicons-check"
|
||||
class="w-8 h-8 text-white dark:text-black" />
|
||||
</div>
|
||||
<h2
|
||||
class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide">
|
||||
You're all set!
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-6">
|
||||
|
|
@ -35,7 +33,10 @@
|
|||
</p>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<button class="export-btn" @click="restartWizard" :disabled="isResetting">
|
||||
<button
|
||||
class="export-btn"
|
||||
@click="restartWizard"
|
||||
:disabled="isResetting">
|
||||
Start Over
|
||||
</button>
|
||||
<button class="export-btn primary" @click="navigateTo('/budget')">
|
||||
|
|
@ -52,40 +53,34 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 1"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
|
||||
focusedStep === 1 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(1)"
|
||||
>
|
||||
@click="setFocusedStep(1)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||
:class="
|
||||
membersStore.isValid
|
||||
membersValid
|
||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
||||
"
|
||||
>
|
||||
">
|
||||
<UIcon
|
||||
v-if="membersStore.isValid"
|
||||
v-if="membersValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
class="w-4 h-4" />
|
||||
<span v-else>1</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Add your team
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -93,15 +88,13 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 1 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 1 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 1"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardMembersStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -112,40 +105,34 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 2"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
|
||||
focusedStep === 2 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(2)"
|
||||
>
|
||||
@click="setFocusedStep(2)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||
:class="
|
||||
policiesStore.isValid
|
||||
policiesValid
|
||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
||||
"
|
||||
>
|
||||
">
|
||||
<UIcon
|
||||
v-if="policiesStore.isValid"
|
||||
v-if="policiesValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
class="w-4 h-4" />
|
||||
<span v-else>2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Set your wage
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -153,15 +140,13 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 2 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 2 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 2"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardPoliciesStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -172,30 +157,25 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 3"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
|
||||
focusedStep === 3 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(3)"
|
||||
>
|
||||
@click="setFocusedStep(3)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white"
|
||||
>
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white">
|
||||
<UIcon name="i-heroicons-check" class="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Monthly costs
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -203,15 +183,13 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 3 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 3 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 3"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardCostsStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -222,40 +200,34 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 4"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
|
||||
focusedStep === 4 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(4)"
|
||||
>
|
||||
@click="setFocusedStep(4)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||
:class="
|
||||
streamsStore.hasValidStreams
|
||||
streamsValid
|
||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
||||
"
|
||||
>
|
||||
">
|
||||
<UIcon
|
||||
v-if="streamsStore.hasValidStreams"
|
||||
v-if="streamsValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
class="w-4 h-4" />
|
||||
<span v-else>4</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Revenue streams
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -263,15 +235,13 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 4 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 4 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 4"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardRevenueStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -282,19 +252,16 @@
|
|||
<!-- Dithered shadow for selected state -->
|
||||
<div
|
||||
v-if="focusedStep === 5"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
|
||||
focusedStep === 5 ? 'item-selected' : '',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
@click="setFocusedStep(5)"
|
||||
>
|
||||
@click="setFocusedStep(5)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
|
|
@ -303,15 +270,16 @@
|
|||
canComplete
|
||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
||||
"
|
||||
>
|
||||
<UIcon v-if="canComplete" name="i-heroicons-check" class="w-4 h-4" />
|
||||
">
|
||||
<UIcon
|
||||
v-if="canComplete"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>5</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
|
||||
>
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Review & finish
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -319,52 +287,57 @@
|
|||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 5 }"
|
||||
/>
|
||||
:class="{ 'rotate-180': focusedStep === 5 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedStep === 5"
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
|
||||
>
|
||||
<WizardReviewStep @complete="completeWizard" @reset="resetWizard" />
|
||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||
<WizardReviewStep
|
||||
@complete="completeWizard"
|
||||
@reset="resetWizard" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Actions -->
|
||||
<div class="flex justify-between items-center pt-8">
|
||||
<button class="export-btn" @click="resetWizard" :disabled="isResetting">
|
||||
<button
|
||||
class="export-btn"
|
||||
@click="resetWizard"
|
||||
:disabled="isResetting">
|
||||
Start Over
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Save status -->
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide"
|
||||
>
|
||||
class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide">
|
||||
<UIcon
|
||||
v-if="saveStatus === 'saving'"
|
||||
name="i-heroicons-arrow-path"
|
||||
class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400"
|
||||
/>
|
||||
class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400" />
|
||||
<UIcon
|
||||
v-if="saveStatus === 'saved'"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-4 h-4 text-black dark:text-white"
|
||||
/>
|
||||
class="w-4 h-4 text-black dark:text-white" />
|
||||
<span
|
||||
v-if="saveStatus === 'saving'"
|
||||
class="text-neutral-500 dark:text-neutral-400"
|
||||
>Saving...</span
|
||||
>
|
||||
<span v-if="saveStatus === 'saved'" class="text-black dark:text-white"
|
||||
<span
|
||||
v-if="saveStatus === 'saved'"
|
||||
class="text-black dark:text-white"
|
||||
>Saved</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<button v-if="canComplete" class="export-btn primary" @click="completeWizard">
|
||||
<button
|
||||
v-if="canComplete"
|
||||
class="export-btn primary"
|
||||
@click="completeWizard">
|
||||
Complete Setup
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -375,12 +348,8 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Stores
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const coopBuilderStore = useCoopBuilderStore();
|
||||
// Store
|
||||
const coop = useCoopBuilder();
|
||||
|
||||
// UI state
|
||||
const focusedStep = ref(1);
|
||||
|
|
@ -389,9 +358,36 @@ const isResetting = ref(false);
|
|||
const isCompleted = ref(false);
|
||||
|
||||
// Computed validation
|
||||
const canComplete = computed(
|
||||
() => membersStore.isValid && policiesStore.isValid && streamsStore.hasValidStreams
|
||||
const canComplete = computed(() => {
|
||||
return coop.members.value.length > 0 && coop.streams.value.length > 0;
|
||||
});
|
||||
|
||||
// Local validity flags for step headers
|
||||
const membersValid = computed(() => {
|
||||
// Valid if at least one member with a name and positive hours
|
||||
return coop.members.value.some((m: any) => {
|
||||
const hasName = typeof m.name === "string" && m.name.trim().length > 0;
|
||||
const hours = Number((m as any).hoursPerMonth ?? 0);
|
||||
return hasName && Number.isFinite(hours) && hours > 0;
|
||||
});
|
||||
});
|
||||
|
||||
const policiesValid = computed(() => {
|
||||
// Placeholder policy validity; mark true when wage text or policy set exists
|
||||
// Since policy not persisted yet in this store, consider valid when any member exists
|
||||
return membersValid.value;
|
||||
});
|
||||
|
||||
const streamsValid = computed(() => {
|
||||
// Valid if all streams have name, category, and non-negative monthly
|
||||
return (
|
||||
coop.streams.value.length > 0 &&
|
||||
coop.streams.value.every((s: any) => {
|
||||
const monthly = Number((s as any).monthly ?? 0);
|
||||
return (s.label || "").toString().trim().length > 0 && monthly >= 0;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Save status handler
|
||||
function handleSaveStatus(status: "saving" | "saved" | "error") {
|
||||
|
|
@ -424,14 +420,8 @@ function completeWizard() {
|
|||
async function resetWizard() {
|
||||
isResetting.value = true;
|
||||
|
||||
// Reset all stores
|
||||
membersStore.resetMembers();
|
||||
policiesStore.resetPolicies();
|
||||
streamsStore.resetStreams();
|
||||
budgetStore.resetBudgetOverhead();
|
||||
|
||||
// Reset coop builder state
|
||||
coopBuilderStore.reset();
|
||||
// Reset centralized store
|
||||
coop.reset();
|
||||
saveStatus.value = "";
|
||||
|
||||
// Small delay for UX
|
||||
|
|
@ -446,12 +436,8 @@ async function restartWizard() {
|
|||
isCompleted.value = false;
|
||||
focusedStep.value = 1;
|
||||
|
||||
// Reset all stores and coop builder state
|
||||
membersStore.resetMembers();
|
||||
policiesStore.resetPolicies();
|
||||
streamsStore.resetStreams();
|
||||
budgetStore.resetBudgetOverhead();
|
||||
coopBuilderStore.reset();
|
||||
// Reset centralized store
|
||||
coop.reset();
|
||||
saveStatus.value = "";
|
||||
|
||||
// Small delay for UX
|
||||
|
|
|
|||
112
pages/dashboard-simple.vue
Normal file
112
pages/dashboard-simple.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
||||
<div class="text-sm text-gray-600">
|
||||
Mode: {{ currentMode }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple Core Metrics -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Core Metrics</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ runwayDisplay }}</div>
|
||||
<div class="text-sm text-gray-600">Runway</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ coverageDisplay }}</div>
|
||||
<div class="text-sm text-gray-600">Coverage</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-purple-600">{{ streamCount }}</div>
|
||||
<div class="text-sm text-gray-600">Revenue Streams</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Simple Member List -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Members ({{ memberCount }})</h3>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(member, index) in membersList" :key="index" class="flex items-center justify-between p-2 border border-gray-200 rounded">
|
||||
<span class="font-medium">{{ member.name }}</span>
|
||||
<span class="text-sm text-gray-600">{{ member.relationship }}</span>
|
||||
</div>
|
||||
<div v-if="memberCount === 0" class="text-sm text-gray-500 italic p-4">
|
||||
No members configured yet.
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Value Accounting Section -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium">Value Accounting</h3>
|
||||
<UBadge color="blue" variant="subtle">January 2024</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
Next Value Session due January 2024
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<UProgress :value="50" :max="100" color="blue" class="w-32" />
|
||||
<span class="text-sm text-gray-600">2/4 prep steps done</span>
|
||||
</div>
|
||||
</div>
|
||||
<UButton color="primary" @click="navigateTo('/session')">
|
||||
Start Session
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Simple reactive data without complex computations
|
||||
const currentMode = ref('minimum')
|
||||
const runwayDisplay = ref('∞')
|
||||
const coverageDisplay = ref('100%')
|
||||
const streamCount = ref(0)
|
||||
const memberCount = ref(0)
|
||||
const membersList = ref([])
|
||||
|
||||
// Try to initialize with store data
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Simple store access without composable
|
||||
const membersStore = useMembersStore()
|
||||
const streamsStore = useStreamsStore()
|
||||
const policiesStore = usePoliciesStore()
|
||||
|
||||
// Update reactive values
|
||||
currentMode.value = policiesStore.operatingMode || 'minimum'
|
||||
memberCount.value = membersStore.members?.length || 0
|
||||
streamCount.value = streamsStore.streams?.length || 0
|
||||
|
||||
// Simple member list
|
||||
membersList.value = membersStore.members?.map(m => ({
|
||||
name: m.displayName || 'Unknown',
|
||||
relationship: m.payRelationship || 'Unknown'
|
||||
})) || []
|
||||
|
||||
console.log('Dashboard initialized:', {
|
||||
mode: currentMode.value,
|
||||
members: memberCount.value,
|
||||
streams: streamCount.value
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing simple dashboard:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
48
pages/dashboard.vue
Normal file
48
pages/dashboard.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 space-y-8" data-ui="dashboard_v1">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">Min</span>
|
||||
<UToggle
|
||||
:model-value="operatingMode === 'target'"
|
||||
@update:model-value="(value) => setOperatingMode(value ? 'target' : 'min')"
|
||||
/>
|
||||
<span class="text-sm text-gray-600">Target</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Metrics -->
|
||||
<DashboardCoreMetrics />
|
||||
|
||||
<!-- Member Coverage -->
|
||||
<MemberCoveragePanel />
|
||||
|
||||
<!-- Advanced Tools -->
|
||||
<AdvancedAccordion />
|
||||
|
||||
<!-- Next Session -->
|
||||
<UCard class="shadow-sm rounded-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-semibold">Next Value Session</h3>
|
||||
<p class="text-sm text-gray-600">Review contributions and distribute surplus</p>
|
||||
</div>
|
||||
<UButton color="primary" @click="navigateTo('/session')">
|
||||
Start Session
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Import components explicitly to avoid auto-import issues
|
||||
import DashboardCoreMetrics from '~/components/dashboard/DashboardCoreMetrics.vue'
|
||||
import MemberCoveragePanel from '~/components/dashboard/MemberCoveragePanel.vue'
|
||||
import AdvancedAccordion from '~/components/dashboard/AdvancedAccordion.vue'
|
||||
|
||||
// Access composable data
|
||||
const { operatingMode, setOperatingMode } = useCoopBuilder()
|
||||
</script>
|
||||
458
pages/index.vue
458
pages/index.vue
|
|
@ -1,7 +1,20 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">Dashboard</h2>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<UBadge
|
||||
:color="policiesStore.operatingMode === 'target' ? 'primary' : 'gray'"
|
||||
size="xs"
|
||||
>
|
||||
{{ policiesStore.operatingMode === 'target' ? '🎯 Target Mode' : '⚡ Min Mode' }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500">
|
||||
Runway: {{ Math.round(metrics.runway) }}mo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-down-tray"
|
||||
|
|
@ -29,10 +42,10 @@
|
|||
description="Funded hours vs target capacity across all members." />
|
||||
|
||||
<ReserveMeter
|
||||
:current-savings="metrics.finances.currentBalances.savings"
|
||||
:savings-target-months="metrics.finances.policies.savingsTargetMonths"
|
||||
:monthly-burn="metrics.monthlyBurn"
|
||||
description="Build savings to your target before increasing paid hours." />
|
||||
:current-savings="savingsProgress.current"
|
||||
:savings-target-months="savingsProgress.targetMonths"
|
||||
:monthly-burn="getMonthlyBurn()"
|
||||
:description="`${savingsProgress.progressPct.toFixed(0)}% of savings target reached. ${savingsProgress.gap > 0 ? 'Gap: ' + $format.currency(savingsProgress.gap) : 'Target achieved!'}`" />
|
||||
|
||||
<UCard>
|
||||
<div class="text-center space-y-3">
|
||||
|
|
@ -58,43 +71,98 @@
|
|||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Quick Wins Dashboard Components -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Needs Coverage Bars -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Member Coverage</h3>
|
||||
</template>
|
||||
<NeedsCoverageBars />
|
||||
</UCard>
|
||||
|
||||
<!-- Revenue Mix -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Revenue Mix</h3>
|
||||
</template>
|
||||
<RevenueMixTable />
|
||||
</UCard>
|
||||
|
||||
<!-- Milestone-Runway Overlay -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Runway vs Milestones</h3>
|
||||
</template>
|
||||
<MilestoneRunwayOverlay />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Alerts Section -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Alerts</h3>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<!-- Concentration Risk Alert -->
|
||||
<UAlert
|
||||
v-if="topSourcePct > 50"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
title="Revenue Concentration Risk"
|
||||
description="Most of your money comes from one place. Add another stream to reduce risk."
|
||||
:actions="[{ label: 'Plan Mix', click: () => navigateTo('/mix') }]" />
|
||||
:description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
|
||||
:actions="[
|
||||
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') },
|
||||
{ label: 'Scenarios', click: () => handleAlertNavigation('/scenarios', 'diversification') }
|
||||
]" />
|
||||
|
||||
<!-- Cushion Breach Alert -->
|
||||
<UAlert
|
||||
v-if="alerts.cushionBreach"
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-calendar"
|
||||
title="Cash Cushion Breach Forecast"
|
||||
:description="cashBreachDescription"
|
||||
:description="`Projected to breach minimum cushion in week ${cushionForecast.firstBreachWeek || 'unknown'}`"
|
||||
:actions="[
|
||||
{ label: 'View Calendar', click: () => navigateTo('/cash') },
|
||||
{ label: 'View Calendar', click: () => handleAlertNavigation('/cash', 'breach-forecast') },
|
||||
{ label: 'Adjust Budget', click: () => handleAlertNavigation('/budget', 'expenses') }
|
||||
]" />
|
||||
|
||||
<!-- Savings Below Target Alert -->
|
||||
<UAlert
|
||||
v-if="alerts.savingsBelowTarget"
|
||||
color="yellow"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-banknotes"
|
||||
title="Savings Below Target"
|
||||
description="Build savings to your target before increasing paid hours."
|
||||
:description="`${savingsProgress.progressPct.toFixed(0)}% of target reached. Build savings before increasing paid hours.`"
|
||||
:actions="[
|
||||
{ label: 'View Progress', click: () => navigateTo('/budget') },
|
||||
{ label: 'View Progress', click: () => handleAlertNavigation('/budget', 'savings') },
|
||||
{ label: 'Adjust Policies', click: () => handleAlertNavigation('/coop-builder', 'policies') }
|
||||
]" />
|
||||
|
||||
<!-- Over-Deferred Member Alert -->
|
||||
<UAlert
|
||||
color="amber"
|
||||
v-if="deferredAlert.show"
|
||||
color="purple"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-clock"
|
||||
title="Over-Deferred Member"
|
||||
description="Alex has reached 85% of quarterly deferred cap." />
|
||||
icon="i-heroicons-user-group"
|
||||
title="Member Over-Deferred"
|
||||
:description="deferredAlert.description"
|
||||
:actions="[
|
||||
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
|
||||
{ label: 'Value Session', click: () => handleAlertNavigation('/session', 'distributions') }
|
||||
]" />
|
||||
|
||||
<!-- Success message when no alerts -->
|
||||
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
|
||||
class="text-center py-8 text-gray-500">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-8 h-8 mx-auto mb-2 text-green-500" />
|
||||
<p class="font-medium">All systems looking good!</p>
|
||||
<p class="text-sm">No critical alerts at this time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
|
|
@ -104,37 +172,46 @@
|
|||
<h3 class="text-lg font-medium">Scenario Snapshots</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Current Scenario -->
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">Operate Current</h4>
|
||||
<UBadge color="green" variant="subtle" size="xs">Active</UBadge>
|
||||
<h4 class="font-medium text-sm">{{ scenarios.current.name }}</h4>
|
||||
<UBadge color="green" variant="subtle" size="xs">{{ scenarios.current.status }}</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-orange-600 mb-1">
|
||||
{{ scenarioMetrics.current.runway }} months
|
||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.current.runway)">
|
||||
{{ Math.round(scenarios.current.runway * 10) / 10 }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">Continue existing plan</p>
|
||||
<p class="text-xs text-neutral-600">
|
||||
Net: {{ $format.currency(scenarios.current.monthlyNet) }}/mo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quit Jobs Scenario -->
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">Quit Day Jobs</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge>
|
||||
<h4 class="font-medium text-sm">{{ scenarios.quitJobs.name }}</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.quitJobs.status }}</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-red-600 mb-1">
|
||||
{{ scenarioMetrics.quitJobs.runway }} months
|
||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.quitJobs.runway)">
|
||||
{{ Math.round(scenarios.quitJobs.runway * 10) / 10 }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">Full-time co-op work</p>
|
||||
<p class="text-xs text-neutral-600">
|
||||
Net: {{ $format.currency(scenarios.quitJobs.monthlyNet) }}/mo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Start Production Scenario -->
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">Start Production</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge>
|
||||
<h4 class="font-medium text-sm">{{ scenarios.startProduction.name }}</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.startProduction.status }}</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-yellow-600 mb-1">
|
||||
{{ scenarioMetrics.startProduction.runway }} months
|
||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.startProduction.runway)">
|
||||
{{ Math.round(scenarios.startProduction.runway * 10) / 10 }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">Launch development</p>
|
||||
<p class="text-xs text-neutral-600">
|
||||
Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
|
@ -214,6 +291,100 @@
|
|||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Advanced Planning Panel -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium">Advanced Planning</h3>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
:icon="showAdvanced ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
|
||||
>
|
||||
{{ showAdvanced ? 'Hide' : 'Show' }} Advanced
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-show="showAdvanced" class="space-y-6">
|
||||
<!-- Stress Tests -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h4 class="font-medium mb-3">Stress Tests</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Revenue Delay (months)</label>
|
||||
<UInput
|
||||
v-model="stressTests.revenueDelay"
|
||||
type="number"
|
||||
min="0"
|
||||
max="6"
|
||||
size="sm"
|
||||
@input="updateStressTest"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Cost Shock (%)</label>
|
||||
<UInput
|
||||
v-model="stressTests.costShockPct"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
size="sm"
|
||||
@input="updateStressTest"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Major Grant Lost</label>
|
||||
<UToggle
|
||||
v-model="stressTests.grantLost"
|
||||
@update:model-value="updateStressTest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stress Test Results -->
|
||||
<div v-if="hasStressTest" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 class="font-medium text-yellow-800">Stress Test Results</h5>
|
||||
<p class="text-sm text-yellow-700">
|
||||
Runway under stress: {{ Math.round(stressedRunway * 10) / 10 }} months
|
||||
({{ Math.round((stressedRunway - metrics.runway) * 10) / 10 }} month change)
|
||||
</p>
|
||||
</div>
|
||||
<UButton size="xs" @click="applyStressTest">Apply to Plan</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policy Sandbox -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<h4 class="font-medium mb-3">Policy Sandbox</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Try different pay relationships without overwriting your current plan.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Test Pay Policy</label>
|
||||
<USelect
|
||||
v-model="sandboxPolicy"
|
||||
:options="policyOptions"
|
||||
size="sm"
|
||||
@update:model-value="updateSandboxPolicy"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="sandboxRunway">
|
||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Projected Runway</label>
|
||||
<div class="text-lg font-bold" :class="getRunwayColor(sandboxRunway)">
|
||||
{{ Math.round(sandboxRunway * 10) / 10 }} months
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<UButton
|
||||
|
|
@ -274,6 +445,34 @@ const streamsStore = useStreamsStore();
|
|||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
|
||||
// Runway composable with operating mode integration
|
||||
const { getDualModeRunway, getMonthlyBurn } = useRunway();
|
||||
|
||||
// Cushion forecast and savings progress
|
||||
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
|
||||
|
||||
// Scenario calculations
|
||||
const { scenarios } = useScenarios();
|
||||
|
||||
// Advanced panel state
|
||||
const showAdvanced = ref(false);
|
||||
|
||||
// Stress testing
|
||||
const stressTests = ref({
|
||||
revenueDelay: 0,
|
||||
costShockPct: 0,
|
||||
grantLost: false
|
||||
});
|
||||
|
||||
// Policy sandbox
|
||||
const sandboxPolicy = ref('equal-pay');
|
||||
const policyOptions = [
|
||||
{ label: 'Equal Pay', value: 'equal-pay' },
|
||||
{ label: 'Needs Weighted', value: 'needs-weighted' },
|
||||
{ label: 'Hours Weighted', value: 'hours-weighted' },
|
||||
{ label: 'Role Banded', value: 'role-banded' }
|
||||
];
|
||||
|
||||
// Calculate metrics from real store data
|
||||
const metrics = computed(() => {
|
||||
const totalTargetHours = membersStore.members.reduce(
|
||||
|
|
@ -291,24 +490,26 @@ const metrics = computed(() => {
|
|||
0
|
||||
);
|
||||
|
||||
const monthlyPayroll =
|
||||
totalTargetHours *
|
||||
policiesStore.equalHourlyWage *
|
||||
(1 + policiesStore.payrollOncostPct / 100);
|
||||
// Use integrated runway calculations that respect operating mode
|
||||
const currentMode = policiesStore.operatingMode || 'minimum';
|
||||
const monthlyBurn = getMonthlyBurn(currentMode);
|
||||
|
||||
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
|
||||
const totalLiquid = cashStore.currentCash + cashStore.currentSavings;
|
||||
|
||||
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0;
|
||||
// Get dual-mode runway data
|
||||
const runwayData = getDualModeRunway(cash, savings);
|
||||
const runway = currentMode === 'target' ? runwayData.target : runwayData.minimum;
|
||||
|
||||
return {
|
||||
totalTargetHours,
|
||||
totalTargetRevenue,
|
||||
monthlyPayroll,
|
||||
monthlyPayroll: runwayData.minBurn, // Use actual calculated payroll
|
||||
monthlyBurn,
|
||||
runway,
|
||||
runwayData, // Include dual-mode data
|
||||
finances: {
|
||||
currentBalances: {
|
||||
cash: cashStore.currentCash,
|
||||
|
|
@ -346,6 +547,14 @@ const topSourcePct = computed(() => {
|
|||
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
|
||||
});
|
||||
|
||||
const topStreamName = computed(() => {
|
||||
if (streamsStore.streams.length === 0) return 'No streams';
|
||||
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
|
||||
const maxAmount = Math.max(...amounts);
|
||||
const topStream = streamsStore.streams.find(s => (s.targetMonthlyAmount || 0) === maxAmount);
|
||||
return topStream?.name || 'Unknown stream';
|
||||
});
|
||||
|
||||
const concentrationStatus = computed(() => {
|
||||
if (topSourcePct.value > 50) return "red";
|
||||
if (topSourcePct.value > 35) return "yellow";
|
||||
|
|
@ -358,6 +567,12 @@ const concentrationColor = computed(() => {
|
|||
return "text-green-600";
|
||||
});
|
||||
|
||||
function getRunwayColor(months: number): string {
|
||||
if (months >= 6) return 'text-green-600'
|
||||
if (months >= 3) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
// Calculate scenario metrics
|
||||
const scenarioMetrics = computed(() => {
|
||||
const baseRunway = metrics.value.runway;
|
||||
|
|
@ -413,4 +628,169 @@ const onImport = async () => {
|
|||
};
|
||||
|
||||
const { exportAll, importAll } = useFixtureIO();
|
||||
|
||||
// Advanced panel computed properties and methods
|
||||
const hasStressTest = computed(() => {
|
||||
return stressTests.value.revenueDelay > 0 ||
|
||||
stressTests.value.costShockPct > 0 ||
|
||||
stressTests.value.grantLost;
|
||||
});
|
||||
|
||||
const stressedRunway = computed(() => {
|
||||
if (!hasStressTest.value) return metrics.value.runway;
|
||||
|
||||
const cash = cashStore.currentCash || 50000;
|
||||
const savings = cashStore.currentSavings || 15000;
|
||||
|
||||
// Apply stress test adjustments
|
||||
let adjustedRevenue = streamsStore.streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0);
|
||||
let adjustedCosts = getMonthlyBurn();
|
||||
|
||||
// Revenue delay impact (reduce revenue by delay percentage)
|
||||
if (stressTests.value.revenueDelay > 0) {
|
||||
adjustedRevenue *= Math.max(0, 1 - (stressTests.value.revenueDelay / 12));
|
||||
}
|
||||
|
||||
// Cost shock impact
|
||||
if (stressTests.value.costShockPct > 0) {
|
||||
adjustedCosts *= (1 + stressTests.value.costShockPct / 100);
|
||||
}
|
||||
|
||||
// Grant lost (remove largest revenue stream if it's a grant)
|
||||
if (stressTests.value.grantLost) {
|
||||
const grantStreams = streamsStore.streams.filter(s =>
|
||||
s.category?.toLowerCase().includes('grant') ||
|
||||
s.name.toLowerCase().includes('grant')
|
||||
);
|
||||
if (grantStreams.length > 0) {
|
||||
const largestGrant = Math.max(...grantStreams.map(s => s.targetMonthlyAmount || 0));
|
||||
adjustedRevenue -= largestGrant;
|
||||
}
|
||||
}
|
||||
|
||||
const netMonthly = adjustedRevenue - adjustedCosts;
|
||||
const burnRate = netMonthly < 0 ? Math.abs(netMonthly) : adjustedCosts;
|
||||
|
||||
return burnRate > 0 ? (cash + savings) / burnRate : Infinity;
|
||||
});
|
||||
|
||||
const sandboxRunway = computed(() => {
|
||||
if (!sandboxPolicy.value || sandboxPolicy.value === policiesStore.payPolicy?.relationship) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate runway with sandbox policy
|
||||
const cash = cashStore.currentCash || 50000;
|
||||
const savings = cashStore.currentSavings || 15000;
|
||||
|
||||
// Create sandbox policy object
|
||||
const testPolicy = {
|
||||
relationship: sandboxPolicy.value,
|
||||
equalHourlyWage: policiesStore.equalHourlyWage,
|
||||
roleBands: policiesStore.payPolicy?.roleBands || []
|
||||
};
|
||||
|
||||
// Use scenario calculation with sandbox policy
|
||||
const { calculateScenarioRunway } = useScenarios();
|
||||
const result = calculateScenarioRunway(membersStore.members, streamsStore.streams);
|
||||
|
||||
// Apply simple adjustment based on policy type
|
||||
let policyMultiplier = 1;
|
||||
switch (sandboxPolicy.value) {
|
||||
case 'needs-weighted':
|
||||
policyMultiplier = 0.9; // Slightly higher costs
|
||||
break;
|
||||
case 'role-banded':
|
||||
policyMultiplier = 0.85; // Higher costs due to senior roles
|
||||
break;
|
||||
case 'hours-weighted':
|
||||
policyMultiplier = 0.95; // Moderate increase
|
||||
break;
|
||||
}
|
||||
|
||||
return result.runway * policyMultiplier;
|
||||
});
|
||||
|
||||
function updateStressTest() {
|
||||
// Reactive computed will handle updates automatically
|
||||
}
|
||||
|
||||
function updateSandboxPolicy() {
|
||||
// Reactive computed will handle updates automatically
|
||||
}
|
||||
|
||||
function applyStressTest() {
|
||||
// Apply stress test adjustments to the actual plan
|
||||
if (stressTests.value.revenueDelay > 0) {
|
||||
// Reduce all stream targets by delay impact
|
||||
streamsStore.streams.forEach(stream => {
|
||||
const reduction = (stressTests.value.revenueDelay / 12) * (stream.targetMonthlyAmount || 0);
|
||||
streamsStore.updateStream(stream.id, {
|
||||
targetMonthlyAmount: Math.max(0, (stream.targetMonthlyAmount || 0) - reduction)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (stressTests.value.costShockPct > 0) {
|
||||
// Increase overhead costs
|
||||
const shockMultiplier = 1 + (stressTests.value.costShockPct / 100);
|
||||
budgetStore.overheadCosts.forEach(cost => {
|
||||
budgetStore.updateOverheadCost(cost.id, {
|
||||
amount: (cost.amount || 0) * shockMultiplier
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (stressTests.value.grantLost) {
|
||||
// Remove or reduce grant streams
|
||||
const grantStreams = streamsStore.streams.filter(s =>
|
||||
s.category?.toLowerCase().includes('grant') ||
|
||||
s.name.toLowerCase().includes('grant')
|
||||
);
|
||||
if (grantStreams.length > 0) {
|
||||
const largestGrant = grantStreams.reduce((prev, current) =>
|
||||
(prev.targetMonthlyAmount || 0) > (current.targetMonthlyAmount || 0) ? prev : current
|
||||
);
|
||||
streamsStore.updateStream(largestGrant.id, { targetMonthlyAmount: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Reset stress tests
|
||||
stressTests.value = { revenueDelay: 0, costShockPct: 0, grantLost: false };
|
||||
};
|
||||
|
||||
// Deferred alert logic
|
||||
const deferredAlert = computed(() => {
|
||||
const maxDeferredRatio = 1.5; // From CLAUDE.md - flag if >1.5× monthly payroll
|
||||
const monthlyPayrollCost = getMonthlyBurn() * 0.7; // Estimate payroll as 70% of burn
|
||||
const totalDeferred = membersStore.members.reduce(
|
||||
(sum, m) => sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
|
||||
0
|
||||
);
|
||||
|
||||
const deferredRatio = monthlyPayrollCost > 0 ? totalDeferred / monthlyPayrollCost : 0;
|
||||
const show = deferredRatio > maxDeferredRatio;
|
||||
|
||||
const overDeferredMembers = membersStore.members.filter(m => {
|
||||
const memberDeferred = (m.deferredHours || 0) * policiesStore.equalHourlyWage;
|
||||
const memberMonthlyPay = m.monthlyPayPlanned || 0;
|
||||
return memberDeferred > memberMonthlyPay * 2; // Member has >2 months of pay deferred
|
||||
});
|
||||
|
||||
return {
|
||||
show,
|
||||
description: show
|
||||
? `${overDeferredMembers.length} member(s) over deferred cap. Total: ${(deferredRatio * 100).toFixed(0)}% of monthly payroll.`
|
||||
: ''
|
||||
};
|
||||
});
|
||||
|
||||
// Alert navigation with context
|
||||
function handleAlertNavigation(path: string, section?: string) {
|
||||
// Store alert context for target page to highlight relevant section
|
||||
if (section) {
|
||||
localStorage.setItem('urgent-tools-alert-context', JSON.stringify({ section, timestamp: Date.now() }));
|
||||
}
|
||||
navigateTo(path);
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { defineNuxtPlugin } from "#app";
|
||||
import { createPersistedState } from "pinia-plugin-persistedstate";
|
||||
import { defineNuxtPlugin } from '#app'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// Register persisted state plugin for Pinia on client
|
||||
nuxtApp.$pinia.use(createPersistedState());
|
||||
});
|
||||
nuxtApp.$pinia.use(piniaPluginPersistedstate)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
};
|
||||
253
stores/budget.ts
253
stores/budget.ts
|
|
@ -1,4 +1,5 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { allocatePayroll } from "~/types/members";
|
||||
|
||||
export const useBudgetStore = defineStore(
|
||||
"budget",
|
||||
|
|
@ -69,8 +70,26 @@ export const useBudgetStore = defineStore(
|
|||
"Other Expenses",
|
||||
]);
|
||||
|
||||
// Define budget item type
|
||||
interface BudgetItem {
|
||||
id: string;
|
||||
name: string;
|
||||
mainCategory: string;
|
||||
subcategory: string;
|
||||
source: string;
|
||||
monthlyValues: Record<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)
|
||||
const budgetWorksheet = ref({
|
||||
const budgetWorksheet = ref<{
|
||||
revenue: BudgetItem[];
|
||||
expenses: BudgetItem[];
|
||||
}>({
|
||||
revenue: [],
|
||||
expenses: [],
|
||||
});
|
||||
|
|
@ -271,6 +290,30 @@ export const useBudgetStore = defineStore(
|
|||
currentPeriod.value = period;
|
||||
}
|
||||
|
||||
// Helper function to map stream category to budget category
|
||||
function mapStreamToBudgetCategory(streamCategory) {
|
||||
const categoryLower = (streamCategory || "").toLowerCase();
|
||||
|
||||
// More comprehensive category mapping
|
||||
if (categoryLower.includes("game") || categoryLower.includes("product")) {
|
||||
return "Games & Products";
|
||||
} else if (categoryLower.includes("service") || categoryLower.includes("consulting")) {
|
||||
return "Services & Contracts";
|
||||
} else if (categoryLower.includes("grant") || categoryLower.includes("funding")) {
|
||||
return "Grants & Funding";
|
||||
} else if (categoryLower.includes("community") || categoryLower.includes("donation")) {
|
||||
return "Community Support";
|
||||
} else if (categoryLower.includes("partnership")) {
|
||||
return "Partnerships";
|
||||
} else if (categoryLower.includes("investment")) {
|
||||
return "Investment Income";
|
||||
} else if (categoryLower.includes("other")) {
|
||||
return "Services & Contracts"; // Map "Other" to services as fallback
|
||||
} else {
|
||||
return "Games & Products"; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize worksheet from wizard data
|
||||
async function initializeFromWizardData() {
|
||||
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
|
||||
|
|
@ -280,62 +323,35 @@ export const useBudgetStore = defineStore(
|
|||
|
||||
console.log("Initializing budget from wizard data...");
|
||||
|
||||
// Import stores dynamically to avoid circular deps
|
||||
const { useStreamsStore } = await import("./streams");
|
||||
const { useMembersStore } = await import("./members");
|
||||
const { usePoliciesStore } = await import("./policies");
|
||||
try {
|
||||
// Use the new coopBuilder store instead of the old stores
|
||||
const coopStore = useCoopBuilderStore();
|
||||
|
||||
const streamsStore = useStreamsStore();
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
|
||||
console.log("Streams:", streamsStore.streams.length, "streams");
|
||||
console.log("Members capacity:", membersStore.capacityTotals);
|
||||
console.log("Policies wage:", policiesStore.equalHourlyWage);
|
||||
console.log("Streams:", coopStore.streams.length, "streams");
|
||||
console.log("Members:", coopStore.members.length, "members");
|
||||
console.log("Equal wage:", coopStore.equalHourlyWage || "No wage set");
|
||||
console.log("Overhead costs:", coopStore.overheadCosts.length, "costs");
|
||||
|
||||
// Clear existing data
|
||||
budgetWorksheet.value.revenue = [];
|
||||
budgetWorksheet.value.expenses = [];
|
||||
|
||||
// Add revenue streams from wizard
|
||||
if (streamsStore.streams.length === 0) {
|
||||
console.log("No wizard streams found, adding sample data");
|
||||
// Initialize with minimal demo if no wizard data exists
|
||||
await streamsStore.initializeWithFixtures();
|
||||
}
|
||||
// Add revenue streams from wizard (but don't auto-load fixtures)
|
||||
// Note: We don't auto-load fixtures anymore, but wizard data should still work
|
||||
|
||||
streamsStore.streams.forEach((stream) => {
|
||||
const monthlyAmount = stream.targetMonthlyAmount || 0;
|
||||
coopStore.streams.forEach((stream) => {
|
||||
const monthlyAmount = stream.monthly || 0;
|
||||
console.log(
|
||||
"Adding stream:",
|
||||
stream.name,
|
||||
stream.label,
|
||||
"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
|
||||
let mappedCategory = "Games & Products"; // Default
|
||||
const categoryLower = (stream.category || "").toLowerCase();
|
||||
if (categoryLower === "games" || categoryLower === "product")
|
||||
mappedCategory = "Games & Products";
|
||||
else if (categoryLower === "services" || categoryLower === "service")
|
||||
mappedCategory = "Services & Contracts";
|
||||
else if (categoryLower === "grants" || categoryLower === "grant")
|
||||
mappedCategory = "Grants & Funding";
|
||||
else if (categoryLower === "community")
|
||||
mappedCategory = "Community Support";
|
||||
else if (
|
||||
categoryLower === "partnerships" ||
|
||||
categoryLower === "partnership"
|
||||
)
|
||||
mappedCategory = "Partnerships";
|
||||
else if (categoryLower === "investment")
|
||||
mappedCategory = "Investment Income";
|
||||
// Use the helper function for category mapping
|
||||
const mappedCategory = mapStreamToBudgetCategory(stream.category);
|
||||
|
||||
console.log(
|
||||
"Mapped category from",
|
||||
|
|
@ -345,7 +361,7 @@ export const useBudgetStore = defineStore(
|
|||
);
|
||||
|
||||
// Create monthly values - split the annual target evenly across 12 months
|
||||
const monthlyValues = {};
|
||||
const monthlyValues: Record<string, number> = {};
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
|
|
@ -356,16 +372,16 @@ export const useBudgetStore = defineStore(
|
|||
}
|
||||
console.log(
|
||||
"Created monthly values for",
|
||||
stream.name,
|
||||
stream.label,
|
||||
":",
|
||||
monthlyValues
|
||||
);
|
||||
|
||||
budgetWorksheet.value.revenue.push({
|
||||
id: `revenue-${stream.id}`,
|
||||
name: stream.name,
|
||||
name: stream.label,
|
||||
mainCategory: mappedCategory,
|
||||
subcategory: stream.subcategory || "Direct sales", // Use actual subcategory from stream
|
||||
subcategory: "Direct sales", // Default subcategory for coopStore streams
|
||||
source: "wizard",
|
||||
monthlyValues,
|
||||
values: {
|
||||
|
|
@ -388,15 +404,20 @@ export const useBudgetStore = defineStore(
|
|||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
// Add payroll from wizard data using the allocatePayroll function
|
||||
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
||||
const hourlyWage = coopStore.equalHourlyWage || 0;
|
||||
const oncostPct = coopStore.payrollOncostPct || 0;
|
||||
|
||||
// Calculate total payroll budget (before oncosts)
|
||||
const basePayrollBudget = totalHours * hourlyWage;
|
||||
|
||||
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
||||
// Calculate total with oncosts
|
||||
const monthlyPayroll = basePayrollBudget * (1 + oncostPct / 100);
|
||||
|
||||
// Create monthly values for payroll
|
||||
const monthlyValues = {};
|
||||
const monthlyValues: Record<string, number> = {};
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
|
|
@ -434,22 +455,22 @@ export const useBudgetStore = defineStore(
|
|||
}
|
||||
|
||||
// Add overhead costs from wizard
|
||||
overheadCosts.value.forEach((cost) => {
|
||||
if (cost.amount > 0) {
|
||||
coopStore.overheadCosts.forEach((cost) => {
|
||||
if (cost.amount && cost.amount > 0) {
|
||||
const annualAmount = cost.amount * 12;
|
||||
// Map overhead cost categories to expense categories
|
||||
let expenseCategory = "Other Expenses"; // Default
|
||||
if (cost.category === "Operations")
|
||||
expenseCategory = "Office & Operations";
|
||||
else if (cost.category === "Technology")
|
||||
else if (cost.category === "Tools")
|
||||
expenseCategory = "Equipment & Technology";
|
||||
else if (cost.category === "Legal")
|
||||
else if (cost.category === "Professional")
|
||||
expenseCategory = "Legal & Professional";
|
||||
else if (cost.category === "Marketing")
|
||||
expenseCategory = "Marketing & Outreach";
|
||||
|
||||
// Create monthly values for overhead costs
|
||||
const monthlyValues = {};
|
||||
const monthlyValues: Record<string, number> = {};
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
|
|
@ -487,105 +508,19 @@ export const useBudgetStore = defineStore(
|
|||
}
|
||||
});
|
||||
|
||||
// Add production costs from wizard
|
||||
productionCosts.value.forEach((cost) => {
|
||||
if (cost.amount > 0) {
|
||||
const annualAmount = cost.amount * 12;
|
||||
// Create monthly values for production costs
|
||||
const monthlyValues = {};
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
const monthKey = `${date.getFullYear()}-${String(
|
||||
date.getMonth() + 1
|
||||
).padStart(2, "0")}`;
|
||||
monthlyValues[monthKey] = cost.amount;
|
||||
}
|
||||
// Production costs are handled within overhead costs in the new architecture
|
||||
|
||||
budgetWorksheet.value.expenses.push({
|
||||
id: `expense-${cost.id}`,
|
||||
name: cost.name,
|
||||
mainCategory: "Development Costs",
|
||||
subcategory: cost.name, // Use the cost name as subcategory
|
||||
source: "wizard",
|
||||
monthlyValues,
|
||||
values: {
|
||||
year1: {
|
||||
best: annualAmount,
|
||||
worst: annualAmount * 0.7,
|
||||
mostLikely: annualAmount * 0.9,
|
||||
},
|
||||
year2: {
|
||||
best: annualAmount * 1.2,
|
||||
worst: annualAmount * 0.8,
|
||||
mostLikely: annualAmount,
|
||||
},
|
||||
year3: {
|
||||
best: annualAmount * 1.3,
|
||||
worst: annualAmount * 0.9,
|
||||
mostLikely: annualAmount * 1.1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// DISABLED: No sample data - budget should start empty
|
||||
// if (budgetWorksheet.value.revenue.length === 0) {
|
||||
// console.log("Adding sample revenue line");
|
||||
// // ... sample revenue creation code removed
|
||||
// }
|
||||
|
||||
// If still no data after initialization, add a sample row
|
||||
if (budgetWorksheet.value.revenue.length === 0) {
|
||||
console.log("Adding sample revenue line");
|
||||
// Create monthly values for sample revenue
|
||||
const monthlyValues = {};
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
const monthKey = `${date.getFullYear()}-${String(
|
||||
date.getMonth() + 1
|
||||
).padStart(2, "0")}`;
|
||||
monthlyValues[monthKey] = 667; // ~8000/12
|
||||
}
|
||||
|
||||
budgetWorksheet.value.revenue.push({
|
||||
id: "revenue-sample",
|
||||
name: "Sample Revenue",
|
||||
mainCategory: "Games & Products",
|
||||
subcategory: "Direct sales",
|
||||
source: "user",
|
||||
monthlyValues,
|
||||
values: {
|
||||
year1: { best: 10000, worst: 5000, mostLikely: 8000 },
|
||||
year2: { best: 12000, worst: 6000, mostLikely: 10000 },
|
||||
year3: { best: 15000, worst: 8000, mostLikely: 12000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (budgetWorksheet.value.expenses.length === 0) {
|
||||
console.log("Adding sample expense line");
|
||||
// Create monthly values for sample expense
|
||||
const monthlyValues = {};
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
const monthKey = `${date.getFullYear()}-${String(
|
||||
date.getMonth() + 1
|
||||
).padStart(2, "0")}`;
|
||||
monthlyValues[monthKey] = 67; // ~800/12
|
||||
}
|
||||
|
||||
budgetWorksheet.value.expenses.push({
|
||||
id: "expense-sample",
|
||||
name: "Sample Expense",
|
||||
mainCategory: "Other Expenses",
|
||||
subcategory: "Miscellaneous",
|
||||
source: "user",
|
||||
monthlyValues,
|
||||
values: {
|
||||
year1: { best: 1000, worst: 500, mostLikely: 800 },
|
||||
year2: { best: 1200, worst: 600, mostLikely: 1000 },
|
||||
year3: { best: 1500, worst: 800, mostLikely: 1200 },
|
||||
},
|
||||
});
|
||||
}
|
||||
// DISABLED: No sample data - expenses should start empty
|
||||
// if (budgetWorksheet.value.expenses.length === 0) {
|
||||
// console.log("Adding sample expense line");
|
||||
// // ... sample expense creation code removed
|
||||
// }
|
||||
|
||||
// Debug: Log all revenue items and their categories
|
||||
console.log("Final revenue items:");
|
||||
|
|
@ -696,6 +631,14 @@ export const useBudgetStore = defineStore(
|
|||
);
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const useCashStore = defineStore("cash", () => {
|
|||
// Week that first breaches minimum cushion
|
||||
const firstBreachWeek = ref(null);
|
||||
|
||||
// Current cash and savings balances - start with zeros
|
||||
// Current cash and savings balances - start empty
|
||||
const currentCash = ref(0);
|
||||
const currentSavings = ref(0);
|
||||
|
||||
|
|
@ -111,4 +111,14 @@ export const useCashStore = defineStore("cash", () => {
|
|||
stagePayment,
|
||||
updateCurrentBalances,
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: "urgent-tools-cash",
|
||||
paths: [
|
||||
"currentCash",
|
||||
"currentSavings",
|
||||
"cashEvents",
|
||||
"paymentQueue"
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
277
stores/coopBuilder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from 'vue';
|
||||
import { coverage, teamCoverageStats } from "~/types/members";
|
||||
|
||||
export const useMembersStore = defineStore(
|
||||
"members",
|
||||
|
|
@ -34,10 +36,16 @@ export const useMembersStore = defineStore(
|
|||
|
||||
// Normalize a member object to ensure required structure and sane defaults
|
||||
function normalizeMember(raw) {
|
||||
// Calculate hoursPerWeek from targetHours (monthly) if not explicitly set
|
||||
const targetHours = Number(raw.capacity?.targetHours) || 0;
|
||||
const hoursPerWeek = raw.hoursPerWeek ?? (targetHours > 0 ? targetHours / 4.33 : 0);
|
||||
|
||||
const normalized = {
|
||||
id: raw.id || Date.now().toString(),
|
||||
displayName: typeof raw.displayName === "string" ? raw.displayName : "",
|
||||
roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "",
|
||||
role: raw.role || raw.roleFocus || "",
|
||||
hoursPerWeek: hoursPerWeek,
|
||||
payRelationship: raw.payRelationship || "FullyPaid",
|
||||
capacity: {
|
||||
minHours: Number(raw.capacity?.minHours) || 0,
|
||||
|
|
@ -49,6 +57,11 @@ export const useMembersStore = defineStore(
|
|||
privacyNeeds: raw.privacyNeeds || "aggregate_ok",
|
||||
deferredHours: Number(raw.deferredHours ?? 0),
|
||||
quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240),
|
||||
// NEW fields for needs coverage
|
||||
minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0,
|
||||
targetMonthlyPay: Number(raw.targetMonthlyPay) || 0,
|
||||
externalMonthlyIncome: Number(raw.externalMonthlyIncome) || 0,
|
||||
monthlyPayPlanned: Number(raw.monthlyPayPlanned) || 0,
|
||||
...raw,
|
||||
};
|
||||
return normalized;
|
||||
|
|
@ -187,6 +200,56 @@ export const useMembersStore = defineStore(
|
|||
members.value = [];
|
||||
}
|
||||
|
||||
// Coverage calculations for individual members
|
||||
function getMemberCoverage(memberId) {
|
||||
const member = members.value.find((m) => m.id === memberId);
|
||||
if (!member) return { minPct: undefined, targetPct: undefined };
|
||||
|
||||
return coverage(
|
||||
member.minMonthlyNeeds || 0,
|
||||
member.targetMonthlyPay || 0,
|
||||
member.monthlyPayPlanned || 0,
|
||||
member.externalMonthlyIncome || 0
|
||||
);
|
||||
}
|
||||
|
||||
// Team-wide coverage statistics
|
||||
const teamStats = computed(() => teamCoverageStats(members.value));
|
||||
|
||||
// Pay policy configuration
|
||||
const payPolicy = ref({
|
||||
relationship: 'equal-pay' as const,
|
||||
notes: '',
|
||||
equalBase: 0,
|
||||
needsWeight: 0.5,
|
||||
roleBands: {},
|
||||
hoursRate: 0,
|
||||
customFormula: ''
|
||||
});
|
||||
|
||||
// Setters for new fields
|
||||
function setMonthlyNeeds(memberId, minNeeds, targetPay) {
|
||||
const member = members.value.find((m) => m.id === memberId);
|
||||
if (member) {
|
||||
member.minMonthlyNeeds = Number(minNeeds) || 0;
|
||||
member.targetMonthlyPay = Number(targetPay) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setExternalIncome(memberId, income) {
|
||||
const member = members.value.find((m) => m.id === memberId);
|
||||
if (member) {
|
||||
member.externalMonthlyIncome = Number(income) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setPlannedPay(memberId, planned) {
|
||||
const member = members.value.find((m) => m.id === memberId);
|
||||
if (member) {
|
||||
member.monthlyPayPlanned = Number(planned) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
members,
|
||||
capacityTotals,
|
||||
|
|
@ -194,6 +257,8 @@ export const useMembersStore = defineStore(
|
|||
validationDetails,
|
||||
isValid,
|
||||
schemaVersion,
|
||||
payPolicy,
|
||||
teamStats,
|
||||
// Wizard actions
|
||||
upsertMember,
|
||||
setCapacity,
|
||||
|
|
@ -202,6 +267,11 @@ export const useMembersStore = defineStore(
|
|||
setExternalCoveragePct,
|
||||
setPrivacy,
|
||||
resetMembers,
|
||||
// New coverage actions
|
||||
setMonthlyNeeds,
|
||||
setExternalIncome,
|
||||
setPlannedPay,
|
||||
getMemberCoverage,
|
||||
// Legacy actions
|
||||
addMember,
|
||||
updateMember,
|
||||
|
|
@ -211,7 +281,7 @@ export const useMembersStore = defineStore(
|
|||
{
|
||||
persist: {
|
||||
key: "urgent-tools-members",
|
||||
paths: ["members", "privacyFlags"],
|
||||
paths: ["members", "privacyFlags", "payPolicy"],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ export const usePoliciesStore = defineStore(
|
|||
const payrollOncostPct = ref(0);
|
||||
const savingsTargetMonths = ref(0);
|
||||
const minCashCushionAmount = ref(0);
|
||||
const operatingMode = ref<'minimum' | 'target'>('minimum');
|
||||
|
||||
// Pay policy for member needs coverage
|
||||
const payPolicy = ref({
|
||||
relationship: 'equal-pay' as 'equal-pay' | 'needs-weighted' | 'role-banded' | 'hours-weighted',
|
||||
roleBands: [] as Array<{ role: string; hourlyWage: number }>
|
||||
});
|
||||
|
||||
// Deferred pay limits
|
||||
const deferredCapHoursPerQtr = ref(0);
|
||||
|
|
@ -96,6 +103,13 @@ export const usePoliciesStore = defineStore(
|
|||
paymentPriority.value = [...priority];
|
||||
}
|
||||
|
||||
function setPayPolicy(relationship, roleBands = []) {
|
||||
payPolicy.value = {
|
||||
relationship,
|
||||
roleBands: [...roleBands]
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy actions
|
||||
function updatePolicy(key, value) {
|
||||
if (key === "equalHourlyWage") setEqualWage(value);
|
||||
|
|
@ -120,6 +134,8 @@ export const usePoliciesStore = defineStore(
|
|||
payrollOncostPct.value = 0;
|
||||
savingsTargetMonths.value = 0;
|
||||
minCashCushionAmount.value = 0;
|
||||
operatingMode.value = 'minimum';
|
||||
payPolicy.value = { relationship: 'equal-pay', roleBands: [] };
|
||||
deferredCapHoursPerQtr.value = 0;
|
||||
deferredSunsetMonths.value = 0;
|
||||
surplusOrder.value = [
|
||||
|
|
@ -139,6 +155,8 @@ export const usePoliciesStore = defineStore(
|
|||
payrollOncostPct,
|
||||
savingsTargetMonths,
|
||||
minCashCushionAmount,
|
||||
operatingMode,
|
||||
payPolicy,
|
||||
deferredCapHoursPerQtr,
|
||||
deferredSunsetMonths,
|
||||
surplusOrder,
|
||||
|
|
@ -151,6 +169,7 @@ export const usePoliciesStore = defineStore(
|
|||
setOncostPct,
|
||||
setSavingsTargetMonths,
|
||||
setMinCashCushion,
|
||||
setPayPolicy,
|
||||
setDeferredCap,
|
||||
setDeferredSunset,
|
||||
setVolunteerScope,
|
||||
|
|
@ -171,6 +190,8 @@ export const usePoliciesStore = defineStore(
|
|||
"payrollOncostPct",
|
||||
"savingsTargetMonths",
|
||||
"minCashCushionAmount",
|
||||
"operatingMode",
|
||||
"payPolicy",
|
||||
"deferredCapHoursPerQtr",
|
||||
"deferredSunsetMonths",
|
||||
"surplusOrder",
|
||||
|
|
|
|||
|
|
@ -87,30 +87,6 @@ export const useStreamsStore = defineStore(
|
|||
}
|
||||
}
|
||||
|
||||
// Initialize with fixture data if empty
|
||||
async function initializeWithFixtures() {
|
||||
if (streams.value.length === 0) {
|
||||
const { useFixtures } = await import('~/composables/useFixtures');
|
||||
const fixtures = useFixtures();
|
||||
const { revenueStreams } = await fixtures.loadStreams();
|
||||
|
||||
revenueStreams.forEach(stream => {
|
||||
upsertStream(stream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load realistic demo data (for better user experience)
|
||||
async function loadDemoData() {
|
||||
resetStreams();
|
||||
const { useFixtures } = await import('~/composables/useFixtures');
|
||||
const fixtures = useFixtures();
|
||||
const { revenueStreams } = await fixtures.loadStreams();
|
||||
|
||||
revenueStreams.forEach(stream => {
|
||||
upsertStream(stream);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset function
|
||||
function resetStreams() {
|
||||
|
|
@ -127,8 +103,6 @@ export const useStreamsStore = defineStore(
|
|||
// Wizard actions
|
||||
upsertStream,
|
||||
resetStreams,
|
||||
initializeWithFixtures,
|
||||
loadDemoData,
|
||||
// Legacy actions
|
||||
addStream,
|
||||
updateStream,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: [
|
||||
'./app.vue',
|
||||
'./pages/**/*.vue',
|
||||
'./components/**/*.{vue,js,ts}',
|
||||
'./composables/**/*.{js,ts}',
|
||||
'./layouts/**/*.vue',
|
||||
'./plugins/**/*.{js,ts}',
|
||||
],
|
||||
darkMode: "class",
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -9,12 +9,35 @@ import WizardRevenueStep from '~/components/WizardRevenueStep.vue';
|
|||
import { useOfferSuggestor } from '~/composables/useOfferSuggestor';
|
||||
import { usePlanStore } from '~/stores/plan';
|
||||
import { offerToStream, offersToStreams } from '~/utils/offerToStream';
|
||||
import {
|
||||
membersSample,
|
||||
skillsCatalogSample,
|
||||
problemsCatalogSample,
|
||||
sampleSelections
|
||||
} from '~/sample/skillsToOffersSamples';
|
||||
|
||||
// Create inline test data to replace removed sample imports
|
||||
const membersSample = [
|
||||
{ id: "1", name: "Maya Chen", role: "Designer", hourly: 32, availableHrs: 20 },
|
||||
{ id: "2", name: "Alex Rodriguez", role: "Developer", hourly: 35, availableHrs: 30 },
|
||||
{ id: "3", name: "Jordan Kim", role: "Writer", hourly: 28, availableHrs: 15 }
|
||||
];
|
||||
|
||||
const skillsCatalogSample = [
|
||||
{ id: "design", label: "UI/UX Design" },
|
||||
{ id: "writing", label: "Technical Writing" },
|
||||
{ id: "development", label: "Web Development" }
|
||||
];
|
||||
|
||||
const problemsCatalogSample = [
|
||||
{
|
||||
id: "unclear-pitch",
|
||||
label: "Unclear value proposition",
|
||||
examples: ["Need better messaging", "Confusing product pitch"]
|
||||
}
|
||||
];
|
||||
|
||||
const sampleSelections = {
|
||||
selectedSkillsByMember: {
|
||||
"1": ["design"],
|
||||
"3": ["writing"]
|
||||
},
|
||||
selectedProblems: ["unclear-pitch"]
|
||||
};
|
||||
|
||||
// Mock router
|
||||
vi.mock('vue-router', () => ({
|
||||
|
|
@ -159,65 +182,34 @@ describe('Coach Integration Tests', () => {
|
|||
});
|
||||
|
||||
describe('Coach Page Integration', () => {
|
||||
it('loads sample data and generates offers automatically', async () => {
|
||||
it('starts with empty data by default', async () => {
|
||||
const wrapper = mount(CoachSkillsToOffers, {
|
||||
global: {
|
||||
plugins: [pinia]
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger sample data loading
|
||||
await wrapper.vm.loadSampleData();
|
||||
await nextTick();
|
||||
|
||||
// Wait for debounced offer generation
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
|
||||
// Should have loaded sample members
|
||||
expect(wrapper.vm.members).toEqual(membersSample);
|
||||
|
||||
// Should have pre-selected skills and problems
|
||||
expect(wrapper.vm.selectedSkills).toEqual(sampleSelections.selectedSkillsByMember);
|
||||
expect(wrapper.vm.selectedProblems).toEqual(sampleSelections.selectedProblems);
|
||||
|
||||
// Should have generated offers
|
||||
expect(wrapper.vm.offers).toBeDefined();
|
||||
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
|
||||
// Should start with empty data
|
||||
expect(wrapper.vm.members).toEqual([]);
|
||||
expect(wrapper.vm.availableSkills).toEqual([]);
|
||||
expect(wrapper.vm.availableProblems).toEqual([]);
|
||||
expect(wrapper.vm.offers).toBeNull();
|
||||
});
|
||||
|
||||
it('handles "Use these" action correctly', async () => {
|
||||
it('handles empty state gracefully with no offers generated', async () => {
|
||||
const wrapper = mount(CoachSkillsToOffers, {
|
||||
global: {
|
||||
plugins: [pinia]
|
||||
}
|
||||
});
|
||||
|
||||
// Load sample data and generate offers
|
||||
await wrapper.vm.loadSampleData();
|
||||
// Wait for any potential async operations
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Ensure we have offers
|
||||
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
|
||||
|
||||
const initialOffers = wrapper.vm.offers!;
|
||||
|
||||
// Trigger "Use these" action
|
||||
await wrapper.vm.useOffers();
|
||||
|
||||
// Should have added streams to plan store
|
||||
expect(planStore.streams.length).toBe(initialOffers.length);
|
||||
|
||||
// Verify streams are properly converted
|
||||
planStore.streams.forEach((stream: any, index: number) => {
|
||||
const originalOffer = initialOffers[index];
|
||||
expect(stream.id).toBe(`offer-${originalOffer.id}`);
|
||||
expect(stream.name).toBe(originalOffer.name);
|
||||
expect(stream.unitPrice).toBe(originalOffer.price.baseline);
|
||||
expect(stream.payoutDelayDays).toBe(originalOffer.payoutDelayDays);
|
||||
expect(stream.feePercent).toBe(3);
|
||||
expect(stream.notes).toBe(originalOffer.whyThis.join('. '));
|
||||
});
|
||||
// Should have no offers with empty data
|
||||
expect(wrapper.vm.offers).toBeNull();
|
||||
expect(wrapper.vm.canRegenerate).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
137
types/members.ts
Normal file
137
types/members.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue