chore: update application configuration and UI components for improved styling and functionality

This commit is contained in:
Jennie Robinson Faber 2025-08-16 08:13:35 +01:00
parent 0af6b17792
commit 37ab8d7bab
54 changed files with 23293 additions and 1666 deletions

View file

@ -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" },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View 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
View 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>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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",