refactor: enhance AnnualBudget component layout with improved dark mode support, streamline table structure, and update CSS for better visual consistency

This commit is contained in:
Jennie Robinson Faber 2025-09-10 15:24:18 +01:00
parent 24e8b7a3a8
commit f073f91569
14 changed files with 1440 additions and 922 deletions

View file

@ -237,7 +237,7 @@ html.dark .section-card::before {
========================= */
.dither-shadow {
@apply bg-black dark:bg-neutral-600;
@apply bg-black dark:bg-neutral-400;
background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px;
}

View file

@ -2,66 +2,93 @@
<div class="space-y-8">
<!-- Annual Budget Overview -->
<div class="space-y-4">
<h2 class="text-2xl font-bold">Annual Budget Overview</h2>
<div class="border border-black bg-white">
<table class="w-full border-collapse text-sm">
<div class="relative">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative border border-black dark:border-neutral-600 bg-white dark:bg-neutral-950">
<table
class="w-full border-collapse text-sm bg-white dark:bg-neutral-950">
<thead>
<tr class="border-b-2 border-black bg-neutral-100">
<th class="border-r-1 border-black px-4 py-3 text-left font-bold">
<tr class="bg-neutral-100 dark:bg-neutral-950">
<th
class="border-r-1 border-black dark:border-neutral-400 px-4 py-3 text-left font-bold text-black dark:text-white">
Category
</th>
<th
class="border-r border-neutral-400 px-4 py-3 text-right font-bold">
class="border-r border-neutral-400 dark:border-neutral-600 px-4 py-3 text-right font-bold text-black dark:text-white">
Planned
</th>
<th class="px-4 py-3 text-right font-bold">%</th>
<th
class="px-4 py-3 text-right font-bold text-black dark:text-white">
%
</th>
</tr>
</thead>
<tbody>
<!-- Revenue Section -->
<tr class="bg-black text-white">
<td class="px-4 py-2 font-bold" colspan="3">REVENUE</td>
<tr class="bg-black text-white dark:bg-white dark:text-black">
<td
class="px-4 py-2 font-bold text-white dark:text-black"
colspan="3">
REVENUE
</td>
</tr>
<!-- Revenue Categories -->
<tr
v-for="(category, index) in revenueCategories"
:key="`rev-${index}`"
class="border-t border-neutral-200"
class="border-t border-neutral-200 dark:border-neutral-700 text-black dark:text-white"
v-show="category.planned > 0">
<td class="border-r-1 border-black px-4 py-2">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-2 text-black dark:text-white">
{{ category.name }}
</td>
<td class="border-r border-neutral-400 px-4 py-2 text-right">
<td
class="border-r border-neutral-400 dark:border-neutral-600 px-4 py-2 text-right text-black dark:text-white font-mono">
{{ formatCurrency(category.planned) }}
</td>
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
<td
class="px-4 py-2 text-right text-black dark:text-white font-mono">
{{ category.percentage }}%
</td>
</tr>
<!-- Total Revenue -->
<tr class="border-t-2 border-black font-semibold bg-neutral-50">
<td class="border-r-1 border-black px-4 py-2">Total Revenue</td>
<td class="border-r border-neutral-400 px-4 py-2 text-right">
<tr
class="border-t-2 border-black dark:border-neutral-400 font-semibold bg-neutral-50 dark:bg-neutral-800">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-2 text-black dark:text-white">
Total Revenue
</td>
<td
class="border-r border-neutral-400 dark:border-neutral-600 px-4 py-2 text-right text-black dark:text-white font-mono">
{{ formatCurrency(totalRevenuePlanned) }}
</td>
<td class="px-4 py-2 text-right">100%</td>
<td
class="px-4 py-2 text-right text-black dark:text-white font-mono">
100%
</td>
</tr>
<!-- Revenue Diversification Guidance -->
<tr :class="guidanceBackgroundClass">
<td colspan="3" class="border-t border-neutral-300 px-4 py-3">
<tr class="bg-neutral-50 dark:bg-neutral-800">
<td
colspan="3"
class="border-t border-neutral-300 dark:border-neutral-700 px-4 py-3 text-black dark:text-white">
<div class="text-sm">
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
<p class="font-medium mb-2 text-black dark:text-white">
{{ diversificationGuidance }}
</p>
<p
class="text-neutral-600 mb-2"
class="text-neutral-600 dark:text-neutral-100 mb-2"
v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(", ") }}
</p>
<p class="text-xs">
<p class="text-xs text-black dark:text-white">
<NuxtLink
to="/help#revenue-diversification"
class="text-blue-600 hover:text-blue-800 underline">
class="text-white dark:text-neutral-100 hover:text-white dark:hover:text-white underline">
Learn how to develop these revenue streams
</NuxtLink>
</p>
@ -70,49 +97,72 @@
</tr>
<!-- Expenses Section -->
<tr class="bg-black text-white">
<td class="px-4 py-2 font-bold" colspan="3">EXPENSES</td>
<tr class="bg-black text-white dark:bg-white dark:text-black">
<td
class="px-4 py-2 font-bold text-white dark:text-black"
colspan="3">
EXPENSES
</td>
</tr>
<!-- Expense Categories -->
<tr
v-for="(category, index) in expenseCategories"
:key="`exp-${index}`"
class="border-t border-neutral-200"
class="text-black dark:text-white"
v-show="category.planned > 0">
<td class="border-r-1 border-black px-4 py-2">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-2 text-black dark:text-white">
{{ category.name }}
</td>
<td class="border-r border-neutral-400 px-4 py-2 text-right">
<td
class="border-r border-neutral-400 dark:border-neutral-600 px-4 py-2 text-right text-black dark:text-white font-mono">
{{ formatCurrency(category.planned) }}
</td>
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
<td
class="px-4 py-2 text-right text-black dark:text-white font-mono">
{{ category.percentage }}%
</td>
</tr>
<!-- Total Expenses -->
<tr class="border-t-2 border-black font-semibold bg-neutral-50">
<td class="border-r-1 border-black px-4 py-2">Total Expenses</td>
<td class="border-r border-neutral-400 px-4 py-2 text-right">
<tr class="font-semibold bg-neutral-50 dark:bg-neutral-800">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-2 text-black dark:text-white">
Total Expenses
</td>
<td
class="border-r border-neutral-400 dark:border-neutral-600 px-4 py-2 text-right text-black dark:text-white font-mono">
{{ formatCurrency(totalExpensesPlanned) }}
</td>
<td class="px-4 py-2 text-right">100%</td>
<td
class="px-4 py-2 text-right text-black dark:text-white font-mono">
100%
</td>
</tr>
<!-- Net Total -->
<tr
class="border-t-2 border-black font-bold text-lg"
class="border-t-2 border-black dark:border-neutral-400 font-bold text-lg"
:class="netTotalClass">
<td class="border-r-1 border-black px-4 py-3">NET TOTAL</td>
<td class="border-r border-neutral-400 px-4 py-3 text-right">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-3 text-black dark:text-white">
NET TOTAL
</td>
<td
class="border-r border-neutral-400 dark:border-neutral-600 px-4 py-3 text-right text-black dark:text-white font-mono">
{{ formatCurrency(netTotal) }}
</td>
<td class="px-4 py-3 text-right">-</td>
<td class="px-4 py-3 text-right text-black dark:text-white">
-
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@ -242,9 +292,9 @@ const netTotal = computed(
);
const netTotalClass = computed(() => {
if (netTotal.value > 0) return "bg-green-50";
if (netTotal.value < 0) return "bg-red-50";
return "bg-neutral-50";
if (netTotal.value > 0) return "bg-green-50 dark:bg-green-950";
if (netTotal.value < 0) return "bg-red-50 dark:bg-red-950";
return "bg-neutral-50 dark:bg-neutral-800";
});
// Diversification guidance
@ -308,28 +358,6 @@ const diversificationGuidance = computed(() => {
return guidance;
});
const guidanceBackgroundClass = computed(() => {
const topCategory = revenueCategories.value.reduce(
(max, cat) => (cat.percentage > max.percentage ? cat : max),
{ percentage: 0 }
);
if (topCategory.percentage >= 70) {
return "bg-red-50";
} else if (topCategory.percentage >= 50) {
return "bg-red-50";
} else {
const categoriesAbove20 = revenueCategories.value.filter(
(cat) => cat.percentage >= 20
).length;
if (categoriesAbove20 >= 3) {
return "bg-green-50";
} else {
return "bg-yellow-50";
}
}
});
// Suggested categories to develop
const suggestedCategories = computed(() => {
const categoriesWithRevenue = revenueCategories.value.filter(
@ -394,10 +422,11 @@ function formatCurrency(amount: number): string {
}
function getPercentageClass(percentage: number): string {
if (percentage > 50) return "text-red-600 font-bold";
if (percentage > 35) return "text-yellow-600 font-semibold";
if (percentage > 20) return "text-black font-medium";
return "text-neutral-500";
if (percentage > 50) return "text-red-600 dark:text-red-400 font-bold";
if (percentage > 35)
return "text-yellow-600 dark:text-yellow-400 font-semibold";
if (percentage > 20) return "text-black dark:text-white font-medium";
return "text-neutral-500 dark:text-neutral-400";
}
// Initialize

View file

@ -0,0 +1,208 @@
<template>
<UModal
v-model:open="isOpen"
title="Budget Settings"
description="Configure payroll taxes and cash flow settings"
:dismissible="true">
<template #body>
<div class="space-y-8">
<!-- Payroll Tax Settings -->
<div>
<h3 class="text-lg font-semibold text-black dark:text-white mb-4">
Payroll Tax Percentage
</h3>
<p class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Set the percentage added to base payroll for taxes, benefits, and other oncosts.
</p>
<div class="space-y-4">
<UFormField label="Tax Percentage">
<UInput
v-model="newOncostPct"
type="number"
placeholder="Enter percentage"
min="0"
max="100"
step="0.1"
size="lg"
class="text-lg">
<template #trailing>
<span class="text-neutral-500 text-lg">%</span>
</template>
</UInput>
</UFormField>
<!-- Quick selection buttons -->
<div class="flex flex-wrap gap-2">
<span class="text-sm text-neutral-600 dark:text-neutral-400 mr-2">Quick select:</span>
<UButton
v-for="range in commonOncostRanges"
:key="range.value"
@click="newOncostPct = range.value"
size="xs"
variant="outline"
:ui="{ base: 'hover:bg-neutral-100 dark:hover:bg-neutral-800' }">
{{ range.value }}%
</UButton>
</div>
<!-- Preview -->
<div class="p-3 bg-neutral-100 dark:bg-neutral-800 rounded">
<p class="text-sm text-neutral-700 dark:text-neutral-300">
<strong>Example:</strong> 100 base payroll + {{ newOncostPct }}% tax =
<strong>{{ Math.round(100 * (1 + newOncostPct / 100)) }} total cost</strong>
</p>
</div>
</div>
</div>
<!-- Minimum Cash Threshold Settings -->
<div>
<h3 class="text-lg font-semibold text-black dark:text-white mb-4">
Minimum Cash Threshold
</h3>
<p class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Set the minimum cash balance that your co-op should never go below. The budget will automatically reduce payroll allocations if needed to maintain this threshold.
</p>
<div class="space-y-4">
<UFormField label="Minimum Cash Balance">
<UInput
v-model="newMinCashThreshold"
type="number"
placeholder="Enter minimum amount"
min="0"
step="100"
size="lg"
class="text-lg">
<template #leading>
<span class="text-neutral-500 text-lg">{{ getCurrencySymbol(coopStore.currency) }}</span>
</template>
</UInput>
</UFormField>
<!-- Quick selection buttons -->
<div class="flex flex-wrap gap-2">
<span class="text-sm text-neutral-600 dark:text-neutral-400 mr-2">Quick select:</span>
<UButton
v-for="amount in commonCashThresholds"
:key="amount"
@click="newMinCashThreshold = amount"
size="xs"
variant="outline"
:ui="{ base: 'hover:bg-neutral-100 dark:hover:bg-neutral-800' }">
{{ getCurrencySymbol(coopStore.currency) }}{{ formatAmount(amount) }}
</UButton>
</div>
<!-- Preview -->
<div class="p-3 bg-neutral-100 dark:bg-neutral-800 rounded">
<p class="text-sm text-neutral-700 dark:text-neutral-300">
<strong>Safety buffer:</strong> Your co-op will maintain at least
<strong>{{ getCurrencySymbol(coopStore.currency) }}{{ formatAmount(newMinCashThreshold) }}</strong>
in cash at all times.
</p>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-between items-center w-full">
<UButton
@click="resetToDefaults"
variant="ghost"
color="neutral"
:ui="{ base: 'hover:bg-neutral-100 dark:hover:bg-neutral-800' }">
Reset to Defaults
</UButton>
<div class="flex gap-2">
<UButton
@click="isOpen = false"
variant="outline"
color="neutral">
Cancel
</UButton>
<UButton
@click="saveSettings"
:disabled="!isValidSettings"
color="primary">
Save Settings
</UButton>
</div>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { getCurrencySymbol } from "~/utils/currency";
// Props
const isOpen = defineModel<boolean>("open", { default: false });
// Emit events for parent component
const emit = defineEmits<{
"settings-updated": [];
}>();
// Stores
const coopStore = useCoopBuilderStore();
// Reactive form data
const newOncostPct = ref(coopStore.payrollOncostPct || 25);
const newMinCashThreshold = ref(coopStore.minCashThreshold || 0);
// Common ranges for quick selection
const commonOncostRanges = [
{ value: 0 },
{ value: 15 },
{ value: 20 },
{ value: 25 },
{ value: 30 },
{ value: 35 }
];
const commonCashThresholds = [0, 1000, 2500, 5000, 7500, 10000];
// Computed properties
const isValidSettings = computed(() =>
newOncostPct.value >= 0 &&
newOncostPct.value <= 100 &&
newMinCashThreshold.value >= 0
);
// Utility function to format amounts
function formatAmount(amount: number): string {
return new Intl.NumberFormat().format(amount);
}
// Reset form when modal opens
watch(() => isOpen.value, (open) => {
if (open) {
newOncostPct.value = coopStore.payrollOncostPct || 25;
newMinCashThreshold.value = coopStore.minCashThreshold || 0;
}
});
function resetToDefaults() {
newOncostPct.value = 25;
newMinCashThreshold.value = 0;
}
function saveSettings() {
// Update payroll oncost percentage
coopStore.payrollOncostPct = newOncostPct.value;
// Update minimum cash threshold
coopStore.setMinCashThreshold(newMinCashThreshold.value);
// Close modal
isOpen.value = false;
// Emit event to parent to refresh calculations
emit("settings-updated");
}
</script>

View file

@ -1,323 +0,0 @@
<template>
<UModal
v-model:open="isOpen"
title="Payroll Oncost Settings"
description="Configure payroll taxes and benefits percentage"
:dismissible="true">
<template #body>
<div class="space-y-6">
<!-- Explanation -->
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<div class="flex items-start">
<UIcon
name="i-heroicons-information-circle"
class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" />
<div class="text-sm">
<p class="text-blue-800 dark:text-blue-200 font-medium mb-2">
What are payroll oncosts?
</p>
<p class="text-blue-700 dark:text-blue-300">
Payroll oncosts cover taxes, benefits, and other
employee-related expenses beyond base wages. This typically
includes employer payroll taxes, worker's compensation, benefits
contributions, and other statutory requirements.
</p>
</div>
</div>
</div>
<!-- Current Settings Display -->
<div class="bg-neutral-50 dark:bg-neutral-800 p-4 rounded-lg">
<h4 class="font-medium text-neutral-900 dark:text-white mb-3">
Current Impact
</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-neutral-600 dark:text-neutral-400">
Base Payroll
</div>
<div class="font-medium">
{{ formatCurrency(basePayroll) }}/month
</div>
</div>
<div>
<div class="text-neutral-600 dark:text-neutral-400">
Oncosts ({{ currentOncostPct }}%)
</div>
<div class="font-medium">
{{ formatCurrency(currentOncostAmount) }}/month
</div>
</div>
</div>
<div
class="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="text-neutral-600 dark:text-neutral-400 text-sm">
Total Payroll Cost
</div>
<div class="font-semibold text-lg">
{{ formatCurrency(totalPayrollCost) }}/month
</div>
</div>
</div>
<!-- Percentage Input -->
<div class="space-y-3">
<label
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Oncost Percentage
</label>
<div class="flex items-center space-x-3">
<div class="flex-1">
<UInput
v-model.number="newOncostPct"
type="number"
min="0"
max="100"
step="1"
placeholder="25"
class="text-center" />
</div>
<span class="text-sm text-neutral-500">%</span>
</div>
<!-- Slider for easier adjustment -->
<div class="space-y-2">
<input
v-model.number="newOncostPct"
type="range"
min="0"
max="50"
step="1"
class="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 slider" />
<div class="flex justify-between text-xs text-neutral-500">
<span>0%</span>
<span>25%</span>
<span>50%</span>
</div>
</div>
</div>
<!-- Preview of New Settings -->
<div
v-if="newOncostPct !== currentOncostPct"
class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
<h4 class="font-medium text-green-800 dark:text-green-200 mb-3">
Preview Changes
</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-green-700 dark:text-green-300">
New Oncosts ({{ newOncostPct }}%)
</div>
<div class="font-medium">
{{ formatCurrency(newOncostAmount) }}/month
</div>
</div>
<div>
<div class="text-green-700 dark:text-green-300">
New Total Cost
</div>
<div class="font-medium">
{{ formatCurrency(newTotalCost) }}/month
</div>
</div>
</div>
<div class="mt-2 text-xs">
<span class="text-green-700 dark:text-green-300">
{{ newTotalCost > totalPayrollCost ? "Increase" : "Decrease" }} of
{{
formatCurrency(Math.abs(newTotalCost - totalPayrollCost))
}}/month
</span>
</div>
</div>
<!-- Common Oncost Ranges -->
<div class="space-y-2">
<label
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Common Ranges
</label>
<div class="flex flex-wrap gap-2">
<UButton
v-for="preset in commonRanges"
:key="preset.value"
size="xs"
color="neutral"
variant="outline"
@click="newOncostPct = preset.value">
{{ preset.label }}
</UButton>
</div>
</div>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-3">
<UButton color="neutral" variant="ghost" @click="handleCancel">
Cancel
</UButton>
<UButton
color="primary"
@click="handleSave"
:disabled="!isValidPercentage">
Update Oncost Percentage
</UButton>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { allocatePayroll as allocatePayrollImpl } from "~/types/members";
interface Props {
open: boolean;
}
interface Emits {
(e: "update:open", value: boolean): void;
(e: "save", percentage: number): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Modal state
const isOpen = computed({
get: () => props.open,
set: (value) => emit("update:open", value),
});
// Get current payroll data
const coopStore = useCoopBuilderStore();
const currentOncostPct = computed(() => coopStore.payrollOncostPct || 0);
// Calculate current payroll values using the same logic as the budget store
const { allocatePayroll } = useCoopBuilder();
const basePayroll = computed(() => {
// Calculate base payroll the same way the budget store does
const totalHours = coopStore.members.reduce(
(sum, m) =>
sum + (m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0)),
0
);
const hourlyWage = coopStore.equalHourlyWage || 0;
const basePayrollBudget = totalHours * hourlyWage;
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Use policy-driven allocation to get actual member pay amounts
const payPolicy = {
relationship: coopStore.policy.relationship,
roleBands: coopStore.policy.roleBands,
};
// Convert members to the format expected by allocatePayroll
const membersForAllocation = coopStore.members.map((m) => ({
...m,
displayName: m.name,
monthlyPayPlanned: m.monthlyPayPlanned || 0,
minMonthlyNeeds: m.minMonthlyNeeds || 0,
hoursPerMonth:
m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0),
}));
// Use the imported allocatePayroll function
const allocatedMembers = allocatePayrollImpl(
membersForAllocation,
payPolicy,
basePayrollBudget
);
// Sum the allocated amounts for total payroll
return allocatedMembers.reduce(
(sum, m) => sum + (m.monthlyPayPlanned || 0),
0
);
}
return 0;
});
const currentOncostAmount = computed(
() => basePayroll.value * (currentOncostPct.value / 100)
);
const totalPayrollCost = computed(
() => basePayroll.value + currentOncostAmount.value
);
// New percentage input
const newOncostPct = ref(currentOncostPct.value);
// Computed values for preview
const newOncostAmount = computed(
() => basePayroll.value * (newOncostPct.value / 100)
);
const newTotalCost = computed(() => basePayroll.value + newOncostAmount.value);
const isValidPercentage = computed(
() => newOncostPct.value >= 0 && newOncostPct.value <= 100
);
// Common oncost ranges
const commonRanges = [
{ label: "0% (No oncosts)", value: 0 },
{ label: "15% (Basic)", value: 15 },
{ label: "25% (Standard)", value: 25 },
{ label: "35% (Comprehensive)", value: 35 },
];
// Reset to current value when modal opens
watch(isOpen, (open) => {
if (open) {
newOncostPct.value = currentOncostPct.value;
}
});
// Handlers
function handleCancel() {
newOncostPct.value = currentOncostPct.value;
isOpen.value = false;
}
function handleSave() {
if (isValidPercentage.value) {
emit("save", newOncostPct.value);
isOpen.value = false;
}
}
// Currency formatting
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
</script>
<style scoped>
.slider::-webkit-slider-thumb {
appearance: none;
height: 20px;
width: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
}
.slider::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: none;
}
</style>

