444 lines
15 KiB
Vue
444 lines
15 KiB
Vue
<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 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;
|
||
});
|
||
|
||
// 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>
|