This commit is contained in:
Jennie Robinson Faber 2025-09-04 10:42:03 +01:00
parent fc2d9ed56b
commit 983aeca2dc
32 changed files with 1570 additions and 27266 deletions

339
components/AnnualBudget.vue Normal file
View file

@ -0,0 +1,339 @@
<template>
<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-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b-2 border-black bg-gray-100">
<th class="border-r-2 border-black px-4 py-3 text-left font-bold">Category</th>
<th class="border-r border-gray-400 px-4 py-3 text-right font-bold">Planned</th>
<th class="px-4 py-3 text-right font-bold">%</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>
<!-- Revenue Categories -->
<tr v-for="(category, index) in revenueCategories"
:key="`rev-${index}`"
class="border-t border-gray-200"
v-show="category.planned > 0">
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
{{ formatCurrency(category.planned) }}
</td>
<td class="px-4 py-2 text-right">
{{ category.percentage }}%
</td>
</tr>
<!-- Total Revenue -->
<tr class="border-t-2 border-black font-semibold bg-gray-50">
<td class="border-r-2 border-black px-4 py-2">Total Revenue</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
{{ formatCurrency(totalRevenuePlanned) }}
</td>
<td class="px-4 py-2 text-right">100%</td>
</tr>
<!-- Revenue Diversification Guidance -->
<tr :class="guidanceBackgroundClass">
<td colspan="3" class="border-t border-gray-300 px-4 py-3">
<div class="text-sm">
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
<p class="text-gray-600 mb-2" v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(', ') }}
</p>
<p class="text-xs">
<NuxtLink
to="/help#revenue-diversification"
class="text-blue-600 hover:text-blue-800 underline"
>
Learn how to develop these revenue streams
</NuxtLink>
</p>
</div>
</td>
</tr>
<!-- Spacer -->
<tr>
<td colspan="3" class="h-2"></td>
</tr>
<!-- Expenses Section -->
<tr class="bg-black text-white">
<td class="px-4 py-2 font-bold" colspan="3">EXPENSES</td>
</tr>
<!-- Expense Categories -->
<tr v-for="(category, index) in expenseCategories"
:key="`exp-${index}`"
class="border-t border-gray-200"
v-show="category.planned > 0">
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
{{ formatCurrency(category.planned) }}
</td>
<td class="px-4 py-2 text-right">
{{ category.percentage }}%
</td>
</tr>
<!-- Total Expenses -->
<tr class="border-t-2 border-black font-semibold bg-gray-50">
<td class="border-r-2 border-black px-4 py-2">Total Expenses</td>
<td class="border-r border-gray-400 px-4 py-2 text-right">
{{ formatCurrency(totalExpensesPlanned) }}
</td>
<td class="px-4 py-2 text-right">100%</td>
</tr>
<!-- Net Total -->
<tr class="border-t-2 border-black font-bold text-lg" :class="netTotalClass">
<td class="border-r-2 border-black px-4 py-3">NET TOTAL</td>
<td class="border-r border-gray-400 px-4 py-3 text-right">
{{ formatCurrency(netTotal) }}
</td>
<td class="px-4 py-3 text-right">-</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
orgId: string;
year: number;
}
const props = withDefaults(defineProps<Props>(), {
year: () => new Date().getFullYear()
});
// Get budget data from store
const budgetStore = useBudgetStore();
// Revenue categories with calculations
const revenueCategories = computed(() => {
const categories = [
{ key: 'gamesProducts', name: 'Games & Products', planned: 0, percentage: 0 },
{ key: 'servicesContracts', name: 'Services & Contracts', planned: 0, percentage: 0 },
{ key: 'grantsFunding', name: 'Grants & Funding', planned: 0, percentage: 0 },
{ key: 'communitySupport', name: 'Community Support', planned: 0, percentage: 0 },
{ key: 'partnerships', name: 'Partnerships', planned: 0, percentage: 0 },
{ key: 'investmentIncome', name: 'Investment Income', planned: 0, percentage: 0 },
{ key: 'inKindContributions', name: 'In-Kind Contributions', planned: 0, percentage: 0 },
];
// Calculate planned amounts for each category
budgetStore.budgetWorksheet.revenue.forEach(item => {
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
if (categoryIndex !== -1) {
categories[categoryIndex].planned += annualPlanned;
}
});
// Calculate percentages
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
categories.forEach(cat => {
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
});
return categories;
});
// Expense categories with calculations
const expenseCategories = computed(() => {
const categories = [
{ name: 'Salaries & Benefits', planned: 0, percentage: 0 },
{ name: 'Development Costs', planned: 0, percentage: 0 },
{ name: 'Equipment & Technology', planned: 0, percentage: 0 },
{ name: 'Marketing & Outreach', planned: 0, percentage: 0 },
{ name: 'Office & Operations', planned: 0, percentage: 0 },
{ name: 'Legal & Professional', planned: 0, percentage: 0 },
{ name: 'Other Expenses', planned: 0, percentage: 0 },
];
// Calculate planned amounts for each category
budgetStore.budgetWorksheet.expenses.forEach(item => {
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
if (categoryIndex !== -1) {
categories[categoryIndex].planned += annualPlanned;
}
});
// Calculate percentages
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
categories.forEach(cat => {
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
});
return categories;
});
// Totals
const totalRevenuePlanned = computed(() =>
revenueCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
);
const totalExpensesPlanned = computed(() =>
expenseCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
);
const netTotal = computed(() =>
totalRevenuePlanned.value - totalExpensesPlanned.value
);
const netTotalClass = computed(() => {
if (netTotal.value > 0) return 'bg-green-50';
if (netTotal.value < 0) return 'bg-red-50';
return 'bg-gray-50';
});
// Diversification guidance
const diversificationGuidance = computed(() => {
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
const topCategory = categoriesWithRevenue.reduce((max, cat) => cat.percentage > max.percentage ? cat : max, { percentage: 0, name: '' });
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
let guidance = "";
// Concentration Risk
if (topCategory.percentage >= 70) {
guidance += `Very high concentration risk: most of your revenue is from ${topCategory.name} (${topCategory.percentage}%). `;
} else if (topCategory.percentage >= 50) {
guidance += `High concentration risk: ${topCategory.name} makes up ${topCategory.percentage}% of your revenue. `;
} else {
guidance += "No single category dominates your revenue. ";
}
// Diversification Benchmark
if (categoriesAbove20 >= 3) {
guidance += "Your mix is reasonably balanced across multiple sources.";
} else if (categoriesAbove20 === 2) {
guidance += "Your mix is split, but still reliant on just two sources.";
} else {
guidance += "Your revenue is concentrated; aim to grow at least 23 other categories.";
}
// Optional Positive Nudges
const grantsCategory = categoriesWithRevenue.find(cat => cat.name === 'Grants & Funding');
const servicesCategory = categoriesWithRevenue.find(cat => cat.name === 'Services & Contracts');
const productsCategory = categoriesWithRevenue.find(cat => cat.name === 'Games & Products');
if (grantsCategory && grantsCategory.percentage >= 20) {
guidance += " You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
} else if (servicesCategory && servicesCategory.percentage >= 20 && productsCategory && productsCategory.percentage >= 20) {
guidance += " Strong foundation in both services and products — this balance helps smooth cash flow.";
}
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(cat => cat.percentage > 0);
const categoriesWithoutRevenue = revenueCategories.value.filter(cat => cat.percentage === 0);
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
// If we have fewer than 3 categories above 20%, suggest developing others
if (categoriesAbove20 < 3) {
// Prioritize categories that complement existing strengths
const suggestions = [];
// If they have services, suggest products for balance
if (categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts') &&
!categoriesWithRevenue.some(cat => cat.name === 'Games & Products')) {
suggestions.push('Games & Products');
}
// If they have products, suggest services for stability
if (categoriesWithRevenue.some(cat => cat.name === 'Games & Products') &&
!categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts')) {
suggestions.push('Services & Contracts');
}
// Always suggest grants if not present
if (!categoriesWithRevenue.some(cat => cat.name === 'Grants & Funding')) {
suggestions.push('Grants & Funding');
}
// Add community support for stability
if (!categoriesWithRevenue.some(cat => cat.name === 'Community Support')) {
suggestions.push('Community Support');
}
return suggestions.slice(0, 3); // Limit to 3 suggestions
}
return [];
});
// Utility functions
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount || 0);
}
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-gray-500';
}
// Initialize
onMounted(() => {
console.log(`Annual budget view for org: ${props.orgId}, year: ${props.year}`);
});
</script>
<style scoped>
/* 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>