refactor: update routing paths in app.vue, enhance AnnualBudget component layout, and streamline dashboard and budget pages for improved user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-08 09:39:30 +01:00
parent 09d8794d72
commit 864a81065c
23 changed files with 3211 additions and 1978 deletions

View file

@ -3,13 +3,18 @@
<!-- 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)]">
<div class="border border-black bg-white">
<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="border-r-1 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>
@ -18,24 +23,25 @@
<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>
<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-1 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>
<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-1 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>
@ -47,14 +53,15 @@
<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
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"
>
<NuxtLink
to="/help#revenue-diversification"
class="text-blue-600 hover:text-blue-800 underline">
Learn how to develop these revenue streams
</NuxtLink>
</p>
@ -62,33 +69,29 @@
</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>
<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-1 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>
<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-1 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>
@ -96,8 +99,10 @@
</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>
<tr
class="border-t-2 border-black font-bold text-lg"
:class="netTotalClass">
<td class="border-r-1 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>
@ -107,7 +112,6 @@
</table>
</div>
</div>
</div>
</template>
@ -118,7 +122,7 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
year: () => new Date().getFullYear()
year: () => new Date().getFullYear(),
});
// Get budget data from store
@ -127,19 +131,54 @@ 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 },
{
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);
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;
}
@ -147,30 +186,34 @@ const revenueCategories = computed(() => {
// Calculate percentages
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
categories.forEach(cat => {
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 },
{ 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);
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;
}
@ -178,7 +221,7 @@ const expenseCategories = computed(() => {
// Calculate percentages
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
categories.forEach(cat => {
categories.forEach((cat) => {
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
});
@ -186,33 +229,39 @@ const expenseCategories = computed(() => {
});
// Totals
const totalRevenuePlanned = computed(() =>
const totalRevenuePlanned = computed(() =>
revenueCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
);
const totalExpensesPlanned = computed(() =>
const totalExpensesPlanned = computed(() =>
expenseCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
);
const netTotal = computed(() =>
totalRevenuePlanned.value - totalExpensesPlanned.value
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';
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;
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}%). `;
@ -221,107 +270,141 @@ const diversificationGuidance = computed(() => {
} 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.";
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');
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.";
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 });
const topCategory = revenueCategories.value.reduce(
(max, cat) => (cat.percentage > max.percentage ? cat : max),
{ percentage: 0 }
);
if (topCategory.percentage >= 70) {
return 'bg-red-50';
return "bg-red-50";
} else if (topCategory.percentage >= 50) {
return 'bg-red-50';
return "bg-red-50";
} else {
const categoriesAbove20 = revenueCategories.value.filter(cat => cat.percentage >= 20).length;
const categoriesAbove20 = revenueCategories.value.filter(
(cat) => cat.percentage >= 20
).length;
if (categoriesAbove20 >= 3) {
return 'bg-green-50';
return "bg-green-50";
} else {
return 'bg-yellow-50';
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;
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 (
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');
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');
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');
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',
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';
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}`);
console.log(
`Annual budget view for org: ${props.orgId}, year: ${props.year}`
);
});
</script>
@ -336,4 +419,4 @@ input[type="number"]::-webkit-outer-spin-button {
input[type="number"] {
-moz-appearance: textfield;
}
</style>
</style>