app/components/AnnualBudget.vue

444 lines
15 KiB
Vue
Raw Permalink 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">
<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="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 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 text-black dark:text-white">
%
</th>
</tr>
</thead>
<tbody>
<!-- Revenue Section -->
<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 dark:border-neutral-700 text-black dark:text-white"
v-show="category.planned > 0">
<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 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 text-black dark:text-white font-mono">
{{ category.percentage }}%
</td>
</tr>
<!-- Total Revenue -->
<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 text-black dark:text-white font-mono">
100%
</td>
</tr>
<!-- Revenue Diversification Guidance -->
<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 text-black dark:text-white">
{{ diversificationGuidance }}
</p>
<p
class="text-neutral-600 dark:text-neutral-100 mb-2"
v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(", ") }}
</p>
</div>
</td>
</tr>
<!-- Expenses Section -->
<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="text-black dark:text-white"
v-show="category.planned > 0">
<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 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 text-black dark:text-white font-mono">
{{ category.percentage }}%
</td>
</tr>
<!-- Total Expenses -->
<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 text-black dark:text-white font-mono">
100%
</td>
</tr>
<!-- Net Total -->
<tr
class="border-t-2 border-black dark:border-neutral-400 font-bold text-lg"
:class="netTotalClass">
<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 text-black dark:text-white">
-
</td>
</tr>
</tbody>
</table>
</div>
</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 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
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;
});
// 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 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
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>