339 lines
No EOL
13 KiB
Vue
339 lines
No EOL
13 KiB
Vue
<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 2–3 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> |