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

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,27 @@
</div>
</div>
<!-- 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() {

View file

@ -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>
</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">
<CoverageChip
:coverage-min-pct="memberCoverage(member).minPct"
:coverage-target-pct="memberCoverage(member).targetPct"
:member-name="member.displayName || 'This member'"
/>
</div>
<UButton
size="xs"
variant="solid"
@ -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() {

View file

@ -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,18 +22,68 @@
</div>
</div>
<div class="max-w-md">
<UInput
v-model="wageText"
type="text"
placeholder="0.00"
size="xl"
class="text-4xl font-black w-full h-20"
@update:model-value="validateAndSaveWage">
<template #leading>
<span class="text-neutral-500 text-3xl">$</span>
<!-- 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>
</UInput>
</UAlert>
</div>
<!-- Hourly Wage Input -->
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
<h4 class="font-bold mb-4">Base Hourly Wage</h4>
<div class="max-w-md">
<UInput
v-model="wageText"
type="text"
placeholder="0.00"
size="xl"
class="text-4xl font-black w-full h-20"
@update:model-value="validateAndSaveWage">
<template #leading>
<span class="text-neutral-500 text-3xl"></span>
</template>
</UInput>
</div>
</div>
</div>
</template>
@ -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);
// Set sensible defaults when wage is set
if (numValue > 0) {
setDefaults();
emit("save-status", "saved");
}
coop.setEqualWage(numValue)
// Trigger payroll reallocation after wage change
const allocatedMembers = coop.allocatePayroll()
allocatedMembers.forEach(m => {
coop.upsertMember(m)
})
emit("save-status", "saved");
}
}
// Set reasonable defaults for hidden fields
function setDefaults() {
if (policiesStore.payrollOncostPct === 0) {
policiesStore.setOncostPct(25); // 25% on-costs
}
if (policiesStore.savingsTargetMonths === 0) {
policiesStore.setSavingsTargetMonths(3); // 3 months savings
}
if (policiesStore.minCashCushionAmount === 0) {
policiesStore.setMinCashCushion(3000); // 3k cushion
}
if (policiesStore.deferredCapHoursPerQtr === 0) {
policiesStore.setDeferredCap(240); // 240 hours quarterly cap
}
if (policiesStore.deferredSunsetMonths === 0) {
policiesStore.setDeferredSunset(12); // 12 month sunset
}
// Set default volunteer flows
if (policiesStore.volunteerScope.allowedFlows.length === 0) {
policiesStore.setVolunteerScope(["Care", "SharedLearning"]);
}
}
// Set defaults on mount if needed
onMounted(() => {
if (policiesStore.equalHourlyWage > 0) {
setDefaults();
}
});
function exportPolicies() {
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",

View file

@ -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() {

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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