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:
parent
09d8794d72
commit
864a81065c
23 changed files with 3211 additions and 1978 deletions
2
app.vue
2
app.vue
|
|
@ -91,7 +91,7 @@ const isCoopBuilderSection = computed(
|
|||
route.path === "/dashboard" ||
|
||||
route.path === "/mix" ||
|
||||
route.path === "/budget" ||
|
||||
route.path === "/runway-lite" ||
|
||||
route.path === "/cash-flow" ||
|
||||
route.path === "/settings" ||
|
||||
route.path === "/glossary"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@
|
|||
<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>
|
||||
|
|
@ -20,22 +25,23 @@
|
|||
</tr>
|
||||
|
||||
<!-- Revenue Categories -->
|
||||
<tr v-for="(category, index) in revenueCategories"
|
||||
<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-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"
|
||||
>
|
||||
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"
|
||||
<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-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;
|
||||
});
|
||||
|
||||
|
|
@ -194,22 +237,28 @@ 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 = "";
|
||||
|
||||
|
|
@ -228,45 +277,70 @@ const diversificationGuidance = computed(() => {
|
|||
} 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.";
|
||||
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');
|
||||
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) {
|
||||
|
|
@ -274,25 +348,33 @@ const suggestedCategories = computed(() => {
|
|||
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
|
||||
|
|
@ -303,25 +385,26 @@ const suggestedCategories = computed(() => {
|
|||
|
||||
// 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>
|
||||
|
||||
|
|
|
|||
295
components/CashFlowChart.vue
Normal file
295
components/CashFlowChart.vue
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
<template>
|
||||
<div class="w-full h-full">
|
||||
<canvas
|
||||
ref="chartCanvas"
|
||||
class="w-full h-full"
|
||||
:width="width"
|
||||
:height="height"
|
||||
></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ChartData {
|
||||
month: number
|
||||
monthName: string
|
||||
revenue: number
|
||||
expenses: number
|
||||
netCashFlow: number
|
||||
runningBalance: number
|
||||
oneOffEvents?: Array<{ type: string, amount: number, name: string }>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ChartData[]
|
||||
viewMode: 'combined' | 'runway' | 'cashflow'
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 800,
|
||||
height: 320
|
||||
})
|
||||
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
function drawChart() {
|
||||
if (!chartCanvas.value || !props.data.length) return
|
||||
|
||||
const canvas = chartCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const padding = 60
|
||||
const chartWidth = canvas.width - padding * 2
|
||||
const chartHeight = canvas.height - padding * 2
|
||||
|
||||
const dataLength = props.data.length
|
||||
const scaleX = chartWidth / (dataLength - 1)
|
||||
|
||||
// Calculate data ranges based on view mode
|
||||
let maxValue = 0
|
||||
let minValue = 0
|
||||
|
||||
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
|
||||
const balances = props.data.map(d => d.runningBalance)
|
||||
maxValue = Math.max(maxValue, ...balances)
|
||||
minValue = Math.min(minValue, ...balances)
|
||||
}
|
||||
|
||||
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
|
||||
const revenues = props.data.map(d => d.revenue)
|
||||
const expenses = props.data.map(d => d.expenses)
|
||||
maxValue = Math.max(maxValue, ...revenues, ...expenses)
|
||||
}
|
||||
|
||||
// Add some padding to the range
|
||||
const range = maxValue - minValue
|
||||
maxValue += range * 0.1
|
||||
minValue -= range * 0.1
|
||||
|
||||
// Ensure we show zero line
|
||||
if (minValue > 0) minValue = Math.min(0, minValue)
|
||||
if (maxValue < 0) maxValue = Math.max(0, maxValue)
|
||||
|
||||
const valueRange = maxValue - minValue
|
||||
const scaleY = valueRange > 0 ? chartHeight / valueRange : 1
|
||||
|
||||
// Helper functions
|
||||
const getX = (index: number) => padding + (index * scaleX)
|
||||
const getY = (value: number) => padding + chartHeight - ((value - minValue) * scaleY)
|
||||
|
||||
// Draw background for negative values
|
||||
if (minValue < 0) {
|
||||
const zeroY = getY(0)
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
|
||||
ctx.fillRect(padding, zeroY, chartWidth, canvas.height - zeroY - padding)
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
drawGrid(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange, scaleY)
|
||||
|
||||
// Draw data based on view mode
|
||||
if (props.viewMode === 'cashflow' || props.viewMode === 'combined') {
|
||||
drawCashFlowData(ctx, getX, getY)
|
||||
}
|
||||
|
||||
if (props.viewMode === 'runway' || props.viewMode === 'combined') {
|
||||
drawRunwayData(ctx, getX, getY)
|
||||
}
|
||||
|
||||
// Draw one-off events
|
||||
drawOneOffEvents(ctx, getX, getY)
|
||||
|
||||
// Draw axis labels
|
||||
drawAxisLabels(ctx, padding, chartWidth, chartHeight, minValue, maxValue, valueRange)
|
||||
}
|
||||
|
||||
function drawGrid(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number, scaleY: number) {
|
||||
ctx.strokeStyle = '#e5e7eb'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// Horizontal grid lines
|
||||
const gridLines = 5
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = padding + (chartHeight / gridLines) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, y)
|
||||
ctx.lineTo(padding + chartWidth, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Vertical grid lines (every 3 months)
|
||||
for (let i = 0; i < props.data.length; i += 3) {
|
||||
const x = padding + (i * chartWidth) / (props.data.length - 1)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Zero line if applicable
|
||||
if (minValue < 0 && maxValue > 0) {
|
||||
const zeroY = padding + chartHeight - ((0 - minValue) * scaleY)
|
||||
ctx.strokeStyle = '#6b7280'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, zeroY)
|
||||
ctx.lineTo(padding + chartWidth, zeroY)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function drawCashFlowData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
// Draw revenue line
|
||||
ctx.strokeStyle = '#3b82f6'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.revenue)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Draw expenses line
|
||||
ctx.strokeStyle = '#ef4444'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.expenses)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawRunwayData(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
// Draw balance line
|
||||
ctx.strokeStyle = '#10b981'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(dataPoint.runningBalance)
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Mark zero crossing points
|
||||
ctx.fillStyle = '#ef4444'
|
||||
for (let i = 1; i < props.data.length; i++) {
|
||||
const prev = props.data[i - 1].runningBalance
|
||||
const current = props.data[i].runningBalance
|
||||
|
||||
if (prev >= 0 && current < 0) {
|
||||
// Crossed from positive to negative
|
||||
const x = getX(i)
|
||||
const y = getY(0)
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add warning label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Out of Money', x, y - 10)
|
||||
ctx.fillStyle = '#ef4444' // Reset for next point
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawOneOffEvents(ctx: CanvasRenderingContext2D, getX: (i: number) => number, getY: (v: number) => number) {
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
if (dataPoint.oneOffEvents && dataPoint.oneOffEvents.length > 0) {
|
||||
const x = getX(index)
|
||||
let yOffset = 0
|
||||
|
||||
dataPoint.oneOffEvents.forEach(event => {
|
||||
const isIncome = event.type === 'income'
|
||||
const baseY = getY(isIncome ? dataPoint.revenue : dataPoint.expenses)
|
||||
const y = baseY + yOffset
|
||||
|
||||
// Draw event marker
|
||||
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add tooltip-style label
|
||||
if (event.amount > 0) {
|
||||
ctx.fillStyle = isIncome ? '#8b5cf6' : '#f59e0b'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
const shortName = event.name.length > 15 ? event.name.substring(0, 12) + '...' : event.name
|
||||
ctx.fillText(shortName, x, y - 8)
|
||||
}
|
||||
|
||||
yOffset += 15
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawAxisLabels(ctx: CanvasRenderingContext2D, padding: number, chartWidth: number, chartHeight: number, minValue: number, maxValue: number, valueRange: number) {
|
||||
ctx.fillStyle = '#6b7280'
|
||||
ctx.font = '12px sans-serif'
|
||||
|
||||
// X-axis labels (months)
|
||||
ctx.textAlign = 'center'
|
||||
props.data.forEach((dataPoint, index) => {
|
||||
if (index % 2 === 0) { // Show every other month to avoid crowding
|
||||
const x = padding + (index * chartWidth) / (props.data.length - 1)
|
||||
ctx.fillText(monthNames[dataPoint.month], x, padding + chartHeight + 20)
|
||||
}
|
||||
})
|
||||
|
||||
// Y-axis labels (values)
|
||||
ctx.textAlign = 'right'
|
||||
const gridLines = 5
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const value = minValue + (valueRange / gridLines) * (gridLines - i)
|
||||
const y = padding + (chartHeight / gridLines) * i + 4
|
||||
ctx.fillText(formatShort(value), padding - 10, y)
|
||||
}
|
||||
}
|
||||
|
||||
function formatShort(value: number): string {
|
||||
if (Math.abs(value) >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (Math.abs(value) >= 1000) {
|
||||
return `${(value / 1000).toFixed(0)}k`
|
||||
}
|
||||
return Math.round(value).toString()
|
||||
}
|
||||
|
||||
// Watch for changes and redraw
|
||||
watch(() => [props.data, props.viewMode], () => {
|
||||
nextTick(() => drawChart())
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => drawChart())
|
||||
})
|
||||
</script>
|
||||
|
|
@ -26,30 +26,25 @@
|
|||
const route = useRoute();
|
||||
|
||||
const coopBuilderItems = [
|
||||
{
|
||||
id: "dashboard",
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
id: "coop-builder",
|
||||
name: "Setup Wizard",
|
||||
path: "/coop-builder",
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
name: "Compensation",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
path: "/budget",
|
||||
},
|
||||
{
|
||||
id: "mix",
|
||||
name: "Revenue Mix",
|
||||
path: "/mix",
|
||||
},
|
||||
{
|
||||
id: "runway-lite",
|
||||
name: "Runway Lite",
|
||||
path: "/runway-lite",
|
||||
id: "cash-flow",
|
||||
name: "Cash Flow",
|
||||
path: "/cash-flow",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -48,20 +48,43 @@
|
|||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const membersStore = useMembersStore()
|
||||
const { members, teamStats } = storeToRefs(membersStore)
|
||||
// Use coopBuilder store which has the actual data
|
||||
const coopStore = useCoopBuilderStore()
|
||||
const { members, equalHourlyWage } = storeToRefs(coopStore)
|
||||
|
||||
const membersWithCoverage = computed(() => {
|
||||
return members.value.map(member => {
|
||||
const coverage = membersStore.getMemberCoverage(member.id)
|
||||
// Calculate coverage based on member hours vs pay
|
||||
const hourlyWage = equalHourlyWage.value || 50
|
||||
const monthlyHours = member.hoursPerMonth || 0
|
||||
const expectedPay = monthlyHours * hourlyWage
|
||||
const actualPay = member.monthlyPayPlanned || 0
|
||||
|
||||
const coverageMinPct = expectedPay > 0 ? Math.min(100, (actualPay / expectedPay) * 100) : 0
|
||||
|
||||
return {
|
||||
...member,
|
||||
coverageMinPct: coverage.minPct,
|
||||
coverageTargetPct: coverage.targetPct
|
||||
displayName: member.name,
|
||||
coverageMinPct: coverageMinPct,
|
||||
coverageTargetPct: coverageMinPct // Same for now since we don't have separate min/target
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const teamStats = computed(() => {
|
||||
const coverageValues = membersWithCoverage.value.map(m => m.coverageMinPct).filter(v => v !== undefined)
|
||||
|
||||
if (coverageValues.length === 0) {
|
||||
return { under100: 0, median: 0 }
|
||||
}
|
||||
|
||||
const sorted = [...coverageValues].sort((a, b) => a - b)
|
||||
const median = sorted[Math.floor(sorted.length / 2)]
|
||||
const under100 = coverageValues.filter(v => v < 100).length
|
||||
|
||||
return { under100, median }
|
||||
})
|
||||
|
||||
function getBarColor(coverage: number | undefined) {
|
||||
const pct = coverage || 0
|
||||
if (pct >= 100) return 'bg-green-500'
|
||||
|
|
|
|||
305
components/OneOffEventEditor.vue
Normal file
305
components/OneOffEventEditor.vue
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
One-Off Transactions
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Add one-time income or expense transactions with expected dates.
|
||||
</p>
|
||||
</div>
|
||||
<UButton @click="addEvent" size="sm" icon="i-heroicons-plus">
|
||||
Add Transaction
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="sortedEvents.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-heroicons-banknotes" class="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No transactions yet
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Add one-off income or expense transactions.
|
||||
</p>
|
||||
<UButton @click="addEvent" color="primary">
|
||||
Add Your First Transaction
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Events list -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Month grouping -->
|
||||
<div
|
||||
v-for="monthGroup in eventsByMonth"
|
||||
:key="monthGroup.month"
|
||||
class="space-y-3"
|
||||
>
|
||||
<!-- Month header -->
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
{{ monthGroup.monthName }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge variant="subtle" color="gray">
|
||||
{{ monthGroup.events.length }} transaction{{ monthGroup.events.length !== 1 ? 's' : '' }}
|
||||
</UBadge>
|
||||
<div class="text-sm font-medium" :class="monthGroup.netAmount >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ monthGroup.netAmount >= 0 ? '+' : '' }}{{ formatCurrency(monthGroup.netAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events in this month -->
|
||||
<div class="space-y-3">
|
||||
<UCard
|
||||
v-for="event in monthGroup.events"
|
||||
:key="event.id"
|
||||
:ui="{
|
||||
background: event.type === 'income' ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20',
|
||||
ring: event.type === 'income' ? 'ring-green-200 dark:ring-green-800' : 'ring-red-200 dark:ring-red-800'
|
||||
}"
|
||||
>
|
||||
<UForm :state="event" @submit="() => {}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Category -->
|
||||
<UFormField label="Category" name="category" required>
|
||||
<USelect
|
||||
v-model="event.category"
|
||||
:options="categoryOptions"
|
||||
@update:model-value="updateEvent(event.id, { category: $event })"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Name -->
|
||||
<UFormField label="Name" name="name" required>
|
||||
<UInput
|
||||
v-model="event.name"
|
||||
placeholder="e.g., Equipment purchase"
|
||||
@update:model-value="updateEvent(event.id, { name: $event })"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Type -->
|
||||
<UFormField label="Type" name="type" required>
|
||||
<USelect
|
||||
v-model="event.type"
|
||||
:options="typeOptions"
|
||||
@update:model-value="updateEvent(event.id, { type: $event })"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Amount -->
|
||||
<UFormField label="Amount" name="amount" required>
|
||||
<UInput
|
||||
v-model="event.amount"
|
||||
type="number"
|
||||
placeholder="5000"
|
||||
@update:model-value="updateEvent(event.id, { amount: Number($event) })"
|
||||
>
|
||||
<template #leading>
|
||||
<span class="text-gray-500">$</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Date Expected -->
|
||||
<div class="mt-4">
|
||||
<UFormField label="Date Expected" name="dateExpected" required>
|
||||
<UInput
|
||||
v-model="event.dateExpected"
|
||||
type="date"
|
||||
@update:model-value="updateEventWithDate(event.id, $event)"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</UForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="red"
|
||||
size="sm"
|
||||
icon="i-heroicons-trash"
|
||||
@click="removeEvent(event.id)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
<UDropdown :items="getEventActions(event)">
|
||||
<UButton variant="ghost" color="gray" size="sm" icon="i-heroicons-ellipsis-horizontal" />
|
||||
</UDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<UCard>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
Total {{ sortedEvents.length }} transaction{{ sortedEvents.length !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span class="text-lg font-bold" :class="totalAnnualImpact >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ totalAnnualImpact >= 0 ? '+' : '' }}{{ formatCurrency(totalAnnualImpact) }}
|
||||
</span>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OneOffEvent } from '~/types/cash'
|
||||
|
||||
const cashStore = useCashStore()
|
||||
|
||||
// Constants
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December']
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Income', value: 'income' },
|
||||
{ label: 'Expense', value: 'expense' }
|
||||
]
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: 'Equipment', value: 'Equipment' },
|
||||
{ label: 'Marketing', value: 'Marketing' },
|
||||
{ label: 'Legal', value: 'Legal' },
|
||||
{ label: 'Contractors', value: 'Contractors' },
|
||||
{ label: 'Office', value: 'Office' },
|
||||
{ label: 'Development', value: 'Development' },
|
||||
{ label: 'Other', value: 'Other' }
|
||||
]
|
||||
|
||||
// Computed
|
||||
const { oneOffEvents } = storeToRefs(cashStore)
|
||||
|
||||
const sortedEvents = computed(() => {
|
||||
return oneOffEvents.value
|
||||
.slice()
|
||||
.sort((a, b) => a.month - b.month || a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
const eventsByMonth = computed(() => {
|
||||
const groups: Record<number, OneOffEvent[]> = {}
|
||||
|
||||
sortedEvents.value.forEach(event => {
|
||||
if (!groups[event.month]) {
|
||||
groups[event.month] = []
|
||||
}
|
||||
groups[event.month].push(event)
|
||||
})
|
||||
|
||||
return Object.entries(groups).map(([month, events]) => {
|
||||
const monthNum = parseInt(month)
|
||||
const netAmount = events.reduce((sum, event) => {
|
||||
return sum + (event.type === 'income' ? event.amount : -event.amount)
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
month: monthNum,
|
||||
monthName: monthNames[monthNum],
|
||||
events,
|
||||
netAmount
|
||||
}
|
||||
}).sort((a, b) => a.month - b.month)
|
||||
})
|
||||
|
||||
const totalIncome = computed(() => {
|
||||
return oneOffEvents.value
|
||||
.filter(e => e.type === 'income')
|
||||
.reduce((sum, e) => sum + e.amount, 0)
|
||||
})
|
||||
|
||||
const totalExpenses = computed(() => {
|
||||
return oneOffEvents.value
|
||||
.filter(e => e.type === 'expense')
|
||||
.reduce((sum, e) => sum + e.amount, 0)
|
||||
})
|
||||
|
||||
const totalAnnualImpact = computed(() => totalIncome.value - totalExpenses.value)
|
||||
|
||||
// Methods
|
||||
function addEvent() {
|
||||
const currentMonth = new Date().getMonth()
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
cashStore.addOneOffEvent({
|
||||
name: '',
|
||||
type: 'income',
|
||||
amount: 0,
|
||||
category: 'Other',
|
||||
dateExpected: today
|
||||
})
|
||||
}
|
||||
|
||||
function updateEvent(eventId: string, updates: Partial<OneOffEvent>) {
|
||||
cashStore.updateOneOffEvent(eventId, updates)
|
||||
}
|
||||
|
||||
function updateEventWithDate(eventId: string, dateExpected: string) {
|
||||
const eventDate = new Date(dateExpected)
|
||||
const month = eventDate.getMonth()
|
||||
cashStore.updateOneOffEvent(eventId, { dateExpected, month })
|
||||
}
|
||||
|
||||
function removeEvent(eventId: string) {
|
||||
cashStore.removeOneOffEvent(eventId)
|
||||
}
|
||||
|
||||
function getEventActions(event: OneOffEvent) {
|
||||
return [
|
||||
[{
|
||||
label: 'Move to Different Month',
|
||||
icon: 'i-heroicons-arrow-right',
|
||||
click: () => moveToMonth(event.id)
|
||||
}],
|
||||
[{
|
||||
label: 'Duplicate Event',
|
||||
icon: 'i-heroicons-document-duplicate',
|
||||
click: () => duplicateEvent(event)
|
||||
}]
|
||||
]
|
||||
}
|
||||
|
||||
function moveToMonth(eventId: string) {
|
||||
// This could open a month selector modal
|
||||
// For now, just move to next month
|
||||
const event = oneOffEvents.value.find(e => e.id === eventId)
|
||||
if (event) {
|
||||
const newMonth = (event.month + 1) % 12
|
||||
updateEvent(eventId, { month: newMonth })
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateEvent(event: OneOffEvent) {
|
||||
cashStore.addOneOffEvent({
|
||||
name: `${event.name} (Copy)`,
|
||||
type: event.type,
|
||||
amount: event.amount,
|
||||
category: event.category,
|
||||
dateExpected: event.dateExpected
|
||||
})
|
||||
}
|
||||
|
||||
function getDateValue(dateExpected: string | undefined): string {
|
||||
if (!dateExpected) {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
return dateExpected
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.abs(amount))
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,513 +0,0 @@
|
|||
<template>
|
||||
<div class="section-card">
|
||||
<!-- Main Headline -->
|
||||
<div class="text-center mb-6" v-if="hasData">
|
||||
<div class="text-3xl font-mono font-bold text-black dark:text-white mb-2">
|
||||
{{ mainHeadline }}
|
||||
</div>
|
||||
<div class="text-lg font-mono text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{{ subHeadline }}
|
||||
</div>
|
||||
|
||||
<!-- Coverage Text -->
|
||||
<div class="text-base font-mono text-neutral-700 dark:text-neutral-300 mb-4" v-if="coverageText">
|
||||
{{ coverageText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Toggles (Experiments) -->
|
||||
<div class="mb-6 space-y-3" v-if="hasData">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
v-model="includePlannedRevenue"
|
||||
type="checkbox"
|
||||
class="bitmap-checkbox"
|
||||
>
|
||||
<span class="text-sm font-mono font-bold text-black dark:text-white">Count planned income</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
v-model="imagineNoIncome"
|
||||
type="checkbox"
|
||||
class="bitmap-checkbox"
|
||||
>
|
||||
<span class="text-sm font-mono font-bold text-black dark:text-white">Imagine no income</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Chart Container -->
|
||||
<div class="mb-6">
|
||||
<div class="h-48 relative bg-white dark:bg-neutral-950 border border-black dark:border-white">
|
||||
<canvas
|
||||
ref="chartCanvas"
|
||||
class="w-full h-full"
|
||||
width="400"
|
||||
height="192"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart Caption -->
|
||||
<div class="text-center text-sm font-mono text-neutral-600 dark:text-neutral-400 mb-4" v-if="hasData">
|
||||
This shows how your coop's money might hold up over a year.
|
||||
</div>
|
||||
|
||||
<!-- Guidance Sentence -->
|
||||
<div class="text-center mb-4" v-if="hasData">
|
||||
<div class="text-base font-mono text-neutral-700 dark:text-neutral-300">
|
||||
{{ guidanceText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diversification Risk -->
|
||||
<div v-if="diversificationGuidance" class="text-center text-sm font-mono text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{{ diversificationGuidance }}
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<div class="text-neutral-400 mb-4">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="font-mono text-neutral-500 dark:text-neutral-500 text-sm">
|
||||
Complete the Setup Wizard to see your runway projection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
startingCash?: number
|
||||
revenuePlanned?: number[]
|
||||
expensePlanned?: number[]
|
||||
members?: Array<{ name: string, needs: number, targetPay: number, payRelationship: string }>
|
||||
diversificationGuidance?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
startingCash: 0,
|
||||
revenuePlanned: () => [],
|
||||
expensePlanned: () => [],
|
||||
members: () => [],
|
||||
diversificationGuidance: ''
|
||||
})
|
||||
|
||||
const includePlannedRevenue = ref(true)
|
||||
const imagineNoIncome = ref(false)
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const months = [...Array(12).keys()] // 0..11
|
||||
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||
const targetMonths = 6
|
||||
const horizon = 6
|
||||
|
||||
const toNum = (v:any) => Number.isFinite(+v) ? +v : 0
|
||||
|
||||
const monthlyCosts = computed(() => {
|
||||
if (!Array.isArray(props.expensePlanned)) return 0
|
||||
const sum = props.expensePlanned.reduce((a, b) => toNum(a) + toNum(b), 0)
|
||||
return sum / 12
|
||||
})
|
||||
|
||||
// Keep the old name for compatibility
|
||||
const monthlyBurn = monthlyCosts
|
||||
|
||||
const fmtCurrency = (v:number) => Number.isFinite(v) ? new Intl.NumberFormat(undefined,{style:'currency',currency:'USD',maximumFractionDigits:0}).format(v) : ''
|
||||
const fmtShort = (v:number) => {
|
||||
if (!Number.isFinite(v)) return ''
|
||||
if (Math.abs(v) >= 1000) return `${(v/1000).toFixed(0)}k`
|
||||
return `${Math.round(v)}`
|
||||
}
|
||||
|
||||
function outOfCashMonth(balances: number[]): number {
|
||||
return balances.findIndex(b => b < 0) // -1 if none
|
||||
}
|
||||
|
||||
// Pay coverage calculations
|
||||
const memberCoverage = computed(() => {
|
||||
if (!Array.isArray(props.members) || props.members.length === 0) return []
|
||||
|
||||
return props.members.map(member => {
|
||||
const coverage = member.needs > 0 ? ((member.targetPay || 0) / member.needs) * 100 : 0
|
||||
return {
|
||||
name: member.name,
|
||||
coverage: Math.min(coverage, 200) // Cap at 200%
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const coverageText = computed(() => {
|
||||
if (memberCoverage.value.length === 0) return ''
|
||||
|
||||
const coverages = memberCoverage.value.map(m => m.coverage)
|
||||
const min = Math.min(...coverages)
|
||||
const max = Math.max(...coverages)
|
||||
|
||||
if (min >= 80) {
|
||||
return "Most members' needs are nearly covered."
|
||||
} else if (min < 50) {
|
||||
return "Some members' needs are far from covered — consider adjusting pay relationships."
|
||||
} else {
|
||||
return `On this plan, coverage ranges from ${Math.round(min)}% to ${Math.round(max)}% of members' needs.`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
function project(includeRevenue: boolean, forceNoIncome = false) {
|
||||
const balances: number[] = []
|
||||
let bal = toNum(props.startingCash)
|
||||
|
||||
// Pad arrays to 12 elements if shorter
|
||||
const revenuePadded = Array.isArray(props.revenuePlanned) ?
|
||||
[...props.revenuePlanned, ...Array(12 - props.revenuePlanned.length).fill(0)].slice(0, 12) :
|
||||
Array(12).fill(0)
|
||||
const expensesPadded = Array.isArray(props.expensePlanned) ?
|
||||
[...props.expensePlanned, ...Array(12 - props.expensePlanned.length).fill(0)].slice(0, 12) :
|
||||
Array(12).fill(0)
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const inflow = (includeRevenue && !forceNoIncome) ? toNum(revenuePadded[i]) : 0
|
||||
const outflow = toNum(expensesPadded[i])
|
||||
bal = bal + inflow - outflow
|
||||
balances.push(bal)
|
||||
}
|
||||
return balances
|
||||
}
|
||||
|
||||
const withRev = computed(() => project(true, imagineNoIncome.value))
|
||||
const noRev = computed(() => project(false, true))
|
||||
|
||||
function runwayFrom(balances: number[]) {
|
||||
// find first month index where balance < 0
|
||||
const i = balances.findIndex(b => b < 0)
|
||||
if (i === -1) return 12 // survived 12+ months
|
||||
// linear interpolation within month i
|
||||
const prev = i === 0 ? toNum(props.startingCash) : toNum(balances[i-1])
|
||||
const delta = toNum(balances[i]) - prev
|
||||
const frac = delta === 0 ? 0 : (0 - prev) / delta // 0..1
|
||||
return Math.max(0, (i-1) + frac + 1) // months from now
|
||||
}
|
||||
|
||||
// Check if we have meaningful data
|
||||
const hasData = computed(() => {
|
||||
return props.startingCash > 0 ||
|
||||
(Array.isArray(props.revenuePlanned) && props.revenuePlanned.some(v => v > 0)) ||
|
||||
(Array.isArray(props.expensePlanned) && props.expensePlanned.some(v => v > 0))
|
||||
})
|
||||
|
||||
const runwayMonths = computed(() => {
|
||||
if (!hasData.value) return 0
|
||||
if (monthlyCosts.value <= 0) return 12
|
||||
|
||||
if (imagineNoIncome.value) {
|
||||
return runwayFrom(noRev.value)
|
||||
} else {
|
||||
return includePlannedRevenue.value ? runwayFrom(withRev.value) : runwayFrom(noRev.value)
|
||||
}
|
||||
})
|
||||
|
||||
const mainHeadline = computed(() => {
|
||||
const months = runwayMonths.value >= 12 ? '12+' : runwayMonths.value.toFixed(1)
|
||||
return `You could keep going for about ${months} months.`
|
||||
})
|
||||
|
||||
const subHeadline = computed(() => {
|
||||
return `That's with monthly costs of ${fmtCurrency(monthlyCosts.value)} and the income ideas you entered.`
|
||||
})
|
||||
|
||||
const guidanceText = computed(() => {
|
||||
const months = runwayMonths.value
|
||||
if (months < 3) {
|
||||
return "This sketch shows less than 3 months covered — that's risky."
|
||||
} else if (months <= 6) {
|
||||
return "This sketch shows about 3–6 months — that's a common minimum target."
|
||||
} else {
|
||||
return "This sketch shows more than 6 months — a safer position."
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const bufferFlagText = computed(() => {
|
||||
return guidanceText.value
|
||||
})
|
||||
|
||||
|
||||
// Out-of-money month computations
|
||||
const oocWith = computed(() => outOfCashMonth(withRev.value))
|
||||
const oocNo = computed(() => outOfCashMonth(noRev.value))
|
||||
|
||||
// Path to Safe Buffer calculation
|
||||
|
||||
// Break-even Month calculation
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const projectionData = computed(() => {
|
||||
const monthIndices = [...Array(13).keys()] // 0..12 for chart display
|
||||
const withIncome = []
|
||||
const withoutIncome = []
|
||||
|
||||
// Pad arrays to 12 elements if shorter
|
||||
const revenuePadded = Array.isArray(props.revenuePlanned) ?
|
||||
[...props.revenuePlanned, ...Array(12 - props.revenuePlanned.length).fill(0)].slice(0, 12) :
|
||||
Array(12).fill(0)
|
||||
const expensesPadded = Array.isArray(props.expensePlanned) ?
|
||||
[...props.expensePlanned, ...Array(12 - props.expensePlanned.length).fill(0)].slice(0, 12) :
|
||||
Array(12).fill(0)
|
||||
|
||||
// Start with initial balance
|
||||
withIncome.push(toNum(props.startingCash))
|
||||
withoutIncome.push(toNum(props.startingCash))
|
||||
|
||||
// Project forward month by month
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const lastWithIncome = withIncome[withIncome.length - 1]
|
||||
const lastWithoutIncome = withoutIncome[withoutIncome.length - 1]
|
||||
|
||||
// Safe access to array values using toNum helper
|
||||
const revenueAmount = toNum(revenuePadded[i])
|
||||
const expenseAmount = toNum(expensesPadded[i])
|
||||
|
||||
// Line A: with income ideas
|
||||
const withIncomeBalance = lastWithIncome + revenueAmount - expenseAmount
|
||||
withIncome.push(withIncomeBalance)
|
||||
|
||||
// Line B: no income
|
||||
const withoutIncomeBalance = lastWithoutIncome - expenseAmount
|
||||
withoutIncome.push(withoutIncomeBalance)
|
||||
}
|
||||
|
||||
return { months: monthIndices, withIncome, withoutIncome }
|
||||
})
|
||||
|
||||
const drawChart = () => {
|
||||
if (!chartCanvas.value) return
|
||||
|
||||
const canvas = chartCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
if (!hasData.value) {
|
||||
// Draw empty state in chart
|
||||
ctx.fillStyle = '#6b7280'
|
||||
ctx.font = '14px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('No data available', canvas.width / 2, canvas.height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
const { months, withIncome, withoutIncome } = projectionData.value
|
||||
|
||||
const padding = 40
|
||||
const chartWidth = canvas.width - padding * 2
|
||||
const chartHeight = canvas.height - padding * 2
|
||||
|
||||
// Calculate scale - ensure all values are finite numbers
|
||||
const allValues = [...withIncome, ...withoutIncome].map(v => toNum(v))
|
||||
const maxValue = Math.max(...allValues, toNum(props.startingCash))
|
||||
const minValue = Math.min(...allValues, 0)
|
||||
const valueRange = Math.max(maxValue - minValue, 1000) // ensure minimum range
|
||||
|
||||
const scaleX = chartWidth / 12
|
||||
const scaleY = chartHeight / valueRange
|
||||
|
||||
// Helper function to get canvas coordinates
|
||||
const getX = (month: number) => padding + (month * scaleX)
|
||||
const getY = (value: number) => padding + chartHeight - ((value - minValue) * scaleY)
|
||||
|
||||
// Fill background red where balance < 0
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'
|
||||
const zeroY = getY(0)
|
||||
ctx.fillRect(padding, zeroY, chartWidth, canvas.height - zeroY - padding)
|
||||
|
||||
// Draw grid lines
|
||||
ctx.strokeStyle = '#e5e7eb'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// Horizontal grid lines
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = padding + (chartHeight / 4) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, y)
|
||||
ctx.lineTo(padding + chartWidth, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Vertical grid lines
|
||||
for (let i = 0; i <= 12; i += 3) {
|
||||
const x = getX(i)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Draw zero line
|
||||
ctx.strokeStyle = '#6b7280'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, zeroY)
|
||||
ctx.lineTo(padding + chartWidth, zeroY)
|
||||
ctx.stroke()
|
||||
|
||||
// Draw vertical reference lines at out-of-cash points
|
||||
ctx.strokeStyle = '#ef4444'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([5, 5])
|
||||
|
||||
if (oocWith.value !== -1) {
|
||||
const x = getX(oocWith.value)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('OOC', x, padding - 5)
|
||||
}
|
||||
|
||||
if (oocNo.value !== -1 && oocNo.value !== oocWith.value) {
|
||||
const x = getX(oocNo.value)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padding)
|
||||
ctx.lineTo(x, padding + chartHeight)
|
||||
ctx.stroke()
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('OOC', x, padding - 5)
|
||||
}
|
||||
|
||||
ctx.setLineDash([])
|
||||
|
||||
// Draw Line A (with income) - always show, bold if selected
|
||||
ctx.strokeStyle = '#22c55e'
|
||||
ctx.lineWidth = (includePlannedRevenue.value && !imagineNoIncome.value) ? 3 : 2
|
||||
ctx.globalAlpha = (includePlannedRevenue.value && !imagineNoIncome.value) ? 1 : 0.6
|
||||
ctx.beginPath()
|
||||
withIncome.forEach((value, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(toNum(value))
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Add point annotation where active scenario crosses y=0
|
||||
if (includePlannedRevenue.value && !imagineNoIncome.value) {
|
||||
const crossingIdx = withIncome.findIndex((value, idx) => {
|
||||
if (idx === 0) return false
|
||||
const prev = toNum(withIncome[idx - 1])
|
||||
const curr = toNum(value)
|
||||
return prev >= 0 && curr < 0
|
||||
})
|
||||
|
||||
if (crossingIdx !== -1) {
|
||||
const x = getX(crossingIdx)
|
||||
const y = getY(0)
|
||||
ctx.fillStyle = '#22c55e'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add label
|
||||
ctx.fillStyle = '#22c55e'
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Out of money', x, y - 10)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Line B (no income) - always show, bold if selected
|
||||
ctx.strokeStyle = '#ef4444'
|
||||
ctx.lineWidth = (!includePlannedRevenue.value || imagineNoIncome.value) ? 3 : 2
|
||||
ctx.globalAlpha = (!includePlannedRevenue.value || imagineNoIncome.value) ? 1 : 0.6
|
||||
ctx.beginPath()
|
||||
withoutIncome.forEach((value, index) => {
|
||||
const x = getX(index)
|
||||
const y = getY(toNum(value))
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// Add point annotation where active scenario crosses y=0
|
||||
if (!includePlannedRevenue.value || imagineNoIncome.value) {
|
||||
const crossingIdx = withoutIncome.findIndex((value, idx) => {
|
||||
if (idx === 0) return false
|
||||
const prev = toNum(withoutIncome[idx - 1])
|
||||
const curr = toNum(value)
|
||||
return prev >= 0 && curr < 0
|
||||
})
|
||||
|
||||
if (crossingIdx !== -1) {
|
||||
const x = getX(crossingIdx)
|
||||
const y = getY(0)
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Add label
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Out of money', x, y - 10)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
// Draw axis labels
|
||||
ctx.fillStyle = '#6b7280'
|
||||
ctx.font = '12px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
|
||||
// X-axis labels (months)
|
||||
for (let i = 0; i <= 12; i += 3) {
|
||||
const x = getX(i)
|
||||
ctx.fillText(i.toString(), x, canvas.height - 10)
|
||||
}
|
||||
|
||||
// Y-axis labels (balance) - guarded formatting
|
||||
ctx.textAlign = 'right'
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const value = minValue + (valueRange / 4) * (4 - i)
|
||||
const y = padding + (chartHeight / 4) * i + 4
|
||||
const formattedValue = Number.isFinite(value) ? fmtShort(value) : '0'
|
||||
ctx.fillText(formattedValue, padding - 10, y)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes that should trigger chart redraw
|
||||
watch([includePlannedRevenue, imagineNoIncome, projectionData], () => {
|
||||
nextTick(() => drawChart())
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => drawChart())
|
||||
})
|
||||
</script>
|
||||
255
components/UnifiedCashFlowDashboard.vue
Normal file
255
components/UnifiedCashFlowDashboard.vue
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header with key metrics -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
13-Week Cash Flow
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Weekly cash flow analysis with one-off transactions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Key metrics cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold" :class="runwayWeeks >= 8 ? 'text-green-600' : runwayWeeks >= 4 ? 'text-yellow-600' : 'text-red-600'">
|
||||
{{ runwayWeeks.toFixed(1) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Weeks Runway</div>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
{{ getRunwayStatus(runwayWeeks) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatCurrency(weeklyBurn) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Weekly Burn</div>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
Average outflow
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold" :class="finalBalance >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ formatCurrency(finalBalance) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Week 13 Balance</div>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
End of quarter
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Alerts panel -->
|
||||
<div v-if="alerts.length > 0" class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="mr-2 text-yellow-500" />
|
||||
Cash Flow Alerts
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
class="p-4 rounded-lg border"
|
||||
:class="{
|
||||
'border-red-200 bg-red-50 dark:bg-red-900/20': alert.severity === 'high',
|
||||
'border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20': alert.severity === 'medium',
|
||||
'border-blue-200 bg-blue-50 dark:bg-blue-900/20': alert.severity === 'low'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<UBadge
|
||||
:color="getAlertColor(alert.severity)"
|
||||
variant="subtle"
|
||||
>
|
||||
{{ alert.severity.toUpperCase() }}
|
||||
</UBadge>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
{{ alert.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ alert.description }}
|
||||
</p>
|
||||
<p v-if="alert.suggestion" class="text-sm font-medium text-gray-900 dark:text-white mt-2">
|
||||
💡 {{ alert.suggestion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly breakdown table -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
13-Week Breakdown
|
||||
</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Week</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dates</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Inflow</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Outflow</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Net Flow</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Balance</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Transactions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="week in weeklyProjections" :key="week.number">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
Week {{ week.number }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ formatDate(week.weekStart) }} - {{ formatDate(week.weekEnd) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-green-600">
|
||||
{{ formatCurrency(week.inflow) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-red-600">
|
||||
{{ formatCurrency(week.outflow) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm" :class="week.net >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ week.net >= 0 ? '+' : '' }}{{ formatCurrency(week.net) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium" :class="week.balance >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ formatCurrency(week.balance) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div v-if="week.oneOffEvents && week.oneOffEvents.length > 0" class="space-y-1">
|
||||
<div v-for="event in week.oneOffEvents" :key="event.id" class="text-xs">
|
||||
{{ event.name }} ({{ formatCurrency(event.amount) }})
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
const cashStore = useCashStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
|
||||
// Computed
|
||||
const { weeklyProjections } = storeToRefs(cashStore)
|
||||
|
||||
const runwayWeeks = computed(() => {
|
||||
const projections = weeklyProjections.value
|
||||
|
||||
for (let i = 0; i < projections.length; i++) {
|
||||
if (projections[i].balance < 0) {
|
||||
// Linear interpolation for fractional week
|
||||
const prevBalance = i === 0 ? 0 : projections[i-1].balance
|
||||
const currentNet = projections[i].net
|
||||
|
||||
if (currentNet !== 0) {
|
||||
const fraction = prevBalance / Math.abs(currentNet)
|
||||
return Math.max(0, i + fraction)
|
||||
}
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
|
||||
return 13 // Survived all 13 weeks
|
||||
})
|
||||
|
||||
const weeklyBurn = computed(() => {
|
||||
const totalOutflow = weeklyProjections.value.reduce((sum, p) => sum + p.outflow, 0)
|
||||
return totalOutflow / 13
|
||||
})
|
||||
|
||||
|
||||
const finalBalance = computed(() => {
|
||||
const projections = weeklyProjections.value
|
||||
return projections.length > 0 ? projections[projections.length - 1].balance : 0
|
||||
})
|
||||
|
||||
|
||||
const alerts = computed(() => {
|
||||
const alertsList = []
|
||||
|
||||
// Check for negative cash flow periods
|
||||
const negativeWeeks = weeklyProjections.value.filter(p => p.balance < 0).length
|
||||
if (negativeWeeks > 0) {
|
||||
alertsList.push({
|
||||
id: 'negative-cashflow',
|
||||
severity: negativeWeeks > 6 ? 'high' : 'medium',
|
||||
title: 'Negative Cash Flow Detected',
|
||||
description: `Your cash flow goes negative in ${negativeWeeks} weeks of the quarter.`,
|
||||
suggestion: 'Consider increasing confirmed revenue sources or reducing fixed costs.'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for low runway
|
||||
if (runwayWeeks.value < 4) {
|
||||
alertsList.push({
|
||||
id: 'low-runway',
|
||||
severity: 'high',
|
||||
title: 'Critical: Very Low Runway',
|
||||
description: `You have less than 4 weeks of runway (${runwayWeeks.value.toFixed(1)} weeks).`,
|
||||
suggestion: 'Urgent action needed: secure immediate funding or dramatically reduce expenses.'
|
||||
})
|
||||
} else if (runwayWeeks.value < 8) {
|
||||
alertsList.push({
|
||||
id: 'medium-runway',
|
||||
severity: 'medium',
|
||||
title: 'Warning: Limited Runway',
|
||||
description: `You have ${runwayWeeks.value.toFixed(1)} weeks of runway.`,
|
||||
suggestion: 'Start fundraising or revenue diversification efforts soon.'
|
||||
})
|
||||
}
|
||||
|
||||
return alertsList
|
||||
})
|
||||
|
||||
// Methods
|
||||
|
||||
function getRunwayStatus(weeks: number): string {
|
||||
if (weeks < 4) return 'Critical'
|
||||
if (weeks < 8) return 'Warning'
|
||||
if (weeks < 13) return 'Healthy'
|
||||
return 'Strong'
|
||||
}
|
||||
|
||||
function getAlertColor(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'high': return 'red'
|
||||
case 'medium': return 'yellow'
|
||||
case 'low': return 'blue'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
<template>
|
||||
<div class="hidden" data-ui="advanced_accordion_v1" />
|
||||
<UAccordion :items="accordionItems" :multiple="false" class="shadow-sm rounded-xl">
|
||||
<UAccordion
|
||||
:items="accordionItems"
|
||||
:multiple="false"
|
||||
class="shadow-sm rounded-xl">
|
||||
<template #advanced>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Scenarios Panel -->
|
||||
<div class="space-y-6">
|
||||
<h4 class="font-semibold">Scenarios</h4>
|
||||
<USelect
|
||||
v-model="scenario"
|
||||
:options="scenarioOptions"
|
||||
placeholder="Select scenario"
|
||||
/>
|
||||
placeholder="Select scenario" />
|
||||
</div>
|
||||
|
||||
<!-- Stress Test Panel -->
|
||||
|
|
@ -19,15 +20,18 @@
|
|||
<h4 class="font-semibold">Stress Test</h4>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Revenue Delay (months)</label>
|
||||
<label class="text-xs text-gray-600"
|
||||
>Revenue Delay (months)</label
|
||||
>
|
||||
<URange
|
||||
v-model="stress.revenueDelay"
|
||||
:min="0"
|
||||
:max="6"
|
||||
:step="1"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div class="text-xs text-gray-500">{{ stress.revenueDelay }} months</div>
|
||||
class="mt-1" />
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ stress.revenueDelay }} months
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600">Cost Shock (%)</label>
|
||||
|
|
@ -36,14 +40,12 @@
|
|||
:min="0"
|
||||
:max="30"
|
||||
:step="1"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div class="text-xs text-gray-500">{{ stress.costShockPct }}%</div>
|
||||
class="mt-1" />
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ stress.costShockPct }}%
|
||||
</div>
|
||||
<UCheckbox
|
||||
v-model="stress.grantLost"
|
||||
label="Grant lost"
|
||||
/>
|
||||
</div>
|
||||
<UCheckbox v-model="stress.grantLost" label="Grant lost" />
|
||||
<div class="text-sm text-gray-600 pt-2 border-t">
|
||||
Projected runway: {{ projectedRunway }}
|
||||
</div>
|
||||
|
|
@ -57,8 +59,7 @@
|
|||
<UButton
|
||||
size="xs"
|
||||
variant="outline"
|
||||
@click="showMilestoneModal = true"
|
||||
>
|
||||
@click="showMilestoneModal = true">
|
||||
+ Add
|
||||
</UButton>
|
||||
</div>
|
||||
|
|
@ -66,20 +67,22 @@
|
|||
<div
|
||||
v-for="milestone in milestoneStatus()"
|
||||
:key="milestone.id"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ milestone.willReach ? '✅' : '⚠️' }}</span>
|
||||
<span>{{ milestone.willReach ? "✅" : "⚠️" }}</span>
|
||||
<span>{{ milestone.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600">{{ formatDate(milestone.date) }}</span>
|
||||
<span class="text-xs text-gray-600">{{
|
||||
formatDate(milestone.date)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="milestones.length === 0" class="text-sm text-gray-600 italic">
|
||||
<div
|
||||
v-if="milestones.length === 0"
|
||||
class="text-sm text-gray-600 italic">
|
||||
No milestones yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</UAccordion>
|
||||
|
|
@ -92,12 +95,14 @@
|
|||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UFormGroup label="Label">
|
||||
<UInput v-model="newMilestone.label" placeholder="e.g. Product launch" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Date">
|
||||
<UFormField label="Label">
|
||||
<UInput
|
||||
v-model="newMilestone.label"
|
||||
placeholder="e.g. Product launch" />
|
||||
</UFormField>
|
||||
<UFormField label="Date">
|
||||
<UInput v-model="newMilestone.date" type="date" />
|
||||
</UFormGroup>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
|
|
@ -105,7 +110,9 @@
|
|||
<UButton variant="ghost" @click="showMilestoneModal = false">
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton @click="addNewMilestone" :disabled="!newMilestone.label || !newMilestone.date">
|
||||
<UButton
|
||||
@click="addNewMilestone"
|
||||
:disabled="!newMilestone.label || !newMilestone.date">
|
||||
Add Milestone
|
||||
</UButton>
|
||||
</div>
|
||||
|
|
@ -123,64 +130,70 @@ const {
|
|||
milestones,
|
||||
milestoneStatus,
|
||||
addMilestone,
|
||||
runwayMonths
|
||||
} = useCoopBuilder()
|
||||
runwayMonths,
|
||||
} = useCoopBuilder();
|
||||
|
||||
// Accordion setup
|
||||
const accordionItems = [{
|
||||
label: 'Advanced Planning',
|
||||
icon: 'i-heroicons-wrench-screwdriver',
|
||||
slot: 'advanced',
|
||||
defaultOpen: false
|
||||
}]
|
||||
const accordionItems = [
|
||||
{
|
||||
label: "Advanced Planning",
|
||||
icon: "i-heroicons-wrench-screwdriver",
|
||||
slot: "advanced",
|
||||
defaultOpen: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Scenarios
|
||||
const scenarioOptions = [
|
||||
{ label: 'Current', value: 'current' },
|
||||
{ label: 'Quit Day Jobs', value: 'quit-jobs' },
|
||||
{ label: 'Start Production', value: 'start-production' },
|
||||
{ label: 'Custom', value: 'custom' }
|
||||
]
|
||||
{ label: "Current", value: "current" },
|
||||
{ label: "Quit Day Jobs", value: "quit-jobs" },
|
||||
{ label: "Start Production", value: "start-production" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
];
|
||||
|
||||
// Stress test with live preview
|
||||
const projectedRunway = computed(() => {
|
||||
const months = runwayMonths(undefined, { useStress: true })
|
||||
if (!isFinite(months)) return '∞'
|
||||
if (months < 1) return '<1m'
|
||||
return `${Math.round(months)}m`
|
||||
})
|
||||
const months = runwayMonths(undefined, { useStress: true });
|
||||
if (!isFinite(months)) return "∞";
|
||||
if (months < 1) return "<1m";
|
||||
return `${Math.round(months)}m`;
|
||||
});
|
||||
|
||||
// Milestones modal
|
||||
const showMilestoneModal = ref(false)
|
||||
const showMilestoneModal = ref(false);
|
||||
const newMilestone = reactive({
|
||||
label: '',
|
||||
date: ''
|
||||
})
|
||||
label: "",
|
||||
date: "",
|
||||
});
|
||||
|
||||
function addNewMilestone() {
|
||||
if (!newMilestone.label || !newMilestone.date) return
|
||||
if (!newMilestone.label || !newMilestone.date) return;
|
||||
|
||||
addMilestone(newMilestone.label, newMilestone.date)
|
||||
newMilestone.label = ''
|
||||
newMilestone.date = ''
|
||||
showMilestoneModal.value = false
|
||||
addMilestone(newMilestone.label, newMilestone.date);
|
||||
newMilestone.label = "";
|
||||
newMilestone.date = "";
|
||||
showMilestoneModal.value = false;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Watch scenario changes
|
||||
watch(scenario, (newValue) => {
|
||||
setScenario(newValue)
|
||||
})
|
||||
setScenario(newValue);
|
||||
});
|
||||
|
||||
// Watch stress changes
|
||||
watch(stress, (newValue) => {
|
||||
updateStress(newValue)
|
||||
}, { deep: true })
|
||||
watch(
|
||||
stress,
|
||||
(newValue) => {
|
||||
updateStress(newValue);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
|
@ -66,9 +66,6 @@ export const useSetupState = () => {
|
|||
navigateTo('/settings#members')
|
||||
}
|
||||
|
||||
const goToRevenueMix = () => {
|
||||
navigateTo('/mix')
|
||||
}
|
||||
|
||||
return {
|
||||
isSetupComplete,
|
||||
|
|
@ -78,6 +75,5 @@ export const useSetupState = () => {
|
|||
setupProgress,
|
||||
goToSetup,
|
||||
goToMemberManagement,
|
||||
goToRevenueMix
|
||||
}
|
||||
}
|
||||
779
pages/budget.vue
779
pages/budget.vue
File diff suppressed because it is too large
Load diff
30
pages/cash-flow.vue
Normal file
30
pages/cash-flow.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Cash Flow Analysis
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Detailed cash flow projections with one-time events and scenario planning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Unified Cash Flow Dashboard -->
|
||||
<UnifiedCashFlowDashboard />
|
||||
|
||||
<!-- One-Off Events Editor -->
|
||||
<OneOffEventEditor />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Component auto-imported
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: 'Cash Flow Analysis - Plan Your Cooperative Finances',
|
||||
description: 'Detailed cash flow analysis with runway projections, one-time events, and scenario planning for your cooperative.'
|
||||
})
|
||||
</script>
|
||||
|
|
@ -289,6 +289,16 @@
|
|||
>
|
||||
</div>
|
||||
|
||||
<!-- View Dashboard button (when partially complete) -->
|
||||
<button
|
||||
v-if="hasBasicData && !canComplete"
|
||||
class="export-btn"
|
||||
@click="navigateTo('/dashboard')"
|
||||
>
|
||||
<UIcon name="i-heroicons-chart-bar" class="mr-2" />
|
||||
View Dashboard
|
||||
</button>
|
||||
|
||||
<UTooltip :text="incompleteSectionsText" :prevent="canComplete">
|
||||
<button
|
||||
class="export-btn primary"
|
||||
|
|
@ -347,6 +357,11 @@ const streamsValid = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
// Check if we have basic data for scenario exploration
|
||||
const hasBasicData = computed(() => {
|
||||
return membersValid.value && (costsValid.value || streamsValid.value);
|
||||
});
|
||||
|
||||
// Computed validation - all 4 steps must be valid
|
||||
const canComplete = computed(() => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,32 +1,166 @@
|
|||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 space-y-8" data-ui="dashboard_v1">
|
||||
<section class="py-8 space-y-6 max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">Min</span>
|
||||
<UToggle
|
||||
:model-value="operatingMode === 'target'"
|
||||
@update:model-value="(value) => setOperatingMode(value ? 'target' : 'min')"
|
||||
/>
|
||||
<span class="text-sm text-gray-600">Target</span>
|
||||
<h2 class="text-2xl font-semibold">Compensation</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">Mode:</span>
|
||||
<button
|
||||
@click="setOperatingMode('min')"
|
||||
class="px-3 py-1 text-sm font-bold border-2 border-black"
|
||||
:class="coopStore.operatingMode === 'min' ? 'bg-black text-white' : 'bg-white'">
|
||||
MIN
|
||||
</button>
|
||||
<button
|
||||
@click="setOperatingMode('target')"
|
||||
class="px-3 py-1 text-sm font-bold border-2 border-black"
|
||||
:class="coopStore.operatingMode === 'target' ? 'bg-black text-white' : 'bg-white'">
|
||||
TARGET
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Metrics -->
|
||||
<DashboardCoreMetrics />
|
||||
|
||||
<!-- Member Coverage -->
|
||||
<MemberCoveragePanel />
|
||||
|
||||
<!-- Simple Policy Display -->
|
||||
<div class="border-2 border-black bg-white p-4">
|
||||
<div class="text-lg font-bold mb-2">
|
||||
{{ getPolicyName() }} Policy
|
||||
</div>
|
||||
<div class="text-2xl font-mono">
|
||||
{{ getPolicyFormula() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member List -->
|
||||
<div class="border-2 border-black bg-white">
|
||||
<div class="border-b-2 border-black p-4">
|
||||
<h3 class="font-bold">Members ({{ coopStore.members.length }})</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-300">
|
||||
<div v-if="coopStore.members.length === 0" class="p-4 text-gray-500 text-center">
|
||||
No members yet. Add members in Setup Wizard.
|
||||
</div>
|
||||
<div v-for="member in membersWithPay" :key="member.id" class="p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<div class="font-bold">{{ member.name || 'Unnamed' }}</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<span v-if="coopStore.policy?.relationship === 'needs-weighted'">
|
||||
Needs: {{ $format.currency(member.minMonthlyNeeds || 0) }}/month
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ member.hoursPerMonth || 0 }} hrs/month
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-mono font-bold">{{ $format.currency(member.expectedPay) }}</div>
|
||||
<div class="text-xs" :class="member.coverage >= 100 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ member.coverage }}% covered
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="border-2 border-black bg-gray-100 p-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-bold">Total Monthly Payroll</span>
|
||||
<span class="text-xl font-mono font-bold">{{ $format.currency(totalPayroll) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-2 text-sm text-gray-600">
|
||||
<span>+ Oncosts ({{ coopStore.payrollOncostPct }}%)</span>
|
||||
<span class="font-mono">{{ $format.currency(totalPayroll * coopStore.payrollOncostPct / 100) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-2 pt-2 border-t border-gray-400">
|
||||
<span class="font-bold">Total Cost</span>
|
||||
<span class="text-xl font-mono font-bold">{{ $format.currency(totalPayroll * (1 + coopStore.payrollOncostPct / 100)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="navigateTo('/coop-builder')"
|
||||
class="px-4 py-2 border-2 border-black bg-white font-bold hover:bg-gray-100">
|
||||
Edit in Setup Wizard
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Import components explicitly to avoid auto-import issues
|
||||
import DashboardCoreMetrics from '~/components/dashboard/DashboardCoreMetrics.vue'
|
||||
import MemberCoveragePanel from '~/components/dashboard/MemberCoveragePanel.vue'
|
||||
const { $format } = useNuxtApp();
|
||||
const coopStore = useCoopBuilderStore();
|
||||
|
||||
// Access composable data
|
||||
const { operatingMode, setOperatingMode } = useCoopBuilder()
|
||||
// Calculate member pay based on policy
|
||||
const membersWithPay = computed(() => {
|
||||
const policyType = coopStore.policy?.relationship || 'equal-pay';
|
||||
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
||||
const totalNeeds = coopStore.members.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0);
|
||||
|
||||
return coopStore.members.map(member => {
|
||||
let expectedPay = 0;
|
||||
const hours = member.hoursPerMonth || 0;
|
||||
|
||||
if (policyType === 'equal-pay') {
|
||||
// Equal pay: hours × wage
|
||||
expectedPay = hours * coopStore.equalHourlyWage;
|
||||
} else if (policyType === 'hours-weighted') {
|
||||
// Hours weighted: proportion of total hours
|
||||
expectedPay = totalHours > 0 ? (hours / totalHours) * (totalHours * coopStore.equalHourlyWage) : 0;
|
||||
} else if (policyType === 'needs-weighted') {
|
||||
// Needs weighted: based on individual needs
|
||||
const needs = member.minMonthlyNeeds || 0;
|
||||
expectedPay = totalNeeds > 0 ? (needs / totalNeeds) * (totalHours * coopStore.equalHourlyWage) : 0;
|
||||
}
|
||||
|
||||
const actualPay = member.monthlyPayPlanned || expectedPay;
|
||||
const coverage = expectedPay > 0 ? Math.round((actualPay / expectedPay) * 100) : 100;
|
||||
|
||||
return {
|
||||
...member,
|
||||
expectedPay,
|
||||
actualPay,
|
||||
coverage
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Total payroll
|
||||
const totalPayroll = computed(() => {
|
||||
return membersWithPay.value.reduce((sum, m) => sum + m.expectedPay, 0);
|
||||
});
|
||||
|
||||
// Operating mode toggle
|
||||
function setOperatingMode(mode: 'min' | 'target') {
|
||||
coopStore.setOperatingMode(mode);
|
||||
}
|
||||
|
||||
// Get current policy name
|
||||
function getPolicyName() {
|
||||
// Check both coopStore.policy and the root level policy.relationship
|
||||
const policyType = coopStore.policy?.relationship || coopStore.policy || 'equal-pay';
|
||||
|
||||
if (policyType === 'equal-pay') return 'Equal Pay';
|
||||
if (policyType === 'hours-weighted') return 'Hours Based';
|
||||
if (policyType === 'needs-weighted') return 'Needs Based';
|
||||
return 'Equal Pay'; // fallback
|
||||
}
|
||||
|
||||
// Get policy formula display
|
||||
function getPolicyFormula() {
|
||||
const policyType = coopStore.policy?.relationship || coopStore.policy || 'equal-pay';
|
||||
const mode = coopStore.operatingMode === 'target' ? 'Target' : 'Min';
|
||||
|
||||
if (policyType === 'equal-pay') {
|
||||
return `${$format.currency(coopStore.equalHourlyWage)}/hour × ${mode} Hours`;
|
||||
}
|
||||
if (policyType === 'hours-weighted') {
|
||||
return `Based on ${mode} Hours Proportion`;
|
||||
}
|
||||
if (policyType === 'needs-weighted') {
|
||||
return `Based on Individual Needs`;
|
||||
}
|
||||
return `${$format.currency(coopStore.equalHourlyWage)}/hour × ${mode} Hours`;
|
||||
}
|
||||
</script>
|
||||
263
pages/index.vue
263
pages/index.vue
|
|
@ -2,40 +2,32 @@
|
|||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">Dashboard</h2>
|
||||
<h2 class="text-2xl font-semibold">Compensation</h2>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<UBadge
|
||||
:color="policiesStore.operatingMode === 'target' ? 'primary' : 'gray'"
|
||||
size="xs"
|
||||
>
|
||||
{{ policiesStore.operatingMode === 'target' ? '🎯 Target Mode' : '⚡ Min Mode' }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500">
|
||||
<span class="px-2 py-1 border border-black bg-white text-xs font-bold uppercase">
|
||||
{{ policiesStore.operatingMode === 'target' ? 'Target Mode' : 'Min Mode' }}
|
||||
</span>
|
||||
<span class="text-xs font-mono">
|
||||
Runway: {{ Math.round(metrics.runway) }}mo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-down-tray"
|
||||
color="gray"
|
||||
<button
|
||||
@click="onExport"
|
||||
>Export JSON</UButton
|
||||
>
|
||||
<UButton icon="i-heroicons-arrow-up-tray" color="gray" @click="onImport"
|
||||
>Import JSON</UButton
|
||||
>
|
||||
class="px-4 py-2 border-2 border-black bg-white font-bold uppercase text-sm hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
@click="onImport"
|
||||
class="px-4 py-2 border-2 border-black bg-white font-bold uppercase text-sm hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||
Import JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<RunwayMeter
|
||||
:months="metrics.runway"
|
||||
:description="`You have ${$format.number(
|
||||
metrics.runway
|
||||
)} months of runway with current spending.`" />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CoverageMeter
|
||||
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
|
||||
:target-hours="metrics.totalTargetHours"
|
||||
|
|
@ -46,141 +38,152 @@
|
|||
:savings-target-months="savingsProgress.targetMonths"
|
||||
:monthly-burn="getMonthlyBurn()"
|
||||
:description="`${savingsProgress.progressPct.toFixed(0)}% of savings target reached. ${savingsProgress.gap > 0 ? 'Gap: ' + $format.currency(savingsProgress.gap) : 'Target achieved!'}`" />
|
||||
|
||||
<UCard>
|
||||
<div class="text-center space-y-3">
|
||||
<div class="text-3xl font-bold" :class="concentrationColor">
|
||||
{{ topSourcePct }}%
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600">
|
||||
<GlossaryTooltip
|
||||
term="Concentration"
|
||||
term-id="concentration"
|
||||
definition="Dependence on few revenue sources. UI shows top source percentage." />
|
||||
</div>
|
||||
<ConcentrationChip
|
||||
:status="concentrationStatus"
|
||||
:top-source-pct="topSourcePct"
|
||||
:show-percentage="false"
|
||||
variant="soft" />
|
||||
<p class="text-xs text-neutral-500 mt-2">
|
||||
Most of your money comes from one place. Add another stream to
|
||||
reduce risk.
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Quick Wins Dashboard Components -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Dashboard Components with Wizard Styling -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Needs Coverage Bars -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Member Coverage</h3>
|
||||
</template>
|
||||
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div class="border-b-2 border-black p-4">
|
||||
<h3 class="text-lg font-bold uppercase">Member Coverage</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<NeedsCoverageBars />
|
||||
</UCard>
|
||||
|
||||
<!-- Revenue Mix -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Revenue Mix</h3>
|
||||
</template>
|
||||
<RevenueMixTable />
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Milestone-Runway Overlay -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Runway vs Milestones</h3>
|
||||
</template>
|
||||
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div class="border-b-2 border-black p-4">
|
||||
<h3 class="text-lg font-bold uppercase">Runway vs Milestones</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<MilestoneRunwayOverlay />
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts Section -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Alerts</h3>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<!-- Alerts Section with Wizard Styling -->
|
||||
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div class="border-b-2 border-black p-4">
|
||||
<h3 class="text-lg font-bold uppercase">Alerts</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Concentration Risk Alert -->
|
||||
<UAlert
|
||||
<div
|
||||
v-if="topSourcePct > 50"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-exclamation-triangle"
|
||||
title="Revenue Concentration Risk"
|
||||
:description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
|
||||
:actions="[
|
||||
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') }
|
||||
]" />
|
||||
class="border-2 border-red-600 bg-red-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-600 font-bold text-xl">!</span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold uppercase mb-1">Revenue Concentration Risk</h4>
|
||||
<p class="text-sm mb-2">{{ topStreamName }} = {{ topSourcePct }}% of total → consider balancing</p>
|
||||
<button
|
||||
@click="handleAlertNavigation('/dashboard', 'concentration')"
|
||||
class="text-sm underline font-bold">
|
||||
VIEW DETAILS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cushion Breach Alert -->
|
||||
<UAlert
|
||||
<div
|
||||
v-if="alerts.cushionBreach"
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-calendar"
|
||||
title="Cash Cushion Breach Forecast"
|
||||
:description="`Projected to breach minimum cushion in week ${cushionForecast.firstBreachWeek || 'unknown'}`"
|
||||
:actions="[
|
||||
{ label: 'View Calendar', click: () => handleAlertNavigation('/cash', 'breach-forecast') },
|
||||
{ label: 'Adjust Budget', click: () => handleAlertNavigation('/budget', 'expenses') }
|
||||
]" />
|
||||
class="border-2 border-orange-600 bg-orange-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-orange-600 font-bold text-xl">!</span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold uppercase mb-1">Cash Cushion Breach Forecast</h4>
|
||||
<p class="text-sm mb-2">Projected to breach minimum cushion in week {{ cushionForecast.firstBreachWeek || 'unknown' }}</p>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
@click="handleAlertNavigation('/cash', 'breach-forecast')"
|
||||
class="text-sm underline font-bold">
|
||||
VIEW CALENDAR
|
||||
</button>
|
||||
<button
|
||||
@click="handleAlertNavigation('/budget', 'expenses')"
|
||||
class="text-sm underline font-bold">
|
||||
ADJUST BUDGET
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Savings Below Target Alert -->
|
||||
<UAlert
|
||||
<div
|
||||
v-if="alerts.savingsBelowTarget"
|
||||
color="yellow"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-banknotes"
|
||||
title="Savings Below Target"
|
||||
:description="`${savingsProgress.progressPct.toFixed(0)}% of target reached. Build savings before increasing paid hours.`"
|
||||
:actions="[
|
||||
{ label: 'View Progress', click: () => handleAlertNavigation('/budget', 'savings') },
|
||||
{ label: 'Adjust Policies', click: () => handleAlertNavigation('/coop-builder', 'policies') }
|
||||
]" />
|
||||
class="border-2 border-yellow-600 bg-yellow-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-yellow-600 font-bold text-xl">!</span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold uppercase mb-1">Savings Below Target</h4>
|
||||
<p class="text-sm mb-2">{{ savingsProgress.progressPct.toFixed(0) }}% of target reached. Build savings before increasing paid hours.</p>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
@click="handleAlertNavigation('/budget', 'savings')"
|
||||
class="text-sm underline font-bold">
|
||||
VIEW PROGRESS
|
||||
</button>
|
||||
<button
|
||||
@click="handleAlertNavigation('/coop-builder', 'policies')"
|
||||
class="text-sm underline font-bold">
|
||||
ADJUST POLICIES
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Over-Deferred Member Alert -->
|
||||
<UAlert
|
||||
<div
|
||||
v-if="deferredAlert.show"
|
||||
color="purple"
|
||||
variant="subtle"
|
||||
icon="i-heroicons-user-group"
|
||||
title="Member Over-Deferred"
|
||||
:description="deferredAlert.description"
|
||||
:actions="[
|
||||
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
|
||||
]" />
|
||||
class="border-2 border-purple-600 bg-purple-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-purple-600 font-bold text-xl">!</span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold uppercase mb-1">Member Over-Deferred</h4>
|
||||
<p class="text-sm mb-2">{{ deferredAlert.description }}</p>
|
||||
<button
|
||||
@click="handleAlertNavigation('/coop-builder', 'members')"
|
||||
class="text-sm underline font-bold">
|
||||
REVIEW MEMBERS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success message when no alerts -->
|
||||
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
|
||||
class="text-center py-8 text-gray-500">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-8 h-8 mx-auto mb-2 text-green-500" />
|
||||
<p class="font-medium">All systems looking good!</p>
|
||||
<p class="text-sm">No critical alerts at this time.</p>
|
||||
class="text-center py-8">
|
||||
<span class="text-4xl font-bold">✓</span>
|
||||
<p class="font-bold uppercase mt-2">All systems looking good!</p>
|
||||
<p class="text-sm mt-1">No critical alerts at this time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<UButton
|
||||
block
|
||||
variant="ghost"
|
||||
class="justify-start h-auto p-4"
|
||||
@click="navigateTo('/mix')">
|
||||
<div class="text-left">
|
||||
<div class="font-medium">Revenue Mix</div>
|
||||
<div class="text-xs text-neutral-500">Plan revenue streams</div>
|
||||
</div>
|
||||
</UButton>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Quick Actions with Wizard Styling -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
@click="navigateTo('/cash-flow')"
|
||||
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||
<div class="font-bold uppercase mb-1">Cash Flow Analysis</div>
|
||||
<div class="text-sm">Detailed runway & one-time events</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="navigateTo('/budget')"
|
||||
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||
<div class="font-bold uppercase mb-1">Budget Planning</div>
|
||||
<div class="text-sm">Manage expenses & savings</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
|||
345
pages/mix.vue
345
pages/mix.vue
|
|
@ -1,345 +0,0 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">Revenue Mix Planner</h2>
|
||||
<div v-if="isSetupComplete" class="flex items-center gap-2 mt-1">
|
||||
<UBadge color="green" variant="subtle" size="xs">
|
||||
Synchronized with Setup
|
||||
</UBadge>
|
||||
<UButton variant="ghost" size="xs" @click="goToSetup">
|
||||
Edit in Setup
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<UButton color="primary" @click="sendToBudget">
|
||||
Send to Budget & Scenarios
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Concentration Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Concentration Risk</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold mb-2" :class="concentrationColor">
|
||||
{{ topSourcePct }}%
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600 mb-3">
|
||||
Top source percentage
|
||||
</div>
|
||||
<ConcentrationChip
|
||||
:status="concentrationStatus"
|
||||
:top-source-pct="topSourcePct"
|
||||
:show-percentage="false"
|
||||
variant="solid"
|
||||
size="md" />
|
||||
</div>
|
||||
<p class="text-sm text-neutral-600 text-center">
|
||||
Most of your money comes from one place. Add another stream to
|
||||
reduce risk.
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Payout Delay Exposure</h3>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-yellow-600 mb-2">35 days</div>
|
||||
<div class="text-sm text-neutral-600 mb-3">
|
||||
Weighted average delay
|
||||
</div>
|
||||
<UBadge color="yellow" variant="subtle">Moderate Risk</UBadge>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-600 text-center">
|
||||
Money is earned now but arrives later. Delays can create mid-month
|
||||
dips.
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Streams Table -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium">Revenue Streams</h3>
|
||||
<UButton icon="i-heroicons-plus" size="sm" @click="addStream">
|
||||
Add Stream
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UTable :rows="streams" :columns="columns">
|
||||
<template #name-data="{ row }">
|
||||
<div>
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="text-xs text-neutral-500">{{ row.category }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #targetPct-data="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="row.targetPct"
|
||||
type="number"
|
||||
size="xs"
|
||||
class="w-16"
|
||||
@update:model-value="updateStream(row.id, 'targetPct', $event)" />
|
||||
<span class="text-xs text-neutral-500">%</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #targetAmount-data="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-neutral-500">€</span>
|
||||
<UInput
|
||||
v-model="row.targetMonthlyAmount"
|
||||
type="number"
|
||||
size="xs"
|
||||
class="w-20"
|
||||
@update:model-value="
|
||||
updateStream(row.id, 'targetMonthlyAmount', $event)
|
||||
" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #fees-data="{ row }">
|
||||
<div class="text-sm">
|
||||
<div v-if="row.platformFeePct > 0">
|
||||
Platform: {{ row.platformFeePct }}%
|
||||
</div>
|
||||
<div v-if="row.revenueSharePct > 0">
|
||||
Share: {{ row.revenueSharePct }}%
|
||||
</div>
|
||||
<div
|
||||
v-if="row.platformFeePct === 0 && row.revenueSharePct === 0"
|
||||
class="text-neutral-400">
|
||||
None
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #delay-data="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="row.payoutDelayDays"
|
||||
type="number"
|
||||
size="xs"
|
||||
class="w-16"
|
||||
@update:model-value="
|
||||
updateStream(row.id, 'payoutDelayDays', $event)
|
||||
" />
|
||||
<span class="text-xs text-neutral-500">days</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #restrictions-data="{ row }">
|
||||
<RestrictionChip :restriction="row.restrictions" />
|
||||
</template>
|
||||
|
||||
<template #certainty-data="{ row }">
|
||||
<UBadge
|
||||
:color="getCertaintyColor(row.certainty)"
|
||||
variant="subtle"
|
||||
size="xs">
|
||||
{{ row.certainty }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<template #actions-data="{ row }">
|
||||
<UDropdown :items="getRowActions(row)">
|
||||
<UButton
|
||||
icon="i-heroicons-ellipsis-horizontal"
|
||||
size="xs"
|
||||
variant="ghost" />
|
||||
</UDropdown>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
<div class="mt-4 p-4 bg-neutral-50 rounded-lg">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-medium">Totals</span>
|
||||
<div class="flex gap-6">
|
||||
<span>{{ totalTargetPct }}%</span>
|
||||
<span>{{ $format.currency(totalMonthlyAmount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { $format } = useNuxtApp();
|
||||
|
||||
// Use synchronized store data - setup is the source of truth
|
||||
const { initSync, getStreams, unifiedStreams } = useStoreSync();
|
||||
const { isSetupComplete, goToSetup } = useSetupState();
|
||||
const streamsStore = useStreamsStore();
|
||||
const coopStore = useCoopBuilderStore();
|
||||
|
||||
// Initialize synchronization on mount
|
||||
onMounted(async () => {
|
||||
await initSync();
|
||||
});
|
||||
|
||||
// Use reactive synchronized streams data
|
||||
const streams = unifiedStreams;
|
||||
|
||||
const columns = [
|
||||
{ id: "name", key: "name", label: "Stream" },
|
||||
{ id: "targetPct", key: "targetPct", label: "Target %" },
|
||||
{ id: "targetAmount", key: "targetAmount", label: "Monthly €" },
|
||||
{ id: "fees", key: "fees", label: "Fees" },
|
||||
{ id: "delay", key: "delay", label: "Payout Delay" },
|
||||
{ id: "restrictions", key: "restrictions", label: "Use" },
|
||||
{ id: "certainty", key: "certainty", label: "Certainty" },
|
||||
{ id: "actions", key: "actions", label: "" },
|
||||
];
|
||||
|
||||
const totalTargetPct = computed(() => {
|
||||
// Calculate from the unified streams data
|
||||
return streams.value.reduce((sum, stream) => sum + (stream.targetPct || 0), 0);
|
||||
});
|
||||
|
||||
const totalMonthlyAmount = computed(() => {
|
||||
// Calculate from the unified streams data
|
||||
return streams.value.reduce((sum, stream) => sum + (stream.targetMonthlyAmount || stream.monthly || 0), 0);
|
||||
});
|
||||
|
||||
// Calculate concentration metrics
|
||||
const topSourcePct = computed(() => {
|
||||
if (streams.value.length === 0) return 0;
|
||||
const amounts = streams.value.map((s) => s.targetMonthlyAmount || 0);
|
||||
return (
|
||||
Math.round((Math.max(...amounts) / totalMonthlyAmount.value) * 100) || 0
|
||||
);
|
||||
});
|
||||
|
||||
const concentrationStatus = computed(() => {
|
||||
if (topSourcePct.value > 50) return "red";
|
||||
if (topSourcePct.value > 35) return "yellow";
|
||||
return "green";
|
||||
});
|
||||
|
||||
const concentrationColor = computed(() => {
|
||||
if (topSourcePct.value > 50) return "text-red-600";
|
||||
if (topSourcePct.value > 35) return "text-yellow-600";
|
||||
return "text-green-600";
|
||||
});
|
||||
|
||||
function getCertaintyColor(certainty: string) {
|
||||
switch (certainty) {
|
||||
case "Committed":
|
||||
return "green";
|
||||
case "Probable":
|
||||
return "blue";
|
||||
case "Aspirational":
|
||||
return "yellow";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
function getRowActions(row: any) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: "i-heroicons-pencil",
|
||||
click: () => editStream(row),
|
||||
},
|
||||
{
|
||||
label: "Duplicate",
|
||||
icon: "i-heroicons-document-duplicate",
|
||||
click: () => duplicateStream(row),
|
||||
},
|
||||
{
|
||||
label: "Remove",
|
||||
icon: "i-heroicons-trash",
|
||||
click: () => removeStream(row),
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function updateStream(id: string, field: string, value: any) {
|
||||
// Update the primary CoopBuilder store (source of truth)
|
||||
const coopStream = coopStore.streams.find((s) => s.id === id);
|
||||
if (coopStream) {
|
||||
if (field === 'targetMonthlyAmount') {
|
||||
coopStream.monthly = Number(value) || 0;
|
||||
} else {
|
||||
coopStream[field] = Number(value) || value;
|
||||
}
|
||||
coopStore.upsertStream(coopStream);
|
||||
}
|
||||
|
||||
// Also update the legacy store for backward compatibility
|
||||
const legacyStream = streams.value.find((s) => s.id === id);
|
||||
if (legacyStream) {
|
||||
legacyStream[field] = Number(value) || value;
|
||||
streamsStore.upsertStream(legacyStream);
|
||||
}
|
||||
}
|
||||
|
||||
function addStream() {
|
||||
const newStreamId = Date.now().toString();
|
||||
|
||||
// Add to CoopBuilder store first (primary source)
|
||||
const coopStream = {
|
||||
id: newStreamId,
|
||||
label: "",
|
||||
monthly: 0,
|
||||
category: "games",
|
||||
certainty: "Aspirational",
|
||||
};
|
||||
coopStore.upsertStream(coopStream);
|
||||
|
||||
// Add to legacy store for compatibility
|
||||
const legacyStream = {
|
||||
id: newStreamId,
|
||||
name: "",
|
||||
category: "games",
|
||||
subcategory: "",
|
||||
targetPct: 0,
|
||||
targetMonthlyAmount: 0,
|
||||
certainty: "Aspirational",
|
||||
payoutDelayDays: 30,
|
||||
terms: "Net 30",
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: "General",
|
||||
seasonalityWeights: new Array(12).fill(1),
|
||||
effortHoursPerMonth: 0,
|
||||
};
|
||||
streamsStore.upsertStream(legacyStream);
|
||||
}
|
||||
|
||||
function editStream(row: any) {
|
||||
// Edit stream logic
|
||||
console.log("Edit stream", row);
|
||||
}
|
||||
|
||||
function duplicateStream(row: any) {
|
||||
// Duplicate stream logic
|
||||
console.log("Duplicate stream", row);
|
||||
}
|
||||
|
||||
function removeStream(row: any) {
|
||||
// Remove from both stores to maintain sync
|
||||
coopStore.removeStream(row.id);
|
||||
streamsStore.removeStream(row.id);
|
||||
}
|
||||
|
||||
function sendToBudget() {
|
||||
navigateTo("/budget");
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Runway Lite
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Quick runway assessment with revenue scenarios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RunwayLite
|
||||
:starting-cash="budgetData.startingCash.value"
|
||||
:revenue-planned="budgetData.revenuePlanned.value"
|
||||
:expense-planned="budgetData.expensePlanned.value"
|
||||
:diversification-guidance="budgetData.diversification.value.guidance"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const budgetData = useBudget('default', new Date().getFullYear())
|
||||
</script>
|
||||
|
|
@ -172,15 +172,16 @@
|
|||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
Any person who:
|
||||
</p>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
<li>Shares our values and purpose</li>
|
||||
<li>
|
||||
Contributes labour to the cooperative (by doing actual work,
|
||||
not just investing money)
|
||||
</li>
|
||||
<li>Commits to collective decision-making</li>
|
||||
<li>Participates in governance responsibilities</li>
|
||||
</ul>
|
||||
<UFormField label="Member Requirements" class="form-group-large">
|
||||
<UTextarea
|
||||
v-model="formData.memberRequirements"
|
||||
:rows="4"
|
||||
placeholder="Enter member requirements"
|
||||
size="xl"
|
||||
class="large-field"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -413,6 +414,21 @@
|
|||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||
Paying Ourselves
|
||||
</h3>
|
||||
|
||||
<!-- Pay Policy Selection -->
|
||||
<UFormField label="Pay Policy" class="form-group-large mb-4">
|
||||
<USelect
|
||||
v-model="formData.payPolicy"
|
||||
:items="payPolicyOptions"
|
||||
placeholder="Select pay policy"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<!-- Equal Pay Policy -->
|
||||
<div v-if="formData.payPolicy === 'equal-pay'" class="space-y-3">
|
||||
<p class="content-paragraph">All members receive equal compensation regardless of role or hours worked.</p>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
Base rate: $<UInput
|
||||
|
|
@ -431,12 +447,55 @@
|
|||
@change="autoSave" />
|
||||
per member
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Hours-Weighted Policy -->
|
||||
<div v-if="formData.payPolicy === 'hours-weighted'" class="space-y-3">
|
||||
<p class="content-paragraph">Compensation is proportional to hours worked by each member.</p>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
Hourly rate: $<UInput
|
||||
v-model="formData.hourlyRate"
|
||||
type="number"
|
||||
placeholder="25"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave" />/hour
|
||||
</li>
|
||||
<li>Members track their hours and are paid accordingly</li>
|
||||
<li>Minimum hours commitment may apply</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Needs-Weighted Policy -->
|
||||
<div v-if="formData.payPolicy === 'needs-weighted'" class="space-y-3">
|
||||
<p class="content-paragraph">Compensation is allocated based on each member's individual financial needs.</p>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
<li>Members declare their minimum monthly needs</li>
|
||||
<li>Available payroll is distributed proportionally to cover needs</li>
|
||||
<li>Regular needs assessment and adjustment process</li>
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
Minimum guaranteed amount: $<UInput
|
||||
v-model="formData.minGuaranteedPay"
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave" />/month
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Common payment details -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<p class="content-paragraph font-semibold">Payment Schedule:</p>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
Paid on the
|
||||
<USelect
|
||||
v-model="formData.paymentDay"
|
||||
:items="dayOptions"
|
||||
placeholder="15"
|
||||
placeholder="15th"
|
||||
arrow
|
||||
class="inline-field"
|
||||
@change="autoSave" />
|
||||
of each month
|
||||
|
|
@ -451,6 +510,7 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
|
|
@ -522,15 +582,16 @@
|
|||
@change="autoSave" />
|
||||
months. Current roles include:
|
||||
</p>
|
||||
<ul class="content-list">
|
||||
<li>
|
||||
Financial coordinator (handles bookkeeping, not financial
|
||||
decisions)
|
||||
</li>
|
||||
<li>Meeting facilitator</li>
|
||||
<li>External communications</li>
|
||||
<li>Others</li>
|
||||
</ul>
|
||||
<UFormField label="Rotating Roles" class="form-group-large">
|
||||
<UTextarea
|
||||
v-model="formData.rotatingRoles"
|
||||
:rows="4"
|
||||
placeholder="List rotating operational roles"
|
||||
size="xl"
|
||||
class="large-field"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -541,11 +602,16 @@
|
|||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
All members participate in:
|
||||
</p>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
<li>Governance and decision-making</li>
|
||||
<li>Strategic planning</li>
|
||||
<li>Mutual support and care</li>
|
||||
</ul>
|
||||
<UFormField label="Shared Responsibilities" class="form-group-large">
|
||||
<UTextarea
|
||||
v-model="formData.sharedResponsibilities"
|
||||
:rows="3"
|
||||
placeholder="List shared responsibilities for all members"
|
||||
size="xl"
|
||||
class="large-field"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -690,47 +756,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 10: Agreement Review -->
|
||||
<div class="section-card">
|
||||
<h2
|
||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||
10. Agreement Review
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
By using this agreement, we commit to these principles and to
|
||||
showing up for each other.
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300">
|
||||
<p
|
||||
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
|
||||
This agreement was last updated on
|
||||
<UInput
|
||||
v-model="formData.lastUpdated"
|
||||
type="date"
|
||||
class="inline-field"
|
||||
@change="autoSave" />. We commit to reviewing it on
|
||||
<UInput
|
||||
v-model="formData.nextReview"
|
||||
type="date"
|
||||
class="inline-field"
|
||||
@change="autoSave" />
|
||||
or sooner if circumstances require.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950">
|
||||
<p
|
||||
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic">
|
||||
[Space for member signatures when printed]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -768,13 +793,36 @@ const monthOptions = [
|
|||
"December",
|
||||
];
|
||||
|
||||
const dayOptions = Array.from({ length: 31 }, (_, i) => (i + 1).toString());
|
||||
const dayOptions = Array.from({ length: 31 }, (_, i) => ({
|
||||
value: i + 1,
|
||||
label: `${i + 1}${getOrdinalSuffix(i + 1)}`
|
||||
}));
|
||||
|
||||
// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.)
|
||||
function getOrdinalSuffix(num) {
|
||||
if (num >= 11 && num <= 13) {
|
||||
return 'th';
|
||||
}
|
||||
switch (num % 10) {
|
||||
case 1: return 'st';
|
||||
case 2: return 'nd';
|
||||
case 3: return 'rd';
|
||||
default: return 'th';
|
||||
}
|
||||
}
|
||||
|
||||
const payPolicyOptions = [
|
||||
{ value: 'equal-pay', label: 'Equal Pay - All members receive equal compensation' },
|
||||
{ value: 'hours-weighted', label: 'Hours-Weighted - Pay proportional to hours worked' },
|
||||
{ value: 'needs-weighted', label: 'Needs-Weighted - Pay proportional to individual needs' }
|
||||
];
|
||||
|
||||
const formData = ref({
|
||||
cooperativeName: "",
|
||||
dateEstablished: "",
|
||||
purpose: "",
|
||||
coreValues: "",
|
||||
memberRequirements: "Shares our values and purpose\nContributes labour to the cooperative (by doing actual work, not just investing money)\nCommits to collective decision-making\nParticipates in governance responsibilities",
|
||||
members: [{ name: "", email: "", joinDate: "", role: "" }],
|
||||
trialPeriodMonths: 3,
|
||||
buyInAmount: "",
|
||||
|
|
@ -787,20 +835,24 @@ const formData = ref({
|
|||
majorDebtThreshold: 5000,
|
||||
meetingFrequency: "weekly",
|
||||
emergencyNoticeHours: 24,
|
||||
// Pay policy settings
|
||||
payPolicy: "equal-pay",
|
||||
baseRate: 25,
|
||||
monthlyDraw: "",
|
||||
hourlyRate: 25,
|
||||
minGuaranteedPay: 1000,
|
||||
paymentDay: 15,
|
||||
surplusFrequency: "quarter",
|
||||
targetHours: 40,
|
||||
roleRotationMonths: 6,
|
||||
rotatingRoles: "Financial coordinator (handles bookkeeping, not financial decisions)\nMeeting facilitator\nExternal communications\nOthers",
|
||||
sharedResponsibilities: "Governance and decision-making\nStrategic planning\nMutual support and care",
|
||||
reviewFrequency: "year",
|
||||
assetDonationTarget: "",
|
||||
legalStructure: "",
|
||||
registeredLocation: "",
|
||||
fiscalYearEndMonth: "December",
|
||||
fiscalYearEndDay: 31,
|
||||
lastUpdated: new Date().toISOString().split("T")[0],
|
||||
nextReview: "",
|
||||
});
|
||||
|
||||
// Load saved data immediately (before watchers)
|
||||
|
|
@ -1017,28 +1069,12 @@ const handlePrint = () => {
|
|||
|
||||
// Export data for the ExportOptions component
|
||||
const exportData = computed(() => ({
|
||||
// Pass the complete formData object - this is what the export functions use
|
||||
formData: formData.value,
|
||||
// Also provide direct access to key fields for backward compatibility
|
||||
cooperativeName: formData.value.cooperativeName || "Worker Cooperative",
|
||||
dateEstablished: formData.value.dateEstablished,
|
||||
purpose: formData.value.purpose,
|
||||
coreValues: formData.value.coreValues,
|
||||
members: formData.value.members,
|
||||
trialPeriodMonths: formData.value.trialPeriodMonths,
|
||||
policies: {
|
||||
buyInAmount: formData.value.buyInAmount,
|
||||
noticeDays: formData.value.noticeDays,
|
||||
surplusPayoutDays: formData.value.surplusPayoutDays,
|
||||
buyInReturnDays: formData.value.buyInReturnDays,
|
||||
dayToDayLimit: formData.value.dayToDayLimit,
|
||||
regularDecisionMin: formData.value.regularDecisionMin,
|
||||
regularDecisionMax: formData.value.regularDecisionMax,
|
||||
majorDebtThreshold: formData.value.majorDebtThreshold,
|
||||
meetingFrequency: formData.value.meetingFrequency,
|
||||
emergencyNoticeHours: formData.value.emergencyNoticeHours,
|
||||
baseRate: formData.value.baseRate,
|
||||
},
|
||||
exportedAt: new Date().toISOString(),
|
||||
section: "membership-agreement",
|
||||
exportedAt: new Date().toISOString(),
|
||||
}));
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -431,13 +431,16 @@ export const useBudgetStore = defineStore(
|
|||
console.log("Current revenue items:", budgetWorksheet.value.revenue.length);
|
||||
console.log("Current expense items:", budgetWorksheet.value.expenses.length);
|
||||
|
||||
// Check if we have actual budget data (not just initialized flag)
|
||||
if (isInitialized.value && (budgetWorksheet.value.revenue.length > 0 || budgetWorksheet.value.expenses.length > 0)) {
|
||||
console.log("Already initialized with data, skipping...");
|
||||
// Check if we have actual budget data - prioritize preserving user data
|
||||
const hasUserData = budgetWorksheet.value.revenue.length > 0 || budgetWorksheet.value.expenses.length > 0;
|
||||
|
||||
if (hasUserData) {
|
||||
console.log("Budget data already exists with user changes, preserving...");
|
||||
isInitialized.value = true; // Mark as initialized to prevent future overwrites
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Initializing budget from wizard data...");
|
||||
console.log("No existing budget data found, initializing from wizard data...");
|
||||
|
||||
try {
|
||||
// Use the new coopBuilder store instead of the old stores
|
||||
|
|
@ -449,7 +452,7 @@ export const useBudgetStore = defineStore(
|
|||
console.log("- Equal wage:", coopStore.equalHourlyWage || "No wage set");
|
||||
console.log("- Overhead costs:", coopStore.overheadCosts.length, coopStore.overheadCosts);
|
||||
|
||||
// Clear existing data
|
||||
// Only clear data if we're truly initializing from scratch
|
||||
budgetWorksheet.value.revenue = [];
|
||||
budgetWorksheet.value.expenses = [];
|
||||
|
||||
|
|
@ -851,13 +854,22 @@ export const useBudgetStore = defineStore(
|
|||
}
|
||||
|
||||
function updateMonthlyValue(category, itemId, monthKey, value) {
|
||||
console.log('updateMonthlyValue called:', { category, itemId, monthKey, value });
|
||||
const items = budgetWorksheet.value[category];
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (item) {
|
||||
if (!item.monthlyValues) {
|
||||
item.monthlyValues = {};
|
||||
}
|
||||
item.monthlyValues[monthKey] = Number(value) || 0;
|
||||
const numericValue = Number(value) || 0;
|
||||
|
||||
// Update directly - Pinia's reactivity will handle persistence
|
||||
item.monthlyValues[monthKey] = numericValue;
|
||||
|
||||
console.log('Updated item.monthlyValues:', item.monthlyValues);
|
||||
console.log('Item updated:', item.name);
|
||||
} else {
|
||||
console.error('Item not found:', { category, itemId, availableItems: items.map(i => ({id: i.id, name: i.name})) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
191
stores/cash.ts
191
stores/cash.ts
|
|
@ -1,8 +1,28 @@
|
|||
import { defineStore } from "pinia";
|
||||
import type { OneOffEvent } from '~/types/cash'
|
||||
|
||||
export interface CashEvent {
|
||||
id: string
|
||||
date: string
|
||||
week: number
|
||||
month: number
|
||||
type: 'Influx' | 'Outflow'
|
||||
amount: number
|
||||
sourceRef: string
|
||||
policyTag: string
|
||||
category: string
|
||||
name: string
|
||||
certainty?: 'Confirmed' | 'Likely' | 'Potential'
|
||||
isRecurring: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export const useCashStore = defineStore("cash", () => {
|
||||
// 13-week calendar events
|
||||
const cashEvents = ref([]);
|
||||
const cashEvents = ref<CashEvent[]>([]);
|
||||
|
||||
// One-off events for longer-term planning (12+ months)
|
||||
const oneOffEvents = ref<OneOffEvent[]>([]);
|
||||
|
||||
// Payment queue - staged payments within policy
|
||||
const paymentQueue = ref([]);
|
||||
|
|
@ -18,33 +38,123 @@ export const useCashStore = defineStore("cash", () => {
|
|||
const weeklyProjections = computed(() => {
|
||||
const weeks = [];
|
||||
let runningBalance = currentCash.value;
|
||||
const budgetStore = useBudgetStore();
|
||||
|
||||
// Get budget data for the next 3+ months to cover 13 weeks
|
||||
const today = new Date();
|
||||
let totalMonthlyRevenue = 0;
|
||||
let totalMonthlyExpenses = 0;
|
||||
|
||||
// Average across current and next 2 months
|
||||
for (let monthOffset = 0; monthOffset < 3; monthOffset++) {
|
||||
const targetMonth = new Date(today.getFullYear(), today.getMonth() + monthOffset, 1);
|
||||
const monthKey = `${targetMonth.getFullYear()}-${String(targetMonth.getMonth() + 1).padStart(2, '0')}`;
|
||||
totalMonthlyRevenue += budgetStore.monthlyTotals?.[monthKey]?.revenue || 0;
|
||||
totalMonthlyExpenses += budgetStore.monthlyTotals?.[monthKey]?.expenses || 0;
|
||||
}
|
||||
|
||||
// Convert to weekly averages (3 months = 13 weeks)
|
||||
const weeklyRevenue = totalMonthlyRevenue / 13;
|
||||
const weeklyExpenses = totalMonthlyExpenses / 13;
|
||||
|
||||
for (let week = 1; week <= 13; week++) {
|
||||
// Start with budget-based weekly flows
|
||||
let weekInflow = weeklyRevenue;
|
||||
let weekOutflow = weeklyExpenses;
|
||||
|
||||
// Add any specific cash events for this week
|
||||
const weekEvents = cashEvents.value.filter((e) => e.week === week);
|
||||
const weekInflow = weekEvents
|
||||
weekInflow += weekEvents
|
||||
.filter((e) => e.type === "Influx")
|
||||
.reduce((sum, e) => sum + e.amount, 0);
|
||||
const weekOutflow = weekEvents
|
||||
weekOutflow += weekEvents
|
||||
.filter((e) => e.type === "Outflow")
|
||||
.reduce((sum, e) => sum + e.amount, 0);
|
||||
|
||||
// Add one-off transactions that fall in this week
|
||||
const weekStart = new Date(today.getTime() + (week - 1) * 7 * 24 * 60 * 60 * 1000);
|
||||
const weekEnd = new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const weekOneOffs = oneOffEvents.value.filter(event => {
|
||||
if (!event.dateExpected) return false;
|
||||
const eventDate = new Date(event.dateExpected);
|
||||
return eventDate >= weekStart && eventDate <= weekEnd;
|
||||
});
|
||||
|
||||
weekOneOffs.forEach(event => {
|
||||
if (event.type === 'income') {
|
||||
weekInflow += event.amount;
|
||||
} else {
|
||||
weekOutflow += event.amount;
|
||||
}
|
||||
});
|
||||
|
||||
const net = weekInflow - weekOutflow;
|
||||
runningBalance += net;
|
||||
|
||||
weeks.push({
|
||||
number: week,
|
||||
inflow: weekInflow,
|
||||
outflow: weekOutflow,
|
||||
net,
|
||||
balance: runningBalance,
|
||||
cushion: runningBalance, // Will be calculated properly later
|
||||
breachesCushion: false, // Will be calculated properly later
|
||||
inflow: Math.round(weekInflow),
|
||||
outflow: Math.round(weekOutflow),
|
||||
net: Math.round(net),
|
||||
balance: Math.round(runningBalance),
|
||||
cushion: Math.round(runningBalance),
|
||||
breachesCushion: runningBalance < 0,
|
||||
weekStart: weekStart.toISOString().split('T')[0],
|
||||
weekEnd: weekEnd.toISOString().split('T')[0],
|
||||
oneOffEvents: weekOneOffs
|
||||
});
|
||||
}
|
||||
|
||||
return weeks;
|
||||
});
|
||||
|
||||
// Computed monthly projections including one-off events
|
||||
const monthlyProjections = computed(() => {
|
||||
const months = [];
|
||||
let runningBalance = 0; // Always start with $0 for new cooperatives
|
||||
const budgetStore = useBudgetStore();
|
||||
|
||||
for (let month = 0; month < 12; month++) {
|
||||
let monthlyRevenue = 0;
|
||||
let monthlyExpenses = 0;
|
||||
|
||||
// Get regular revenue/expenses from budget
|
||||
if (budgetStore.monthlyTotals) {
|
||||
const today = new Date();
|
||||
const targetMonth = new Date(today.getFullYear(), today.getMonth() + month, 1);
|
||||
const monthKey = `${targetMonth.getFullYear()}-${String(targetMonth.getMonth() + 1).padStart(2, '0')}`;
|
||||
monthlyRevenue = budgetStore.monthlyTotals[monthKey]?.revenue || 0;
|
||||
monthlyExpenses = budgetStore.monthlyTotals[monthKey]?.expenses || 0;
|
||||
}
|
||||
|
||||
// Add one-off events for this month
|
||||
const monthOneOffs = oneOffEvents.value.filter(event => event.month === month);
|
||||
monthOneOffs.forEach(event => {
|
||||
if (event.type === 'income') {
|
||||
monthlyRevenue += event.amount;
|
||||
} else {
|
||||
monthlyExpenses += event.amount;
|
||||
}
|
||||
});
|
||||
|
||||
const netCashFlow = monthlyRevenue - monthlyExpenses;
|
||||
runningBalance += netCashFlow;
|
||||
|
||||
months.push({
|
||||
month,
|
||||
monthName: new Date(2024, month).toLocaleString('en', { month: 'short' }),
|
||||
revenue: monthlyRevenue,
|
||||
expenses: monthlyExpenses,
|
||||
netCashFlow,
|
||||
runningBalance,
|
||||
oneOffEvents: monthOneOffs
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
});
|
||||
|
||||
// Actions
|
||||
function addCashEvent(event) {
|
||||
cashEvents.value.push({
|
||||
|
|
@ -97,19 +207,79 @@ export const useCashStore = defineStore("cash", () => {
|
|||
currentSavings.value = savings;
|
||||
}
|
||||
|
||||
// One-off events management
|
||||
function addOneOffEvent(event: Omit<OneOffEvent, 'id'>): string {
|
||||
const id = `oneoff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
// Calculate month from dateExpected
|
||||
const eventDate = new Date(event.dateExpected);
|
||||
const month = eventDate.getMonth();
|
||||
|
||||
const newEvent: OneOffEvent = {
|
||||
id,
|
||||
month,
|
||||
...event
|
||||
};
|
||||
oneOffEvents.value.push(newEvent);
|
||||
return id;
|
||||
}
|
||||
|
||||
function updateOneOffEvent(eventId: string, updates: Partial<OneOffEvent>) {
|
||||
const event = oneOffEvents.value.find(e => e.id === eventId);
|
||||
if (event) {
|
||||
Object.assign(event, updates);
|
||||
}
|
||||
}
|
||||
|
||||
function removeOneOffEvent(eventId: string) {
|
||||
const index = oneOffEvents.value.findIndex(e => e.id === eventId);
|
||||
if (index > -1) {
|
||||
oneOffEvents.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getEventsByMonth(month: number) {
|
||||
return oneOffEvents.value.filter(event => event.month === month);
|
||||
}
|
||||
|
||||
function addCashEventFromOneOff(oneOffEvent: OneOffEvent, weekNumber: number) {
|
||||
// Convert a one-off event to a weekly cash event
|
||||
const cashEvent: Partial<CashEvent> = {
|
||||
date: oneOffEvent.dateExpected,
|
||||
week: weekNumber,
|
||||
month: oneOffEvent.month,
|
||||
type: oneOffEvent.type === 'income' ? 'Influx' : 'Outflow',
|
||||
amount: oneOffEvent.amount,
|
||||
sourceRef: oneOffEvent.id,
|
||||
policyTag: oneOffEvent.category,
|
||||
category: oneOffEvent.category,
|
||||
name: oneOffEvent.name,
|
||||
isRecurring: false
|
||||
};
|
||||
|
||||
addCashEvent(cashEvent);
|
||||
}
|
||||
|
||||
return {
|
||||
cashEvents: readonly(cashEvents),
|
||||
oneOffEvents: readonly(oneOffEvents),
|
||||
paymentQueue: readonly(paymentQueue),
|
||||
firstBreachWeek: readonly(firstBreachWeek),
|
||||
currentCash: readonly(currentCash),
|
||||
currentSavings: readonly(currentSavings),
|
||||
weeklyProjections,
|
||||
monthlyProjections,
|
||||
addCashEvent,
|
||||
updateCashEvent,
|
||||
removeCashEvent,
|
||||
addToPaymentQueue,
|
||||
stagePayment,
|
||||
updateCurrentBalances,
|
||||
// One-off events
|
||||
addOneOffEvent,
|
||||
updateOneOffEvent,
|
||||
removeOneOffEvent,
|
||||
getEventsByMonth,
|
||||
addCashEventFromOneOff
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
|
|
@ -118,7 +288,8 @@ export const useCashStore = defineStore("cash", () => {
|
|||
"currentCash",
|
||||
"currentSavings",
|
||||
"cashEvents",
|
||||
"paymentQueue"
|
||||
"paymentQueue",
|
||||
"oneOffEvents"
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,6 +141,16 @@ export const useMembersStore = defineStore(
|
|||
const member = members.value.find((m) => m.id === memberId);
|
||||
if (member) {
|
||||
member.capacity = { ...member.capacity, ...capacity };
|
||||
// Recalculate monthly pay based on new capacity
|
||||
recalculateMemberPay(memberId);
|
||||
}
|
||||
}
|
||||
|
||||
function recalculateMemberPay(memberId, equalWage = 25) {
|
||||
const member = members.value.find((m) => m.id === memberId);
|
||||
if (member) {
|
||||
const targetHours = member.capacity?.targetHours || 0;
|
||||
member.monthlyPayPlanned = targetHours * equalWage;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,18 +207,47 @@ export const useMembersStore = defineStore(
|
|||
}
|
||||
|
||||
// Coverage calculations for individual members
|
||||
function getMemberCoverage(memberId) {
|
||||
function getMemberCoverage(memberId, equalWage = 25) {
|
||||
const member = members.value.find((m) => m.id === memberId);
|
||||
if (!member) return { coveragePct: undefined };
|
||||
if (!member) return { minPct: 0, targetPct: 0 };
|
||||
|
||||
return coverage(
|
||||
member.minMonthlyNeeds || 0,
|
||||
member.monthlyPayPlanned || 0
|
||||
);
|
||||
// Calculate what they're getting paid based on their hours
|
||||
const minHours = member.capacity?.minHours || 0;
|
||||
const targetHours = member.capacity?.targetHours || 0;
|
||||
|
||||
// Current monthly pay planned
|
||||
const monthlyPay = member.monthlyPayPlanned || 0;
|
||||
|
||||
// Calculate coverage percentages
|
||||
const minMonthlyPay = minHours * equalWage;
|
||||
const targetMonthlyPay = targetHours * equalWage;
|
||||
|
||||
const minPct = minMonthlyPay > 0 ? Math.min(100, (monthlyPay / minMonthlyPay) * 100) : 0;
|
||||
const targetPct = targetMonthlyPay > 0 ? Math.min(100, (monthlyPay / targetMonthlyPay) * 100) : 0;
|
||||
|
||||
return { minPct, targetPct };
|
||||
}
|
||||
|
||||
// Team-wide coverage statistics
|
||||
const teamStats = computed(() => teamCoverageStats(members.value));
|
||||
// Team-wide coverage statistics - accepts equalWage parameter
|
||||
function getTeamStats(equalWage = 25) {
|
||||
const coverageValues = members.value.map(m => {
|
||||
const coverage = getMemberCoverage(m.id, equalWage);
|
||||
return coverage.minPct;
|
||||
}).filter(v => v !== undefined);
|
||||
|
||||
if (coverageValues.length === 0) {
|
||||
return { under100: 0, median: 0 };
|
||||
}
|
||||
|
||||
const sorted = [...coverageValues].sort((a, b) => a - b);
|
||||
const median = sorted[Math.floor(sorted.length / 2)];
|
||||
const under100 = coverageValues.filter(v => v < 100).length;
|
||||
|
||||
return { under100, median };
|
||||
}
|
||||
|
||||
// Computed team stats (using default wage)
|
||||
const teamStats = computed(() => getTeamStats());
|
||||
|
||||
// Pay policy configuration
|
||||
const payPolicy = ref({
|
||||
|
|
@ -258,6 +297,8 @@ export const useMembersStore = defineStore(
|
|||
setMonthlyNeeds,
|
||||
setPlannedPay,
|
||||
getMemberCoverage,
|
||||
getTeamStats,
|
||||
recalculateMemberPay,
|
||||
// Legacy actions
|
||||
addMember,
|
||||
updateMember,
|
||||
|
|
|
|||
9
types/cash.ts
Normal file
9
types/cash.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface OneOffEvent {
|
||||
id: string
|
||||
name: string
|
||||
amount: number
|
||||
month: number
|
||||
type: 'income' | 'expense'
|
||||
category: string
|
||||
dateExpected: string
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue