app/components/AnnualBudget.vue

339 lines
No EOL
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 revenue timing.";
}
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>