1323 lines
45 KiB
Vue
1323 lines
45 KiB
Vue
<template>
|
||
<div>
|
||
<section class="py-8 space-y-6">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-4">
|
||
<h2 class="text-2xl font-bold">Budget Worksheet</h2>
|
||
<div class="flex border-2 border-black bg-white">
|
||
<button
|
||
@click="activeView = 'monthly'"
|
||
:class="[
|
||
'px-4 py-2 font-medium transition-none',
|
||
activeView === 'monthly'
|
||
? 'bg-black text-white'
|
||
: 'bg-white text-black hover:bg-zinc-100',
|
||
]">
|
||
Monthly
|
||
</button>
|
||
<button
|
||
@click="activeView = 'annual'"
|
||
:class="[
|
||
'px-4 py-2 font-medium border-l-2 border-black transition-none',
|
||
activeView === 'annual'
|
||
? 'bg-black text-white'
|
||
: 'bg-white text-black hover:bg-zinc-100',
|
||
]">
|
||
Annual
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<UButton @click="showCalculationModal = true" variant="ghost" size="sm">
|
||
How are these calculated?
|
||
</UButton>
|
||
<UButton @click="exportBudget" variant="ghost" size="sm">
|
||
Export
|
||
</UButton>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty State Message -->
|
||
<div
|
||
v-if="
|
||
activeView === 'monthly' &&
|
||
budgetWorksheet.revenue.length === 0 &&
|
||
budgetWorksheet.expenses.length === 0
|
||
"
|
||
class="border border-black bg-white p-12 text-center">
|
||
<div class="max-w-md mx-auto space-y-6">
|
||
<div class="text-6xl">📊</div>
|
||
<h3 class="text-xl font-bold text-black">No budget data found</h3>
|
||
<p class="text-gray-600">
|
||
Your budget is empty. Complete the setup wizard to add your revenue
|
||
streams, team members, and expenses.
|
||
</p>
|
||
<div class="flex justify-center">
|
||
<NuxtLink
|
||
to="/coop-builder"
|
||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-black hover:bg-zinc-800 border-2 border-black transition-colors">
|
||
Complete Setup Wizard
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Monthly View -->
|
||
<div
|
||
v-else-if="activeView === 'monthly'"
|
||
class="relative">
|
||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||
<div class="relative border border-black bg-white">
|
||
<div class="relative overflow-x-auto">
|
||
<table class="relative w-full border-collapse text-sm bg-white dark:bg-neutral-950">
|
||
<thead>
|
||
<tr class="border-b-2 border-black">
|
||
<th
|
||
class="border-r-1 border-black px-4 py-3 text-left font-bold min-w-[250px] sticky left-0 bg-white z-10">
|
||
Item
|
||
</th>
|
||
<th
|
||
v-for="month in monthlyHeaders"
|
||
:key="month.key"
|
||
class="border-r border-gray-400 px-2 py-3 text-center font-medium min-w-[80px] last:border-r-0">
|
||
{{ month.label }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<!-- REVENUE SECTION -->
|
||
<tr class="bg-black text-white">
|
||
<td class="px-4 py-2 font-bold sticky left-0 bg-black z-10">
|
||
<div class="flex items-center justify-between">
|
||
<span>REVENUE</span>
|
||
<UButton
|
||
@click="showAddRevenueModal = true"
|
||
size="xs"
|
||
:ui="{
|
||
base: 'bg-white text-black hover:bg-zinc-200 transition-none',
|
||
}">
|
||
+ Add
|
||
</UButton>
|
||
</div>
|
||
</td>
|
||
<td class="px-2 py-2" :colspan="monthlyHeaders.length"></td>
|
||
</tr>
|
||
|
||
<!-- Revenue by Category -->
|
||
<template
|
||
v-for="(items, categoryName) in groupedRevenue"
|
||
:key="`revenue-${categoryName}`">
|
||
<tr v-if="items.length > 0" class="border-t border-gray-300">
|
||
<td
|
||
class="px-4 py-1 font-semibold sticky left-0 bg-zinc-100 z-10 border-black"
|
||
:colspan="monthlyHeaders.length + 1">
|
||
{{ categoryName }}
|
||
</td>
|
||
</tr>
|
||
<tr
|
||
v-for="item in items"
|
||
:key="item.id"
|
||
:class="[
|
||
'border-t border-gray-200 hover:bg-zinc-50 transition-all duration-300',
|
||
highlightedItemId === item.id &&
|
||
'bg-yellow-100 animate-pulse',
|
||
]">
|
||
<td
|
||
class="border-r-1 border-zinc-200 px-4 py-2 sticky left-0 bg-white z-10">
|
||
<div class="flex items-center justify-between group">
|
||
<div class="flex-1">
|
||
<div class="text-left w-full">
|
||
<div class="font-medium">{{ item.name }}</div>
|
||
<div class="text-xs text-gray-600">
|
||
{{ item.subcategory }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<UButton
|
||
@click="removeItem('revenue', item.id)"
|
||
size="xs"
|
||
variant="ghost"
|
||
:ui="{
|
||
base: 'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
|
||
}">
|
||
×
|
||
</UButton>
|
||
</div>
|
||
</td>
|
||
<td
|
||
v-for="month in monthlyHeaders"
|
||
:key="month.key"
|
||
class="border-r border-gray-200 px-1 py-1 last:border-r-0">
|
||
<input
|
||
type="text"
|
||
:value="formatValue(item.monthlyValues?.[month.key] || 0)"
|
||
@focus="handleFocus($event)"
|
||
@input="
|
||
handleInput($event, 'revenue', item.id, month.key)
|
||
"
|
||
@blur="handleBlur($event, 'revenue', item.id, month.key)"
|
||
@keydown.enter="handleEnter($event)"
|
||
class="w-full text-right px-1 py-0.5 border border-transparent hover:border-gray-400 focus:border-black focus:outline-none transition-none"
|
||
:class="{
|
||
'bg-zinc-50': !item.monthlyValues?.[month.key],
|
||
}" />
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
|
||
<!-- Total Revenue Row -->
|
||
<tr
|
||
class="border-t-1 border-black border-b-1 font-bold bg-zinc-100">
|
||
<td
|
||
class="border-r-1 border-black px-4 py-2 sticky left-0 bg-zinc-100 z-10">
|
||
TOTAL REVENUE
|
||
</td>
|
||
<td
|
||
v-for="month in monthlyHeaders"
|
||
:key="month.key"
|
||
class="border-r border-black px-2 py-2 text-right last:border-r-0">
|
||
{{ formatCurrency(monthlyTotals[month.key]?.revenue || 0) }}
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- EXPENSES SECTION -->
|
||
<tr class="bg-black text-white">
|
||
<td class="px-4 py-2 font-bold sticky left-0 bg-black z-10">
|
||
<div class="flex items-center justify-between">
|
||
<span>EXPENSES</span>
|
||
<UButton
|
||
@click="showAddExpenseModal = true"
|
||
size="xs"
|
||
:ui="{
|
||
base: 'bg-white text-black hover:bg-zinc-200 transition-none',
|
||
}">
|
||
+ Add
|
||
</UButton>
|
||
</div>
|
||
</td>
|
||
<td class="px-2 py-2" :colspan="monthlyHeaders.length"></td>
|
||
</tr>
|
||
|
||
<!-- Expenses by Category -->
|
||
<template
|
||
v-for="(items, categoryName) in groupedExpenses"
|
||
:key="`expense-${categoryName}`">
|
||
<tr v-if="items.length > 0" class="border-t border-gray-300">
|
||
<td
|
||
class="px-4 py-1 font-semibold sticky left-0 bg-zinc-100 z-10"
|
||
:colspan="monthlyHeaders.length + 1">
|
||
{{ categoryName }}
|
||
</td>
|
||
</tr>
|
||
<tr
|
||
v-for="item in items"
|
||
:key="item.id"
|
||
:class="[
|
||
'border-t border-gray-200 hover:bg-zinc-50 transition-all duration-300',
|
||
highlightedItemId === item.id &&
|
||
'bg-yellow-100 animate-pulse',
|
||
]">
|
||
<td
|
||
class="border-r-1 border-zinc-200 px-4 py-2 sticky left-0 bg-white z-10">
|
||
<div class="flex items-center justify-between group">
|
||
<div class="flex-1">
|
||
<div class="text-left w-full">
|
||
<div class="font-medium flex items-start gap-2">
|
||
<UTooltip
|
||
v-if="isPayrollItem(item.id)"
|
||
text="Calculated based on available revenue after overhead costs. This represents realistic, sustainable payroll."
|
||
:content="{ side: 'top', align: 'start' }">
|
||
<span class="cursor-help">{{ item.name }}</span>
|
||
</UTooltip>
|
||
<span v-else>{{ item.name }}</span>
|
||
<span
|
||
v-if="isPayrollItem(item.id)"
|
||
class="inline-flex items-start px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||
Auto
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-gray-600" v-if="!isPayrollItem(item.id)">
|
||
{{ item.subcategory }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete button for non-payroll items only -->
|
||
<UButton
|
||
v-if="!isPayrollItem(item.id)"
|
||
@click="removeItem('expenses', item.id)"
|
||
size="xs"
|
||
variant="ghost"
|
||
:ui="{
|
||
base: 'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
|
||
}">
|
||
×
|
||
</UButton>
|
||
</div>
|
||
</td>
|
||
<td
|
||
v-for="month in monthlyHeaders"
|
||
:key="month.key"
|
||
class="border-r border-gray-200 px-1 py-1 last:border-r-0">
|
||
<input
|
||
type="text"
|
||
:value="formatValue(item.monthlyValues?.[month.key] || 0)"
|
||
:readonly="isPayrollItem(item.id)"
|
||
@focus="handleFocus($event)"
|
||
@input="
|
||
handleInput($event, 'expenses', item.id, month.key)
|
||
"
|
||
@blur="handleBlur($event, 'expenses', item.id, month.key)"
|
||
@keydown.enter="handleEnter($event)"
|
||
class="w-full text-right px-1 py-0.5 border-2 transition-none"
|
||
:class="{
|
||
'bg-zinc-50':
|
||
!item.monthlyValues?.[month.key] &&
|
||
!isPayrollItem(item.id),
|
||
'bg-zinc-50 border-none cursor-not-allowed text-zinc-500':
|
||
isPayrollItem(item.id),
|
||
'border-transparent hover:border-zinc-400 focus:border-black focus:outline-none':
|
||
!isPayrollItem(item.id),
|
||
}"
|
||
:title="
|
||
isPayrollItem(item.id)
|
||
? 'Calculated from compensation settings - edit on Compensation page'
|
||
: ''
|
||
" />
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
|
||
<!-- Total Expenses Row -->
|
||
<tr class="border-t-1 border-black font-bold bg-zinc-100">
|
||
<td
|
||
class="border-r-1 border-black px-4 py-2 sticky left-0 bg-zinc-100 z-10">
|
||
TOTAL EXPENSES
|
||
</td>
|
||
<td
|
||
v-for="month in monthlyHeaders"
|
||
:key="month.key"
|
||
class="border-r border-gray-400 px-2 py-2 text-right last:border-r-0">
|
||
{{ formatCurrency(monthlyTotals[month.key]?.expenses || 0) }}
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Net Income Row -->
|
||
<tr class="border-t-1 border-black font-bold text-lg">
|
||
<td
|
||
class="border-r-1 border-black px-4 py-3 sticky left-0 bg-white z-10">
|
||
NET INCOME
|
||
</td>
|
||
<td
|
||
v-for="month in monthlyHeaders"
|
||
:key="month.key"
|
||
class="border-r border-gray-400 px-2 py-3 text-right last:border-r-0"
|
||
:class="
|
||
getNetIncomeClass(monthlyTotals[month.key]?.net || 0)
|
||
">
|
||
{{ formatCurrency(monthlyTotals[month.key]?.net || 0) }}
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Cumulative Balance Row -->
|
||
<tr class="border-t-1 border-gray-400 font-bold text-lg bg-blue-50">
|
||
<td
|
||
class="border-r-1 border-black px-4 py-3 sticky left-0 bg-blue-50 z-10">
|
||
CUMULATIVE BALANCE
|
||
</td>
|
||
<td
|
||
v-for="month in monthlyHeaders"
|
||
:key="month.key"
|
||
class="border-r border-gray-400 px-2 py-3 text-right last:border-r-0"
|
||
:class="
|
||
getCumulativeBalanceClass(cumulativeBalances[month.key] || 0)
|
||
">
|
||
{{ formatCurrency(cumulativeBalances[month.key] || 0) }}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Annual View -->
|
||
<div v-if="activeView === 'annual'">
|
||
<AnnualBudget :orgId="'default'" :year="new Date().getFullYear()" />
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Add Revenue Modal -->
|
||
<UModal
|
||
v-model:open="showAddRevenueModal"
|
||
size="xl"
|
||
title="Add Revenue Source"
|
||
description="Create a new revenue stream for your budget">
|
||
<template #body>
|
||
<!-- Basic Information Section -->
|
||
<div class="space-y-6">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<UFormField label="Category" required>
|
||
<USelectMenu
|
||
v-model="newRevenue.category"
|
||
:items="revenueCategories"
|
||
placeholder="Select a category"
|
||
class="text-sm font-medium w-full"
|
||
size="lg" />
|
||
</UFormField>
|
||
|
||
<UFormField label="Subcategory" :required="false">
|
||
<USelectMenu
|
||
v-model="newRevenue.subcategory"
|
||
:items="revenueSubcategories"
|
||
placeholder="Select a subcategory"
|
||
:disabled="!newRevenue.category"
|
||
class="text-sm font-medium w-full"
|
||
size="lg" />
|
||
</UFormField>
|
||
</div>
|
||
|
||
<UFormField label="Revenue Name" required>
|
||
<UInput
|
||
v-model="newRevenue.name"
|
||
type="text"
|
||
placeholder="e.g., Monthly Subscription"
|
||
size="lg"
|
||
class="text-sm font-medium w-full" />
|
||
</UFormField>
|
||
</div>
|
||
|
||
<!-- Initial Values Section -->
|
||
<div class="space-y-6 mt-6">
|
||
<UTabs
|
||
v-model="revenueInitialTab"
|
||
:items="revenueHelperTabs"
|
||
class="w-full">
|
||
<template #content="{ item }">
|
||
<!-- Annual Distribution -->
|
||
<div v-if="item.value === 'annual'" class="space-y-6">
|
||
<UFormField label="Annual Total Amount">
|
||
<UInput
|
||
v-model.number="newRevenue.annualAmount"
|
||
type="number"
|
||
placeholder="Enter annual amount (e.g., 12000)"
|
||
size="lg">
|
||
<template #leading>
|
||
<span class="text-gray-500 font-medium">$</span>
|
||
</template>
|
||
</UInput>
|
||
</UFormField>
|
||
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||
<div class="mb-2">
|
||
<span class="text-sm font-medium text-gray-700"
|
||
>Distribution Preview</span
|
||
>
|
||
</div>
|
||
<p class="text-sm text-gray-600">
|
||
This will divide
|
||
<span class="font-semibold text-gray-900"
|
||
>${{ newRevenue.annualAmount || 0 }}</span
|
||
>
|
||
equally across all 12 months (<span
|
||
class="font-semibold text-green-600"
|
||
>${{
|
||
newRevenue.annualAmount
|
||
? Math.round(newRevenue.annualAmount / 12)
|
||
: 0
|
||
}}</span
|
||
>
|
||
per month)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Monthly Amount -->
|
||
<div v-else-if="item.value === 'monthly'" class="space-y-6">
|
||
<UFormField label="Monthly Amount">
|
||
<UInput
|
||
v-model.number="newRevenue.monthlyAmount"
|
||
type="number"
|
||
placeholder="Enter monthly amount (e.g., 1000)"
|
||
size="lg">
|
||
<template #leading>
|
||
<span class="text-gray-500 font-medium">$</span>
|
||
</template>
|
||
</UInput>
|
||
</UFormField>
|
||
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||
<div class="mb-2">
|
||
<span class="text-sm font-medium text-gray-700"
|
||
>Monthly Preview</span
|
||
>
|
||
</div>
|
||
<p class="text-sm text-gray-600">
|
||
This will set
|
||
<span class="font-semibold text-green-600"
|
||
>${{ newRevenue.monthlyAmount || 0 }}</span
|
||
>
|
||
for all 12 months
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Start Empty -->
|
||
<div v-else>
|
||
<div
|
||
class="bg-white rounded-lg p-6 border border-gray-200 text-center">
|
||
<p class="text-sm text-gray-600">
|
||
The revenue item will be created with no initial values. You
|
||
can fill them in later directly in the budget table.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</UTabs>
|
||
</div>
|
||
</template>
|
||
|
||
<template #footer>
|
||
<div class="flex justify-between w-full">
|
||
<UButton
|
||
@click="showAddRevenueModal = false"
|
||
variant="outline"
|
||
size="md">
|
||
Cancel
|
||
</UButton>
|
||
<UButton
|
||
@click="addRevenueItem"
|
||
class="self-end"
|
||
size="md"
|
||
:disabled="!newRevenue.name || !newRevenue.category"
|
||
color="primary">
|
||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
||
Add Revenue
|
||
</UButton>
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
|
||
<!-- Add Expense Modal -->
|
||
<UModal
|
||
v-model:open="showAddExpenseModal"
|
||
size="xl"
|
||
title="Add Expense Item"
|
||
description="Create a new expense for your budget">
|
||
<template #body>
|
||
<!-- Basic Information Section -->
|
||
<div class="space-y-6">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<UFormField label="Category" required>
|
||
<USelectMenu
|
||
v-model="newExpense.category"
|
||
:items="expenseCategories"
|
||
placeholder="Select a category"
|
||
size="lg"
|
||
class="text-sm font-medium w-full" />
|
||
</UFormField>
|
||
|
||
<div class="flex items-end">
|
||
<UFormField label="Expense Name" required class="flex-1">
|
||
<UInput
|
||
v-model="newExpense.name"
|
||
type="text"
|
||
placeholder="e.g., Office Rent"
|
||
size="lg"
|
||
class="text-sm font-medium w-full" />
|
||
</UFormField>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Initial Values Section -->
|
||
<div class="space-y-6 mt-6">
|
||
<UTabs
|
||
v-model="expenseInitialTab"
|
||
:items="expenseHelperTabs"
|
||
class="w-full">
|
||
<template #content="{ item }">
|
||
<!-- Annual Distribution -->
|
||
<div v-if="item.value === 'annual'" class="space-y-6">
|
||
<UFormField label="Annual Total Amount">
|
||
<UInput
|
||
v-model.number="newExpense.annualAmount"
|
||
type="number"
|
||
placeholder="Enter annual amount (e.g., 12000)"
|
||
size="lg"
|
||
class="text-sm font-medium w-full">
|
||
<template #leading>
|
||
<span class="text-gray-500 font-medium">$</span>
|
||
</template>
|
||
</UInput>
|
||
</UFormField>
|
||
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||
<div class="mb-2">
|
||
<span class="text-sm font-medium text-gray-700"
|
||
>Distribution Preview</span
|
||
>
|
||
</div>
|
||
<p class="text-sm text-gray-600">
|
||
This will divide
|
||
<span class="font-semibold text-gray-900"
|
||
>${{ newExpense.annualAmount || 0 }}</span
|
||
>
|
||
equally across all 12 months (<span
|
||
class="font-semibold text-red-600"
|
||
>${{
|
||
newExpense.annualAmount
|
||
? Math.round(newExpense.annualAmount / 12)
|
||
: 0
|
||
}}</span
|
||
>
|
||
per month)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Monthly Amount -->
|
||
<div v-else-if="item.value === 'monthly'" class="space-y-6">
|
||
<UFormField label="Monthly Amount">
|
||
<UInput
|
||
v-model.number="newExpense.monthlyAmount"
|
||
type="number"
|
||
placeholder="Enter monthly amount (e.g., 1000)"
|
||
size="lg"
|
||
class="text-sm font-medium w-full">
|
||
<template #leading>
|
||
<span class="text-gray-500 font-medium">$</span>
|
||
</template>
|
||
</UInput>
|
||
</UFormField>
|
||
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||
<div class="mb-2">
|
||
<span class="text-sm font-medium text-gray-700"
|
||
>Monthly Preview</span
|
||
>
|
||
</div>
|
||
<p class="text-sm text-gray-600">
|
||
This will set
|
||
<span class="font-semibold text-red-600"
|
||
>${{ newExpense.monthlyAmount || 0 }}</span
|
||
>
|
||
for all 12 months
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Start Empty -->
|
||
<div v-else>
|
||
<div
|
||
class="bg-white rounded-lg p-6 border border-gray-200 text-center">
|
||
<p class="text-sm text-gray-600">
|
||
The expense item will be created with no initial values. You
|
||
can fill them in later directly in the budget table.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</UTabs>
|
||
</div>
|
||
</template>
|
||
|
||
<template #footer>
|
||
<div class="flex justify-between w-full">
|
||
<UButton
|
||
@click="showAddExpenseModal = false"
|
||
variant="outline"
|
||
size="md">
|
||
Cancel
|
||
</UButton>
|
||
<UButton
|
||
@click="addExpenseItem"
|
||
size="md"
|
||
:disabled="!newExpense.name || !newExpense.category"
|
||
color="primary">
|
||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
||
Add Expense
|
||
</UButton>
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
|
||
<!-- Payroll Oncost Settings Modal -->
|
||
<PayrollOncostModal
|
||
v-model:open="showPayrollOncostModal"
|
||
@save="handlePayrollOncostUpdate" />
|
||
|
||
<!-- Calculation Explanation Modal -->
|
||
<UModal v-model:open="showCalculationModal" title="How Budget Calculations Work">
|
||
<template #content>
|
||
<div class="space-y-6 max-w-2xl p-6">
|
||
<!-- Revenue Section -->
|
||
<div>
|
||
<h4 class="font-semibold text-green-600 mb-2">📈 Revenue Calculation</h4>
|
||
<p class="text-sm text-gray-600 mb-2">Revenue comes from your setup wizard streams and any manual additions:</p>
|
||
<ul class="text-sm text-gray-600 space-y-1 ml-4">
|
||
<li>• Monthly amounts you entered for each revenue stream</li>
|
||
<li>• Varies by month based on your specific projections</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Payroll Section -->
|
||
<div>
|
||
<h4 class="font-semibold text-blue-600 mb-2">👥 Smart Payroll Calculation</h4>
|
||
<p class="text-sm text-gray-600 mb-2">Payroll uses a <strong>cumulative balance approach</strong> to ensure sustainability:</p>
|
||
<div class="bg-blue-50 border border-blue-200 rounded p-3 text-sm">
|
||
<p class="font-medium mb-2">Step-by-step process:</p>
|
||
<ol class="space-y-1 ml-4">
|
||
<li>1. Calculate available funds: Revenue - Other Expenses</li>
|
||
<li>2. Check if this maintains minimum cash threshold (${{ $format.currency(coopBuilderStore.minCashThreshold || 0) }})</li>
|
||
<li>3. Allocate using your chosen policy ({{ getPolicyName() }})</li>
|
||
<li>4. Account for payroll taxes ({{ coopBuilderStore.payrollOncostPct || 0 }}%)</li>
|
||
<li>5. Ensure cumulative balance doesn't fall below threshold</li>
|
||
</ol>
|
||
</div>
|
||
<p class="text-sm text-gray-600 mt-2">
|
||
This means payroll varies by month - higher in good cash flow months, lower when cash is tight.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Cumulative Balance Section -->
|
||
<div>
|
||
<h4 class="font-semibold text-purple-600 mb-2">💰 Cumulative Balance</h4>
|
||
<p class="text-sm text-gray-600 mb-2">Shows your running cash position over time:</p>
|
||
<ul class="text-sm text-gray-600 space-y-1 ml-4">
|
||
<li>• Starts at $0 (current cash position)</li>
|
||
<li>• Adds each month's net income (Revenue - All Expenses)</li>
|
||
<li>• Helps you see when cash might run low</li>
|
||
<li>• Payroll is reduced to prevent going below minimum threshold</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Policy Explanation -->
|
||
<div>
|
||
<h4 class="font-semibold text-orange-600 mb-2">⚖️ Pay Policy: {{ getPolicyName() }}</h4>
|
||
<div class="text-sm text-gray-600">
|
||
<p v-if="coopBuilderStore.policy?.relationship === 'equal-pay'">
|
||
Everyone gets equal hourly wage (${{ coopBuilderStore.equalHourlyWage || 0 }}/hour) based on their monthly hours.
|
||
</p>
|
||
<p v-else-if="coopBuilderStore.policy?.relationship === 'needs-weighted'">
|
||
Pay is allocated proportionally based on each member's minimum monthly needs, ensuring fair coverage.
|
||
</p>
|
||
<p v-else-if="coopBuilderStore.policy?.relationship === 'hours-weighted'">
|
||
Pay is allocated proportionally based on hours worked, with higher hours getting more pay.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-gray-50 border border-gray-200 rounded p-3">
|
||
<p class="text-sm text-gray-700">
|
||
<strong>Key insight:</strong> This system prioritizes sustainability over theoretical maximums.
|
||
You might not always get full theoretical wages, but you'll never run out of cash.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</UModal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
// Stores and synchronization
|
||
const budgetStore = useBudgetStore();
|
||
console.log("Budget store initialized:", budgetStore);
|
||
console.log("Budget worksheet data:", budgetStore.budgetWorksheet);
|
||
const streamsStore = useStreamsStore();
|
||
const membersStore = useMembersStore();
|
||
const policiesStore = usePoliciesStore();
|
||
const coopBuilderStore = useCoopBuilderStore();
|
||
const { initSync, getStreams, getMembers, unifiedStreams, unifiedMembers } =
|
||
useStoreSync();
|
||
|
||
// Initialize synchronization and budget data
|
||
const initializeBudgetPage = async () => {
|
||
console.log("📊 Budget Page: Starting initialization");
|
||
|
||
// First, sync stores to ensure data is available (now async)
|
||
await initSync();
|
||
|
||
// Additional wait to ensure all reactive updates have propagated
|
||
await nextTick();
|
||
|
||
// Now check if we need to initialize budget data
|
||
const hasCoopData =
|
||
coopBuilderStore.streams.length > 0 || coopBuilderStore.members.length > 0;
|
||
const hasBudgetData =
|
||
budgetStore.budgetWorksheet.revenue.length > 0 ||
|
||
budgetStore.budgetWorksheet.expenses.length > 0;
|
||
|
||
console.log(
|
||
"📊 Budget Page: After sync - hasCoopData:",
|
||
hasCoopData,
|
||
"hasBudgetData:",
|
||
hasBudgetData
|
||
);
|
||
console.log("📊 Budget Page: Coop streams:", coopBuilderStore.streams);
|
||
console.log("📊 Budget Page: Coop members:", coopBuilderStore.members);
|
||
|
||
if (hasCoopData && !hasBudgetData) {
|
||
console.log("📊 Budget Page: Initializing budget from coop data");
|
||
// Only use regular initialization (not force) to avoid overwriting user changes
|
||
await budgetStore.initializeFromWizardData();
|
||
// Refresh the page data after initialization
|
||
await nextTick();
|
||
} else if (!hasCoopData && !hasBudgetData) {
|
||
console.log("📊 Budget Page: No data found in any store");
|
||
// Try one more time to get the data after a delay
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
const retryCoopData =
|
||
coopBuilderStore.streams.length > 0 ||
|
||
coopBuilderStore.members.length > 0;
|
||
if (retryCoopData) {
|
||
console.log("📊 Budget Page: Found data on retry, initializing");
|
||
await budgetStore.initializeFromWizardData();
|
||
}
|
||
} else if (hasBudgetData) {
|
||
console.log(
|
||
"📊 Budget Page: Budget data already exists, using existing data"
|
||
);
|
||
}
|
||
};
|
||
|
||
// Initialize on mount
|
||
onMounted(async () => {
|
||
// Initialize without overwriting existing user data
|
||
await initializeBudgetPage();
|
||
// Mark initial load as complete after initialization
|
||
await nextTick();
|
||
initialLoadComplete = true;
|
||
});
|
||
|
||
// Track if initial load is complete
|
||
let initialLoadComplete = false;
|
||
|
||
// Re-initialize when coop data changes (but not on initial load)
|
||
watch(
|
||
[() => coopBuilderStore.streams, () => coopBuilderStore.members],
|
||
async (newVal, oldVal) => {
|
||
// Skip the initial trigger
|
||
if (!initialLoadComplete) {
|
||
return;
|
||
}
|
||
|
||
// Only reinitialize if we actually have NEW data (arrays changed in size)
|
||
const [newStreams, newMembers] = newVal;
|
||
const [oldStreams, oldMembers] = oldVal || [[], []];
|
||
|
||
const streamsChanged = newStreams.length !== oldStreams.length;
|
||
const membersChanged = newMembers.length !== oldMembers.length;
|
||
|
||
if (streamsChanged || membersChanged) {
|
||
console.log(
|
||
"📊 Budget Page: Coop data structure changed, checking if reinit needed"
|
||
);
|
||
// Only reinitialize if budget is truly empty (no user-entered data)
|
||
const hasBudgetData =
|
||
budgetStore.budgetWorksheet.revenue.length > 0 ||
|
||
budgetStore.budgetWorksheet.expenses.length > 0;
|
||
|
||
if (!hasBudgetData) {
|
||
console.log(
|
||
"📊 Budget Page: No budget data exists, reinitializing from coop data"
|
||
);
|
||
await nextTick();
|
||
await initializeBudgetPage();
|
||
} else {
|
||
console.log(
|
||
"📊 Budget Page: Budget data exists, preserving user changes"
|
||
);
|
||
}
|
||
}
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// Use reactive synchronized data
|
||
const syncedStreams = unifiedStreams;
|
||
const syncedMembers = unifiedMembers;
|
||
|
||
// State
|
||
const activeView = ref("monthly");
|
||
const showAddRevenueModal = ref(false);
|
||
const showAddExpenseModal = ref(false);
|
||
const showPayrollOncostModal = ref(false);
|
||
const showCalculationModal = ref(false);
|
||
const activeTab = ref(0);
|
||
const highlightedItemId = ref<string | null>(null);
|
||
|
||
// New item forms
|
||
const newRevenue = ref({
|
||
category: "",
|
||
subcategory: "",
|
||
name: "",
|
||
initialAmount: 0,
|
||
annualAmount: 0,
|
||
monthlyAmount: 0,
|
||
});
|
||
|
||
const newExpense = ref({
|
||
category: "",
|
||
subcategory: "",
|
||
name: "",
|
||
initialAmount: 0,
|
||
annualAmount: 0,
|
||
monthlyAmount: 0,
|
||
});
|
||
|
||
// New modal helper tabs
|
||
const revenueInitialTab = ref("annual");
|
||
const revenueHelperTabs = [
|
||
{
|
||
value: "annual",
|
||
label: "Annual Distribution",
|
||
icon: "i-heroicons-calendar",
|
||
},
|
||
{
|
||
value: "monthly",
|
||
label: "Set All Months",
|
||
icon: "i-heroicons-squares-2x2",
|
||
},
|
||
{
|
||
value: "empty",
|
||
label: "Start Empty",
|
||
icon: "i-heroicons-minus",
|
||
},
|
||
];
|
||
|
||
const expenseInitialTab = ref("annual");
|
||
const expenseHelperTabs = [
|
||
{
|
||
value: "annual",
|
||
label: "Annual Distribution",
|
||
icon: "i-heroicons-calendar",
|
||
},
|
||
{
|
||
value: "monthly",
|
||
label: "Set All Months",
|
||
icon: "i-heroicons-squares-2x2",
|
||
},
|
||
{
|
||
value: "empty",
|
||
label: "Start Empty",
|
||
icon: "i-heroicons-minus",
|
||
},
|
||
];
|
||
|
||
// Data from store - just use the string arrays directly
|
||
const revenueCategories = computed(() => budgetStore.revenueCategories);
|
||
const expenseCategories = computed(() => budgetStore.expenseCategories);
|
||
|
||
// Revenue subcategories based on selected category
|
||
const revenueSubcategories = computed(() => {
|
||
if (!newRevenue.value.category) return [];
|
||
const subcategories = budgetStore.revenueSubcategories as Record<
|
||
string,
|
||
string[]
|
||
>;
|
||
return subcategories[newRevenue.value.category] || [];
|
||
});
|
||
|
||
// Clear subcategory when category changes
|
||
watch(
|
||
() => newRevenue.value.category,
|
||
(newCategory, oldCategory) => {
|
||
if (newCategory !== oldCategory) {
|
||
newRevenue.value.subcategory = "";
|
||
}
|
||
}
|
||
);
|
||
|
||
// Generate monthly headers
|
||
const monthlyHeaders = computed(() => {
|
||
const headers = [];
|
||
const today = new Date();
|
||
|
||
for (let i = 0; i < 12; i++) {
|
||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||
const monthName = date.toLocaleString("default", { month: "short" });
|
||
const year = date.getFullYear().toString().slice(-2);
|
||
|
||
headers.push({
|
||
key: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
|
||
2,
|
||
"0"
|
||
)}`,
|
||
label: `${monthName} '${year}`,
|
||
});
|
||
}
|
||
|
||
return headers;
|
||
});
|
||
|
||
// Grouped data with safe fallbacks
|
||
const budgetWorksheet = computed(
|
||
() => budgetStore.budgetWorksheet || { revenue: [], expenses: [] }
|
||
);
|
||
const groupedRevenue = computed(() => budgetStore.groupedRevenue);
|
||
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
|
||
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
|
||
const cumulativeBalances = computed(() => budgetStore.cumulativeBalances);
|
||
|
||
// Initialize on mount
|
||
// Removed duplicate onMounted - initialization is now handled above
|
||
|
||
// Add revenue item
|
||
function addRevenueItem() {
|
||
const id = budgetStore.addBudgetItem(
|
||
"revenue",
|
||
newRevenue.value.name,
|
||
newRevenue.value.category,
|
||
newRevenue.value.subcategory
|
||
);
|
||
|
||
// Apply helper values based on selected tab
|
||
const activeTabValue = revenueInitialTab.value;
|
||
|
||
if (
|
||
activeTabValue === "annual" &&
|
||
Number(newRevenue.value.annualAmount) > 0
|
||
) {
|
||
// Annual distribution
|
||
const monthlyAmount = Math.round(newRevenue.value.annualAmount / 12);
|
||
monthlyHeaders.value.forEach((month) => {
|
||
budgetStore.updateMonthlyValue(
|
||
"revenue",
|
||
id,
|
||
month.key,
|
||
monthlyAmount.toString()
|
||
);
|
||
});
|
||
} else if (
|
||
activeTabValue === "monthly" &&
|
||
Number(newRevenue.value.monthlyAmount) > 0
|
||
) {
|
||
// Set all months
|
||
monthlyHeaders.value.forEach((month) => {
|
||
budgetStore.updateMonthlyValue(
|
||
"revenue",
|
||
id,
|
||
month.key,
|
||
newRevenue.value.monthlyAmount.toString()
|
||
);
|
||
});
|
||
}
|
||
// If tab === 2 (empty), don't set any values
|
||
|
||
// Reset form and close modal
|
||
newRevenue.value = {
|
||
category: "",
|
||
subcategory: "",
|
||
name: "",
|
||
initialAmount: 0,
|
||
annualAmount: 0,
|
||
monthlyAmount: 0,
|
||
};
|
||
revenueInitialTab.value = "annual";
|
||
showAddRevenueModal.value = false;
|
||
}
|
||
|
||
// Add expense item
|
||
function addExpenseItem() {
|
||
const id = budgetStore.addBudgetItem(
|
||
"expenses",
|
||
newExpense.value.name,
|
||
newExpense.value.category
|
||
);
|
||
|
||
// Apply helper values based on selected tab
|
||
const activeExpenseTabValue = expenseInitialTab.value;
|
||
|
||
if (
|
||
activeExpenseTabValue === "annual" &&
|
||
Number(newExpense.value.annualAmount) > 0
|
||
) {
|
||
// Annual distribution
|
||
const monthlyAmount = Math.round(newExpense.value.annualAmount / 12);
|
||
monthlyHeaders.value.forEach((month) => {
|
||
budgetStore.updateMonthlyValue(
|
||
"expenses",
|
||
id,
|
||
month.key,
|
||
monthlyAmount.toString()
|
||
);
|
||
});
|
||
} else if (
|
||
activeExpenseTabValue === "monthly" &&
|
||
Number(newExpense.value.monthlyAmount) > 0
|
||
) {
|
||
// Set all months
|
||
monthlyHeaders.value.forEach((month) => {
|
||
budgetStore.updateMonthlyValue(
|
||
"expenses",
|
||
id,
|
||
month.key,
|
||
newExpense.value.monthlyAmount.toString()
|
||
);
|
||
});
|
||
}
|
||
// If tab === 2 (empty), don't set any values
|
||
|
||
// Reset form and close modal
|
||
newExpense.value = {
|
||
category: "",
|
||
subcategory: "",
|
||
name: "",
|
||
initialAmount: 0,
|
||
annualAmount: 0,
|
||
monthlyAmount: 0,
|
||
};
|
||
expenseInitialTab.value = "annual";
|
||
showAddExpenseModal.value = false;
|
||
}
|
||
|
||
// Remove item
|
||
function removeItem(category: string, itemId: string) {
|
||
// Prevent deletion of payroll items - they are managed through compensation settings
|
||
if (isPayrollItem(itemId)) {
|
||
alert(
|
||
"Payroll items are automatically managed through the Compensation page. To modify payroll costs, please use the Compensation page."
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (confirm("Are you sure you want to remove this item?")) {
|
||
budgetStore.removeBudgetItem(category, itemId);
|
||
}
|
||
}
|
||
|
||
// Value entry handlers
|
||
function formatValue(value: number): string {
|
||
if (value === 0) return "";
|
||
return value.toString();
|
||
}
|
||
|
||
function isPayrollItem(itemId: string): boolean {
|
||
return (
|
||
itemId === "expense-payroll-base" ||
|
||
itemId === "expense-payroll-oncosts" ||
|
||
itemId === "expense-payroll"
|
||
);
|
||
}
|
||
|
||
function handleFocus(event: FocusEvent) {
|
||
const input = event.target as HTMLInputElement;
|
||
input.select();
|
||
}
|
||
|
||
function handleBlur(
|
||
event: FocusEvent,
|
||
category: string,
|
||
itemId: string,
|
||
monthKey: string
|
||
) {
|
||
// Prevent editing payroll items - they are calculated automatically
|
||
if (isPayrollItem(itemId)) {
|
||
return;
|
||
}
|
||
|
||
const input = event.target as HTMLInputElement;
|
||
const rawValue = input.value;
|
||
const cleanValue = rawValue.replace(/[^0-9.-]/g, "");
|
||
const numericValue = parseFloat(cleanValue) || 0;
|
||
|
||
console.log("handleBlur called:", {
|
||
category,
|
||
itemId,
|
||
monthKey,
|
||
rawValue,
|
||
cleanValue,
|
||
numericValue,
|
||
});
|
||
budgetStore.updateMonthlyValue(category, itemId, monthKey, numericValue);
|
||
|
||
// Ensure the store is marked as initialized to prevent overwrites
|
||
if (!budgetStore.isInitialized) {
|
||
budgetStore.isInitialized = true;
|
||
}
|
||
}
|
||
|
||
function handleInput(
|
||
event: Event,
|
||
category: string,
|
||
itemId: string,
|
||
monthKey: string
|
||
) {
|
||
// Prevent editing payroll items - they are calculated automatically
|
||
if (isPayrollItem(itemId)) {
|
||
return;
|
||
}
|
||
|
||
const input = event.target as HTMLInputElement;
|
||
const rawValue = input.value;
|
||
const cleanValue = rawValue.replace(/[^0-9.-]/g, "");
|
||
const numericValue = parseFloat(cleanValue) || 0;
|
||
|
||
console.log("handleInput called:", {
|
||
category,
|
||
itemId,
|
||
monthKey,
|
||
rawValue,
|
||
cleanValue,
|
||
numericValue,
|
||
});
|
||
budgetStore.updateMonthlyValue(category, itemId, monthKey, numericValue);
|
||
|
||
// Ensure the store is marked as initialized to prevent overwrites
|
||
if (!budgetStore.isInitialized) {
|
||
budgetStore.isInitialized = true;
|
||
}
|
||
}
|
||
|
||
function handleEnter(event: KeyboardEvent) {
|
||
const input = event.target as HTMLInputElement;
|
||
input.blur();
|
||
}
|
||
|
||
// Highlight row after changes
|
||
function highlightRow(itemId: string) {
|
||
highlightedItemId.value = itemId;
|
||
setTimeout(() => {
|
||
highlightedItemId.value = null;
|
||
}, 1500);
|
||
}
|
||
|
||
// Reset worksheet
|
||
function resetWorksheet() {
|
||
if (
|
||
confirm(
|
||
"Are you sure you want to reset all budget data? This cannot be undone."
|
||
)
|
||
) {
|
||
budgetStore.resetBudgetWorksheet();
|
||
budgetStore.isInitialized = false;
|
||
nextTick(() => {
|
||
budgetStore.initializeFromWizardData();
|
||
});
|
||
}
|
||
}
|
||
|
||
// Export budget
|
||
function exportBudget() {
|
||
const data = {
|
||
worksheet: budgetStore.budgetWorksheet,
|
||
monthlyTotals: budgetStore.monthlyTotals,
|
||
exportedAt: new Date().toISOString(),
|
||
};
|
||
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||
type: "application/json",
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `budget-worksheet-${
|
||
new Date().toISOString().split("T")[0]
|
||
}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// Formatting helpers
|
||
function formatCurrency(amount: number): string {
|
||
return new Intl.NumberFormat("en-US", {
|
||
style: "currency",
|
||
currency: "USD",
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: 0,
|
||
}).format(amount || 0);
|
||
}
|
||
|
||
function getNetIncomeClass(amount: number): string {
|
||
if (amount > 0) return "text-green-600 font-bold";
|
||
if (amount < 0) return "text-red-600 font-bold";
|
||
return "text-gray-600";
|
||
}
|
||
|
||
function getCumulativeBalanceClass(amount: number): string {
|
||
if (amount > 50000) return "text-green-700 font-bold"; // Healthy cash position
|
||
if (amount > 10000) return "text-green-600 font-bold"; // Good cash position
|
||
if (amount > 0) return "text-blue-600 font-bold"; // Positive but low
|
||
if (amount > -10000) return "text-orange-600 font-bold"; // Concerning
|
||
return "text-red-700 font-bold"; // Critical cash position
|
||
}
|
||
|
||
function getPolicyName(): string {
|
||
const policyType = coopBuilderStore.policy?.relationship || 'equal-pay';
|
||
|
||
if (policyType === 'equal-pay') return 'Equal Pay';
|
||
if (policyType === 'hours-weighted') return 'Hours Based';
|
||
if (policyType === 'needs-weighted') return 'Needs Weighted';
|
||
return 'Equal Pay';
|
||
}
|
||
|
||
// Payroll oncost handling
|
||
function handlePayrollOncostUpdate(newPercentage: number) {
|
||
// Update the coop store
|
||
coopBuilderStore.payrollOncostPct = newPercentage;
|
||
|
||
// Refresh the budget to reflect the new oncost percentage
|
||
budgetStore.refreshPayrollInBudget();
|
||
}
|
||
|
||
// SEO
|
||
useSeoMeta({
|
||
title: "Budget Worksheet - Plan Your Co-op's Financial Future",
|
||
description:
|
||
"Interactive budget planning tool with monthly projections for worker cooperatives.",
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.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);
|
||
}
|
||
|
||
/* Ensure modal content doesn't inherit global styles */
|
||
.isolate {
|
||
isolation: isolate;
|
||
}
|
||
|
||
.modal-header {
|
||
text-decoration: none !important;
|
||
border-bottom: none !important;
|
||
}
|
||
|
||
.isolate h1,
|
||
.isolate h2,
|
||
.isolate h3,
|
||
.isolate h4,
|
||
.isolate h5,
|
||
.isolate h6 {
|
||
text-decoration: none !important;
|
||
border-bottom: none !important;
|
||
}
|
||
|
||
.isolate p {
|
||
text-decoration: none !important;
|
||
}
|
||
/* Remove number input spinners */
|
||
input[type="number"]::-webkit-inner-spin-button,
|
||
input[type="number"]::-webkit-outer-spin-button {
|
||
-webkit-appearance: none;
|
||
margin: 0;
|
||
}
|
||
|
||
input[type="number"] {
|
||
-moz-appearance: textfield;
|
||
}
|
||
</style>
|