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

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

View file

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

View file

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

View file

@ -0,0 +1,56 @@
<template>
<div class="hidden" data-ui="member_coverage_panel_v1" />
<UCard class="shadow-sm rounded-xl">
<template #header>
<h3 class="font-semibold">Member needs coverage</h3>
</template>
<div class="space-y-6">
<div
v-for="member in allocatedMembers"
:key="member.id"
class="flex items-center gap-4"
>
<div class="w-20 text-sm font-medium text-gray-700 truncate">
{{ member.name }}
</div>
<div class="flex-1">
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="getBarColor(coverage(member).minPct)"
:style="{ width: `${Math.min(100, (coverage(member).minPct / 200) * 100)}%` }"
/>
</div>
</div>
<div class="w-12 text-sm font-medium text-right">
{{ Math.round(coverage(member).minPct) }}%
</div>
</div>
<div v-if="allocatedMembers.length === 0" class="text-sm text-gray-600 text-center py-8">
Add members in Setup Members to see coverage.
</div>
</div>
<template #footer v-if="allocatedMembers.length > 0">
<div class="text-sm text-gray-600 text-center">
Team median {{ Math.round(stats.median) }}% {{ stats.under100 }} under 100%{{ allCovered ? ' • All covered ✓' : '' }}
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
const allocatedMembers = computed(() => allocatePayroll())
const stats = computed(() => teamCoverageStats())
const allCovered = computed(() => stats.value.under100 === 0)
function getBarColor(pct: number): string {
if (pct >= 100) return 'bg-green-500'
if (pct >= 80) return 'bg-amber-500'
return 'bg-red-500'
}
</script>

View file

@ -0,0 +1,37 @@
<template>
<div class="hidden" data-ui="needs_coverage_card_v1" />
<UCard class="min-h-[140px] shadow-sm rounded-xl">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-user-group" class="h-5 w-5" />
<h3 class="font-semibold">Members covered</h3>
</div>
</template>
<div class="text-center space-y-6">
<div class="text-2xl font-semibold" :class="statusColor">
{{ pctCovered }}%
</div>
<div class="text-sm text-gray-600">
Median {{ median }}%
</div>
<div v-if="stats.under100 > 0" class="flex items-center justify-center gap-1 text-xs text-amber-600 mt-3">
<span></span>
<span>{{ stats.under100 }} under 100%</span>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const { members, teamCoverageStats } = useCoopBuilder()
const stats = computed(() => teamCoverageStats())
const pctCovered = computed(() => Math.round(stats.value.over100Pct || 0))
const median = computed(() => Math.round(stats.value.median ?? 0))
const statusColor = computed(() => {
if (pctCovered.value >= 100) return 'text-green-600'
if (pctCovered.value >= 80) return 'text-amber-600'
return 'text-red-600'
})
</script>

View file

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

View file

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