View file

@ -102,7 +102,7 @@ export function useCoopBuilder() {
streams: baseStreams.map(s => {
// Reduce service revenue by 30%
if (s.category?.toLowerCase().includes('service') || s.label.toLowerCase().includes('service')) {
return { ...s, monthly: (s.monthly || 0) * 0.7 }
return { ...s, monthly: Math.round((s.monthly || 0) * 0.7) }
}
return s
})
@ -128,7 +128,7 @@ export function useCoopBuilder() {
if (revenueDelay > 0) {
adjustedStreams = adjustedStreams.map(s => ({
...s,
monthly: (s.monthly || 0) * Math.max(0, 1 - (revenueDelay / 12))
monthly: Math.round((s.monthly || 0) * Math.max(0, 1 - (revenueDelay / 12)))
}))
}
@ -251,7 +251,7 @@ export function useCoopBuilder() {
// Calculate monthly payroll
const payrollCost = monthlyPayroll(scenarioMembers || [], currentMode) || 0
const oncostPct = store.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + Math.max(0, oncostPct) / 100)
const totalPayroll = Math.round(payrollCost * (1 + Math.max(0, oncostPct) / 100))
// Calculate revenue and costs
const totalRevenue = (scenarioStreams || []).reduce((sum, s) => sum + (s.monthly || 0), 0)

View file

@ -63,7 +63,7 @@ export function useCushionForecast() {
const operatingMode = policiesStore.operatingMode || 'minimum'
const payrollCost = monthlyPayroll(membersStore.members, operatingMode)
const oncostPct = policiesStore.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + oncostPct / 100)
const totalPayroll = Math.round(payrollCost * (1 + oncostPct / 100))
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
return totalPayroll + overheadCost

View file

@ -18,7 +18,7 @@ export const useDeferredMetrics = () => {
): number => {
const totalTargetHours = members.reduce((sum, member) => sum + (member.targetHours || 0), 0)
const grossPayroll = totalTargetHours * hourlyWage
return grossPayroll * (1 + oncostPct / 100)
return Math.round(grossPayroll * (1 + oncostPct / 100))
}
const calculateDeferredRatio = (

View file

@ -33,7 +33,7 @@ export function usePayrollAllocation() {
// Total payroll with oncosts
const totalPayrollWithOncosts = computed(() => {
return basePayrollBudget.value * (1 + payrollOncostPct.value / 100)
return Math.round(basePayrollBudget.value * (1 + payrollOncostPct.value / 100))
})
// Update member planned pay when allocation changes

View file

@ -23,7 +23,7 @@ export const useRunway = () => {
// Add oncosts
const oncostPct = policiesStore.payrollOncostPct || 0
const totalPayroll = payrollCost * (1 + oncostPct / 100)
const totalPayroll = Math.round(payrollCost * (1 + oncostPct / 100))
// Add overhead costs
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)

View file

@ -106,7 +106,7 @@ export const useStoreSync = () => {
id: member.id,
name: member.displayName,
role: member.role,
hoursPerMonth: Number(((member.hoursPerWeek || 0) * 4.33).toFixed(2)),
hoursPerMonth: Math.round((member.hoursPerWeek || 0) * 4.33),
minMonthlyNeeds: member.minMonthlyNeeds,
monthlyPayPlanned: member.monthlyPayPlanned,
targetMonthlyPay: member.targetMonthlyPay,

File diff suppressed because it is too large Load diff

View file

@ -1,46 +1,56 @@
<template>
<div>
<div
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
>
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8">
<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">
More Resources & Templates
</h1>
<p class="text-neutral-700 dark:text-neutral-200">
Additional tools, templates, and resources to support your cooperative journey.
Additional tools, templates, and resources to support your
cooperative journey.
</p>
</div>
<div class="space-y-8">
<!-- External Templates Section -->
<section>
<h2 class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
<h2
class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
External Templates
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Miro Template -->
<div class="template-card">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6"
>
<h3 class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
<h3
class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
Goals & Values Exercise
</h3>
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
A Miro template to help your team align on shared goals and values through collaborative exercises.
A Miro template to help your team align on shared goals and
values through collaborative exercises.
</p>
<a
href="https://miro.com/miroverse/goals-values-exercise/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity"
>
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity">
Open in Miro
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
<svg
class="w-4 h-4 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
@ -48,25 +58,34 @@
<!-- CommunityRule Templates -->
<div class="template-card">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6"
>
<h3 class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
<h3
class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
CommunityRule Governance Templates
</h3>
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
A collection of governance templates and patterns for democratic organizations and communities.
A collection of governance templates and patterns for
democratic organizations and communities.
</p>
<a
href="https://communityrule.info/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity"
>
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity">
Browse Templates
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002 2v-4M14 4h6m0 0v6m0-6L10 14" />
<svg
class="w-4 h-4 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002 2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
@ -76,7 +95,8 @@
<!-- PDF Downloads Section -->
<section>
<h2 class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
<h2
class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
Wizard PDF Downloads
</h2>
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
@ -86,21 +106,23 @@
<div
v-for="pdf in pdfDownloads"
:key="pdf.id"
class="template-card"
>
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
class="template-card">
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6"
>
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
<div class="flex items-start justify-between mb-2">
<h3 class="text-lg font-semibold text-neutral-900 dark:text-white">
<h3
class="text-lg font-semibold text-neutral-900 dark:text-white">
{{ pdf.name }}
</h3>
<span class="text-xs text-neutral-600 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-2 py-1 rounded">
<span
class="text-xs text-neutral-600 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-2 py-1 rounded">
PDF
</span>
</div>
<p class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
<p
class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
{{ pdf.description }}
</p>
<button
@ -109,13 +131,20 @@
'inline-flex items-center px-4 py-2 font-medium transition-opacity',
pdf.available
? 'bg-black dark:bg-white text-white dark:text-black hover:opacity-90'
: 'bg-neutral-200 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-500 cursor-not-allowed'
]"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
: 'bg-neutral-200 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-500 cursor-not-allowed',
]">
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ pdf.available ? 'Download' : 'Coming Soon' }}
{{ pdf.available ? "Download" : "Coming Soon" }}
</button>
</div>
</div>
@ -131,48 +160,48 @@
// PDF downloads list with placeholder data
const pdfDownloads = [
{
id: 'bylaws',
name: 'Bylaws Wizard',
description: 'Create comprehensive bylaws for your cooperative',
id: "bylaws",
name: "Bylaws Wizard",
description: "Create comprehensive bylaws for your cooperative",
available: false,
url: '#'
url: "#",
},
{
id: 'operating-agreement',
name: 'Operating Agreement Wizard',
description: 'Draft an operating agreement for your LLC cooperative',
id: "operating-agreement",
name: "Operating Agreement Wizard",
description: "Draft an operating agreement for your LLC cooperative",
available: false,
url: '#'
url: "#",
},
{
id: 'articles',
name: 'Articles of Incorporation',
description: 'Template for articles of incorporation',
id: "articles",
name: "Articles of Incorporation",
description: "Template for articles of incorporation",
available: false,
url: '#'
url: "#",
},
{
id: 'membership',
name: 'Membership Agreement',
description: 'Define membership terms and conditions',
id: "membership",
name: "Membership Agreement",
description: "Define membership terms and conditions",
available: false,
url: '#'
url: "#",
},
{
id: 'patronage',
name: 'Patronage Policy',
description: 'Structure your patronage distribution system',
id: "patronage",
name: "Patronage Policy",
description: "Structure your patronage distribution system",
available: false,
url: '#'
url: "#",
},
{
id: 'conflict',
name: 'Conflict Resolution Process',
description: 'Establish clear conflict resolution procedures',
id: "conflict",
name: "Conflict Resolution Process",
description: "Establish clear conflict resolution procedures",
available: false,
url: '#'
}
]
url: "#",
},
];
</script>
<style scoped>
@ -180,18 +209,6 @@ const pdfDownloads = [
position: relative;
}
.dither-shadow {
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 2px,
currentColor 2px,
currentColor 4px
);
opacity: 0.1;
pointer-events: none;
}
.dither-tag {
background-image: repeating-linear-gradient(
135deg,

View file

@ -416,7 +416,7 @@ export const useBudgetStore = defineStore(
return sum + (m.monthlyPayPlanned || 0);
}, 0);
let monthlyOncostAmount = monthlyAllocatedPayroll * (oncostPct / 100);
let monthlyOncostAmount = Math.round(monthlyAllocatedPayroll * (oncostPct / 100));
// Calculate projected balance after this month's expenses and payroll
const totalExpensesWithPayroll = nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount;
@ -442,7 +442,7 @@ export const useBudgetStore = defineStore(
}
monthlyAllocatedPayroll = maxSustainablePayroll;
monthlyOncostAmount = monthlyAllocatedPayroll * (oncostPct / 100);
monthlyOncostAmount = Math.round(monthlyAllocatedPayroll * (oncostPct / 100));
}
// Update running balance with actual net income after payroll adjustments
@ -451,17 +451,17 @@ export const useBudgetStore = defineStore(
// Update this specific month's payroll values
if (basePayrollIndex !== -1) {
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = monthlyAllocatedPayroll;
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = Math.round(monthlyAllocatedPayroll);
}
if (oncostIndex !== -1) {
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = monthlyOncostAmount;
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = Math.round(monthlyOncostAmount);
}
// Handle legacy single payroll entry
if (legacyIndex !== -1 && basePayrollIndex === -1) {
const combinedPayroll = monthlyAllocatedPayroll * (1 + oncostPct / 100);
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = combinedPayroll;
const combinedPayroll = Math.round(monthlyAllocatedPayroll * (1 + oncostPct / 100));
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = Math.round(combinedPayroll);
}
// Accumulate for annual totals
@ -490,9 +490,9 @@ export const useBudgetStore = defineStore(
// Update annual values based on actual totals
if (basePayrollIndex !== -1) {
budgetWorksheet.value.expenses[basePayrollIndex].values = {
year1: { best: totalAnnualPayroll * 1.2, worst: totalAnnualPayroll * 0.8, mostLikely: totalAnnualPayroll },
year2: { best: totalAnnualPayroll * 1.3, worst: totalAnnualPayroll * 0.9, mostLikely: totalAnnualPayroll * 1.1 },
year3: { best: totalAnnualPayroll * 1.5, worst: totalAnnualPayroll, mostLikely: totalAnnualPayroll * 1.25 }
year1: { best: Math.round(totalAnnualPayroll * 1.2), worst: Math.round(totalAnnualPayroll * 0.8), mostLikely: Math.round(totalAnnualPayroll) },
year2: { best: Math.round(totalAnnualPayroll * 1.3), worst: Math.round(totalAnnualPayroll * 0.9), mostLikely: Math.round(totalAnnualPayroll * 1.1) },
year3: { best: Math.round(totalAnnualPayroll * 1.5), worst: Math.round(totalAnnualPayroll), mostLikely: Math.round(totalAnnualPayroll * 1.25) }
};
}
@ -501,9 +501,9 @@ export const useBudgetStore = defineStore(
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
budgetWorksheet.value.expenses[oncostIndex].values = {
year1: { best: totalAnnualOncosts * 1.2, worst: totalAnnualOncosts * 0.8, mostLikely: totalAnnualOncosts },
year2: { best: totalAnnualOncosts * 1.3, worst: totalAnnualOncosts * 0.9, mostLikely: totalAnnualOncosts * 1.1 },
year3: { best: totalAnnualOncosts * 1.5, worst: totalAnnualOncosts, mostLikely: totalAnnualOncosts * 1.25 }
year1: { best: Math.round(totalAnnualOncosts * 1.2), worst: Math.round(totalAnnualOncosts * 0.8), mostLikely: Math.round(totalAnnualOncosts) },
year2: { best: Math.round(totalAnnualOncosts * 1.3), worst: Math.round(totalAnnualOncosts * 0.9), mostLikely: Math.round(totalAnnualOncosts * 1.1) },
year3: { best: Math.round(totalAnnualOncosts * 1.5), worst: Math.round(totalAnnualOncosts), mostLikely: Math.round(totalAnnualOncosts * 1.25) }
};
}
@ -511,9 +511,9 @@ export const useBudgetStore = defineStore(
if (legacyIndex !== -1 && basePayrollIndex === -1) {
const totalCombined = totalAnnualPayroll + totalAnnualOncosts;
budgetWorksheet.value.expenses[legacyIndex].values = {
year1: { best: totalCombined * 1.2, worst: totalCombined * 0.8, mostLikely: totalCombined },
year2: { best: totalCombined * 1.3, worst: totalCombined * 0.9, mostLikely: totalCombined * 1.1 },
year3: { best: totalCombined * 1.5, worst: totalCombined, mostLikely: totalCombined * 1.25 }
year1: { best: Math.round(totalCombined * 1.2), worst: Math.round(totalCombined * 0.8), mostLikely: Math.round(totalCombined) },
year2: { best: Math.round(totalCombined * 1.3), worst: Math.round(totalCombined * 0.9), mostLikely: Math.round(totalCombined * 1.1) },
year3: { best: Math.round(totalCombined * 1.5), worst: Math.round(totalCombined), mostLikely: Math.round(totalCombined * 1.25) }
};
}
}
@ -807,19 +807,19 @@ export const useBudgetStore = defineStore(
monthlyValues,
values: {
year1: {
best: annualAmount,
worst: annualAmount * 0.8,
mostLikely: annualAmount,
best: Math.round(annualAmount),
worst: Math.round(annualAmount * 0.8),
mostLikely: Math.round(annualAmount),
},
year2: {
best: annualAmount * 1.1,
worst: annualAmount * 0.9,
mostLikely: annualAmount * 1.05,
best: Math.round(annualAmount * 1.1),
worst: Math.round(annualAmount * 0.9),
mostLikely: Math.round(annualAmount * 1.05),
},
year3: {
best: annualAmount * 1.2,
worst: annualAmount,
mostLikely: annualAmount * 1.1,
best: Math.round(annualAmount * 1.2),
worst: Math.round(annualAmount),
mostLikely: Math.round(annualAmount * 1.1),
},
},
});

View file

@ -51,7 +51,7 @@ export const useCoopBuilderStore = defineStore("coop", {
payrollOncostPct: 25,
// Cash flow management
minCashThreshold: 5000, // Minimum cash balance to maintain
minCashThreshold: 0, // Minimum cash balance to maintain
savingsTargetMonths: 6,
minCashCushion: 10000,