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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue