chore: update application configuration and UI components for improved styling and functionality
This commit is contained in:
parent
0af6b17792
commit
37ab8d7bab
54 changed files with 23293 additions and 1666 deletions
256
pages/budget.vue
256
pages/budget.vue
|
|
@ -16,43 +16,59 @@
|
|||
</h3>
|
||||
</template>
|
||||
<div
|
||||
class="flex items-center justify-between py-4 border-b border-gray-200">
|
||||
class="flex items-center justify-between py-4 border-b border-neutral-200">
|
||||
<div class="flex items-center gap-8">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">€12,000</div>
|
||||
<div class="text-xs text-gray-600">Gross Revenue</div>
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
€{{ budgetMetrics.grossRevenue.toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Gross Revenue</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-gray-400" />
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">-€450</div>
|
||||
<div class="text-xs text-gray-600">Fees</div>
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
-€{{ budgetMetrics.totalFees.toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Fees</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-gray-400" />
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">€11,550</div>
|
||||
<div class="text-xs text-gray-600">Net Revenue</div>
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
€{{ budgetMetrics.netRevenue.toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Net Revenue</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-gray-400" />
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">€300</div>
|
||||
<div class="text-xs text-gray-600">To Savings</div>
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
€{{ Math.round(budgetMetrics.savingsAmount).toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">To Savings</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-gray-400" />
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">€6,400</div>
|
||||
<div class="text-xs text-gray-600">Payroll</div>
|
||||
<div class="text-2xl font-bold text-purple-600">
|
||||
€{{ Math.round(budgetMetrics.totalPayroll).toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Payroll</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-gray-400" />
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-orange-600">€2,300</div>
|
||||
<div class="text-xs text-gray-600">Overhead</div>
|
||||
<div class="text-2xl font-bold text-orange-600">
|
||||
€{{ budgetMetrics.totalOverhead.toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Overhead</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-medium">Available for Operations</span>
|
||||
<span class="text-2xl font-bold text-green-600">€2,550</span>
|
||||
<span class="text-2xl font-bold text-green-600"
|
||||
>€{{
|
||||
Math.round(budgetMetrics.availableForOps).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
|
@ -111,17 +127,35 @@
|
|||
<h4 class="font-medium text-sm mb-2">Payroll</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Wages (320h @ €20)</span>
|
||||
<span class="font-medium">€6,400</span>
|
||||
<span class="text-neutral-600"
|
||||
>Wages ({{ budgetMetrics.totalHours }}h @ €{{
|
||||
budgetMetrics.hourlyWage
|
||||
}})</span
|
||||
>
|
||||
<span class="font-medium"
|
||||
>€{{
|
||||
Math.round(budgetMetrics.grossWages).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">On-costs (25%)</span>
|
||||
<span class="font-medium">€1,600</span>
|
||||
<span class="text-neutral-600"
|
||||
>On-costs ({{ budgetMetrics.oncostPct }}%)</span
|
||||
>
|
||||
<span class="font-medium"
|
||||
>€{{
|
||||
Math.round(budgetMetrics.oncosts).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between text-sm font-medium border-t pt-2">
|
||||
<span>Total Payroll</span>
|
||||
<span>€8,000</span>
|
||||
<span
|
||||
>€{{
|
||||
Math.round(budgetMetrics.totalPayroll).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -129,22 +163,24 @@
|
|||
<div>
|
||||
<h4 class="font-medium text-sm mb-2">Overhead</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Coworking space</span>
|
||||
<span class="font-medium">€800</span>
|
||||
<div
|
||||
v-if="budgetStore.overheadCosts.length === 0"
|
||||
class="text-sm text-neutral-500 italic">
|
||||
No overhead costs added yet
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Tools & software</span>
|
||||
<span class="font-medium">€400</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Insurance</span>
|
||||
<span class="font-medium">€200</span>
|
||||
<div
|
||||
v-for="cost in budgetStore.overheadCosts"
|
||||
:key="cost.id"
|
||||
class="flex justify-between text-sm">
|
||||
<span class="text-neutral-600">{{ cost.name }}</span>
|
||||
<span class="font-medium"
|
||||
>€{{ (cost.amount || 0).toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between text-sm font-medium border-t pt-2">
|
||||
<span>Total Overhead</span>
|
||||
<span>€1,400</span>
|
||||
<span>€{{ budgetMetrics.totalOverhead.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -153,7 +189,7 @@
|
|||
<h4 class="font-medium text-sm mb-2">Production</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Dev kits</span>
|
||||
<span class="text-neutral-600">Dev kits</span>
|
||||
<span class="font-medium">€500</span>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -173,34 +209,59 @@
|
|||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Net Revenue</span>
|
||||
<span class="font-medium text-green-600">€11,550</span>
|
||||
<span class="text-neutral-600">Net Revenue</span>
|
||||
<span class="font-medium text-green-600"
|
||||
>€{{ budgetMetrics.netRevenue.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Total Costs</span>
|
||||
<span class="font-medium text-red-600">-€9,900</span>
|
||||
<span class="text-neutral-600">Total Costs</span>
|
||||
<span class="font-medium text-red-600"
|
||||
>-€{{
|
||||
Math.round(budgetMetrics.totalCosts).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-lg font-bold border-t pt-3">
|
||||
<span>Net</span>
|
||||
<span class="text-green-600">+€1,650</span>
|
||||
<span
|
||||
:class="
|
||||
budgetMetrics.monthlyNet >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
"
|
||||
>{{ budgetMetrics.monthlyNet >= 0 ? "+" : "" }}€{{
|
||||
Math.round(budgetMetrics.monthlyNet).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-neutral-50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-sm mb-3">Allocation</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">To Savings</span>
|
||||
<span class="font-medium">€1,200</span>
|
||||
<span class="text-neutral-600">To Savings</span>
|
||||
<span class="font-medium"
|
||||
>€{{
|
||||
Math.round(budgetMetrics.savingsAmount).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Available</span>
|
||||
<span class="font-medium">€450</span>
|
||||
<span class="text-neutral-600">Available</span>
|
||||
<span class="font-medium"
|
||||
>€{{
|
||||
Math.round(
|
||||
budgetMetrics.availableAfterSavings
|
||||
).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-600 space-y-1">
|
||||
<div class="text-xs text-neutral-600 space-y-1">
|
||||
<p>
|
||||
<RestrictionChip restriction="Restricted" size="xs" /> funds can
|
||||
only be used for approved purposes.
|
||||
|
|
@ -217,6 +278,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Use real store data
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
|
||||
const selectedMonth = ref("2024-01");
|
||||
const months = ref([
|
||||
{ label: "January 2024", value: "2024-01" },
|
||||
|
|
@ -224,35 +292,71 @@ const months = ref([
|
|||
{ label: "March 2024", value: "2024-03" },
|
||||
]);
|
||||
|
||||
const revenueStreams = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: "Client Services",
|
||||
target: 7800,
|
||||
committed: 6500,
|
||||
actual: 7200,
|
||||
variance: 700,
|
||||
restrictions: "General",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Platform Sales",
|
||||
target: 3000,
|
||||
committed: 2000,
|
||||
actual: 2400,
|
||||
variance: 400,
|
||||
restrictions: "General",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Grant Funding",
|
||||
target: 1200,
|
||||
committed: 0,
|
||||
actual: 1400,
|
||||
variance: 1400,
|
||||
restrictions: "Restricted",
|
||||
},
|
||||
]);
|
||||
// Calculate budget values from real data
|
||||
const budgetMetrics = computed(() => {
|
||||
const totalHours = membersStore.capacityTotals.targetHours || 0;
|
||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
||||
|
||||
const grossWages = totalHours * hourlyWage;
|
||||
const oncosts = grossWages * (oncostPct / 100);
|
||||
const totalPayroll = grossWages + oncosts;
|
||||
|
||||
const totalOverhead = budgetStore.overheadCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
);
|
||||
const grossRevenue = streamsStore.totalMonthlyAmount || 0;
|
||||
|
||||
// Calculate fees from streams with platform fees
|
||||
const totalFees = streamsStore.streams.reduce((sum, stream) => {
|
||||
const revenue = stream.targetMonthlyAmount || 0;
|
||||
const platformFee = (stream.platformFeePct || 0) / 100;
|
||||
const revShareFee = (stream.revenueSharePct || 0) / 100;
|
||||
return sum + revenue * platformFee + revenue * revShareFee;
|
||||
}, 0);
|
||||
|
||||
const netRevenue = grossRevenue - totalFees;
|
||||
const totalCosts = totalPayroll + totalOverhead;
|
||||
const monthlyNet = netRevenue - totalCosts;
|
||||
const savingsAmount = Math.max(0, monthlyNet * 0.3); // Save 30% of positive net if possible
|
||||
const availableAfterSavings = Math.max(0, monthlyNet - savingsAmount);
|
||||
const availableForOps = Math.max(
|
||||
0,
|
||||
netRevenue - totalPayroll - totalOverhead - savingsAmount
|
||||
);
|
||||
|
||||
return {
|
||||
grossRevenue,
|
||||
totalFees,
|
||||
netRevenue,
|
||||
totalCosts,
|
||||
monthlyNet,
|
||||
savingsAmount,
|
||||
availableAfterSavings,
|
||||
totalPayroll,
|
||||
grossWages,
|
||||
oncosts,
|
||||
totalOverhead,
|
||||
availableForOps,
|
||||
totalHours,
|
||||
hourlyWage,
|
||||
oncostPct,
|
||||
};
|
||||
});
|
||||
|
||||
// Convert streams to budget table format
|
||||
const revenueStreams = computed(() =>
|
||||
streamsStore.streams.map((stream) => ({
|
||||
id: stream.id,
|
||||
name: stream.name,
|
||||
target: stream.targetMonthlyAmount || 0,
|
||||
committed: Math.round((stream.targetMonthlyAmount || 0) * 0.8), // 80% committed assumption
|
||||
actual: Math.round((stream.targetMonthlyAmount || 0) * 0.9), // 90% actual assumption
|
||||
variance: Math.round((stream.targetMonthlyAmount || 0) * 0.1), // 10% positive variance
|
||||
restrictions: stream.restrictions || "General",
|
||||
}))
|
||||
);
|
||||
|
||||
const revenueColumns = [
|
||||
{ id: "name", key: "name", label: "Stream" },
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Cash Calendar</h2>
|
||||
<UBadge color="red" variant="subtle">Week 7 cushion breach</UBadge>
|
||||
<UBadge v-if="firstBreachWeek" color="red" variant="subtle"
|
||||
>Week {{ firstBreachWeek }} cushion breach</UBadge
|
||||
>
|
||||
<UBadge v-else color="green" variant="subtle"
|
||||
>No cushion breach projected</UBadge
|
||||
>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
|
|
@ -10,11 +15,12 @@
|
|||
<h3 class="text-lg font-medium">13-Week Cash Flow</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
<div class="text-sm text-neutral-600">
|
||||
Week-by-week cash inflows and outflows with minimum cushion tracking.
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-2 text-xs font-medium text-gray-500">
|
||||
|
||||
<div
|
||||
class="grid grid-cols-7 gap-2 text-xs font-medium text-neutral-500">
|
||||
<div>Week</div>
|
||||
<div>Inflow</div>
|
||||
<div>Outflow</div>
|
||||
|
|
@ -23,33 +29,40 @@
|
|||
<div>Cushion</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
|
||||
<div v-for="week in weeks" :key="week.number"
|
||||
class="grid grid-cols-7 gap-2 text-sm py-2 border-b border-gray-100"
|
||||
:class="{ 'bg-red-50': week.breachesCushion }">
|
||||
|
||||
<div
|
||||
v-for="week in weeks"
|
||||
:key="week.number"
|
||||
class="grid grid-cols-7 gap-2 text-sm py-2 border-b border-neutral-100"
|
||||
:class="{ 'bg-red-50': week.breachesCushion }">
|
||||
<div class="font-medium">{{ week.number }}</div>
|
||||
<div class="text-green-600">+€{{ week.inflow.toLocaleString() }}</div>
|
||||
<div class="text-red-600">-€{{ week.outflow.toLocaleString() }}</div>
|
||||
<div :class="week.net >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ week.net >= 0 ? '+' : '' }}€{{ week.net.toLocaleString() }}
|
||||
{{ week.net >= 0 ? "+" : "" }}€{{ week.net.toLocaleString() }}
|
||||
</div>
|
||||
<div class="font-medium">€{{ week.balance.toLocaleString() }}</div>
|
||||
<div :class="week.breachesCushion ? 'text-red-600 font-medium' : 'text-gray-600'">
|
||||
<div
|
||||
:class="
|
||||
week.breachesCushion
|
||||
? 'text-red-600 font-medium'
|
||||
: 'text-neutral-600'
|
||||
">
|
||||
€{{ week.cushion.toLocaleString() }}
|
||||
</div>
|
||||
<div>
|
||||
<UBadge v-if="week.breachesCushion" color="red" size="xs">
|
||||
Breach
|
||||
</UBadge>
|
||||
<UBadge v-else color="green" size="xs">
|
||||
OK
|
||||
</UBadge>
|
||||
<UBadge v-else color="green" size="xs"> OK </UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4 p-3 bg-orange-50 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="text-orange-500" />
|
||||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="text-orange-500" />
|
||||
<span class="text-sm font-medium text-orange-800">
|
||||
This week would drop below your minimum cushion.
|
||||
</span>
|
||||
|
|
@ -61,14 +74,28 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const weeks = ref([
|
||||
{ number: 1, inflow: 3000, outflow: 2200, net: 800, balance: 5800, cushion: 2800, breachesCushion: false },
|
||||
{ number: 2, inflow: 2500, outflow: 2200, net: 300, balance: 6100, cushion: 3100, breachesCushion: false },
|
||||
{ number: 3, inflow: 0, outflow: 2200, net: -2200, balance: 3900, cushion: 900, breachesCushion: false },
|
||||
{ number: 4, inflow: 4000, outflow: 2200, net: 1800, balance: 5700, cushion: 2700, breachesCushion: false },
|
||||
{ number: 5, inflow: 2000, outflow: 2200, net: -200, balance: 5500, cushion: 2500, breachesCushion: false },
|
||||
{ number: 6, inflow: 1500, outflow: 2200, net: -700, balance: 4800, cushion: 1800, breachesCushion: false },
|
||||
{ number: 7, inflow: 1000, outflow: 2200, net: -1200, balance: 3600, cushion: 600, breachesCushion: true },
|
||||
// ... more weeks
|
||||
])
|
||||
const cashStore = useCashStore();
|
||||
const { weeklyProjections } = storeToRefs(cashStore);
|
||||
|
||||
const weeks = computed(() => {
|
||||
// If no projections, show empty state
|
||||
if (weeklyProjections.value.length === 0) {
|
||||
return Array.from({ length: 13 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
inflow: 0,
|
||||
outflow: 0,
|
||||
net: 0,
|
||||
balance: 0,
|
||||
cushion: 0,
|
||||
breachesCushion: false,
|
||||
}));
|
||||
}
|
||||
return weeklyProjections.value;
|
||||
});
|
||||
|
||||
// Find first week that breaches cushion
|
||||
const firstBreachWeek = computed(() => {
|
||||
const breachWeek = weeks.value.find((week) => week.breachesCushion);
|
||||
return breachWeek ? breachWeek.number : null;
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,29 +7,33 @@
|
|||
placeholder="Search definitions..."
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
class="w-64"
|
||||
:ui="{ icon: { trailing: { pointer: '' } } }"
|
||||
/>
|
||||
:ui="{ icon: { trailing: { pointer: '' } } }" />
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div v-for="letter in alphabeticalGroups" :key="letter.letter" class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-primary-600 border-b border-gray-200 pb-2">
|
||||
<div
|
||||
v-for="letter in alphabeticalGroups"
|
||||
:key="letter.letter"
|
||||
class="space-y-4">
|
||||
<h3
|
||||
class="text-lg font-semibold text-primary-600 border-b border-neutral-200 pb-2">
|
||||
{{ letter.letter }}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="term in letter.terms"
|
||||
<div
|
||||
v-for="term in letter.terms"
|
||||
:key="term.id"
|
||||
:id="term.id"
|
||||
class="scroll-mt-20"
|
||||
>
|
||||
<dt class="font-medium text-gray-900 mb-1">
|
||||
class="scroll-mt-20">
|
||||
<dt class="font-medium text-neutral-900 mb-1">
|
||||
{{ term.term }}
|
||||
</dt>
|
||||
<dd class="text-gray-600 text-sm leading-relaxed">
|
||||
<dd class="text-neutral-600 text-sm leading-relaxed">
|
||||
{{ term.definition }}
|
||||
<span v-if="term.example" class="block mt-1 text-gray-500 italic">
|
||||
<span
|
||||
v-if="term.example"
|
||||
class="block mt-1 text-neutral-500 italic">
|
||||
Example: {{ term.example }}
|
||||
</span>
|
||||
</dd>
|
||||
|
|
@ -42,139 +46,146 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const searchQuery = ref('')
|
||||
const searchQuery = ref("");
|
||||
|
||||
// Glossary terms based on CLAUDE.md definitions
|
||||
const glossaryTerms = ref([
|
||||
{
|
||||
id: 'budget',
|
||||
term: 'Budget',
|
||||
definition: 'Month-by-month plan of money in and money out. Not exact dates.',
|
||||
example: 'January budget shows €12,000 revenue and €9,900 costs'
|
||||
id: "budget",
|
||||
term: "Budget",
|
||||
definition:
|
||||
"Month-by-month plan of money in and money out. Not exact dates.",
|
||||
example: "January budget shows €12,000 revenue and €9,900 costs",
|
||||
},
|
||||
{
|
||||
id: 'cash-flow',
|
||||
term: 'Cash Flow',
|
||||
definition: 'The actual dates money moves. Shows timing risk.',
|
||||
example: 'Client pays Net 30, so January work arrives in February'
|
||||
id: "cash-flow",
|
||||
term: "Cash Flow",
|
||||
definition: "The actual dates money moves. Shows timing risk.",
|
||||
example: "Client pays Net 30, so January work arrives in February",
|
||||
},
|
||||
{
|
||||
id: 'concentration',
|
||||
term: 'Concentration',
|
||||
definition: 'Dependence on few revenue sources. UI shows top source percentage.',
|
||||
example: 'If 65% comes from one client, concentration is high risk'
|
||||
id: "concentration",
|
||||
term: "Concentration",
|
||||
definition:
|
||||
"Dependence on few revenue sources. UI shows top source percentage.",
|
||||
example: "If 65% comes from one client, concentration is high risk",
|
||||
},
|
||||
{
|
||||
id: 'coverage',
|
||||
term: 'Coverage',
|
||||
definition: 'Funded paid hours divided by target hours across all members.',
|
||||
example: '208 funded hours ÷ 320 target hours = 65% coverage'
|
||||
id: "coverage",
|
||||
term: "Coverage",
|
||||
definition: "Funded paid hours divided by target hours across all members.",
|
||||
example: "208 funded hours ÷ 320 target hours = 65% coverage",
|
||||
},
|
||||
{
|
||||
id: 'deferred-pay',
|
||||
term: 'Deferred Pay',
|
||||
definition: 'Unpaid hours the co-op owes later at the same wage.',
|
||||
example: 'Alex worked 40 hours unpaid in January, owed €800 later'
|
||||
id: "deferred-pay",
|
||||
term: "Deferred Pay",
|
||||
definition: "Unpaid hours the co-op owes later at the same wage.",
|
||||
example: "Alex worked 40 hours unpaid in January, owed €800 later",
|
||||
},
|
||||
{
|
||||
id: 'equal-wage',
|
||||
term: 'Equal Wage',
|
||||
definition: 'Same hourly rate for all paid hours.',
|
||||
example: 'Everyone gets €20/hour for paid work, regardless of role'
|
||||
id: "equal-wage",
|
||||
term: "Equal Wage",
|
||||
definition: "Same hourly rate for all paid hours.",
|
||||
example: "Everyone gets €20/hour for paid work, regardless of role",
|
||||
},
|
||||
{
|
||||
id: 'minimum-cash-cushion',
|
||||
term: 'Minimum Cash Cushion',
|
||||
definition: 'Lowest operating balance we agree not to breach.',
|
||||
example: '€3,000 minimum means never go below this amount'
|
||||
id: "minimum-cash-cushion",
|
||||
term: "Minimum Cash Cushion",
|
||||
definition: "Lowest operating balance we agree not to breach.",
|
||||
example: "€3,000 minimum means never go below this amount",
|
||||
},
|
||||
{
|
||||
id: 'on-costs',
|
||||
term: 'On-costs',
|
||||
definition: 'Employer taxes, benefits, and payroll fees on top of wages.',
|
||||
example: '€6,400 wages + 25% on-costs = €8,000 total payroll'
|
||||
id: "on-costs",
|
||||
term: "On-costs",
|
||||
definition: "Employer taxes, benefits, and payroll fees on top of wages.",
|
||||
example: "€6,400 wages + 25% on-costs = €8,000 total payroll",
|
||||
},
|
||||
{
|
||||
id: 'patronage',
|
||||
term: 'Patronage',
|
||||
definition: 'A way to share surplus based on recorded contributions.',
|
||||
example: 'Extra profits shared based on hours worked or value added'
|
||||
id: "patronage",
|
||||
term: "Patronage",
|
||||
definition: "A way to share surplus based on recorded contributions.",
|
||||
example: "Extra profits shared based on hours worked or value added",
|
||||
},
|
||||
{
|
||||
id: 'payout-delay',
|
||||
term: 'Payout Delay',
|
||||
definition: 'Time between earning money and receiving it.',
|
||||
example: 'Platform sales have 14-day delay, grants have 45-day delay'
|
||||
id: "payout-delay",
|
||||
term: "Payout Delay",
|
||||
definition: "Time between earning money and receiving it.",
|
||||
example: "Platform sales have 14-day delay, grants have 45-day delay",
|
||||
},
|
||||
{
|
||||
id: 'restricted-funds',
|
||||
term: 'Restricted Funds',
|
||||
definition: 'Money that can only be used for approved purposes.',
|
||||
example: 'Grant money restricted to development costs only'
|
||||
id: "restricted-funds",
|
||||
term: "Restricted Funds",
|
||||
definition: "Money that can only be used for approved purposes.",
|
||||
example: "Grant money restricted to development costs only",
|
||||
},
|
||||
{
|
||||
id: 'revenue-share',
|
||||
term: 'Revenue Share',
|
||||
definition: 'Percentage of earnings paid to platform or partner.',
|
||||
example: 'App store takes 30% revenue share on sales'
|
||||
id: "revenue-share",
|
||||
term: "Revenue Share",
|
||||
definition: "Percentage of earnings paid to platform or partner.",
|
||||
example: "App store takes 30% revenue share on sales",
|
||||
},
|
||||
{
|
||||
id: 'runway',
|
||||
term: 'Runway',
|
||||
definition: 'Months until cash plus savings run out under the current plan.',
|
||||
example: '€13,000 available ÷ €4,600 monthly burn = 2.8 months runway'
|
||||
id: "runway",
|
||||
term: "Runway",
|
||||
definition:
|
||||
"Months until cash plus savings run out under the current plan.",
|
||||
example: "€13,000 available ÷ €4,600 monthly burn = 2.8 months runway",
|
||||
},
|
||||
{
|
||||
id: 'savings-target',
|
||||
term: 'Savings Target',
|
||||
definition: 'Money held for stability. Aim to reach before ramping hours.',
|
||||
example: '3 months target = €13,800 for 3 months of expenses'
|
||||
id: "savings-target",
|
||||
term: "Savings Target",
|
||||
definition: "Money held for stability. Aim to reach before ramping hours.",
|
||||
example: "3 months target = €13,800 for 3 months of expenses",
|
||||
},
|
||||
{
|
||||
id: 'surplus',
|
||||
term: 'Surplus',
|
||||
definition: 'Money left over after all costs are paid.',
|
||||
example: '€12,000 revenue - €9,900 costs = €2,100 surplus'
|
||||
id: "surplus",
|
||||
term: "Surplus",
|
||||
definition: "Money left over after all costs are paid.",
|
||||
example: "€12,000 revenue - €9,900 costs = €2,100 surplus",
|
||||
},
|
||||
{
|
||||
id: 'value-accounting',
|
||||
term: 'Value Accounting',
|
||||
definition: 'Monthly process to review contributions and distribute surplus.',
|
||||
example: 'January session: review work, repay deferred pay, fund training'
|
||||
}
|
||||
])
|
||||
id: "value-accounting",
|
||||
term: "Value Accounting",
|
||||
definition:
|
||||
"Monthly process to review contributions and distribute surplus.",
|
||||
example: "January session: review work, repay deferred pay, fund training",
|
||||
},
|
||||
]);
|
||||
|
||||
// Filter terms based on search
|
||||
const filteredTerms = computed(() => {
|
||||
if (!searchQuery.value) return glossaryTerms.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return glossaryTerms.value.filter(term =>
|
||||
term.term.toLowerCase().includes(query) ||
|
||||
term.definition.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
if (!searchQuery.value) return glossaryTerms.value;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return glossaryTerms.value.filter(
|
||||
(term) =>
|
||||
term.term.toLowerCase().includes(query) ||
|
||||
term.definition.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Group terms alphabetically
|
||||
const alphabeticalGroups = computed(() => {
|
||||
const groups = new Map()
|
||||
|
||||
const groups = new Map();
|
||||
|
||||
filteredTerms.value
|
||||
.sort((a, b) => a.term.localeCompare(b.term))
|
||||
.forEach(term => {
|
||||
const letter = term.term[0].toUpperCase()
|
||||
.forEach((term) => {
|
||||
const letter = term.term[0].toUpperCase();
|
||||
if (!groups.has(letter)) {
|
||||
groups.set(letter, { letter, terms: [] })
|
||||
groups.set(letter, { letter, terms: [] });
|
||||
}
|
||||
groups.get(letter).terms.push(term)
|
||||
})
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => a.letter.localeCompare(b.letter))
|
||||
})
|
||||
groups.get(letter).terms.push(term);
|
||||
});
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) =>
|
||||
a.letter.localeCompare(b.letter)
|
||||
);
|
||||
});
|
||||
|
||||
// SEO and accessibility
|
||||
useSeoMeta({
|
||||
title: 'Glossary - Plain English Definitions',
|
||||
description: 'Plain English definitions of co-op financial terms. No jargon.',
|
||||
})
|
||||
title: "Glossary - Plain English Definitions",
|
||||
description: "Plain English definitions of co-op financial terms. No jargon.",
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
305
pages/index.vue
305
pages/index.vue
|
|
@ -17,42 +17,42 @@
|
|||
|
||||
<!-- Key Metrics Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<RunwayMeter
|
||||
:months="metrics.runway"
|
||||
:description="`You have ${$format.number(metrics.runway)} months of runway with current spending.`"
|
||||
/>
|
||||
|
||||
<CoverageMeter
|
||||
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
|
||||
<RunwayMeter
|
||||
:months="metrics.runway"
|
||||
:description="`You have ${$format.number(
|
||||
metrics.runway
|
||||
)} months of runway with current spending.`" />
|
||||
|
||||
<CoverageMeter
|
||||
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
|
||||
:target-hours="metrics.totalTargetHours"
|
||||
description="Funded hours vs target capacity across all members."
|
||||
/>
|
||||
|
||||
<ReserveMeter
|
||||
description="Funded hours vs target capacity across all members." />
|
||||
|
||||
<ReserveMeter
|
||||
:current-savings="metrics.finances.currentBalances.savings"
|
||||
:savings-target-months="metrics.finances.policies.savingsTargetMonths"
|
||||
:monthly-burn="metrics.monthlyBurn"
|
||||
description="Build savings to your target before increasing paid hours."
|
||||
/>
|
||||
|
||||
description="Build savings to your target before increasing paid hours." />
|
||||
|
||||
<UCard>
|
||||
<div class="text-center space-y-3">
|
||||
<div class="text-3xl font-bold text-red-600">65%</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<GlossaryTooltip
|
||||
term="Concentration"
|
||||
term-id="concentration"
|
||||
definition="Dependence on few revenue sources. UI shows top source percentage."
|
||||
/>
|
||||
<div class="text-3xl font-bold" :class="concentrationColor">
|
||||
{{ topSourcePct }}%
|
||||
</div>
|
||||
<ConcentrationChip
|
||||
status="red"
|
||||
:top-source-pct="65"
|
||||
<div class="text-sm text-neutral-600">
|
||||
<GlossaryTooltip
|
||||
term="Concentration"
|
||||
term-id="concentration"
|
||||
definition="Dependence on few revenue sources. UI shows top source percentage." />
|
||||
</div>
|
||||
<ConcentrationChip
|
||||
:status="concentrationStatus"
|
||||
:top-source-pct="topSourcePct"
|
||||
:show-percentage="false"
|
||||
variant="soft"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Most of your money comes from one place. Add another stream to reduce risk.
|
||||
variant="soft" />
|
||||
<p class="text-xs text-neutral-500 mt-2">
|
||||
Most of your money comes from one place. Add another stream to
|
||||
reduce risk.
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
|
@ -70,31 +70,31 @@
|
|||
icon="i-heroicons-exclamation-triangle"
|
||||
title="Revenue Concentration Risk"
|
||||
description="Most of your money comes from one place. Add another stream to reduce risk."
|
||||
:actions="[{ label: 'Plan Mix', click: () => navigateTo('/mix') }]"
|
||||
/>
|
||||
:actions="[{ label: 'Plan Mix', click: () => navigateTo('/mix') }]" />
|
||||
<UAlert
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-calendar"
|
||||
title="Cash Cushion Breach Forecast"
|
||||
description="Week 7 would drop below your minimum cushion."
|
||||
:actions="[{ label: 'View Calendar', click: () => navigateTo('/cash') }]"
|
||||
/>
|
||||
:description="cashBreachDescription"
|
||||
:actions="[
|
||||
{ label: 'View Calendar', click: () => navigateTo('/cash') },
|
||||
]" />
|
||||
<UAlert
|
||||
color="yellow"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-banknotes"
|
||||
title="Savings Below Target"
|
||||
description="Build savings to your target before increasing paid hours."
|
||||
:actions="[{ label: 'View Progress', click: () => navigateTo('/budget') }]"
|
||||
/>
|
||||
:actions="[
|
||||
{ label: 'View Progress', click: () => navigateTo('/budget') },
|
||||
]" />
|
||||
<UAlert
|
||||
color="amber"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-clock"
|
||||
title="Over-Deferred Member"
|
||||
description="Alex has reached 85% of quarterly deferred cap."
|
||||
/>
|
||||
description="Alex has reached 85% of quarterly deferred cap." />
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
|
|
@ -104,31 +104,37 @@
|
|||
<h3 class="text-lg font-medium">Scenario Snapshots</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">Operate Current</h4>
|
||||
<UBadge color="green" variant="subtle" size="xs">Active</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-orange-600 mb-1">2.8 months</div>
|
||||
<p class="text-xs text-gray-600">Continue existing plan</p>
|
||||
<div class="text-2xl font-bold text-orange-600 mb-1">
|
||||
{{ scenarioMetrics.current.runway }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">Continue existing plan</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">Quit Day Jobs</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-red-600 mb-1">1.4 months</div>
|
||||
<p class="text-xs text-gray-600">Full-time co-op work</p>
|
||||
<div class="text-2xl font-bold text-red-600 mb-1">
|
||||
{{ scenarioMetrics.quitJobs.runway }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">Full-time co-op work</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
|
||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-medium text-sm">Start Production</h4>
|
||||
<UBadge color="gray" variant="subtle" size="xs">Scenario</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-yellow-600 mb-1">2.1 months</div>
|
||||
<p class="text-xs text-gray-600">Launch development</p>
|
||||
<div class="text-2xl font-bold text-yellow-600 mb-1">
|
||||
{{ scenarioMetrics.startProduction.runway }} months
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600">Launch development</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
|
@ -159,34 +165,44 @@
|
|||
<span class="text-sm">Contributions logged</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-x-circle" class="text-gray-400" />
|
||||
<span class="text-sm text-gray-600">Surplus calculated</span>
|
||||
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
|
||||
<span class="text-sm text-neutral-600">Surplus calculated</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-heroicons-x-circle" class="text-gray-400" />
|
||||
<span class="text-sm text-gray-600">Member needs reviewed</span>
|
||||
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
|
||||
<span class="text-sm text-neutral-600"
|
||||
>Member needs reviewed</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<UProgress value="50" :max="100" color="blue" />
|
||||
<p class="text-xs text-gray-600 mt-1">2 of 4 items complete</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">2 of 4 items complete</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Available for Distribution</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Surplus</span>
|
||||
<span class="font-medium text-green-600">{{ $format.currency(1200) }}</span>
|
||||
<span class="text-neutral-600">Surplus</span>
|
||||
<span class="font-medium text-green-600">{{
|
||||
$format.currency(metrics.finances.surplus || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Deferred owed</span>
|
||||
<span class="font-medium text-orange-600">{{ $format.currency(metrics.finances.deferredLiabilities.totalDeferred) }}</span>
|
||||
<span class="text-neutral-600">Deferred owed</span>
|
||||
<span class="font-medium text-orange-600">{{
|
||||
$format.currency(
|
||||
metrics.finances.deferredLiabilities.totalDeferred
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Savings gap</span>
|
||||
<span class="font-medium text-blue-600">{{ $format.currency(2000) }}</span>
|
||||
<span class="text-neutral-600">Savings gap</span>
|
||||
<span class="font-medium text-blue-600">{{
|
||||
$format.currency(metrics.finances.savingsGap || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
|
@ -200,48 +216,44 @@
|
|||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
class="justify-start h-auto p-4"
|
||||
@click="navigateTo('/mix')"
|
||||
>
|
||||
@click="navigateTo('/mix')">
|
||||
<div class="text-left">
|
||||
<div class="font-medium">Revenue Mix</div>
|
||||
<div class="text-xs text-gray-500">Plan revenue streams</div>
|
||||
<div class="text-xs text-neutral-500">Plan revenue streams</div>
|
||||
</div>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
class="justify-start h-auto p-4"
|
||||
@click="navigateTo('/cash')"
|
||||
>
|
||||
@click="navigateTo('/cash')">
|
||||
<div class="text-left">
|
||||
<div class="font-medium">Cash Calendar</div>
|
||||
<div class="text-xs text-gray-500">13-week cash flow</div>
|
||||
<div class="text-xs text-neutral-500">13-week cash flow</div>
|
||||
</div>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
class="justify-start h-auto p-4"
|
||||
@click="navigateTo('/scenarios')"
|
||||
>
|
||||
@click="navigateTo('/scenarios')">
|
||||
<div class="text-left">
|
||||
<div class="font-medium">Scenarios</div>
|
||||
<div class="text-xs text-gray-500">What-if analysis</div>
|
||||
<div class="text-xs text-neutral-500">What-if analysis</div>
|
||||
</div>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
block
|
||||
|
||||
<UButton
|
||||
block
|
||||
color="primary"
|
||||
class="justify-start h-auto p-4"
|
||||
@click="navigateTo('/session')"
|
||||
>
|
||||
@click="navigateTo('/session')">
|
||||
<div class="text-left">
|
||||
<div class="font-medium">Next Session</div>
|
||||
<div class="text-xs">Value Accounting</div>
|
||||
|
|
@ -253,11 +265,126 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
// Dashboard page
|
||||
const { $format } = useNuxtApp()
|
||||
const { calculateMetrics } = useFixtures()
|
||||
const { $format } = useNuxtApp();
|
||||
|
||||
// Load fixture data and calculate metrics
|
||||
const metrics = await calculateMetrics()
|
||||
// Use real store data instead of fixtures
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
|
||||
// Calculate metrics from real store data
|
||||
const metrics = computed(() => {
|
||||
const totalTargetHours = membersStore.members.reduce(
|
||||
(sum, member) => sum + (member.capacity?.targetHours || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const totalTargetRevenue = streamsStore.streams.reduce(
|
||||
(sum, stream) => sum + (stream.targetMonthlyAmount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const totalOverheadCosts = budgetStore.overheadCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const monthlyPayroll =
|
||||
totalTargetHours *
|
||||
policiesStore.equalHourlyWage *
|
||||
(1 + policiesStore.payrollOncostPct / 100);
|
||||
|
||||
const monthlyBurn = monthlyPayroll + totalOverheadCosts;
|
||||
|
||||
// Use actual cash store values
|
||||
const totalLiquid = cashStore.currentCash + cashStore.currentSavings;
|
||||
|
||||
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0;
|
||||
|
||||
return {
|
||||
totalTargetHours,
|
||||
totalTargetRevenue,
|
||||
monthlyPayroll,
|
||||
monthlyBurn,
|
||||
runway,
|
||||
finances: {
|
||||
currentBalances: {
|
||||
cash: cashStore.currentCash,
|
||||
savings: cashStore.currentSavings,
|
||||
totalLiquid,
|
||||
},
|
||||
policies: {
|
||||
equalHourlyWage: policiesStore.equalHourlyWage,
|
||||
payrollOncostPct: policiesStore.payrollOncostPct,
|
||||
savingsTargetMonths: policiesStore.savingsTargetMonths,
|
||||
minCashCushionAmount: policiesStore.minCashCushionAmount,
|
||||
},
|
||||
deferredLiabilities: {
|
||||
totalDeferred: membersStore.members.reduce(
|
||||
(sum, m) =>
|
||||
sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
|
||||
0
|
||||
),
|
||||
},
|
||||
surplus: Math.max(0, totalTargetRevenue - monthlyBurn),
|
||||
savingsGap: Math.max(
|
||||
0,
|
||||
policiesStore.savingsTargetMonths * monthlyBurn -
|
||||
cashStore.currentSavings
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate concentration metrics
|
||||
const topSourcePct = computed(() => {
|
||||
if (streamsStore.streams.length === 0) return 0;
|
||||
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
|
||||
const total = amounts.reduce((sum, amt) => sum + amt, 0);
|
||||
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
|
||||
});
|
||||
|
||||
const concentrationStatus = computed(() => {
|
||||
if (topSourcePct.value > 50) return "red";
|
||||
if (topSourcePct.value > 35) return "yellow";
|
||||
return "green";
|
||||
});
|
||||
|
||||
const concentrationColor = computed(() => {
|
||||
if (topSourcePct.value > 50) return "text-red-600";
|
||||
if (topSourcePct.value > 35) return "text-yellow-600";
|
||||
return "text-green-600";
|
||||
});
|
||||
|
||||
// Calculate scenario metrics
|
||||
const scenarioMetrics = computed(() => {
|
||||
const baseRunway = metrics.value.runway;
|
||||
return {
|
||||
current: {
|
||||
runway: Math.round(baseRunway * 100) / 100 || 0,
|
||||
},
|
||||
quitJobs: {
|
||||
runway: Math.round(baseRunway * 0.7 * 100) / 100 || 0, // Shorter runway due to higher costs
|
||||
},
|
||||
startProduction: {
|
||||
runway: Math.round(baseRunway * 0.8 * 100) / 100 || 0, // Moderate impact
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Cash breach description
|
||||
const cashBreachDescription = computed(() => {
|
||||
// Check cash store for first breach week from projections
|
||||
const breachWeek = cashStore.weeklyProjections.find(
|
||||
(week) => week.breachesCushion
|
||||
);
|
||||
if (breachWeek) {
|
||||
return `Week ${breachWeek.number} would drop below your minimum cushion.`;
|
||||
}
|
||||
return "No cushion breach currently projected.";
|
||||
});
|
||||
|
||||
const onExport = () => {
|
||||
const data = exportAll();
|
||||
|
|
|
|||
108
pages/mix.vue
108
pages/mix.vue
|
|
@ -15,16 +15,20 @@
|
|||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-red-600 mb-2">65%</div>
|
||||
<div class="text-sm text-gray-600 mb-3">Top source percentage</div>
|
||||
<div class="text-4xl font-bold mb-2" :class="concentrationColor">
|
||||
{{ topSourcePct }}%
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600 mb-3">
|
||||
Top source percentage
|
||||
</div>
|
||||
<ConcentrationChip
|
||||
status="red"
|
||||
:top-source-pct="65"
|
||||
:status="concentrationStatus"
|
||||
:top-source-pct="topSourcePct"
|
||||
:show-percentage="false"
|
||||
variant="solid"
|
||||
size="md" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
<p class="text-sm text-neutral-600 text-center">
|
||||
Most of your money comes from one place. Add another stream to
|
||||
reduce risk.
|
||||
</p>
|
||||
|
|
@ -38,10 +42,12 @@
|
|||
<div class="space-y-4">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-yellow-600 mb-2">35 days</div>
|
||||
<div class="text-sm text-gray-600 mb-3">Weighted average delay</div>
|
||||
<div class="text-sm text-neutral-600 mb-3">
|
||||
Weighted average delay
|
||||
</div>
|
||||
<UBadge color="yellow" variant="subtle">Moderate Risk</UBadge>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
<p class="text-sm text-neutral-600 text-center">
|
||||
Money is earned now but arrives later. Delays can create mid-month
|
||||
dips.
|
||||
</p>
|
||||
|
|
@ -64,7 +70,7 @@
|
|||
<template #name-data="{ row }">
|
||||
<div>
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.category }}</div>
|
||||
<div class="text-xs text-neutral-500">{{ row.category }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -76,13 +82,13 @@
|
|||
size="xs"
|
||||
class="w-16"
|
||||
@update:model-value="updateStream(row.id, 'targetPct', $event)" />
|
||||
<span class="text-xs text-gray-500">%</span>
|
||||
<span class="text-xs text-neutral-500">%</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #targetAmount-data="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">€</span>
|
||||
<span class="text-xs text-neutral-500">€</span>
|
||||
<UInput
|
||||
v-model="row.targetMonthlyAmount"
|
||||
type="number"
|
||||
|
|
@ -104,7 +110,7 @@
|
|||
</div>
|
||||
<div
|
||||
v-if="row.platformFeePct === 0 && row.revenueSharePct === 0"
|
||||
class="text-gray-400">
|
||||
class="text-neutral-400">
|
||||
None
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -120,7 +126,7 @@
|
|||
@update:model-value="
|
||||
updateStream(row.id, 'payoutDelayDays', $event)
|
||||
" />
|
||||
<span class="text-xs text-gray-500">days</span>
|
||||
<span class="text-xs text-neutral-500">days</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -147,7 +153,7 @@
|
|||
</template>
|
||||
</UTable>
|
||||
|
||||
<div class="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="mt-4 p-4 bg-neutral-50 rounded-lg">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-medium">Totals</span>
|
||||
<div class="flex gap-6">
|
||||
|
|
@ -162,24 +168,10 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
const { $format } = useNuxtApp();
|
||||
const { loadStreams } = useFixtures();
|
||||
|
||||
// Load fixture data
|
||||
const fixtureData = await loadStreams();
|
||||
const streams = ref(
|
||||
fixtureData.revenueStreams.map((stream) => ({
|
||||
id: stream.id,
|
||||
name: stream.name,
|
||||
category: stream.category,
|
||||
targetPct: stream.targetPct,
|
||||
targetMonthlyAmount: stream.targetMonthlyAmount,
|
||||
certainty: stream.certainty,
|
||||
payoutDelayDays: stream.payoutDelayDays,
|
||||
platformFeePct: stream.platformFeePct || 0,
|
||||
revenueSharePct: stream.revenueSharePct || 0,
|
||||
restrictions: stream.restrictions,
|
||||
}))
|
||||
);
|
||||
// Use real store data instead of fixtures
|
||||
const streamsStore = useStreamsStore();
|
||||
const { streams } = storeToRefs(streamsStore);
|
||||
|
||||
const columns = [
|
||||
{ id: "name", key: "name", label: "Stream" },
|
||||
|
|
@ -192,16 +184,29 @@ const columns = [
|
|||
{ id: "actions", key: "actions", label: "" },
|
||||
];
|
||||
|
||||
const totalTargetPct = computed(() =>
|
||||
streams.value.reduce((sum, stream) => sum + (stream.targetPct || 0), 0)
|
||||
);
|
||||
const totalTargetPct = computed(() => streamsStore.totalTargetPct);
|
||||
const totalMonthlyAmount = computed(() => streamsStore.totalMonthlyAmount);
|
||||
|
||||
const totalMonthlyAmount = computed(() =>
|
||||
streams.value.reduce(
|
||||
(sum, stream) => sum + (stream.targetMonthlyAmount || 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
// Calculate concentration metrics
|
||||
const topSourcePct = computed(() => {
|
||||
if (streams.value.length === 0) return 0;
|
||||
const amounts = streams.value.map((s) => s.targetMonthlyAmount || 0);
|
||||
return (
|
||||
Math.round((Math.max(...amounts) / totalMonthlyAmount.value) * 100) || 0
|
||||
);
|
||||
});
|
||||
|
||||
const concentrationStatus = computed(() => {
|
||||
if (topSourcePct.value > 50) return "red";
|
||||
if (topSourcePct.value > 35) return "yellow";
|
||||
return "green";
|
||||
});
|
||||
|
||||
const concentrationColor = computed(() => {
|
||||
if (topSourcePct.value > 50) return "text-red-600";
|
||||
if (topSourcePct.value > 35) return "text-yellow-600";
|
||||
return "text-green-600";
|
||||
});
|
||||
|
||||
function getCertaintyColor(certainty: string) {
|
||||
switch (certainty) {
|
||||
|
|
@ -242,12 +247,28 @@ function updateStream(id: string, field: string, value: any) {
|
|||
const stream = streams.value.find((s) => s.id === id);
|
||||
if (stream) {
|
||||
stream[field] = Number(value) || value;
|
||||
streamsStore.upsertStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
function addStream() {
|
||||
// Add stream logic
|
||||
console.log("Add new stream");
|
||||
const newStream = {
|
||||
id: Date.now().toString(),
|
||||
name: "",
|
||||
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,
|
||||
};
|
||||
streamsStore.upsertStream(newStream);
|
||||
}
|
||||
|
||||
function editStream(row: any) {
|
||||
|
|
@ -261,10 +282,7 @@ function duplicateStream(row: any) {
|
|||
}
|
||||
|
||||
function removeStream(row: any) {
|
||||
const index = streams.value.findIndex((s) => s.id === row.id);
|
||||
if (index > -1) {
|
||||
streams.value.splice(index, 1);
|
||||
}
|
||||
streamsStore.removeStream(row.id);
|
||||
}
|
||||
|
||||
function sendToBudget() {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@
|
|||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Scenarios & Runway</h2>
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="red"
|
||||
size="sm"
|
||||
@click="restartWizard"
|
||||
:disabled="isResetting">
|
||||
<UIcon name="i-heroicons-arrow-path" class="mr-1" />
|
||||
Restart Setup (Testing)
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- 6-Month Preset Card -->
|
||||
|
|
@ -16,13 +25,15 @@
|
|||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600 mb-2">18.2 months</div>
|
||||
<div class="text-sm text-gray-600 mb-3">Extended runway</div>
|
||||
<div class="text-3xl font-bold text-blue-600 mb-2">
|
||||
{{ sixMonthScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600 mb-3">Extended runway</div>
|
||||
<UProgress value="91" color="info" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Key Changes</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<ul class="text-sm text-neutral-600 space-y-1">
|
||||
<li>• Diversify revenue mix</li>
|
||||
<li>• Build 6-month savings buffer</li>
|
||||
<li>• Gradual capacity scaling</li>
|
||||
|
|
@ -59,8 +70,10 @@
|
|||
<h4 class="font-medium text-sm">Operate Current</h4>
|
||||
<UBadge color="success" variant="solid" size="xs">Active</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-orange-600">2.8 months</div>
|
||||
<div class="text-xs text-gray-600">Baseline scenario</div>
|
||||
<div class="text-2xl font-bold text-orange-600">
|
||||
{{ currentScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Baseline scenario</div>
|
||||
<UButton size="xs" variant="ghost" @click="setScenario('current')">
|
||||
<UIcon name="i-heroicons-play" class="mr-1" />
|
||||
Continue
|
||||
|
|
@ -74,8 +87,10 @@
|
|||
<h4 class="font-medium text-sm">Quit Day Jobs</h4>
|
||||
<UBadge color="error" variant="subtle" size="xs">High Risk</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-red-600">1.4 months</div>
|
||||
<div class="text-xs text-gray-600">Full-time co-op work</div>
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
{{ quitDayJobsScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Full-time co-op work</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
|
|
@ -94,8 +109,10 @@
|
|||
>Medium Risk</UBadge
|
||||
>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-yellow-600">2.1 months</div>
|
||||
<div class="text-xs text-gray-600">Launch development</div>
|
||||
<div class="text-2xl font-bold text-yellow-600">
|
||||
{{ startProductionScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Launch development</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
|
|
@ -112,8 +129,10 @@
|
|||
<h4 class="font-medium text-sm">6-Month Plan</h4>
|
||||
<UBadge color="info" variant="solid" size="xs">Planned</UBadge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-blue-600">18.2 months</div>
|
||||
<div class="text-xs text-gray-600">Extended planning</div>
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
{{ sixMonthScenario.runway }} months
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Extended planning</div>
|
||||
<UButton size="xs" color="primary" @click="setScenario('sixMonth')">
|
||||
<UIcon name="i-heroicons-calendar" class="mr-1" />
|
||||
Plan
|
||||
|
|
@ -135,14 +154,14 @@
|
|||
<span class="text-sm">Savings Target Reached</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-x-circle" class="text-red-500" />
|
||||
<span class="text-sm text-gray-600">€5,200 short</span>
|
||||
<span class="text-sm text-neutral-600">€5,200 short</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Cash Floor Maintained</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
||||
<span class="text-sm text-gray-600">Week 4+</span>
|
||||
<span class="text-sm text-neutral-600">Week 4+</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
@ -151,7 +170,9 @@
|
|||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="text-yellow-500" />
|
||||
<span class="text-sm text-gray-600">Top: 65%</span>
|
||||
<span class="text-sm text-neutral-600"
|
||||
>Top: {{ topSourcePct }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -161,18 +182,22 @@
|
|||
<h4 class="font-medium mb-3">Key Dates</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Savings gate clear:</span>
|
||||
<span class="text-sm font-medium">March 2024</span>
|
||||
<span class="text-sm text-neutral-600">Savings gate clear:</span>
|
||||
<span class="text-sm font-medium">{{
|
||||
keyDates.savingsGate || "Not projected"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">First cash breach:</span>
|
||||
<span class="text-sm font-medium text-red-600"
|
||||
>Week 7 (Feb 12)</span
|
||||
>
|
||||
<span class="text-sm text-neutral-600">First cash breach:</span>
|
||||
<span class="text-sm font-medium text-red-600">{{
|
||||
keyDates.firstBreach || "None projected"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Deferred cap reset:</span>
|
||||
<span class="text-sm font-medium">April 1, 2024</span>
|
||||
<span class="text-sm text-neutral-600">Deferred cap reset:</span>
|
||||
<span class="text-sm font-medium">{{
|
||||
keyDates.deferredReset || "Not scheduled"
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -270,7 +295,7 @@
|
|||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-neutral-50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-sm mb-3">Impact on Runway</h4>
|
||||
<div class="text-center">
|
||||
<div
|
||||
|
|
@ -288,13 +313,13 @@
|
|||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Monthly burn:</span>
|
||||
<span class="text-neutral-600">Monthly burn:</span>
|
||||
<span class="font-medium"
|
||||
>€{{ monthlyBurn.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Coverage ratio:</span>
|
||||
<span class="text-neutral-600">Coverage ratio:</span>
|
||||
<span class="font-medium"
|
||||
>{{ Math.round((paidHours / 400) * 100) }}%</span
|
||||
>
|
||||
|
|
@ -312,25 +337,176 @@ const route = useRoute();
|
|||
const router = useRouter();
|
||||
const scenariosStore = useScenariosStore();
|
||||
|
||||
const revenue = ref(12000);
|
||||
const paidHours = ref(320);
|
||||
const winRate = ref(70);
|
||||
// Restart wizard functionality
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const wizardStore = useWizardStore();
|
||||
|
||||
// Calculate dynamic metrics
|
||||
const isResetting = ref(false);
|
||||
|
||||
// Get initial values from stores
|
||||
const initialRevenue = computed(() => streamsStore.totalMonthlyAmount || 0);
|
||||
const initialHours = computed(
|
||||
() => membersStore.capacityTotals.targetHours || 0
|
||||
);
|
||||
|
||||
const revenue = ref(initialRevenue.value || 0);
|
||||
const paidHours = ref(initialHours.value || 0);
|
||||
const winRate = ref(0);
|
||||
|
||||
// Watch for store changes and update sliders
|
||||
watch(initialRevenue, (newVal) => {
|
||||
if (newVal > 0) revenue.value = newVal;
|
||||
});
|
||||
watch(initialHours, (newVal) => {
|
||||
if (newVal > 0) paidHours.value = newVal;
|
||||
});
|
||||
|
||||
// Calculate dynamic metrics from real store data
|
||||
const monthlyBurn = computed(() => {
|
||||
const payroll = paidHours.value * 20 * 1.25; // €20/hr + 25% oncost
|
||||
const overhead = 1400;
|
||||
const production = 500;
|
||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
||||
const payroll = paidHours.value * hourlyWage * (1 + oncostPct / 100);
|
||||
const overhead =
|
||||
budgetStore.overheadCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
) || 0;
|
||||
const production =
|
||||
budgetStore.productionCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
) || 0;
|
||||
return payroll + overhead + production;
|
||||
});
|
||||
|
||||
const calculatedRunway = computed(() => {
|
||||
const totalCash = 13000; // cash + savings
|
||||
const totalCash = cashStore.currentCash + cashStore.currentSavings;
|
||||
const adjustedRevenue = revenue.value * (winRate.value / 100);
|
||||
const netPerMonth = adjustedRevenue - monthlyBurn.value;
|
||||
|
||||
if (netPerMonth >= 0) return 999; // Infinite/sustainable
|
||||
return Math.max(0, totalCash / Math.abs(netPerMonth));
|
||||
if (netPerMonth >= 0)
|
||||
return monthlyBurn.value > 0
|
||||
? Math.round((totalCash / monthlyBurn.value) * 100) / 100
|
||||
: 0;
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round((totalCash / Math.abs(netPerMonth)) * 100) / 100
|
||||
);
|
||||
});
|
||||
|
||||
// Scenario calculations based on real data
|
||||
const totalCash = computed(
|
||||
() => cashStore.currentCash + cashStore.currentSavings
|
||||
);
|
||||
|
||||
const baseRunway = computed(() => {
|
||||
const baseBurn = monthlyBurn.value;
|
||||
return baseBurn > 0
|
||||
? Math.round((totalCash.value / baseBurn) * 100) / 100
|
||||
: 0;
|
||||
});
|
||||
|
||||
const currentScenario = computed(() => ({
|
||||
runway: baseRunway.value || 0,
|
||||
}));
|
||||
|
||||
const quitDayJobsScenario = computed(() => ({
|
||||
runway:
|
||||
monthlyBurn.value > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round((totalCash.value / (monthlyBurn.value * 1.8)) * 100) / 100
|
||||
)
|
||||
: 0, // Higher burn rate
|
||||
}));
|
||||
|
||||
const startProductionScenario = computed(() => ({
|
||||
runway:
|
||||
monthlyBurn.value > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round((totalCash.value / (monthlyBurn.value * 1.4)) * 100) / 100
|
||||
)
|
||||
: 0, // Medium higher burn
|
||||
}));
|
||||
|
||||
const sixMonthScenario = computed(() => ({
|
||||
runway:
|
||||
monthlyBurn.value > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round((totalCash.value / (monthlyBurn.value * 0.6)) * 100) / 100
|
||||
)
|
||||
: 0, // Lower burn with optimization
|
||||
}));
|
||||
|
||||
// Calculate concentration from real data
|
||||
const topSourcePct = computed(() => {
|
||||
if (streamsStore.streams.length === 0) return 0;
|
||||
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
|
||||
const total = amounts.reduce((sum, amt) => sum + amt, 0);
|
||||
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
|
||||
});
|
||||
|
||||
// Calculate key dates from real data
|
||||
const keyDates = computed(() => {
|
||||
const currentDate = new Date();
|
||||
|
||||
// Calculate savings gate clear date based on current savings and target
|
||||
const savingsNeeded =
|
||||
(policiesStore.savingsTargetMonths || 0) * monthlyBurn.value;
|
||||
const currentSavings = cashStore.currentSavings;
|
||||
const monthlyNet = revenue.value - monthlyBurn.value;
|
||||
|
||||
let savingsGate = null;
|
||||
if (savingsNeeded > 0 && currentSavings < savingsNeeded && monthlyNet > 0) {
|
||||
const monthsToTarget = Math.ceil(
|
||||
(savingsNeeded - currentSavings) / monthlyNet
|
||||
);
|
||||
const targetDate = new Date(currentDate);
|
||||
targetDate.setMonth(targetDate.getMonth() + monthsToTarget);
|
||||
savingsGate = targetDate.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// First cash breach from cash store projections
|
||||
const firstBreachWeek = cashStore.firstBreachWeek;
|
||||
let firstBreach = null;
|
||||
if (firstBreachWeek) {
|
||||
const breachDate = new Date(currentDate);
|
||||
breachDate.setDate(breachDate.getDate() + firstBreachWeek * 7);
|
||||
firstBreach = `Week ${firstBreachWeek} (${breachDate.toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "short", day: "numeric" }
|
||||
)})`;
|
||||
}
|
||||
|
||||
// Deferred cap reset - quarterly (every 3 months)
|
||||
let deferredReset = null;
|
||||
if (policiesStore.deferredCapHoursPerQtr > 0) {
|
||||
const nextQuarter = new Date(currentDate);
|
||||
const currentMonth = nextQuarter.getMonth();
|
||||
const quarterStartMonth = Math.floor(currentMonth / 3) * 3;
|
||||
nextQuarter.setMonth(quarterStartMonth + 3, 1);
|
||||
deferredReset = nextQuarter.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
savingsGate,
|
||||
firstBreach,
|
||||
deferredReset,
|
||||
};
|
||||
});
|
||||
|
||||
function getRunwayColor(months: number) {
|
||||
|
|
@ -353,9 +529,41 @@ function setScenario(scenario: string) {
|
|||
}
|
||||
|
||||
function resetSliders() {
|
||||
revenue.value = 12000;
|
||||
paidHours.value = 320;
|
||||
winRate.value = 70;
|
||||
revenue.value = initialRevenue.value || 0;
|
||||
paidHours.value = initialHours.value || 0;
|
||||
winRate.value = 0;
|
||||
}
|
||||
|
||||
async function restartWizard() {
|
||||
isResetting.value = true;
|
||||
|
||||
// Clear all localStorage persistence
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem("urgent-tools-members");
|
||||
localStorage.removeItem("urgent-tools-policies");
|
||||
localStorage.removeItem("urgent-tools-streams");
|
||||
localStorage.removeItem("urgent-tools-budget");
|
||||
localStorage.removeItem("urgent-tools-cash");
|
||||
localStorage.removeItem("urgent-tools-session");
|
||||
localStorage.removeItem("urgent-tools-scenarios");
|
||||
}
|
||||
|
||||
// Reset all stores
|
||||
membersStore.resetMembers();
|
||||
policiesStore.resetPolicies();
|
||||
streamsStore.resetStreams();
|
||||
budgetStore.resetBudgetOverhead();
|
||||
sessionStore.resetSession();
|
||||
|
||||
// Reset wizard state
|
||||
wizardStore.reset();
|
||||
|
||||
// Small delay for UX
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
isResetting.value = false;
|
||||
|
||||
// Navigate to wizard
|
||||
await navigateTo("/wizard");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
|
|||
|
|
@ -36,16 +36,22 @@
|
|||
</template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Surplus</span>
|
||||
<span class="font-medium text-green-600">€1,200</span>
|
||||
<span class="text-neutral-600">Surplus</span>
|
||||
<span class="font-medium text-green-600"
|
||||
>€{{ availableAmounts.surplus.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Deferred owed</span>
|
||||
<span class="font-medium text-orange-600">€800</span>
|
||||
<span class="text-neutral-600">Deferred owed</span>
|
||||
<span class="font-medium text-orange-600"
|
||||
>€{{ availableAmounts.deferredOwed.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Savings target</span>
|
||||
<span class="font-medium text-blue-600">€2,000</span>
|
||||
<span class="text-neutral-600">Savings gap</span>
|
||||
<span class="font-medium text-blue-600"
|
||||
>€{{ availableAmounts.savingsNeeded.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
|
@ -57,19 +63,32 @@
|
|||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Deferred Repay</label>
|
||||
<UInput v-model="distribution.deferred" type="number" size="sm" />
|
||||
<UInput
|
||||
v-model.number="draftAllocations.deferredRepay"
|
||||
type="number"
|
||||
size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Savings</label>
|
||||
<UInput v-model="distribution.savings" type="number" size="sm" />
|
||||
<UInput
|
||||
v-model.number="draftAllocations.savings"
|
||||
type="number"
|
||||
size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Training</label>
|
||||
<UInput v-model="distribution.training" type="number" size="sm" />
|
||||
<UInput
|
||||
v-model.number="draftAllocations.training"
|
||||
type="number"
|
||||
size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">Retained</label>
|
||||
<UInput v-model="distribution.retained" type="number" size="sm" readonly />
|
||||
<UInput
|
||||
v-model.number="draftAllocations.retained"
|
||||
type="number"
|
||||
size="sm"
|
||||
readonly />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
|
@ -83,12 +102,9 @@
|
|||
<UTextarea
|
||||
v-model="rationale"
|
||||
placeholder="Brief rationale for this month's distribution decisions..."
|
||||
rows="3"
|
||||
/>
|
||||
rows="3" />
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton variant="ghost">
|
||||
Save Draft
|
||||
</UButton>
|
||||
<UButton variant="ghost"> Save Draft </UButton>
|
||||
<UButton color="primary" :disabled="!allChecklistComplete">
|
||||
Complete Session
|
||||
</UButton>
|
||||
|
|
@ -99,23 +115,59 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const checklist = ref({
|
||||
monthClosed: false,
|
||||
contributionsLogged: false,
|
||||
surplusCalculated: false,
|
||||
needsReviewed: false
|
||||
})
|
||||
// Use stores
|
||||
const sessionStore = useSessionStore();
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
|
||||
const distribution = ref({
|
||||
deferred: 800,
|
||||
savings: 400,
|
||||
training: 0,
|
||||
retained: 0
|
||||
})
|
||||
|
||||
const rationale = ref('')
|
||||
// Use store refs
|
||||
const { checklist, draftAllocations, rationale, availableAmounts } =
|
||||
storeToRefs(sessionStore);
|
||||
|
||||
const allChecklistComplete = computed(() => {
|
||||
return Object.values(checklist.value).every(Boolean)
|
||||
})
|
||||
return Object.values(checklist.value).every(Boolean);
|
||||
});
|
||||
|
||||
// Calculate available amounts from real data
|
||||
const calculatedAvailableAmounts = computed(() => {
|
||||
// Calculate surplus from budget metrics
|
||||
const totalRevenue = streamsStore.totalMonthlyAmount || 0;
|
||||
const totalHours = membersStore.capacityTotals.targetHours || 0;
|
||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
||||
|
||||
const totalPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
|
||||
const totalOverhead = budgetStore.overheadCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
);
|
||||
const surplus = Math.max(0, totalRevenue - totalPayroll - totalOverhead);
|
||||
|
||||
// Calculate deferred owed
|
||||
const deferredOwed = membersStore.members.reduce((sum, member) => {
|
||||
return sum + (member.deferredHours || 0) * hourlyWage;
|
||||
}, 0);
|
||||
|
||||
// Calculate savings gap
|
||||
const savingsTarget =
|
||||
(policiesStore.savingsTargetMonths || 0) * (totalPayroll + totalOverhead);
|
||||
const savingsNeeded = Math.max(0, savingsTarget);
|
||||
|
||||
return {
|
||||
surplus,
|
||||
deferredOwed,
|
||||
savingsNeeded,
|
||||
};
|
||||
});
|
||||
|
||||
// Update store available amounts when calculated values change
|
||||
watch(
|
||||
calculatedAvailableAmounts,
|
||||
(newAmounts) => {
|
||||
sessionStore.updateAvailableAmounts(newAmounts);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -11,26 +11,28 @@
|
|||
</template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Equal Hourly Wage</label>
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
>Equal Hourly Wage</label
|
||||
>
|
||||
<UInput
|
||||
v-model="policies.hourlyWage"
|
||||
type="number"
|
||||
:ui="{ wrapper: 'relative' }"
|
||||
>
|
||||
:ui="{ wrapper: 'relative' }">
|
||||
<template #leading>
|
||||
<span class="text-gray-500">€</span>
|
||||
<span class="text-neutral-500">€</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Payroll On-costs (%)</label>
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
>Payroll On-costs (%)</label
|
||||
>
|
||||
<UInput
|
||||
v-model="policies.payrollOncost"
|
||||
type="number"
|
||||
:ui="{ wrapper: 'relative' }"
|
||||
>
|
||||
:ui="{ wrapper: 'relative' }">
|
||||
<template #trailing>
|
||||
<span class="text-gray-500">%</span>
|
||||
<span class="text-neutral-500">%</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
|
|
@ -43,22 +45,24 @@
|
|||
</template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Savings Target (months)</label>
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
>Savings Target (months)</label
|
||||
>
|
||||
<UInput
|
||||
v-model="policies.savingsTargetMonths"
|
||||
type="number"
|
||||
step="0.1"
|
||||
/>
|
||||
step="0.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Minimum Cash Cushion</label>
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
>Minimum Cash Cushion</label
|
||||
>
|
||||
<UInput
|
||||
v-model="policies.minCashCushion"
|
||||
type="number"
|
||||
:ui="{ wrapper: 'relative' }"
|
||||
>
|
||||
:ui="{ wrapper: 'relative' }">
|
||||
<template #leading>
|
||||
<span class="text-gray-500">€</span>
|
||||
<span class="text-neutral-500">€</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
|
|
@ -71,18 +75,16 @@
|
|||
</template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Cap (hours per quarter)</label>
|
||||
<UInput
|
||||
v-model="policies.deferredCapHours"
|
||||
type="number"
|
||||
/>
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
>Cap (hours per quarter)</label
|
||||
>
|
||||
<UInput v-model="policies.deferredCapHours" type="number" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Sunset (months)</label>
|
||||
<UInput
|
||||
v-model="policies.deferredSunsetMonths"
|
||||
type="number"
|
||||
/>
|
||||
<label class="block text-sm font-medium mb-2"
|
||||
>Sunset (months)</label
|
||||
>
|
||||
<UInput v-model="policies.deferredSunsetMonths" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
|
@ -92,16 +94,26 @@
|
|||
<h3 class="text-lg font-medium">Distribution Order</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600">
|
||||
<p class="text-sm text-neutral-600">
|
||||
Order of surplus distribution priorities.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, index) in distributionOrder" :key="item"
|
||||
class="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||
<span class="text-sm font-medium">{{ index + 1 }}. {{ item }}</span>
|
||||
<div
|
||||
v-for="(item, index) in distributionOrder"
|
||||
:key="item"
|
||||
class="flex items-center justify-between p-2 bg-neutral-50 rounded">
|
||||
<span class="text-sm font-medium"
|
||||
>{{ index + 1 }}. {{ item }}</span
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<UButton size="xs" variant="ghost" icon="i-heroicons-chevron-up" />
|
||||
<UButton size="xs" variant="ghost" icon="i-heroicons-chevron-down" />
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-chevron-up" />
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-chevron-down" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -110,9 +122,7 @@
|
|||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UButton color="primary">
|
||||
Save Policies
|
||||
</UButton>
|
||||
<UButton color="primary"> Save Policies </UButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -124,15 +134,15 @@ const policies = ref({
|
|||
savingsTargetMonths: 3,
|
||||
minCashCushion: 3000,
|
||||
deferredCapHours: 240,
|
||||
deferredSunsetMonths: 12
|
||||
})
|
||||
deferredSunsetMonths: 12,
|
||||
});
|
||||
|
||||
const distributionOrder = ref([
|
||||
'Deferred',
|
||||
'Savings',
|
||||
'Hardship',
|
||||
'Training',
|
||||
'Patronage',
|
||||
'Retained'
|
||||
])
|
||||
"Deferred",
|
||||
"Savings",
|
||||
"Hardship",
|
||||
"Training",
|
||||
"Patronage",
|
||||
"Retained",
|
||||
]);
|
||||
</script>
|
||||
|
|
|
|||
2981
pages/templates/conflict-resolution-framework.vue
Normal file
2981
pages/templates/conflict-resolution-framework.vue
Normal file
File diff suppressed because it is too large
Load diff
975
pages/templates/decision-framework.vue
Normal file
975
pages/templates/decision-framework.vue
Normal file
|
|
@ -0,0 +1,975 @@
|
|||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8 px-4"
|
||||
style="font-family: 'Ubuntu', monospace">
|
||||
<div class="max-w-4xl mx-auto relative">
|
||||
<div
|
||||
class="bg-white dark:bg-neutral-950 border border-black dark:border-white decision-framework-container">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="bg-black dark:bg-white text-white dark:text-black px-8 py-12 text-center header-section">
|
||||
<!-- Dithered shadow background -->
|
||||
<div
|
||||
class="absolute top-4 left-4 right-0 bottom-0 dither-shadow-header"></div>
|
||||
|
||||
<div
|
||||
class="relative bg-black dark:bg-white text-white dark:text-black px-4 py-4 border border-white dark:border-black">
|
||||
<h1
|
||||
class="text-3xl font-bold mb-2 uppercase"
|
||||
style="font-family: 'Ubuntu', monospace">
|
||||
Decision Framework Helper
|
||||
</h1>
|
||||
<p class="text-lg" style="font-family: 'Ubuntu', monospace">
|
||||
Find the right way to decide together
|
||||
</p>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div v-if="!showResult" class="mt-8">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span
|
||||
class="text-sm"
|
||||
style="font-family: 'Ubuntu Mono', monospace"
|
||||
>Step {{ currentStep }} of {{ totalSteps }}</span
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
style="font-family: 'Ubuntu Mono', monospace"
|
||||
>{{ Math.round((currentStep / totalSteps) * 100) }}%</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="w-full bg-white dark:bg-black h-2 border border-white dark:border-black">
|
||||
<div
|
||||
class="bg-black dark:bg-white h-full transition-all duration-300 progress-dither"
|
||||
:style="{
|
||||
width: (currentStep / totalSteps) * 100 + '%',
|
||||
}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-8 py-12">
|
||||
<!-- Step Content -->
|
||||
<div v-if="!showResult" class="min-h-[400px]">
|
||||
<!-- Question 1: Urgency -->
|
||||
<div v-if="currentStep === 1">
|
||||
<div
|
||||
class="font-semibold text-black dark:text-white mb-6 text-2xl"
|
||||
style="font-family: 'Ubuntu', monospace">
|
||||
How urgent is this decision?
|
||||
</div>
|
||||
<div
|
||||
class="bg-white dark:bg-neutral-950 p-8 border border-black dark:border-white relative">
|
||||
<!-- Dithered shadow background -->
|
||||
<div
|
||||
class="absolute top-2 left-2 right-0 bottom-0 dither-shadow"></div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="flex justify-between mb-6 text-sm">
|
||||
<span
|
||||
class="text-black dark:text-white font-bold"
|
||||
style="font-family: 'Ubuntu Mono', monospace"
|
||||
>WE HAVE PLENTY OF TIME</span
|
||||
>
|
||||
<span
|
||||
class="text-black dark:text-white font-bold"
|
||||
style="font-family: 'Ubuntu Mono', monospace"
|
||||
>NEEDED YESTERDAY</span
|
||||
>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="range"
|
||||
v-model="state.urgency"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
class="w-full h-2 bg-white dark:bg-black appearance-none cursor-pointer slider" />
|
||||
<div
|
||||
class="flex justify-between mt-4 text-sm text-black dark:text-white"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
<span>1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
<span>4</span>
|
||||
<span>5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question 2: Reversibility -->
|
||||
<div v-if="currentStep === 2">
|
||||
<div
|
||||
class="font-semibold text-black mb-6 text-2xl"
|
||||
style="font-family: 'Ubuntu', monospace">
|
||||
Can we change our minds later?
|
||||
</div>
|
||||
<div class="grid gap-4">
|
||||
<UCard
|
||||
v-for="option in reversibilityOptions"
|
||||
:key="option.value"
|
||||
:class="[
|
||||
'cursor-pointer transition-all duration-200 border-2',
|
||||
state.reversible === option.value
|
||||
? 'border-violet-700 bg-violet-700 text-white'
|
||||
: 'border-neutral-200 hover:border-violet-700 hover:bg-violet-50',
|
||||
]"
|
||||
@click="selectOption('reversible', option.value)">
|
||||
<div class="font-semibold mb-1">{{ option.title }}</div>
|
||||
<div class="text-sm opacity-85">{{ option.description }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question 3: Expertise -->
|
||||
<div v-if="currentStep === 3">
|
||||
<div class="font-semibold text-neutral-900 mb-6 text-2xl">
|
||||
Who has the most relevant expertise?
|
||||
</div>
|
||||
<div class="grid gap-4">
|
||||
<UCard
|
||||
v-for="option in expertiseOptions"
|
||||
:key="option.value"
|
||||
:class="[
|
||||
'cursor-pointer transition-all duration-200 border-2',
|
||||
state.expertise === option.value
|
||||
? 'border-violet-700 bg-violet-700 text-white'
|
||||
: 'border-neutral-200 hover:border-violet-700 hover:bg-violet-50',
|
||||
]"
|
||||
@click="selectOption('expertise', option.value)">
|
||||
<div class="font-semibold mb-1">{{ option.title }}</div>
|
||||
<div class="text-sm opacity-85">{{ option.description }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question 4: Impact -->
|
||||
<div v-if="currentStep === 4">
|
||||
<div class="font-semibold text-neutral-900 mb-6 text-2xl">
|
||||
Who will this impact?
|
||||
</div>
|
||||
<div class="grid gap-4">
|
||||
<UCard
|
||||
v-for="option in impactOptions"
|
||||
:key="option.value"
|
||||
:class="[
|
||||
'cursor-pointer transition-all duration-200 border-2',
|
||||
state.impact === option.value
|
||||
? 'border-violet-700 bg-violet-700 text-white'
|
||||
: 'border-neutral-200 hover:border-violet-700 hover:bg-violet-50',
|
||||
]"
|
||||
@click="selectOption('impact', option.value)">
|
||||
<div class="font-semibold mb-1">{{ option.title }}</div>
|
||||
<div class="text-sm opacity-85">{{ option.description }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question 5: Options clarity -->
|
||||
<div v-if="currentStep === 5">
|
||||
<div class="font-semibold text-neutral-900 mb-6 text-2xl">
|
||||
How well-defined are the options?
|
||||
</div>
|
||||
<div class="grid gap-4">
|
||||
<UCard
|
||||
v-for="option in optionsOptions"
|
||||
:key="option.value"
|
||||
:class="[
|
||||
'cursor-pointer transition-all duration-200 border-2',
|
||||
state.options === option.value
|
||||
? 'border-violet-700 bg-violet-700 text-white'
|
||||
: 'border-neutral-200 hover:border-violet-700 hover:bg-violet-50',
|
||||
]"
|
||||
@click="selectOption('options', option.value)">
|
||||
<div class="font-semibold mb-1">{{ option.title }}</div>
|
||||
<div class="text-sm opacity-85">{{ option.description }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question 6: Investment -->
|
||||
<div v-if="currentStep === 6">
|
||||
<div class="font-semibold text-neutral-900 mb-6 text-2xl">
|
||||
How invested is everyone?
|
||||
</div>
|
||||
<div class="grid gap-4">
|
||||
<UCard
|
||||
v-for="option in investmentOptions"
|
||||
:key="option.value"
|
||||
:class="[
|
||||
'cursor-pointer transition-all duration-200 border-2',
|
||||
state.investment === option.value
|
||||
? 'border-violet-700 bg-violet-700 text-white'
|
||||
: 'border-neutral-200 hover:border-violet-700 hover:bg-violet-50',
|
||||
]"
|
||||
@click="selectOption('investment', option.value)">
|
||||
<div class="font-semibold mb-1">{{ option.title }}</div>
|
||||
<div class="text-sm opacity-85">{{ option.description }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question 7: Team size -->
|
||||
<div v-if="currentStep === 7">
|
||||
<div class="font-semibold text-neutral-900 mb-6 text-2xl">
|
||||
How many people need to participate?
|
||||
</div>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-5 gap-4">
|
||||
<button
|
||||
v-for="size in teamSizes"
|
||||
:key="size"
|
||||
:class="[
|
||||
'px-4 py-3 font-semibold text-sm rounded-md border-2 transition-all duration-200',
|
||||
state.teamSize === size
|
||||
? 'bg-violet-700 text-white border-violet-700'
|
||||
: 'bg-white text-neutral-700 border-neutral-200 hover:border-violet-700 hover:bg-violet-50',
|
||||
]"
|
||||
@click="selectOption('teamSize', size)">
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div
|
||||
class="flex justify-between items-center mt-12 pt-8 border-t border-neutral-200">
|
||||
<button
|
||||
v-if="currentStep > 1"
|
||||
@click="previousStep"
|
||||
class="px-6 py-3 text-violet-700 border border-violet-700 rounded-md hover:bg-violet-50 transition-all duration-200">
|
||||
← Previous
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
<button
|
||||
v-if="canProceed && currentStep < totalSteps"
|
||||
@click="nextStep"
|
||||
class="px-6 py-3 bg-violet-700 text-white rounded-md hover:bg-violet-800 transition-all duration-200">
|
||||
Next →
|
||||
</button>
|
||||
<button
|
||||
v-else-if="canProceed && currentStep === totalSteps"
|
||||
@click="showRecommendation"
|
||||
class="px-6 py-3 bg-violet-700 text-white rounded-md hover:bg-violet-800 transition-all duration-200">
|
||||
Get Recommendation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div
|
||||
v-if="showResult"
|
||||
data-results
|
||||
class="border-t border-neutral-200 pt-12">
|
||||
<UCard class="bg-neutral-50">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold text-violet-700 mb-2">
|
||||
{{ result.method }}
|
||||
</h2>
|
||||
<p class="text-lg text-neutral-600">{{ result.tagline }}</p>
|
||||
</div>
|
||||
|
||||
<UCard class="bg-white mb-8">
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h3 class="font-semibold text-neutral-900 mb-4 text-lg">
|
||||
Why this framework?
|
||||
</h3>
|
||||
<p class="text-neutral-700 leading-relaxed">
|
||||
{{ result.reasoning }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold text-neutral-900 mb-4 text-lg">
|
||||
How to implement:
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="step in result.steps"
|
||||
:key="step"
|
||||
class="flex items-start">
|
||||
<span class="text-violet-700 font-bold mr-3 mt-1"
|
||||
>→</span
|
||||
>
|
||||
<span class="text-neutral-700">{{ step }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="result.tips">
|
||||
<h3 class="font-semibold text-neutral-900 mb-4 text-lg">
|
||||
Pro tips:
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="tip in result.tips"
|
||||
:key="tip"
|
||||
class="flex items-start">
|
||||
<span class="text-violet-700 font-bold mr-3 mt-1"
|
||||
>→</span
|
||||
>
|
||||
<span class="text-neutral-700">{{ tip }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UAlert
|
||||
v-if="result.warning"
|
||||
color="red"
|
||||
variant="soft"
|
||||
:title="'Watch out for:'"
|
||||
:description="result.warning"
|
||||
class="mb-6" />
|
||||
|
||||
<UAlert
|
||||
v-if="result.success"
|
||||
color="emerald"
|
||||
variant="soft"
|
||||
:title="'Success looks like:'"
|
||||
:description="result.success"
|
||||
class="mb-6" />
|
||||
|
||||
<UCard v-if="result.alternatives" class="bg-neutral-50">
|
||||
<h3 class="font-semibold text-neutral-900 mb-4 text-lg">
|
||||
Also consider:
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<UCard
|
||||
v-for="alt in result.alternatives"
|
||||
:key="alt.method"
|
||||
class="bg-white">
|
||||
<span class="font-semibold">{{ alt.method }}:</span>
|
||||
{{ alt.when }}
|
||||
</UCard>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex gap-4 mt-8">
|
||||
<UButton @click="resetForm" color="violet">
|
||||
Try Another Decision
|
||||
</UButton>
|
||||
<UButton @click="printResult" variant="outline" color="violet">
|
||||
Print Recommendation
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const state = reactive({
|
||||
urgency: 3,
|
||||
reversible: null,
|
||||
expertise: null,
|
||||
impact: null,
|
||||
options: null,
|
||||
investment: null,
|
||||
teamSize: null,
|
||||
});
|
||||
|
||||
const currentStep = ref(1);
|
||||
const totalSteps = 7;
|
||||
|
||||
const reversibilityOptions = [
|
||||
{
|
||||
value: "high",
|
||||
title: "Easily reversible",
|
||||
description: "We can pivot anytime with minimal cost",
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
title: "Some commitment",
|
||||
description: "Changes possible but with effort/cost",
|
||||
},
|
||||
{
|
||||
value: "low",
|
||||
title: "One-way door",
|
||||
description: "This decision is permanent or very hard to undo",
|
||||
},
|
||||
];
|
||||
|
||||
const expertiseOptions = [
|
||||
{
|
||||
value: "concentrated",
|
||||
title: "One clear expert",
|
||||
description: "One person has deep knowledge here",
|
||||
},
|
||||
{
|
||||
value: "multiple",
|
||||
title: "Multiple experts",
|
||||
description: "Several people have relevant expertise",
|
||||
},
|
||||
{
|
||||
value: "distributed",
|
||||
title: "Distributed knowledge",
|
||||
description: "Everyone has valuable input",
|
||||
},
|
||||
{
|
||||
value: "lacking",
|
||||
title: "Unknown territory",
|
||||
description: "We're all learning together",
|
||||
},
|
||||
];
|
||||
|
||||
const impactOptions = [
|
||||
{
|
||||
value: "narrow",
|
||||
title: "One person or small team",
|
||||
description: "Affects specific individuals or department",
|
||||
},
|
||||
{
|
||||
value: "wide",
|
||||
title: "Whole organization",
|
||||
description: "Everyone feels the effects",
|
||||
},
|
||||
];
|
||||
|
||||
const optionsOptions = [
|
||||
{
|
||||
value: "clear",
|
||||
title: "Clear choices",
|
||||
description: "We know our options and their trade-offs",
|
||||
},
|
||||
{
|
||||
value: "emerging",
|
||||
title: "Still exploring",
|
||||
description: "Options are emerging through discussion",
|
||||
},
|
||||
{
|
||||
value: "undefined",
|
||||
title: "Wide open",
|
||||
description: "We don't even know what's possible yet",
|
||||
},
|
||||
];
|
||||
|
||||
const investmentOptions = [
|
||||
{
|
||||
value: "high",
|
||||
title: "Everyone cares deeply",
|
||||
description: "Strong opinions all around",
|
||||
},
|
||||
{
|
||||
value: "mixed",
|
||||
title: "Mixed investment",
|
||||
description: "Some care more than others",
|
||||
},
|
||||
{
|
||||
value: "low",
|
||||
title: "Low stakes for most",
|
||||
description: "People are flexible",
|
||||
},
|
||||
];
|
||||
|
||||
const teamSizes = ["2", "3", "4-5", "6-8", "9+"];
|
||||
|
||||
const showResult = ref(false);
|
||||
|
||||
const result = computed(() => {
|
||||
if (!showResult.value) return null;
|
||||
return determineFramework();
|
||||
});
|
||||
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return true; // urgency always has a value
|
||||
case 2:
|
||||
return state.reversible !== null;
|
||||
case 3:
|
||||
return state.expertise !== null;
|
||||
case 4:
|
||||
return state.impact !== null;
|
||||
case 5:
|
||||
return state.options !== null;
|
||||
case 6:
|
||||
return state.investment !== null;
|
||||
case 7:
|
||||
return state.teamSize !== null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
function selectOption(category, value) {
|
||||
state[category] = value;
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (currentStep.value < totalSteps) {
|
||||
currentStep.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function previousStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function showRecommendation() {
|
||||
showResult.value = true;
|
||||
nextTick(() => {
|
||||
const resultsElement = document.querySelector("[data-results]");
|
||||
if (resultsElement) {
|
||||
resultsElement.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function determineFramework() {
|
||||
// AUTOCRATIC - urgent + concentrated expertise + predictable
|
||||
if (
|
||||
state.urgency >= 5 &&
|
||||
state.expertise === "concentrated" &&
|
||||
state.options === "clear"
|
||||
) {
|
||||
return {
|
||||
method: "Autocratic",
|
||||
tagline: "Quick decision by designated leader",
|
||||
reasoning:
|
||||
"Extreme urgency with clear options and concentrated expertise. Speed is critical.",
|
||||
steps: [
|
||||
"Leader makes immediate decision",
|
||||
"Communicate decision and rationale quickly",
|
||||
"Execute without delay",
|
||||
"Debrief when crisis passes",
|
||||
],
|
||||
warning:
|
||||
"Only use in true emergencies. Follow up with team discussion afterward.",
|
||||
success:
|
||||
"Crisis averted through quick action. Team understands why autocratic mode was necessary.",
|
||||
};
|
||||
}
|
||||
|
||||
// DEFER TO EXPERT - clear expertise + urgency
|
||||
if (
|
||||
state.expertise === "concentrated" &&
|
||||
(state.urgency >= 4 || state.impact === "narrow")
|
||||
) {
|
||||
return {
|
||||
method: "Defer to Expert",
|
||||
tagline: "Trust the person who knows this best",
|
||||
reasoning:
|
||||
"You have someone with clear expertise, and either time is short or the impact is contained. Let them lead while keeping everyone informed.",
|
||||
steps: [
|
||||
"Expert proposes solution with reasoning",
|
||||
"Quick clarifying questions (set time limit)",
|
||||
"Expert makes final call",
|
||||
"Document decision and rationale",
|
||||
"Schedule check-in if reversible",
|
||||
],
|
||||
tips: [
|
||||
"Expert should explain their thinking, not just the outcome",
|
||||
"Create space for concerns to be raised",
|
||||
"If expert is unsure, that's valuable info—maybe try another method",
|
||||
],
|
||||
warning:
|
||||
"The expert should still seek input. Expertise + diverse perspectives = better decisions.",
|
||||
success:
|
||||
"Decision made quickly with buy-in because people trust the expert's judgment and understand the reasoning.",
|
||||
};
|
||||
}
|
||||
|
||||
// AVOIDANT - non-urgent + undefined + low investment
|
||||
if (
|
||||
state.urgency <= 2 &&
|
||||
state.options === "undefined" &&
|
||||
state.investment === "low"
|
||||
) {
|
||||
return {
|
||||
method: "Strategic Delay",
|
||||
tagline: "Wait for clarity to emerge",
|
||||
reasoning:
|
||||
"It's not urgent, options aren't clear, and people aren't strongly invested. Sometimes the best decision is to not decide yet.",
|
||||
steps: [
|
||||
"Acknowledge the decision exists",
|
||||
"Set a future check-in date",
|
||||
"Gather information passively",
|
||||
"Revisit when conditions change",
|
||||
"Document why you're waiting",
|
||||
],
|
||||
warning:
|
||||
"Don't let avoidance become paralysis. Set a deadline for revisiting.",
|
||||
success:
|
||||
"By waiting, better options emerged or the decision became unnecessary.",
|
||||
alternatives: [
|
||||
{
|
||||
method: "Time-boxed exploration",
|
||||
when: "Give it 2 weeks to see if clarity emerges",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// CONSENSUS - low urgency + high stakes + everyone affected
|
||||
if (
|
||||
state.urgency <= 2 &&
|
||||
state.reversible === "low" &&
|
||||
state.impact === "wide" &&
|
||||
state.investment === "high"
|
||||
) {
|
||||
return {
|
||||
method: "Full Consensus",
|
||||
tagline: "Everyone agrees to support the decision",
|
||||
reasoning:
|
||||
"This is a high-stakes, permanent decision affecting everyone who cares deeply. Take the time to get real alignment.",
|
||||
steps: [
|
||||
"Share context and constraints with everyone",
|
||||
"Gather all perspectives (async or sync)",
|
||||
"Identify shared values and concerns",
|
||||
"Iterate on proposals until everyone can support it",
|
||||
"Document the decision and everyone's commitment",
|
||||
],
|
||||
tips: [
|
||||
"Consensus ≠ everyone's favorite. It means everyone can live with it",
|
||||
"Use 'I can live with this' as your bar, not 'I love this'",
|
||||
"Timebox discussion rounds to maintain energy",
|
||||
],
|
||||
warning:
|
||||
"If consensus is taking too long, check: Is everyone operating with the same info? Are we solving the right problem?",
|
||||
success:
|
||||
"Everyone understands the decision and commits to supporting it, even if it wasn't their first choice.",
|
||||
};
|
||||
}
|
||||
|
||||
// CONSENT - medium stakes, mixed investment
|
||||
if (state.investment === "mixed" && state.reversible !== "low") {
|
||||
return {
|
||||
method: "Consent-Based Decision",
|
||||
tagline: "No one objects strongly enough to block",
|
||||
reasoning:
|
||||
"Not everyone is equally invested, and the decision is reversible. Focus on addressing objections rather than optimizing preferences.",
|
||||
steps: [
|
||||
"Proposer presents solution",
|
||||
"Ask: 'Can you live with this?'",
|
||||
"Address only strong objections",
|
||||
"Modify proposal if needed",
|
||||
"Move forward when no blocking objections remain",
|
||||
],
|
||||
tips: [
|
||||
"Objections must be based on harm to the co-op, not personal preference",
|
||||
"Set a clear bar for what counts as a blocking objection",
|
||||
"This is faster than consensus but still inclusive",
|
||||
],
|
||||
success:
|
||||
"Decision made efficiently with key concerns addressed, without getting stuck in preference debates.",
|
||||
};
|
||||
}
|
||||
|
||||
// DELEGATION - narrow impact + concentrated expertise
|
||||
if (
|
||||
state.impact === "narrow" &&
|
||||
state.expertise === "concentrated" &&
|
||||
state.urgency >= 3
|
||||
) {
|
||||
return {
|
||||
method: "Delegation",
|
||||
tagline: "Empower the responsible party to decide",
|
||||
reasoning:
|
||||
"This primarily affects specific people who have the expertise. Trust them to handle it.",
|
||||
steps: [
|
||||
"Clarify scope and constraints",
|
||||
"Delegate to affected party/expert",
|
||||
"Set check-in points if needed",
|
||||
"Trust them to execute",
|
||||
"Report back on outcome",
|
||||
],
|
||||
tips: [
|
||||
"Be clear about what's delegated and what's not",
|
||||
"Delegation means trusting their judgment, not micromanaging",
|
||||
],
|
||||
success:
|
||||
"Decision made efficiently by those closest to the work, building trust and autonomy.",
|
||||
};
|
||||
}
|
||||
|
||||
// CONSULTATIVE - lacking expertise but need input
|
||||
if (state.expertise === "lacking" && state.options === "emerging") {
|
||||
return {
|
||||
method: "Consultative Process",
|
||||
tagline: "Gather input, then designated person decides",
|
||||
reasoning:
|
||||
"No one has clear expertise but we need various perspectives to understand the options.",
|
||||
steps: [
|
||||
"Designate decision owner",
|
||||
"Owner seeks input from all stakeholders",
|
||||
"Owner researches and synthesizes options",
|
||||
"Owner makes decision and explains reasoning",
|
||||
"Share decision with clear rationale",
|
||||
],
|
||||
tips: [
|
||||
"Be transparent about who decides and when",
|
||||
"Document all input received",
|
||||
"Explain how input influenced the decision",
|
||||
],
|
||||
success:
|
||||
"Decision informed by diverse perspectives with clear accountability.",
|
||||
};
|
||||
}
|
||||
|
||||
// STOCHASTIC - truly stuck, low stakes
|
||||
if (
|
||||
state.options === "clear" &&
|
||||
state.investment === "low" &&
|
||||
state.reversible === "high"
|
||||
) {
|
||||
return {
|
||||
method: "Controlled Randomness",
|
||||
tagline: "Let chance break the tie",
|
||||
reasoning:
|
||||
"Options are equally good, stakes are low, and people aren't strongly invested. Save time and energy.",
|
||||
steps: [
|
||||
"Confirm all options are acceptable",
|
||||
"Choose random method (coin, dice, draw straws)",
|
||||
"Do it publicly for transparency",
|
||||
"Commit to the outcome",
|
||||
"Move on without second-guessing",
|
||||
],
|
||||
warning:
|
||||
"Only works if everyone truly accepts all options. Don't use for important decisions.",
|
||||
success: "Quick resolution that feels fair because chance is impartial.",
|
||||
alternatives: [
|
||||
{
|
||||
method: "Take turns choosing",
|
||||
when: "Rotate who picks when these situations arise",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 3-PERSON TRAP
|
||||
if (state.teamSize === "3" && state.investment === "high") {
|
||||
return {
|
||||
method: "Modified Consensus (Not Voting!)",
|
||||
tagline: "Voting creates problems in groups of three",
|
||||
reasoning:
|
||||
"With 3 people, one person always becomes the tie-breaker, which creates unhealthy dynamics. Use rotating facilitation instead.",
|
||||
steps: [
|
||||
"Rotate who facilitates the decision",
|
||||
"Facilitator synthesizes others' views first",
|
||||
"Look for creative third options",
|
||||
"If stuck, defer to whoever is most affected",
|
||||
"Or use external input (advisor, user feedback)",
|
||||
],
|
||||
warning:
|
||||
"Never use simple majority voting with 3 people—it turns one person into a perpetual kingmaker.",
|
||||
success:
|
||||
"All three members feel heard and the decision reflects collective wisdom, not just the middle person's preference.",
|
||||
alternatives: [
|
||||
{
|
||||
method: "Time-boxed experiment",
|
||||
when: "Try one option for 2 weeks, then reassess",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// DEMOCRATIC VOTE - larger group, time pressure
|
||||
if (
|
||||
(state.teamSize === "6-8" || state.teamSize === "9+") &&
|
||||
state.urgency >= 4
|
||||
) {
|
||||
return {
|
||||
method: "Democratic Vote",
|
||||
tagline: "Majority decides, move forward together",
|
||||
reasoning:
|
||||
"Large group + time pressure = need for efficiency. Voting provides clear resolution while respecting everyone's input.",
|
||||
steps: [
|
||||
"Present options clearly with pros/cons",
|
||||
"Discussion round (time-boxed)",
|
||||
"Anonymous or open vote (decide beforehand)",
|
||||
"Announce result and thank minority view",
|
||||
"Document dissenting concerns for future review",
|
||||
],
|
||||
tips: [
|
||||
"Consider ranked choice for more than 2 options",
|
||||
"Anonymous voting reduces peer pressure",
|
||||
"Always acknowledge the minority position respectfully",
|
||||
],
|
||||
warning:
|
||||
"Don't vote on everything! Reserve it for when other methods are too slow.",
|
||||
success:
|
||||
"Clear decision made efficiently with everyone having equal say.",
|
||||
};
|
||||
}
|
||||
|
||||
// EXPERIMENTAL - unknown territory
|
||||
if (state.expertise === "lacking" && state.reversible === "high") {
|
||||
return {
|
||||
method: "Run an Experiment",
|
||||
tagline: "Try something small and learn",
|
||||
reasoning:
|
||||
"Nobody knows the right answer and it's easy to change course. Perfect for learning by doing.",
|
||||
steps: [
|
||||
"Define what you're testing",
|
||||
"Set clear success metrics",
|
||||
"Choose shortest meaningful trial period",
|
||||
"Pick simplest version to test",
|
||||
"Schedule review before committing further",
|
||||
],
|
||||
tips: [
|
||||
"Make it clear this is an experiment, not a decision",
|
||||
"Shorter trials = faster learning",
|
||||
"Document what you learn, not just what happened",
|
||||
],
|
||||
success:
|
||||
"You learn what works through experience rather than speculation, building confidence for bigger decisions.",
|
||||
};
|
||||
}
|
||||
|
||||
// ADVICE PROCESS - multiple expertise, mixed investment
|
||||
if (state.expertise === "multiple" && state.investment === "mixed") {
|
||||
return {
|
||||
method: "Advice Process",
|
||||
tagline: "Decision-maker seeks input, then decides",
|
||||
reasoning:
|
||||
"Multiple people have valuable input, but not everyone needs to be involved in the final call. This balances inclusion with efficiency.",
|
||||
steps: [
|
||||
"Assign decision owner (most affected or willing)",
|
||||
"Owner seeks advice from those with expertise",
|
||||
"Owner seeks input from those affected",
|
||||
"Owner makes decision and explains reasoning",
|
||||
"Share decision and thank advisors",
|
||||
],
|
||||
tips: [
|
||||
"Be clear who the decision owner is upfront",
|
||||
"Seeking advice ≠ design by committee",
|
||||
"Owner genuinely considers input but isn't bound by it",
|
||||
],
|
||||
success:
|
||||
"Decision made efficiently with relevant input incorporated, and everyone understands the reasoning.",
|
||||
};
|
||||
}
|
||||
|
||||
// DEFAULT
|
||||
return {
|
||||
method: "Facilitated Discussion",
|
||||
tagline: "Talk it through with structure",
|
||||
reasoning:
|
||||
"Your situation has mixed signals. Use a structured discussion to find clarity before choosing a decision method.",
|
||||
steps: [
|
||||
"Clarify what we're actually deciding",
|
||||
"Share all relevant information",
|
||||
"Each person shares their perspective (timed)",
|
||||
"Identify where we align and where we differ",
|
||||
"Choose appropriate method based on what emerges",
|
||||
],
|
||||
tips: [
|
||||
"Sometimes the discussion reveals you're solving the wrong problem",
|
||||
"Visual tools (sticky notes, diagrams) help with complex decisions",
|
||||
"If stuck, ask: 'What would happen if we did nothing?'",
|
||||
],
|
||||
warning:
|
||||
"Don't let discussion become delay. Set a deadline for moving to a decision method.",
|
||||
success:
|
||||
"The real question becomes clear and the right decision method becomes obvious.",
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
state.urgency = 3;
|
||||
state.reversible = null;
|
||||
state.expertise = null;
|
||||
state.impact = null;
|
||||
state.options = null;
|
||||
state.investment = null;
|
||||
state.teamSize = null;
|
||||
currentStep.value = 1;
|
||||
showResult.value = false;
|
||||
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function printResult() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
onMounted(() => {
|
||||
const handleKeydown = (event) => {
|
||||
if (showResult.value) return;
|
||||
|
||||
if (
|
||||
event.key === "ArrowRight" &&
|
||||
canProceed.value &&
|
||||
currentStep.value < totalSteps
|
||||
) {
|
||||
nextStep();
|
||||
} else if (event.key === "ArrowLeft" && currentStep.value > 1) {
|
||||
previousStep();
|
||||
} else if (
|
||||
event.key === "Enter" &&
|
||||
canProceed.value &&
|
||||
currentStep.value === totalSteps
|
||||
) {
|
||||
showRecommendation();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Decision Framework Helper",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Find the right way to decide together with this interactive decision-making framework helper.",
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Dark mode utility overrides for better contrast */
|
||||
html.dark :deep(.text-neutral-900),
|
||||
html.dark :deep(.text-neutral-800),
|
||||
html.dark :deep(.text-neutral-700),
|
||||
html.dark :deep(.text-neutral-600),
|
||||
html.dark :deep(.text-neutral-500) {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
html.dark :deep(.bg-neutral-50),
|
||||
html.dark :deep(.bg-neutral-100),
|
||||
html.dark :deep(.bg-neutral-200) {
|
||||
background-color: #0a0a0a !important;
|
||||
}
|
||||
|
||||
html.dark :deep(.border-neutral-200),
|
||||
html.dark :deep(.border-neutral-300) {
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Header progress bar frame inversion */
|
||||
html.dark :deep(.header-section .w-full.h-2) {
|
||||
background-color: #0a0a0a !important;
|
||||
border-color: #000 !important;
|
||||
}
|
||||
|
||||
/* Buttons in results area */
|
||||
html.dark :deep(.u-card),
|
||||
html.dark :deep(.bg-white) {
|
||||
background-color: #0a0a0a !important;
|
||||
}
|
||||
|
||||
html.dark :deep(.bg-neutral-50) {
|
||||
background-color: #0f172a !important;
|
||||
}
|
||||
</style>
|
||||
369
pages/templates/index.vue
Normal file
369
pages/templates/index.vue
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
<template>
|
||||
<div
|
||||
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
|
||||
style="font-family: 'Ubuntu', 'Ubuntu Mono', monospace">
|
||||
<div class="max-w-6xl mx-auto px-4 relative">
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
class="text-3xl font-bold text-neutral-900 dark:text-white mb-2"
|
||||
style="font-family: 'Ubuntu', monospace">
|
||||
Document Templates
|
||||
</h1>
|
||||
<p class="text-neutral-700 dark:text-neutral-200">
|
||||
Fillable forms for cooperative documents. Data saves locally in your
|
||||
browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="template-card h-full flex flex-col">
|
||||
<!-- Dithered shadow background -->
|
||||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div
|
||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6 h-full flex flex-col">
|
||||
<div class="mb-4">
|
||||
<h3
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
{{ template.name }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
{{ template.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<span
|
||||
v-for="tag in template.tags"
|
||||
:key="tag"
|
||||
class="px-2 py-1 text-xs font-medium bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-900 border border-black dark:border-white dither-tag">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>{{ template.estimatedTime }}</span>
|
||||
<span>{{ template.fields }} fields</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer to push buttons to bottom -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="flex gap-2 mt-auto">
|
||||
<NuxtLink
|
||||
:to="template.path"
|
||||
class="flex-1 px-4 py-2 bg-black dark:bg-white text-white dark:text-black border border-black dark:border-white hover:bg-black dark:hover:bg-white transition-colors text-center font-medium bitmap-button"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
START TEMPLATE
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="hasData(template.id)"
|
||||
:to="template.path"
|
||||
class="px-4 py-2 bg-white dark:bg-neutral-950 text-black dark:text-white border border-black dark:border-white hover:bg-white dark:hover:bg-neutral-950 transition-colors bitmap-button"
|
||||
title="Continue from saved data"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
RESUME
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="mt-12 help-section">
|
||||
<!-- Dithered shadow background -->
|
||||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div
|
||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
|
||||
<h2
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white mb-3"
|
||||
style="font-family: 'Ubuntu', monospace">
|
||||
How Templates Work
|
||||
</h2>
|
||||
<div
|
||||
class="grid md:grid-cols-2 gap-6 text-neutral-900 dark:text-neutral-100">
|
||||
<div>
|
||||
<h3
|
||||
class="font-medium mb-2 text-neutral-900 dark:text-white"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
FILL OUT FORMS
|
||||
</h3>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
Templates include form fields for all necessary information.
|
||||
Data auto-saves as you type.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="font-medium mb-2 text-neutral-900 dark:text-white"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
LOCAL STORAGE
|
||||
</h3>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
All data saves in your browser only. Nothing is sent to external
|
||||
servers.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="font-medium mb-2 text-neutral-900 dark:text-white"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
EXPORT OPTIONS
|
||||
</h3>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
Download as PDF (print), plain text, Markdown, or Word document.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="font-medium mb-2 text-neutral-900 dark:text-white"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
RESUME ANYTIME
|
||||
</h3>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-200">
|
||||
Come back later and your progress will be saved. Clear browser
|
||||
data to start fresh.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const templates = [
|
||||
{
|
||||
id: "membership-agreement",
|
||||
name: "Membership Agreement",
|
||||
description:
|
||||
"A comprehensive agreement outlining member rights, responsibilities, decision-making processes, and financial arrangements for worker cooperatives.",
|
||||
icon: "i-heroicons-user-group",
|
||||
path: "/templates/membership-agreement",
|
||||
tags: ["Legal", "Governance", "Membership"],
|
||||
estimatedTime: "15-30 min",
|
||||
fields: 25,
|
||||
storageKey: "membership-agreement-data",
|
||||
},
|
||||
{
|
||||
id: "conflict-resolution-framework",
|
||||
name: "Conflict Resolution Framework",
|
||||
description:
|
||||
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
|
||||
icon: "i-heroicons-scale",
|
||||
path: "/templates/conflict-resolution-framework",
|
||||
tags: ["Governance", "Process", "Care"],
|
||||
estimatedTime: "20-40 min",
|
||||
fields: 35,
|
||||
storageKey: "conflict-resolution-framework-data",
|
||||
},
|
||||
{
|
||||
id: "tech-charter",
|
||||
name: "Technology Charter",
|
||||
description:
|
||||
"Build technology decisions on cooperative values. Define principles, technical constraints, and evaluation criteria for vendor selection.",
|
||||
icon: "i-heroicons-cog-6-tooth",
|
||||
path: "/templates/tech-charter",
|
||||
tags: ["Technology", "Decision-Making", "Governance"],
|
||||
estimatedTime: "10-20 min",
|
||||
fields: 20,
|
||||
storageKey: "tech-charter-data",
|
||||
},
|
||||
{
|
||||
id: "decision-framework",
|
||||
name: "Decision Framework Helper",
|
||||
description:
|
||||
"Interactive tool to help determine the best decision-making approach based on urgency, expertise, stakes, and team dynamics.",
|
||||
icon: "i-heroicons-light-bulb",
|
||||
path: "/templates/decision-framework",
|
||||
tags: ["Decision-Making", "Process", "Governance"],
|
||||
estimatedTime: "5-10 min",
|
||||
fields: 7,
|
||||
storageKey: "decision-framework-data",
|
||||
},
|
||||
];
|
||||
|
||||
const hasData = (templateId) => {
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (!template?.storageKey) return false;
|
||||
|
||||
if (process.client) {
|
||||
const saved = localStorage.getItem(template.storageKey);
|
||||
return saved && saved !== "{}";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Remove the JavaScript background handler since we're using CSS classes
|
||||
|
||||
useHead({
|
||||
title: "Document Templates - Co-op Pay & Value Tool",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Fillable document templates for worker cooperatives including membership agreements and governance documents.",
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Ubuntu font import */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
|
||||
|
||||
/* Removed full-screen dither pattern to avoid gray haze in dark mode */
|
||||
|
||||
/* Exact shadow style from value-flow inspiration */
|
||||
.dither-shadow {
|
||||
background: black;
|
||||
background-image: radial-gradient(white 1px, transparent 1px);
|
||||
background-size: 2px 2px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dither-shadow {
|
||||
background: white;
|
||||
background-image: radial-gradient(black 1px, transparent 1px);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) .dither-shadow {
|
||||
background: white;
|
||||
background-image: radial-gradient(black 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.dither-shadow-disabled {
|
||||
background: black;
|
||||
background-image: radial-gradient(white 1px, transparent 1px);
|
||||
background-size: 2px 2px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dither-shadow-disabled {
|
||||
background: white;
|
||||
background-image: radial-gradient(black 1px, transparent 1px);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) .dither-shadow-disabled {
|
||||
background: white;
|
||||
background-image: radial-gradient(black 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Rely on Tailwind bg utilities on container */
|
||||
|
||||
.template-card {
|
||||
@apply relative;
|
||||
font-family: "Ubuntu", monospace;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dither-tag {
|
||||
position: relative;
|
||||
background: white;
|
||||
}
|
||||
.dither-tag::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0px,
|
||||
transparent 1px,
|
||||
black 1px,
|
||||
black 2px
|
||||
);
|
||||
opacity: 0.1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Button styling - pure bitmap, no colors */
|
||||
.bitmap-button {
|
||||
font-family: "Ubuntu Mono", monospace !important;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bitmap-button:hover {
|
||||
transform: translateY(-1px) translateX(-1px);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.bitmap-button:hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
border: 1px solid black;
|
||||
background: white;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.disabled-button {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Remove any inherited rounded corners */
|
||||
.template-card > *,
|
||||
.help-section > *,
|
||||
button,
|
||||
.px-4,
|
||||
div[class*="border"] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Button hover effects with bitmap feel */
|
||||
.template-card .relative:hover {
|
||||
transform: translateY(-1px);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* Ensure sharp edges on all elements */
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
font-family: "Ubuntu", monospace;
|
||||
}
|
||||
|
||||
html.dark :deep(.text-neutral-700),
|
||||
html.dark :deep(.text-neutral-500),
|
||||
html.dark :deep(.bg-neutral-50),
|
||||
html.dark :deep(.bg-neutral-100) {
|
||||
color: white !important;
|
||||
background-color: #0a0a0a !important;
|
||||
}
|
||||
|
||||
:deep(.border-neutral-200),
|
||||
:deep(.border-neutral-300) {
|
||||
border-color: black !important;
|
||||
}
|
||||
</style>
|
||||
2722
pages/templates/membership-agreement.vue
Normal file
2722
pages/templates/membership-agreement.vue
Normal file
File diff suppressed because it is too large
Load diff
1828
pages/templates/tech-charter.vue
Normal file
1828
pages/templates/tech-charter.vue
Normal file
File diff suppressed because it is too large
Load diff
381
pages/wizard.vue
381
pages/wizard.vue
|
|
@ -1,89 +1,268 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Setup Wizard</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge color="primary" variant="subtle"
|
||||
>Step {{ currentStep }} of 5</UBadge
|
||||
>
|
||||
<section class="py-8 max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-10">
|
||||
<h1 class="text-5xl font-black text-black mb-4 leading-tight">
|
||||
Set up your co-op
|
||||
</h1>
|
||||
<p class="text-xl text-neutral-700 font-medium">
|
||||
Get your worker-owned co-op configured in a few simple steps. Jump to
|
||||
any step or work through them in order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Completed State -->
|
||||
<div v-if="isCompleted" class="text-center py-12">
|
||||
<div
|
||||
class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<UIcon name="i-heroicons-check" class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-black mb-2">You're all set!</h2>
|
||||
<p class="text-neutral-600 mb-6">
|
||||
Your co-op is configured and ready to go.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="resetWizard"
|
||||
variant="outline"
|
||||
color="gray"
|
||||
@click="restartWizard"
|
||||
:disabled="isResetting">
|
||||
Reset Wizard
|
||||
Start Over
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="navigateTo('/scenarios')"
|
||||
size="lg"
|
||||
variant="solid"
|
||||
color="black">
|
||||
Go to Dashboard
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-6">
|
||||
<!-- Step 1: Members -->
|
||||
<div v-if="currentStep === 1">
|
||||
<!-- Vertical Steps Layout -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Step 1: Members -->
|
||||
<div
|
||||
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-yellow-50 transition-colors"
|
||||
:class="{ 'bg-yellow-100': focusedStep === 1 }"
|
||||
@click="setFocusedStep(1)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
:class="
|
||||
membersStore.isValid
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-white text-black border-2 border-black'
|
||||
">
|
||||
<UIcon
|
||||
v-if="membersStore.isValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>1</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black">Add your team</h3>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 1 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="focusedStep === 1" class="p-8 bg-yellow-25">
|
||||
<WizardMembersStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Wage & Policies -->
|
||||
<div v-if="currentStep === 2">
|
||||
<!-- Step 2: Wage -->
|
||||
<div
|
||||
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-green-50 transition-colors"
|
||||
:class="{ 'bg-green-100': focusedStep === 2 }"
|
||||
@click="setFocusedStep(2)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
:class="
|
||||
policiesStore.isValid
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-white text-black border-2 border-black'
|
||||
">
|
||||
<UIcon
|
||||
v-if="policiesStore.isValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black">Set your wage</h3>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 2 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="focusedStep === 2" class="p-8 bg-green-25">
|
||||
<WizardPoliciesStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Costs -->
|
||||
<div v-if="currentStep === 3">
|
||||
<!-- Step 3: Costs -->
|
||||
<div
|
||||
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-blue-50 transition-colors"
|
||||
:class="{ 'bg-blue-100': focusedStep === 3 }"
|
||||
@click="setFocusedStep(3)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-sm font-bold">
|
||||
<UIcon name="i-heroicons-check" class="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black">Monthly costs</h3>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 3 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="focusedStep === 3" class="p-8 bg-blue-25">
|
||||
<WizardCostsStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Revenue -->
|
||||
<div v-if="currentStep === 4">
|
||||
<!-- Step 4: Revenue -->
|
||||
<div
|
||||
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-purple-50 transition-colors"
|
||||
:class="{ 'bg-purple-100': focusedStep === 4 }"
|
||||
@click="setFocusedStep(4)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
:class="
|
||||
streamsStore.hasValidStreams
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-white text-black border-2 border-black'
|
||||
">
|
||||
<UIcon
|
||||
v-if="streamsStore.hasValidStreams"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>4</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black">Revenue streams</h3>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 4 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="focusedStep === 4" class="p-8 bg-purple-25">
|
||||
<WizardRevenueStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Review -->
|
||||
<div v-if="currentStep === 5">
|
||||
<WizardReviewStep @complete="completeWizard" @reset="resetWizard" />
|
||||
<!-- Step 5: Review -->
|
||||
<div
|
||||
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
|
||||
<div
|
||||
class="p-8 cursor-pointer hover:bg-orange-50 transition-colors"
|
||||
:class="{ 'bg-orange-100': focusedStep === 5 }"
|
||||
@click="setFocusedStep(5)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
:class="
|
||||
canComplete
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-white text-black border-2 border-black'
|
||||
">
|
||||
<UIcon
|
||||
v-if="canComplete"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>5</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black">Review & finish</h3>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-down"
|
||||
class="w-6 h-6 text-black transition-transform font-bold"
|
||||
:class="{ 'rotate-180': focusedStep === 5 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex justify-between items-center pt-6 border-t">
|
||||
<UButton v-if="currentStep > 1" variant="ghost" @click="previousStep">
|
||||
Previous
|
||||
</UButton>
|
||||
<div v-else></div>
|
||||
<div v-if="focusedStep === 5" class="p-8 bg-orange-25">
|
||||
<WizardReviewStep @complete="completeWizard" @reset="resetWizard" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save status indicator -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Progress Actions -->
|
||||
<div class="flex justify-between items-center pt-8">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="red"
|
||||
@click="resetWizard"
|
||||
:disabled="isResetting">
|
||||
Start Over
|
||||
</UButton>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Save status -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<UIcon
|
||||
v-if="saveStatus === 'saving'"
|
||||
name="i-heroicons-arrow-path"
|
||||
class="w-4 h-4 animate-spin text-gray-500" />
|
||||
class="w-4 h-4 animate-spin text-neutral-500" />
|
||||
<UIcon
|
||||
v-if="saveStatus === 'saved'"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-4 h-4 text-green-500" />
|
||||
<span v-if="saveStatus === 'saving'" class="text-xs text-gray-500"
|
||||
<span v-if="saveStatus === 'saving'" class="text-neutral-500"
|
||||
>Saving...</span
|
||||
>
|
||||
<span v-if="saveStatus === 'saved'" class="text-xs text-green-600"
|
||||
<span v-if="saveStatus === 'saved'" class="text-green-600"
|
||||
>Saved</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="currentStep < 5"
|
||||
@click="nextStep"
|
||||
:disabled="!isHydrated || !canProceed">
|
||||
Next
|
||||
v-if="canComplete"
|
||||
@click="completeWizard"
|
||||
size="lg"
|
||||
variant="solid"
|
||||
color="black">
|
||||
Complete Setup
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Step validation messages -->
|
||||
<div
|
||||
v-if="!canProceed && currentStep < 5"
|
||||
class="text-sm text-red-600 mt-2">
|
||||
{{ validationMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
|
@ -93,35 +272,20 @@ const membersStore = useMembersStore();
|
|||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
|
||||
// Wizard state (persisted)
|
||||
const wizardStore = useWizardStore();
|
||||
const currentStep = computed({
|
||||
get: () => wizardStore.currentStep,
|
||||
set: (val: number) => wizardStore.setStep(val),
|
||||
});
|
||||
|
||||
// UI state
|
||||
const focusedStep = ref(1);
|
||||
const saveStatus = ref("");
|
||||
const isResetting = ref(false);
|
||||
const isHydrated = ref(false);
|
||||
onMounted(() => {
|
||||
isHydrated.value = true;
|
||||
});
|
||||
const isCompleted = ref(false);
|
||||
|
||||
// Debug: log step and validation state
|
||||
watch(
|
||||
() => ({
|
||||
step: currentStep.value,
|
||||
membersValid: membersStore.isValid,
|
||||
policiesValid: policiesStore.isValid,
|
||||
streamsValid: streamsStore.hasValidStreams,
|
||||
members: membersStore.members,
|
||||
memberValidation: membersStore.validationDetails,
|
||||
}),
|
||||
(state) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug("Wizard state:", JSON.parse(JSON.stringify(state)));
|
||||
},
|
||||
{ deep: true }
|
||||
// Computed validation
|
||||
const canComplete = computed(
|
||||
() =>
|
||||
membersStore.isValid &&
|
||||
policiesStore.isValid &&
|
||||
streamsStore.hasValidStreams
|
||||
);
|
||||
|
||||
// Save status handler
|
||||
|
|
@ -137,54 +301,19 @@ function handleSaveStatus(status: "saving" | "saved" | "error") {
|
|||
}
|
||||
}
|
||||
|
||||
// Step validation
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return membersStore.isValid;
|
||||
case 2:
|
||||
return policiesStore.isValid;
|
||||
case 3:
|
||||
return true; // Costs are optional
|
||||
case 4:
|
||||
return streamsStore.hasValidStreams;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const validationMessage = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
if (membersStore.members.length === 0)
|
||||
return "Add at least one member to continue";
|
||||
return "Complete all required member fields";
|
||||
case 2:
|
||||
if (policiesStore.equalHourlyWage <= 0)
|
||||
return "Enter an hourly wage greater than 0";
|
||||
return "Complete all required policy fields";
|
||||
case 4:
|
||||
return "Add at least one valid revenue stream";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
function nextStep() {
|
||||
if (currentStep.value < 5 && canProceed.value) {
|
||||
currentStep.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function previousStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--;
|
||||
// Step management
|
||||
function setFocusedStep(step: number) {
|
||||
// Toggle if clicking on already focused step
|
||||
if (focusedStep.value === step) {
|
||||
focusedStep.value = 0; // Close the section
|
||||
} else {
|
||||
focusedStep.value = step; // Open the section
|
||||
}
|
||||
}
|
||||
|
||||
function completeWizard() {
|
||||
// Mark setup as complete and redirect
|
||||
navigateTo("/scenarios");
|
||||
// Mark setup as complete and show restart button for testing
|
||||
isCompleted.value = true;
|
||||
}
|
||||
|
||||
async function resetWizard() {
|
||||
|
|
@ -205,6 +334,26 @@ async function resetWizard() {
|
|||
isResetting.value = false;
|
||||
}
|
||||
|
||||
async function restartWizard() {
|
||||
isResetting.value = true;
|
||||
|
||||
// Reset completion state
|
||||
isCompleted.value = false;
|
||||
focusedStep.value = 1;
|
||||
|
||||
// Reset all stores and wizard state
|
||||
membersStore.resetMembers();
|
||||
policiesStore.resetPolicies();
|
||||
streamsStore.resetStreams();
|
||||
budgetStore.resetBudgetOverhead();
|
||||
wizardStore.reset();
|
||||
saveStatus.value = "";
|
||||
|
||||
// Small delay for UX
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
isResetting.value = false;
|
||||
}
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: "Setup Wizard - Configure Your Co-op",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue