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
|
|
@ -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>
|
||||
</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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue