Compare commits
2 commits
983aeca2dc
...
864a81065c
| Author | SHA1 | Date | |
|---|---|---|---|
| 864a81065c | |||
| 09d8794d72 |
53 changed files with 5216 additions and 4791 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
5
app.vue
5
app.vue
|
|
@ -91,10 +91,7 @@ const isCoopBuilderSection = computed(
|
||||||
route.path === "/dashboard" ||
|
route.path === "/dashboard" ||
|
||||||
route.path === "/mix" ||
|
route.path === "/mix" ||
|
||||||
route.path === "/budget" ||
|
route.path === "/budget" ||
|
||||||
route.path === "/runway-lite" ||
|
route.path === "/cash-flow" ||
|
||||||
route.path === "/scenarios" ||
|
|
||||||
route.path === "/cash" ||
|
|
||||||
route.path === "/session" ||
|
|
||||||
route.path === "/settings" ||
|
route.path === "/settings" ||
|
||||||
route.path === "/glossary"
|
route.path === "/glossary"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,18 @@
|
||||||
<!-- Annual Budget Overview -->
|
<!-- Annual Budget Overview -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-2xl font-bold">Annual Budget Overview</h2>
|
<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">
|
<table class="w-full border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b-2 border-black bg-gray-100">
|
<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-1 border-black px-4 py-3 text-left font-bold">
|
||||||
<th class="border-r border-gray-400 px-4 py-3 text-right font-bold">Planned</th>
|
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>
|
<th class="px-4 py-3 text-right font-bold">%</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -18,24 +23,25 @@
|
||||||
<tr class="bg-black text-white">
|
<tr class="bg-black text-white">
|
||||||
<td class="px-4 py-2 font-bold" colspan="3">REVENUE</td>
|
<td class="px-4 py-2 font-bold" colspan="3">REVENUE</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Revenue Categories -->
|
<!-- Revenue Categories -->
|
||||||
<tr v-for="(category, index) in revenueCategories"
|
<tr
|
||||||
:key="`rev-${index}`"
|
v-for="(category, index) in revenueCategories"
|
||||||
class="border-t border-gray-200"
|
:key="`rev-${index}`"
|
||||||
v-show="category.planned > 0">
|
class="border-t border-gray-200"
|
||||||
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
|
v-show="category.planned > 0">
|
||||||
|
<td class="border-r-1 border-black px-4 py-2">
|
||||||
|
{{ category.name }}
|
||||||
|
</td>
|
||||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||||
{{ formatCurrency(category.planned) }}
|
{{ formatCurrency(category.planned) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-right">
|
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
|
||||||
{{ category.percentage }}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Total Revenue -->
|
<!-- Total Revenue -->
|
||||||
<tr class="border-t-2 border-black font-semibold bg-gray-50">
|
<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">
|
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||||
{{ formatCurrency(totalRevenuePlanned) }}
|
{{ formatCurrency(totalRevenuePlanned) }}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -47,14 +53,15 @@
|
||||||
<td colspan="3" class="border-t border-gray-300 px-4 py-3">
|
<td colspan="3" class="border-t border-gray-300 px-4 py-3">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
|
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
|
||||||
<p class="text-gray-600 mb-2" v-if="suggestedCategories.length > 0">
|
<p
|
||||||
Consider developing: {{ suggestedCategories.join(', ') }}
|
class="text-gray-600 mb-2"
|
||||||
|
v-if="suggestedCategories.length > 0">
|
||||||
|
Consider developing: {{ suggestedCategories.join(", ") }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/help#revenue-diversification"
|
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 →
|
Learn how to develop these revenue streams →
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -62,33 +69,29 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Spacer -->
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="h-2"></td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Expenses Section -->
|
<!-- Expenses Section -->
|
||||||
<tr class="bg-black text-white">
|
<tr class="bg-black text-white">
|
||||||
<td class="px-4 py-2 font-bold" colspan="3">EXPENSES</td>
|
<td class="px-4 py-2 font-bold" colspan="3">EXPENSES</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Expense Categories -->
|
<!-- Expense Categories -->
|
||||||
<tr v-for="(category, index) in expenseCategories"
|
<tr
|
||||||
:key="`exp-${index}`"
|
v-for="(category, index) in expenseCategories"
|
||||||
class="border-t border-gray-200"
|
:key="`exp-${index}`"
|
||||||
v-show="category.planned > 0">
|
class="border-t border-gray-200"
|
||||||
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
|
v-show="category.planned > 0">
|
||||||
|
<td class="border-r-1 border-black px-4 py-2">
|
||||||
|
{{ category.name }}
|
||||||
|
</td>
|
||||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||||
{{ formatCurrency(category.planned) }}
|
{{ formatCurrency(category.planned) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-right">
|
<td class="px-4 py-2 text-right">{{ category.percentage }}%</td>
|
||||||
{{ category.percentage }}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Total Expenses -->
|
<!-- Total Expenses -->
|
||||||
<tr class="border-t-2 border-black font-semibold bg-gray-50">
|
<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">
|
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
||||||
{{ formatCurrency(totalExpensesPlanned) }}
|
{{ formatCurrency(totalExpensesPlanned) }}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -96,8 +99,10 @@
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Net Total -->
|
<!-- Net Total -->
|
||||||
<tr class="border-t-2 border-black font-bold text-lg" :class="netTotalClass">
|
<tr
|
||||||
<td class="border-r-2 border-black px-4 py-3">NET TOTAL</td>
|
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">
|
<td class="border-r border-gray-400 px-4 py-3 text-right">
|
||||||
{{ formatCurrency(netTotal) }}
|
{{ formatCurrency(netTotal) }}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -107,7 +112,6 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -118,7 +122,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
year: () => new Date().getFullYear()
|
year: () => new Date().getFullYear(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get budget data from store
|
// Get budget data from store
|
||||||
|
|
@ -127,19 +131,54 @@ const budgetStore = useBudgetStore();
|
||||||
// Revenue categories with calculations
|
// Revenue categories with calculations
|
||||||
const revenueCategories = computed(() => {
|
const revenueCategories = computed(() => {
|
||||||
const categories = [
|
const categories = [
|
||||||
{ key: 'gamesProducts', name: 'Games & Products', planned: 0, percentage: 0 },
|
{
|
||||||
{ key: 'servicesContracts', name: 'Services & Contracts', planned: 0, percentage: 0 },
|
key: "gamesProducts",
|
||||||
{ key: 'grantsFunding', name: 'Grants & Funding', planned: 0, percentage: 0 },
|
name: "Games & Products",
|
||||||
{ key: 'communitySupport', name: 'Community Support', planned: 0, percentage: 0 },
|
planned: 0,
|
||||||
{ key: 'partnerships', name: 'Partnerships', planned: 0, percentage: 0 },
|
percentage: 0,
|
||||||
{ key: 'investmentIncome', name: 'Investment Income', planned: 0, percentage: 0 },
|
},
|
||||||
{ key: 'inKindContributions', name: 'In-Kind Contributions', 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
|
// Calculate planned amounts for each category
|
||||||
budgetStore.budgetWorksheet.revenue.forEach(item => {
|
budgetStore.budgetWorksheet.revenue.forEach((item) => {
|
||||||
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
|
const annualPlanned = Object.values(item.monthlyValues || {}).reduce(
|
||||||
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
|
(sum, val) => sum + (val || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const categoryIndex = categories.findIndex(
|
||||||
|
(cat) => cat.name === item.mainCategory
|
||||||
|
);
|
||||||
if (categoryIndex !== -1) {
|
if (categoryIndex !== -1) {
|
||||||
categories[categoryIndex].planned += annualPlanned;
|
categories[categoryIndex].planned += annualPlanned;
|
||||||
}
|
}
|
||||||
|
|
@ -147,30 +186,34 @@ const revenueCategories = computed(() => {
|
||||||
|
|
||||||
// Calculate percentages
|
// Calculate percentages
|
||||||
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
|
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;
|
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return categories;
|
return categories;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Expense categories with calculations
|
// Expense categories with calculations
|
||||||
const expenseCategories = computed(() => {
|
const expenseCategories = computed(() => {
|
||||||
const categories = [
|
const categories = [
|
||||||
{ name: 'Salaries & Benefits', planned: 0, percentage: 0 },
|
{ name: "Salaries & Benefits", planned: 0, percentage: 0 },
|
||||||
{ name: 'Development Costs', planned: 0, percentage: 0 },
|
{ name: "Development Costs", planned: 0, percentage: 0 },
|
||||||
{ name: 'Equipment & Technology', planned: 0, percentage: 0 },
|
{ name: "Equipment & Technology", planned: 0, percentage: 0 },
|
||||||
{ name: 'Marketing & Outreach', planned: 0, percentage: 0 },
|
{ name: "Marketing & Outreach", planned: 0, percentage: 0 },
|
||||||
{ name: 'Office & Operations', planned: 0, percentage: 0 },
|
{ name: "Office & Operations", planned: 0, percentage: 0 },
|
||||||
{ name: 'Legal & Professional', planned: 0, percentage: 0 },
|
{ name: "Legal & Professional", planned: 0, percentage: 0 },
|
||||||
{ name: 'Other Expenses', planned: 0, percentage: 0 },
|
{ name: "Other Expenses", planned: 0, percentage: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Calculate planned amounts for each category
|
// Calculate planned amounts for each category
|
||||||
budgetStore.budgetWorksheet.expenses.forEach(item => {
|
budgetStore.budgetWorksheet.expenses.forEach((item) => {
|
||||||
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
|
const annualPlanned = Object.values(item.monthlyValues || {}).reduce(
|
||||||
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
|
(sum, val) => sum + (val || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const categoryIndex = categories.findIndex(
|
||||||
|
(cat) => cat.name === item.mainCategory
|
||||||
|
);
|
||||||
if (categoryIndex !== -1) {
|
if (categoryIndex !== -1) {
|
||||||
categories[categoryIndex].planned += annualPlanned;
|
categories[categoryIndex].planned += annualPlanned;
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +221,7 @@ const expenseCategories = computed(() => {
|
||||||
|
|
||||||
// Calculate percentages
|
// Calculate percentages
|
||||||
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
|
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;
|
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -186,33 +229,39 @@ const expenseCategories = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
const totalRevenuePlanned = computed(() =>
|
const totalRevenuePlanned = computed(() =>
|
||||||
revenueCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
|
revenueCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalExpensesPlanned = computed(() =>
|
const totalExpensesPlanned = computed(() =>
|
||||||
expenseCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
|
expenseCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
const netTotal = computed(() =>
|
const netTotal = computed(
|
||||||
totalRevenuePlanned.value - totalExpensesPlanned.value
|
() => totalRevenuePlanned.value - totalExpensesPlanned.value
|
||||||
);
|
);
|
||||||
|
|
||||||
const netTotalClass = computed(() => {
|
const netTotalClass = computed(() => {
|
||||||
if (netTotal.value > 0) return 'bg-green-50';
|
if (netTotal.value > 0) return "bg-green-50";
|
||||||
if (netTotal.value < 0) return 'bg-red-50';
|
if (netTotal.value < 0) return "bg-red-50";
|
||||||
return 'bg-gray-50';
|
return "bg-gray-50";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Diversification guidance
|
// Diversification guidance
|
||||||
const diversificationGuidance = computed(() => {
|
const diversificationGuidance = computed(() => {
|
||||||
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
|
const categoriesWithRevenue = revenueCategories.value.filter(
|
||||||
const topCategory = categoriesWithRevenue.reduce((max, cat) => cat.percentage > max.percentage ? cat : max, { percentage: 0, name: '' });
|
(cat) => cat.percentage > 0
|
||||||
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
|
);
|
||||||
|
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 = "";
|
let guidance = "";
|
||||||
|
|
||||||
// Concentration Risk
|
// Concentration Risk
|
||||||
if (topCategory.percentage >= 70) {
|
if (topCategory.percentage >= 70) {
|
||||||
guidance += `Very high concentration risk: most of your revenue is from ${topCategory.name} (${topCategory.percentage}%). `;
|
guidance += `Very high concentration risk: most of your revenue is from ${topCategory.name} (${topCategory.percentage}%). `;
|
||||||
|
|
@ -221,107 +270,141 @@ const diversificationGuidance = computed(() => {
|
||||||
} else {
|
} else {
|
||||||
guidance += "No single category dominates your revenue. ";
|
guidance += "No single category dominates your revenue. ";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diversification Benchmark
|
// Diversification Benchmark
|
||||||
if (categoriesAbove20 >= 3) {
|
if (categoriesAbove20 >= 3) {
|
||||||
guidance += "Your mix is reasonably balanced across multiple sources.";
|
guidance += "Your mix is reasonably balanced across multiple sources.";
|
||||||
} else if (categoriesAbove20 === 2) {
|
} else if (categoriesAbove20 === 2) {
|
||||||
guidance += "Your mix is split, but still reliant on just two sources.";
|
guidance += "Your mix is split, but still reliant on just two sources.";
|
||||||
} else {
|
} 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
|
// Optional Positive Nudges
|
||||||
const grantsCategory = categoriesWithRevenue.find(cat => cat.name === 'Grants & Funding');
|
const grantsCategory = categoriesWithRevenue.find(
|
||||||
const servicesCategory = categoriesWithRevenue.find(cat => cat.name === 'Services & Contracts');
|
(cat) => cat.name === "Grants & Funding"
|
||||||
const productsCategory = categoriesWithRevenue.find(cat => cat.name === 'Games & Products');
|
);
|
||||||
|
const servicesCategory = categoriesWithRevenue.find(
|
||||||
|
(cat) => cat.name === "Services & Contracts"
|
||||||
|
);
|
||||||
|
const productsCategory = categoriesWithRevenue.find(
|
||||||
|
(cat) => cat.name === "Games & Products"
|
||||||
|
);
|
||||||
|
|
||||||
if (grantsCategory && grantsCategory.percentage >= 20) {
|
if (grantsCategory && grantsCategory.percentage >= 20) {
|
||||||
guidance += " You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
|
guidance +=
|
||||||
} else if (servicesCategory && servicesCategory.percentage >= 20 && productsCategory && productsCategory.percentage >= 20) {
|
" You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
|
||||||
guidance += " Strong foundation in both services and products — this balance helps smooth cash flow.";
|
} 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;
|
return guidance;
|
||||||
});
|
});
|
||||||
|
|
||||||
const guidanceBackgroundClass = computed(() => {
|
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) {
|
if (topCategory.percentage >= 70) {
|
||||||
return 'bg-red-50';
|
return "bg-red-50";
|
||||||
} else if (topCategory.percentage >= 50) {
|
} else if (topCategory.percentage >= 50) {
|
||||||
return 'bg-red-50';
|
return "bg-red-50";
|
||||||
} else {
|
} else {
|
||||||
const categoriesAbove20 = revenueCategories.value.filter(cat => cat.percentage >= 20).length;
|
const categoriesAbove20 = revenueCategories.value.filter(
|
||||||
|
(cat) => cat.percentage >= 20
|
||||||
|
).length;
|
||||||
if (categoriesAbove20 >= 3) {
|
if (categoriesAbove20 >= 3) {
|
||||||
return 'bg-green-50';
|
return "bg-green-50";
|
||||||
} else {
|
} else {
|
||||||
return 'bg-yellow-50';
|
return "bg-yellow-50";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Suggested categories to develop
|
// Suggested categories to develop
|
||||||
const suggestedCategories = computed(() => {
|
const suggestedCategories = computed(() => {
|
||||||
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
|
const categoriesWithRevenue = revenueCategories.value.filter(
|
||||||
const categoriesWithoutRevenue = revenueCategories.value.filter(cat => cat.percentage === 0);
|
(cat) => cat.percentage > 0
|
||||||
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
|
);
|
||||||
|
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 we have fewer than 3 categories above 20%, suggest developing others
|
||||||
if (categoriesAbove20 < 3) {
|
if (categoriesAbove20 < 3) {
|
||||||
// Prioritize categories that complement existing strengths
|
// Prioritize categories that complement existing strengths
|
||||||
const suggestions = [];
|
const suggestions = [];
|
||||||
|
|
||||||
// If they have services, suggest products for balance
|
// If they have services, suggest products for balance
|
||||||
if (categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts') &&
|
if (
|
||||||
!categoriesWithRevenue.some(cat => cat.name === 'Games & Products')) {
|
categoriesWithRevenue.some(
|
||||||
suggestions.push('Games & Products');
|
(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 they have products, suggest services for stability
|
||||||
if (categoriesWithRevenue.some(cat => cat.name === 'Games & Products') &&
|
if (
|
||||||
!categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts')) {
|
categoriesWithRevenue.some((cat) => cat.name === "Games & Products") &&
|
||||||
suggestions.push('Services & Contracts');
|
!categoriesWithRevenue.some((cat) => cat.name === "Services & Contracts")
|
||||||
|
) {
|
||||||
|
suggestions.push("Services & Contracts");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always suggest grants if not present
|
// Always suggest grants if not present
|
||||||
if (!categoriesWithRevenue.some(cat => cat.name === 'Grants & Funding')) {
|
if (!categoriesWithRevenue.some((cat) => cat.name === "Grants & Funding")) {
|
||||||
suggestions.push('Grants & Funding');
|
suggestions.push("Grants & Funding");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add community support for stability
|
// Add community support for stability
|
||||||
if (!categoriesWithRevenue.some(cat => cat.name === 'Community Support')) {
|
if (
|
||||||
suggestions.push('Community Support');
|
!categoriesWithRevenue.some((cat) => cat.name === "Community Support")
|
||||||
|
) {
|
||||||
|
suggestions.push("Community Support");
|
||||||
}
|
}
|
||||||
|
|
||||||
return suggestions.slice(0, 3); // Limit to 3 suggestions
|
return suggestions.slice(0, 3); // Limit to 3 suggestions
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
function formatCurrency(amount: number): string {
|
function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: 'currency',
|
style: "currency",
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(amount || 0);
|
}).format(amount || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPercentageClass(percentage: number): string {
|
function getPercentageClass(percentage: number): string {
|
||||||
if (percentage > 50) return 'text-red-600 font-bold';
|
if (percentage > 50) return "text-red-600 font-bold";
|
||||||
if (percentage > 35) return 'text-yellow-600 font-semibold';
|
if (percentage > 35) return "text-yellow-600 font-semibold";
|
||||||
if (percentage > 20) return 'text-black font-medium';
|
if (percentage > 20) return "text-black font-medium";
|
||||||
return 'text-gray-500';
|
return "text-gray-500";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
onMounted(() => {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -336,4 +419,4 @@ input[type="number"]::-webkit-outer-spin-button {
|
||||||
input[type="number"] {
|
input[type="number"] {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
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,45 +26,25 @@
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const coopBuilderItems = [
|
const coopBuilderItems = [
|
||||||
{
|
|
||||||
id: "dashboard",
|
|
||||||
name: "Dashboard",
|
|
||||||
path: "/dashboard",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "coop-builder",
|
id: "coop-builder",
|
||||||
name: "Setup Wizard",
|
name: "Setup Wizard",
|
||||||
path: "/coop-builder",
|
path: "/coop-builder",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
name: "Compensation",
|
||||||
|
path: "/dashboard",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "budget",
|
id: "budget",
|
||||||
name: "Budget",
|
name: "Budget",
|
||||||
path: "/budget",
|
path: "/budget",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mix",
|
id: "cash-flow",
|
||||||
name: "Revenue Mix",
|
|
||||||
path: "/mix",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "runway-lite",
|
|
||||||
name: "Runway Lite",
|
|
||||||
path: "/runway-lite",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "scenarios",
|
|
||||||
name: "Scenarios",
|
|
||||||
path: "/scenarios",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cash",
|
|
||||||
name: "Cash Flow",
|
name: "Cash Flow",
|
||||||
path: "/cash",
|
path: "/cash-flow",
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "session",
|
|
||||||
name: "Value Session",
|
|
||||||
path: "/session",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<UTooltip :text="tooltipText">
|
<UTooltip :text="tooltipText">
|
||||||
<UBadge
|
<UBadge :color="badgeColor" variant="solid" size="sm" class="font-medium">
|
||||||
:color="badgeColor"
|
|
||||||
variant="solid"
|
|
||||||
size="sm"
|
|
||||||
class="font-medium"
|
|
||||||
>
|
|
||||||
<UIcon :name="iconName" class="w-3 h-3 mr-1" />
|
<UIcon :name="iconName" class="w-3 h-3 mr-1" />
|
||||||
{{ displayText }}
|
{{ displayText }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
|
|
@ -14,44 +9,46 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
coverageMinPct?: number
|
coveragePct?: number;
|
||||||
coverageTargetPct?: number
|
memberName?: string;
|
||||||
memberName?: string
|
warnIfUnder?: number;
|
||||||
warnIfUnder?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
warnIfUnder: 100,
|
warnIfUnder: 100,
|
||||||
memberName: 'member'
|
memberName: "member",
|
||||||
})
|
});
|
||||||
|
|
||||||
const coverage = computed(() => props.coverageMinPct || 0)
|
const coverage = computed(() => props.coveragePct || 0);
|
||||||
|
|
||||||
const badgeColor = computed(() => {
|
const badgeColor = computed(() => {
|
||||||
if (coverage.value >= 100) return 'success'
|
if (!props.coveragePct) return "neutral";
|
||||||
if (coverage.value >= 80) return 'warning'
|
if (coverage.value >= 100) return "success";
|
||||||
return 'error'
|
if (coverage.value >= 80) return "warning";
|
||||||
})
|
return "error";
|
||||||
|
});
|
||||||
|
|
||||||
const iconName = computed(() => {
|
const iconName = computed(() => {
|
||||||
if (coverage.value >= 100) return 'i-heroicons-check-circle'
|
if (!props.coveragePct) return "i-heroicons-cog-6-tooth";
|
||||||
if (coverage.value >= 80) return 'i-heroicons-exclamation-triangle'
|
if (coverage.value >= 100) return "i-heroicons-check-circle";
|
||||||
return 'i-heroicons-x-circle'
|
if (coverage.value >= 80) return "i-heroicons-exclamation-triangle";
|
||||||
})
|
return "i-heroicons-x-circle";
|
||||||
|
});
|
||||||
|
|
||||||
const displayText = computed(() => {
|
const displayText = computed(() => {
|
||||||
if (!props.coverageMinPct) return 'No needs set'
|
if (!props.coveragePct) return "Set needs";
|
||||||
return `${Math.round(coverage.value)}% coverage`
|
if (coverage.value === 0) return "No coverage";
|
||||||
})
|
return `${Math.round(coverage.value)}% covered`;
|
||||||
|
});
|
||||||
|
|
||||||
const tooltipText = computed(() => {
|
const tooltipText = computed(() => {
|
||||||
if (!props.coverageMinPct) {
|
if (!props.coveragePct) {
|
||||||
return `${props.memberName} hasn't set their minimum needs yet`
|
return `Click 'Set minimum needs' to enable coverage tracking for ${props.memberName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const percent = Math.round(coverage.value)
|
const percent = Math.round(coverage.value);
|
||||||
const status = coverage.value >= 100 ? 'meets' : 'covers'
|
const status = coverage.value >= 100 ? "meets" : "covers";
|
||||||
|
|
||||||
return `${status} ${percent}% of ${props.memberName}'s minimum needs (incl. external income)`
|
return `Co-op pay ${status} ${percent}% of ${props.memberName}'s minimum needs`;
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -48,20 +48,43 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
const membersStore = useMembersStore()
|
// Use coopBuilder store which has the actual data
|
||||||
const { members, teamStats } = storeToRefs(membersStore)
|
const coopStore = useCoopBuilderStore()
|
||||||
|
const { members, equalHourlyWage } = storeToRefs(coopStore)
|
||||||
|
|
||||||
const membersWithCoverage = computed(() => {
|
const membersWithCoverage = computed(() => {
|
||||||
return members.value.map(member => {
|
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 {
|
return {
|
||||||
...member,
|
...member,
|
||||||
coverageMinPct: coverage.minPct,
|
displayName: member.name,
|
||||||
coverageTargetPct: coverage.targetPct
|
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) {
|
function getBarColor(coverage: number | undefined) {
|
||||||
const pct = coverage || 0
|
const pct = coverage || 0
|
||||||
if (pct >= 100) return 'bg-green-500'
|
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>
|
||||||
278
components/PayrollOncostModal.vue
Normal file
278
components/PayrollOncostModal.vue
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
v-model:open="isOpen"
|
||||||
|
title="Payroll Oncost Settings"
|
||||||
|
description="Configure payroll taxes and benefits percentage"
|
||||||
|
:dismissible="true"
|
||||||
|
>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Explanation -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<UIcon name="i-heroicons-information-circle" class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" />
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="text-blue-800 dark:text-blue-200 font-medium mb-2">What are payroll oncosts?</p>
|
||||||
|
<p class="text-blue-700 dark:text-blue-300">
|
||||||
|
Payroll oncosts cover taxes, benefits, and other employee-related expenses beyond base wages.
|
||||||
|
This typically includes employer payroll taxes, worker's compensation, benefits contributions, and other statutory requirements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Settings Display -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||||
|
<h4 class="font-medium text-gray-900 dark:text-white mb-3">Current Impact</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-600 dark:text-gray-400">Base Payroll</div>
|
||||||
|
<div class="font-medium">{{ formatCurrency(basePayroll) }}/month</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-600 dark:text-gray-400">Oncosts ({{ currentOncostPct }}%)</div>
|
||||||
|
<div class="font-medium">{{ formatCurrency(currentOncostAmount) }}/month</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="text-gray-600 dark:text-gray-400 text-sm">Total Payroll Cost</div>
|
||||||
|
<div class="font-semibold text-lg">{{ formatCurrency(totalPayrollCost) }}/month</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Percentage Input -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Oncost Percentage
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<UInput
|
||||||
|
v-model.number="newOncostPct"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
placeholder="25"
|
||||||
|
class="text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-500">%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slider for easier adjustment -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<input
|
||||||
|
v-model.number="newOncostPct"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
step="1"
|
||||||
|
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 slider"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>25%</span>
|
||||||
|
<span>50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview of New Settings -->
|
||||||
|
<div v-if="newOncostPct !== currentOncostPct" class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
|
||||||
|
<h4 class="font-medium text-green-800 dark:text-green-200 mb-3">Preview Changes</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-green-700 dark:text-green-300">New Oncosts ({{ newOncostPct }}%)</div>
|
||||||
|
<div class="font-medium">{{ formatCurrency(newOncostAmount) }}/month</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-green-700 dark:text-green-300">New Total Cost</div>
|
||||||
|
<div class="font-medium">{{ formatCurrency(newTotalCost) }}/month</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs">
|
||||||
|
<span class="text-green-700 dark:text-green-300">
|
||||||
|
{{ newTotalCost > totalPayrollCost ? 'Increase' : 'Decrease' }} of
|
||||||
|
{{ formatCurrency(Math.abs(newTotalCost - totalPayrollCost)) }}/month
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Common Oncost Ranges -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Common Ranges
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
v-for="preset in commonRanges"
|
||||||
|
:key="preset.value"
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
variant="outline"
|
||||||
|
@click="newOncostPct = preset.value"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ close }">
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<UButton color="gray" variant="ghost" @click="handleCancel">
|
||||||
|
Cancel
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
@click="handleSave"
|
||||||
|
:disabled="!isValidPercentage"
|
||||||
|
>
|
||||||
|
Update Oncost Percentage
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { allocatePayroll as allocatePayrollImpl } from '~/types/members'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
(e: 'save', percentage: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.open,
|
||||||
|
set: (value) => emit('update:open', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get current payroll data
|
||||||
|
const coopStore = useCoopBuilderStore()
|
||||||
|
const currentOncostPct = computed(() => coopStore.payrollOncostPct || 0)
|
||||||
|
|
||||||
|
// Calculate current payroll values using the same logic as the budget store
|
||||||
|
const { allocatePayroll } = useCoopBuilder()
|
||||||
|
|
||||||
|
const basePayroll = computed(() => {
|
||||||
|
// Calculate base payroll the same way the budget store does
|
||||||
|
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0)), 0)
|
||||||
|
const hourlyWage = coopStore.equalHourlyWage || 0
|
||||||
|
const basePayrollBudget = totalHours * hourlyWage
|
||||||
|
|
||||||
|
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
||||||
|
// Use policy-driven allocation to get actual member pay amounts
|
||||||
|
const payPolicy = {
|
||||||
|
relationship: coopStore.policy.relationship,
|
||||||
|
roleBands: coopStore.policy.roleBands
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert members to the format expected by allocatePayroll
|
||||||
|
const membersForAllocation = coopStore.members.map(m => ({
|
||||||
|
...m,
|
||||||
|
displayName: m.name,
|
||||||
|
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
||||||
|
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||||
|
hoursPerMonth: m.hoursPerMonth || (m.hoursPerWeek ? m.hoursPerWeek * 4.33 : 0)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Use the imported allocatePayroll function
|
||||||
|
const allocatedMembers = allocatePayrollImpl(membersForAllocation, payPolicy, basePayrollBudget)
|
||||||
|
|
||||||
|
// Sum the allocated amounts for total payroll
|
||||||
|
return allocatedMembers.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentOncostAmount = computed(() =>
|
||||||
|
basePayroll.value * (currentOncostPct.value / 100)
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalPayrollCost = computed(() =>
|
||||||
|
basePayroll.value + currentOncostAmount.value
|
||||||
|
)
|
||||||
|
|
||||||
|
// New percentage input
|
||||||
|
const newOncostPct = ref(currentOncostPct.value)
|
||||||
|
|
||||||
|
// Computed values for preview
|
||||||
|
const newOncostAmount = computed(() => basePayroll.value * (newOncostPct.value / 100))
|
||||||
|
const newTotalCost = computed(() => basePayroll.value + newOncostAmount.value)
|
||||||
|
|
||||||
|
const isValidPercentage = computed(() =>
|
||||||
|
newOncostPct.value >= 0 && newOncostPct.value <= 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common oncost ranges
|
||||||
|
const commonRanges = [
|
||||||
|
{ label: '0% (No oncosts)', value: 0 },
|
||||||
|
{ label: '15% (Basic)', value: 15 },
|
||||||
|
{ label: '25% (Standard)', value: 25 },
|
||||||
|
{ label: '35% (Comprehensive)', value: 35 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Reset to current value when modal opens
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
newOncostPct.value = currentOncostPct.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
function handleCancel() {
|
||||||
|
newOncostPct.value = currentOncostPct.value
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (isValidPercentage.value) {
|
||||||
|
emit('save', newOncostPct.value)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency formatting
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3b82f6;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3b82f6;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -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,42 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
<!-- Section Header with Export Controls -->
|
<!-- Section Header -->
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="mb-8">
|
||||||
<div>
|
<h3 class="text-2xl font-black text-black mb-2">
|
||||||
<h3 class="text-2xl font-black text-black mb-2">
|
Where does your money go?
|
||||||
Where does your money go?
|
</h3>
|
||||||
</h3>
|
<p class="text-neutral-600">
|
||||||
<p class="text-neutral-600">
|
Add costs like rent + utilities, software licenses, insurance, lawyer
|
||||||
Add costs like rent, tools, insurance, or other recurring expenses.
|
fees, accountant fees, and other recurring expenses.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UButton variant="outline" color="gray" size="sm" @click="exportCosts">
|
|
||||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
|
||||||
Export
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Operating Mode Toggle -->
|
|
||||||
<div class="p-4 border-3 border-black rounded-xl bg-white shadow-md">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-bold text-sm">Operating Mode</h4>
|
|
||||||
<p class="text-xs text-gray-600 mt-1">
|
|
||||||
Choose between minimum needs or target pay for payroll calculations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<UToggle
|
|
||||||
v-model="useTargetMode"
|
|
||||||
@update:model-value="updateOperatingMode"
|
|
||||||
:ui="{ active: 'bg-success-500' }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-xs font-medium">
|
|
||||||
{{ useTargetMode ? '🎯 Target Mode' : '⚡ Minimum Mode' }}:
|
|
||||||
{{ useTargetMode ? 'Uses target pay allocations' : 'Uses minimum needs allocations' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overhead Costs -->
|
<!-- Overhead Costs -->
|
||||||
|
|
@ -44,24 +16,12 @@
|
||||||
<div
|
<div
|
||||||
v-if="overheadCosts.length > 0"
|
v-if="overheadCosts.length > 0"
|
||||||
class="flex items-center justify-between">
|
class="flex items-center justify-between">
|
||||||
<h4 class="text-lg font-bold text-black">Monthly Overhead</h4>
|
<h4 class="text-lg font-bold text-black">Overhead</h4>
|
||||||
<UButton
|
|
||||||
size="sm"
|
|
||||||
@click="addOverheadCost"
|
|
||||||
variant="solid"
|
|
||||||
color="success"
|
|
||||||
:ui="{
|
|
||||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
|
||||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
|
||||||
}">
|
|
||||||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
|
||||||
Add Cost
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="overheadCosts.length === 0"
|
v-if="overheadCosts.length === 0"
|
||||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
||||||
<h4 class="font-medium text-neutral-900 mb-2">No overhead costs yet</h4>
|
<h4 class="font-medium text-neutral-900 mb-2">No overhead costs yet</h4>
|
||||||
<p class="text-sm text-neutral-500 mb-4">
|
<p class="text-sm text-neutral-500 mb-4">
|
||||||
Get started by adding your first overhead cost.
|
Get started by adding your first overhead cost.
|
||||||
|
|
@ -79,48 +39,23 @@
|
||||||
<div
|
<div
|
||||||
v-for="cost in overheadCosts"
|
v-for="cost in overheadCosts"
|
||||||
:key="cost.id"
|
:key="cost.id"
|
||||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<!-- Header row with name and delete button -->
|
||||||
<UFormField label="Cost Name" required>
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-4 flex-1">
|
||||||
<UInput
|
<UInput
|
||||||
v-model="cost.name"
|
v-model="cost.name"
|
||||||
placeholder="Office rent"
|
placeholder="Office rent"
|
||||||
size="xl"
|
size="xl"
|
||||||
class="text-lg font-medium w-full"
|
class="text-xl w-full font-bold flex-1"
|
||||||
@update:model-value="saveCost(cost)"
|
@update:model-value="saveCost(cost)"
|
||||||
@blur="saveCost(cost)" />
|
@blur="saveCost(cost)" />
|
||||||
</UFormField>
|
</div>
|
||||||
|
|
||||||
<UFormField label="Monthly Amount" required>
|
|
||||||
<UInput
|
|
||||||
v-model="cost.amount"
|
|
||||||
type="text"
|
|
||||||
placeholder="800.00"
|
|
||||||
size="xl"
|
|
||||||
class="text-lg font-bold w-full"
|
|
||||||
@update:model-value="validateAndSaveAmount($event, cost)"
|
|
||||||
@blur="saveCost(cost)">
|
|
||||||
<template #leading>
|
|
||||||
<span class="text-neutral-500">€</span>
|
|
||||||
</template>
|
|
||||||
</UInput>
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UFormField label="Category">
|
|
||||||
<USelect
|
|
||||||
v-model="cost.category"
|
|
||||||
:items="categoryOptions"
|
|
||||||
size="xl"
|
|
||||||
class="text-lg font-medium w-full"
|
|
||||||
@update:model-value="saveCost(cost)" />
|
|
||||||
</UFormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
|
|
||||||
<UButton
|
<UButton
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="error"
|
color="error"
|
||||||
|
class="ml-4"
|
||||||
@click="removeCost(cost.id)"
|
@click="removeCost(cost.id)"
|
||||||
:ui="{
|
:ui="{
|
||||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||||
|
|
@ -128,6 +63,66 @@
|
||||||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fields grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<UFormField label="Category">
|
||||||
|
<USelect
|
||||||
|
v-model="cost.category"
|
||||||
|
:items="categoryOptions"
|
||||||
|
size="md"
|
||||||
|
class="text-sm font-medium w-full"
|
||||||
|
@update:model-value="saveCost(cost)" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
:label="
|
||||||
|
cost.amountType === 'annual' ? 'Annual Amount' : 'Monthly Amount'
|
||||||
|
"
|
||||||
|
required>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UInput
|
||||||
|
:value="
|
||||||
|
cost.amountType === 'annual' ? cost.annualAmount : cost.amount
|
||||||
|
"
|
||||||
|
type="text"
|
||||||
|
:placeholder="cost.amountType === 'annual' ? '9600' : '800'"
|
||||||
|
size="md"
|
||||||
|
class="text-sm font-medium w-full"
|
||||||
|
@update:model-value="validateAndSaveAmount($event, cost)"
|
||||||
|
@blur="saveCost(cost)">
|
||||||
|
<template #leading>
|
||||||
|
<span class="text-neutral-500">{{ currencySymbol }}</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
<UButtonGroup size="md">
|
||||||
|
<UButton
|
||||||
|
:variant="cost.amountType === 'monthly' ? 'solid' : 'outline'"
|
||||||
|
color="primary"
|
||||||
|
@click="switchAmountType(cost, 'monthly')"
|
||||||
|
class="text-xs">
|
||||||
|
Monthly
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
:variant="cost.amountType === 'annual' ? 'solid' : 'outline'"
|
||||||
|
color="primary"
|
||||||
|
@click="switchAmountType(cost, 'annual')"
|
||||||
|
class="text-xs">
|
||||||
|
Annual
|
||||||
|
</UButton>
|
||||||
|
</UButtonGroup>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-neutral-500 mt-1">
|
||||||
|
<template v-if="cost.amountType === 'annual'">
|
||||||
|
{{ currencySymbol
|
||||||
|
}}{{ Math.round((cost.annualAmount || 0) / 12) }} per month
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ currencySymbol }}{{ (cost.amount || 0) * 12 }} per year
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Cost Button (when items exist) -->
|
<!-- Add Cost Button (when items exist) -->
|
||||||
|
|
@ -155,20 +150,27 @@ const emit = defineEmits<{
|
||||||
"save-status": [status: "saving" | "saved" | "error"];
|
"save-status": [status: "saving" | "saved" | "error"];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Store
|
// Store and Currency
|
||||||
const coop = useCoopBuilder();
|
const coop = useCoopBuilder();
|
||||||
|
const { currencySymbol } = useCurrency();
|
||||||
|
|
||||||
// Get the store directly for overhead costs
|
// Get the store directly for overhead costs
|
||||||
const store = useCoopBuilderStore();
|
const store = useCoopBuilderStore();
|
||||||
|
|
||||||
// Computed for overhead costs (from store)
|
// Computed for overhead costs (from store) with amountType defaults
|
||||||
const overheadCosts = computed(() => store.overheadCosts || []);
|
const overheadCosts = computed(() =>
|
||||||
|
(store.overheadCosts || []).map((cost) => ({
|
||||||
|
...cost,
|
||||||
|
amountType: cost.amountType || "monthly",
|
||||||
|
annualAmount: cost.annualAmount || (cost.amount || 0) * 12,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Operating mode toggle
|
// Operating mode toggle
|
||||||
const useTargetMode = ref(coop.operatingMode.value === 'target');
|
const useTargetMode = ref(coop.operatingMode.value === "target");
|
||||||
|
|
||||||
function updateOperatingMode(value: boolean) {
|
function updateOperatingMode(value: boolean) {
|
||||||
coop.setOperatingMode(value ? 'target' : 'min');
|
coop.setOperatingMode(value ? "target" : "min");
|
||||||
emit("save-status", "saved");
|
emit("save-status", "saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,23 +213,66 @@ const debouncedSave = useDebounceFn((cost: any) => {
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
function saveCost(cost: any) {
|
function saveCost(cost: any) {
|
||||||
if (cost.name && cost.amount >= 0) {
|
const hasValidAmount =
|
||||||
|
cost.amountType === "annual" ? cost.annualAmount >= 0 : cost.amount >= 0;
|
||||||
|
|
||||||
|
if (cost.name && hasValidAmount) {
|
||||||
debouncedSave(cost);
|
debouncedSave(cost);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Immediate save without debounce for UI responsiveness
|
||||||
|
function saveCostImmediate(cost: any) {
|
||||||
|
try {
|
||||||
|
// Use store's upsert method directly
|
||||||
|
store.upsertOverheadCost(cost);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save cost:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validation function for amount
|
// Validation function for amount
|
||||||
function validateAndSaveAmount(value: string, cost: any) {
|
function validateAndSaveAmount(value: string, cost: any) {
|
||||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||||
cost.amount = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||||
|
|
||||||
|
if (cost.amountType === "annual") {
|
||||||
|
cost.annualAmount = validValue;
|
||||||
|
cost.amount = Math.round(validValue / 12);
|
||||||
|
} else {
|
||||||
|
cost.amount = validValue;
|
||||||
|
cost.annualAmount = validValue * 12;
|
||||||
|
}
|
||||||
|
|
||||||
saveCost(cost);
|
saveCost(cost);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to switch between annual and monthly
|
||||||
|
function switchAmountType(cost: any, type: "annual" | "monthly") {
|
||||||
|
cost.amountType = type;
|
||||||
|
|
||||||
|
// Recalculate values based on new type
|
||||||
|
if (type === "annual") {
|
||||||
|
if (!cost.annualAmount) {
|
||||||
|
cost.annualAmount = (cost.amount || 0) * 12;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!cost.amount) {
|
||||||
|
cost.amount = Math.round((cost.annualAmount || 0) / 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save immediately without debounce for instant UI update
|
||||||
|
saveCostImmediate(cost);
|
||||||
|
}
|
||||||
|
|
||||||
function addOverheadCost() {
|
function addOverheadCost() {
|
||||||
const newCost = {
|
const newCost = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
name: "",
|
name: "",
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
annualAmount: 0,
|
||||||
|
amountType: "monthly",
|
||||||
category: "Operations",
|
category: "Operations",
|
||||||
recurring: true,
|
recurring: true,
|
||||||
};
|
};
|
||||||
|
|
@ -240,24 +285,4 @@ function removeCost(id: string) {
|
||||||
store.removeOverheadCost(id);
|
store.removeOverheadCost(id);
|
||||||
emit("save-status", "saved");
|
emit("save-status", "saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCosts() {
|
|
||||||
const exportData = {
|
|
||||||
overheadCosts: overheadCosts.value,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
section: "costs",
|
|
||||||
};
|
|
||||||
|
|
||||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
|
||||||
type: "application/json",
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `coop-costs-${new Date().toISOString().split("T")[0]}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
<!-- Section Header with Export Controls -->
|
<!-- Section Header -->
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="mb-8">
|
||||||
<div>
|
<h3 class="text-2xl font-black text-black mb-2">Who's on your team?</h3>
|
||||||
<h3 class="text-2xl font-black text-black mb-2">Who's on your team?</h3>
|
<p class="text-neutral-600">
|
||||||
<p class="text-neutral-600">
|
Add everyone who'll be working in the co-op. Based on your pay approach,
|
||||||
Add everyone who'll be working in the co-op, even if they're not ready
|
we'll collect the right information for each person.
|
||||||
to be paid yet.
|
</p>
|
||||||
</p>
|
<!-- Debug info -->
|
||||||
</div>
|
<div class="mt-2 p-2 bg-gray-100 rounded text-xs">
|
||||||
<div class="flex items-center gap-3">
|
Debug: Policy = {{ currentPolicy }}, Needs field shown =
|
||||||
<UButton
|
{{ isNeedsWeighted }}
|
||||||
variant="outline"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
@click="exportMembers">
|
|
||||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
|
||||||
Export
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
v-if="members.length > 0"
|
|
||||||
@click="addMember"
|
|
||||||
size="sm"
|
|
||||||
variant="solid"
|
|
||||||
color="success"
|
|
||||||
:ui="{
|
|
||||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
|
||||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
|
||||||
}">
|
|
||||||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
|
||||||
Add member
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -38,7 +18,7 @@
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-if="members.length === 0"
|
v-if="members.length === 0"
|
||||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
||||||
<h4 class="font-medium text-neutral-900 mb-2">No team members yet</h4>
|
<h4 class="font-medium text-neutral-900 mb-2">No team members yet</h4>
|
||||||
<p class="text-sm text-neutral-500 mb-4">
|
<p class="text-sm text-neutral-500 mb-4">
|
||||||
Get started by adding your first team member.
|
Get started by adding your first team member.
|
||||||
|
|
@ -52,27 +32,23 @@
|
||||||
<div
|
<div
|
||||||
v-for="(member, index) in members"
|
v-for="(member, index) in members"
|
||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||||
<!-- Header row with name and coverage chip -->
|
<!-- Header row with name and optional coverage chip -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-4 flex-1">
|
||||||
<UInput
|
<UInput
|
||||||
v-model="member.displayName"
|
v-model="member.displayName"
|
||||||
placeholder="Member name"
|
placeholder="Member name"
|
||||||
size="lg"
|
size="xl"
|
||||||
class="text-lg font-bold w-48"
|
class="text-xl w-full font-bold flex-1"
|
||||||
@update:model-value="saveMember(member)"
|
@update:model-value="saveMember(member)"
|
||||||
@blur="saveMember(member)" />
|
@blur="saveMember(member)" />
|
||||||
<CoverageChip
|
|
||||||
:coverage-min-pct="memberCoverage(member).minPct"
|
|
||||||
:coverage-target-pct="memberCoverage(member).targetPct"
|
|
||||||
:member-name="member.displayName || 'This member'"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<UButton
|
<UButton
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="error"
|
color="error"
|
||||||
|
class="ml-4"
|
||||||
@click="removeMember(member.id)"
|
@click="removeMember(member.id)"
|
||||||
:ui="{
|
:ui="{
|
||||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||||
|
|
@ -80,77 +56,36 @@
|
||||||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compact grid for pay and hours -->
|
<!-- Essential fields based on policy -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<UFormField label="Pay relationship" required>
|
<UFormField label="Hours per month" required>
|
||||||
<USelect
|
<UInputNumber
|
||||||
v-model="member.payRelationship"
|
v-model="member.capacity.targetHours"
|
||||||
:items="payRelationshipOptions"
|
:min="0"
|
||||||
|
:max="500"
|
||||||
|
:step="1"
|
||||||
|
placeholder="160"
|
||||||
size="md"
|
size="md"
|
||||||
class="text-sm font-medium w-full"
|
class="text-sm font-medium w-full"
|
||||||
@update:model-value="saveMember(member)" />
|
@update:model-value="saveMember(member)" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Hours/month" required>
|
<!-- Show minimum needs field when needs-weighted policy is selected -->
|
||||||
<UInput
|
<UFormField
|
||||||
v-model="member.capacity.targetHours"
|
v-if="isNeedsWeighted"
|
||||||
type="text"
|
:label="`Minimum needs (${getCurrencySymbol(coop.currency.value)}/month)`"
|
||||||
placeholder="120"
|
required>
|
||||||
size="md"
|
<UInputNumber
|
||||||
class="text-sm font-medium w-full"
|
|
||||||
@update:model-value="validateAndSaveHours($event, member)"
|
|
||||||
@blur="saveMember(member)" />
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UFormField label="Role (optional)">
|
|
||||||
<UInput
|
|
||||||
v-model="member.role"
|
|
||||||
placeholder="Developer"
|
|
||||||
size="md"
|
|
||||||
class="text-sm font-medium w-full"
|
|
||||||
@update:model-value="saveMember(member)"
|
|
||||||
@blur="saveMember(member)" />
|
|
||||||
</UFormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Compact needs section -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 p-3 bg-gray-50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<label class="text-xs font-medium text-gray-600 mb-1 block">Minimum needs (€/mo)</label>
|
|
||||||
<UInput
|
|
||||||
v-model="member.minMonthlyNeeds"
|
v-model="member.minMonthlyNeeds"
|
||||||
type="text"
|
:min="0"
|
||||||
placeholder="2000"
|
:max="50000"
|
||||||
size="sm"
|
:step="10"
|
||||||
|
placeholder="2500"
|
||||||
|
size="md"
|
||||||
class="text-sm font-medium w-full"
|
class="text-sm font-medium w-full"
|
||||||
@update:model-value="validateAndSaveAmount($event, member, 'minMonthlyNeeds')"
|
@update:model-value="saveMember(member)" />
|
||||||
@blur="saveMember(member)" />
|
</UFormField>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-xs font-medium text-gray-600 mb-1 block">Target pay (€/mo)</label>
|
|
||||||
<UInput
|
|
||||||
v-model="member.targetMonthlyPay"
|
|
||||||
type="text"
|
|
||||||
placeholder="3500"
|
|
||||||
size="sm"
|
|
||||||
class="text-sm font-medium w-full"
|
|
||||||
@update:model-value="validateAndSaveAmount($event, member, 'targetMonthlyPay')"
|
|
||||||
@blur="saveMember(member)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-xs font-medium text-gray-600 mb-1 block">External income (€/mo)</label>
|
|
||||||
<UInput
|
|
||||||
v-model="member.externalMonthlyIncome"
|
|
||||||
type="text"
|
|
||||||
placeholder="1500"
|
|
||||||
size="sm"
|
|
||||||
class="text-sm font-medium w-full"
|
|
||||||
@update:model-value="validateAndSaveAmount($event, member, 'externalMonthlyIncome')"
|
|
||||||
@blur="saveMember(member)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -176,6 +111,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useDebounceFn } from "@vueuse/core";
|
import { useDebounceFn } from "@vueuse/core";
|
||||||
import { coverage } from "~/types/members";
|
import { coverage } from "~/types/members";
|
||||||
|
import { getCurrencySymbol } from "~/utils/currency";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"save-status": [status: "saving" | "saved" | "error"];
|
"save-status": [status: "saving" | "saved" | "error"];
|
||||||
|
|
@ -183,24 +119,30 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
// Store
|
// Store
|
||||||
const coop = useCoopBuilder();
|
const coop = useCoopBuilder();
|
||||||
const members = computed(() =>
|
const members = computed(() =>
|
||||||
coop.members.value.map(m => ({
|
coop.members.value.map((m) => ({
|
||||||
// Map store fields to component expectations
|
// Map store fields to component expectations
|
||||||
id: m.id,
|
id: m.id,
|
||||||
displayName: m.name,
|
displayName: m.name,
|
||||||
role: m.role || '',
|
|
||||||
capacity: {
|
capacity: {
|
||||||
targetHours: m.hoursPerMonth || 0
|
targetHours: Number(m.hoursPerMonth) || 0,
|
||||||
},
|
},
|
||||||
payRelationship: 'FullyPaid', // Default since not in store yet
|
payRelationship: "FullyPaid", // Default since not in store yet
|
||||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
minMonthlyNeeds: Number(m.minMonthlyNeeds) || 0,
|
||||||
targetMonthlyPay: m.targetMonthlyPay || 0,
|
monthlyPayPlanned: Number(m.monthlyPayPlanned) || 0,
|
||||||
externalMonthlyIncome: m.externalMonthlyIncome || 0,
|
|
||||||
monthlyPayPlanned: m.monthlyPayPlanned || 0
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Options
|
// Get current policy to determine which fields to show
|
||||||
|
const isNeedsWeighted = computed(() => {
|
||||||
|
const policy = coop.policy.value?.relationship;
|
||||||
|
return policy === "needs-weighted";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also expose policy for debugging in template
|
||||||
|
const currentPolicy = computed(() => coop.policy.value?.relationship || "none");
|
||||||
|
|
||||||
|
// Simplified options - removed pay relationship as it's now in the policies step
|
||||||
const payRelationshipOptions = [
|
const payRelationshipOptions = [
|
||||||
{ label: "Fully Paid", value: "FullyPaid" },
|
{ label: "Fully Paid", value: "FullyPaid" },
|
||||||
{ label: "Hybrid", value: "Hybrid" },
|
{ label: "Hybrid", value: "Hybrid" },
|
||||||
|
|
@ -236,15 +178,12 @@ const debouncedSave = useDebounceFn((member: any) => {
|
||||||
// Convert component format back to store format
|
// Convert component format back to store format
|
||||||
const memberData = {
|
const memberData = {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
name: member.displayName || '',
|
name: member.displayName || "",
|
||||||
role: member.role || '',
|
hoursPerMonth: Number(member.capacity?.targetHours) || 0,
|
||||||
hoursPerMonth: member.capacity?.targetHours || 0,
|
minMonthlyNeeds: Number(member.minMonthlyNeeds) || 0,
|
||||||
minMonthlyNeeds: member.minMonthlyNeeds || 0,
|
monthlyPayPlanned: Number(member.monthlyPayPlanned) || 0,
|
||||||
targetMonthlyPay: member.targetMonthlyPay || 0,
|
|
||||||
externalMonthlyIncome: member.externalMonthlyIncome || 0,
|
|
||||||
monthlyPayPlanned: member.monthlyPayPlanned || 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
coop.upsertMember(memberData);
|
coop.upsertMember(memberData);
|
||||||
emit("save-status", "saved");
|
emit("save-status", "saved");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -257,13 +196,7 @@ function saveMember(member: any) {
|
||||||
debouncedSave(member);
|
debouncedSave(member);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation functions
|
// Validation functions (simplified since UInputNumber handles numeric validation)
|
||||||
function validateAndSaveHours(value: string, member: any) {
|
|
||||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
|
||||||
member.capacity.targetHours = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
|
||||||
saveMember(member);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAndSavePercentage(value: string, member: any) {
|
function validateAndSavePercentage(value: string, member: any) {
|
||||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||||
member.externalCoveragePct = isNaN(numValue)
|
member.externalCoveragePct = isNaN(numValue)
|
||||||
|
|
@ -272,30 +205,16 @@ function validateAndSavePercentage(value: string, member: any) {
|
||||||
saveMember(member);
|
saveMember(member);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAndSaveAmount(value: string, member: any, field: string) {
|
|
||||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
|
||||||
member[field] = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
|
||||||
saveMember(member);
|
|
||||||
}
|
|
||||||
|
|
||||||
function memberCoverage(member: any) {
|
function memberCoverage(member: any) {
|
||||||
return coverage(
|
return coverage(member.minMonthlyNeeds || 0, member.monthlyPayPlanned || 0);
|
||||||
member.minMonthlyNeeds || 0,
|
|
||||||
member.targetMonthlyPay || 0,
|
|
||||||
member.monthlyPayPlanned || 0,
|
|
||||||
member.externalMonthlyIncome || 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMember() {
|
function addMember() {
|
||||||
const newMember = {
|
const newMember = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
name: "",
|
name: "",
|
||||||
role: "",
|
|
||||||
hoursPerMonth: 0,
|
hoursPerMonth: 0,
|
||||||
minMonthlyNeeds: 0,
|
minMonthlyNeeds: 0,
|
||||||
targetMonthlyPay: 0,
|
|
||||||
externalMonthlyIncome: 0,
|
|
||||||
monthlyPayPlanned: 0,
|
monthlyPayPlanned: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -305,24 +224,4 @@ function addMember() {
|
||||||
function removeMember(id: string) {
|
function removeMember(id: string) {
|
||||||
coop.removeMember(id);
|
coop.removeMember(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportMembers() {
|
|
||||||
const exportData = {
|
|
||||||
members: members.value,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
section: "members",
|
|
||||||
};
|
|
||||||
|
|
||||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
|
||||||
type: "application/json",
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `coop-members-${new Date().toISOString().split("T")[0]}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,78 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
<!-- Section Header with Export Controls -->
|
<!-- Section Header -->
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="mb-8">
|
||||||
<div>
|
<h3 class="text-2xl font-black text-black mb-2">
|
||||||
<h3 class="text-2xl font-black text-black mb-2">Set your wage & pay policy</h3>
|
How will you share money?
|
||||||
<p class="text-neutral-600">
|
</h3>
|
||||||
Choose how to allocate payroll among members and set the base hourly rate.
|
<p class="text-neutral-600">
|
||||||
</p>
|
This is the foundation of your co-op's finances. Choose a pay approach
|
||||||
</div>
|
and set your hourly rate.
|
||||||
<div class="flex items-center gap-3">
|
</p>
|
||||||
<UButton variant="outline" color="neutral" size="sm" @click="exportPolicies">
|
|
||||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
|
||||||
Export
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pay Policy Selection -->
|
<!-- Pay Policy Selection -->
|
||||||
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
<div class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||||
<h4 class="font-bold mb-4">Pay Allocation Policy</h4>
|
<h4 class="font-bold mb-2">Step 1: Choose your pay approach</h4>
|
||||||
<div class="space-y-3">
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
<label
|
How should available money be shared among members?
|
||||||
v-for="option in policyOptions"
|
</p>
|
||||||
:key="option.value"
|
<URadioGroup
|
||||||
class="flex items-start gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded-lg transition-colors"
|
v-model="selectedPolicy"
|
||||||
>
|
:items="policyOptions"
|
||||||
<input
|
@update:model-value="updatePolicy"
|
||||||
type="radio"
|
variant="list"
|
||||||
:value="option.value"
|
size="xl"
|
||||||
v-model="selectedPolicy"
|
class="flex flex-col gap-2 w-full" />
|
||||||
@change="updatePolicy(option.value)"
|
|
||||||
class="mt-1 w-4 h-4 text-black border-2 border-gray-300 focus:ring-2 focus:ring-black"
|
|
||||||
/>
|
|
||||||
<span class="text-sm flex-1">{{ option.label }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Role bands editor if role-banded is selected -->
|
|
||||||
<div v-if="selectedPolicy === 'role-banded'" class="mt-4 p-4 bg-gray-50 rounded-lg">
|
|
||||||
<h5 class="text-sm font-medium mb-3">Role Bands (monthly € or weight)</h5>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="member in uniqueRoles"
|
|
||||||
:key="member.role"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span class="text-sm w-32">{{ member.role || "No role" }}</span>
|
|
||||||
<UInput
|
|
||||||
v-model="roleBands[member.role || '']"
|
|
||||||
type="text"
|
|
||||||
placeholder="3000"
|
|
||||||
size="sm"
|
|
||||||
class="w-24"
|
|
||||||
@update:model-value="updateRoleBands"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UAlert
|
|
||||||
class="mt-4"
|
|
||||||
color="primary"
|
|
||||||
variant="soft"
|
|
||||||
icon="i-heroicons-information-circle"
|
|
||||||
>
|
|
||||||
<template #description>
|
|
||||||
Policies affect payroll allocation and member coverage. You can iterate later.
|
|
||||||
</template>
|
|
||||||
</UAlert>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hourly Wage Input -->
|
<!-- Hourly Wage Input -->
|
||||||
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
<div class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||||
<h4 class="font-bold mb-4">Base Hourly Wage</h4>
|
<h4 class="font-bold mb-2">Step 2: Set your base wage</h4>
|
||||||
<div class="max-w-md">
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
<UInput
|
This hourly rate applies to all paid work in your co-op
|
||||||
v-model="wageText"
|
</p>
|
||||||
type="text"
|
<div class="flex gap-4 items-start">
|
||||||
placeholder="0.00"
|
<!-- Currency Selection -->
|
||||||
size="xl"
|
<UFormField label="Currency" class="w-1/2">
|
||||||
class="text-4xl font-black w-full h-20"
|
<USelect
|
||||||
@update:model-value="validateAndSaveWage"
|
v-model="selectedCurrency"
|
||||||
>
|
:items="currencySelectOptions"
|
||||||
<template #leading>
|
placeholder="Select currency"
|
||||||
<span class="text-neutral-500 text-3xl">€</span>
|
size="xl"
|
||||||
</template>
|
class="w-full"
|
||||||
</UInput>
|
@update:model-value="updateCurrency">
|
||||||
|
<template #leading>
|
||||||
|
<span class="text-lg">{{
|
||||||
|
getCurrencySymbol(selectedCurrency)
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</USelect>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Hourly Rate" class="w-1/2">
|
||||||
|
<UInput
|
||||||
|
v-model="wageText"
|
||||||
|
type="text"
|
||||||
|
placeholder="0.00"
|
||||||
|
size="xl"
|
||||||
|
class="text-2xl font-bold w-full"
|
||||||
|
@update:model-value="validateAndSaveWage">
|
||||||
|
<template #leading>
|
||||||
|
<span class="text-neutral-500 text-xl">{{
|
||||||
|
getCurrencySymbol(selectedCurrency)
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { currencyOptions, getCurrencySymbol } from "~/utils/currency";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"save-status": [status: "saving" | "saved" | "error"];
|
"save-status": [status: "saving" | "saved" | "error"];
|
||||||
}>();
|
}>();
|
||||||
|
|
@ -104,6 +85,7 @@ const store = useCoopBuilderStore();
|
||||||
const selectedPolicy = ref(coop.policy.value?.relationship || "equal-pay");
|
const selectedPolicy = ref(coop.policy.value?.relationship || "equal-pay");
|
||||||
const roleBands = ref(coop.policy.value?.roleBands || {});
|
const roleBands = ref(coop.policy.value?.roleBands || {});
|
||||||
const wageText = ref(String(store.equalHourlyWage || ""));
|
const wageText = ref(String(store.equalHourlyWage || ""));
|
||||||
|
const selectedCurrency = ref(coop.currency.value || "EUR");
|
||||||
|
|
||||||
function parseNumberInput(val: unknown): number {
|
function parseNumberInput(val: unknown): number {
|
||||||
if (typeof val === "number") return val;
|
if (typeof val === "number") return val;
|
||||||
|
|
@ -115,35 +97,42 @@ function parseNumberInput(val: unknown): number {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pay policy options
|
// Simplified pay policy options
|
||||||
const policyOptions = [
|
const policyOptions = [
|
||||||
{
|
{
|
||||||
value: "equal-pay",
|
value: "equal-pay",
|
||||||
label: "Equal pay - Everyone gets the same monthly amount",
|
label: "Equal pay - Everyone gets the same amount",
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "needs-weighted",
|
|
||||||
label: "Needs-weighted - Allocate based on minimum needs",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "hours-weighted",
|
value: "hours-weighted",
|
||||||
label: "Hours-weighted - Allocate based on hours worked",
|
label: "Hours-based - Pay proportional to hours worked",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "needs-weighted",
|
||||||
|
label: "Needs-based - Pay proportional to individual needs",
|
||||||
},
|
},
|
||||||
{ value: "role-banded", label: "Role-banded - Different amounts per role" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Currency options for USelect (simplified format)
|
||||||
|
const currencySelectOptions = computed(() =>
|
||||||
|
currencyOptions.map((currency) => ({
|
||||||
|
label: `${currency.name} (${currency.code})`,
|
||||||
|
value: currency.code,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Already initialized above with store values
|
// Already initialized above with store values
|
||||||
|
|
||||||
const uniqueRoles = computed(() => {
|
// Removed uniqueRoles computed - no longer needed with simplified policies
|
||||||
const roles = new Set(coop.members.value.map((m) => m.role || ""));
|
|
||||||
return Array.from(roles).map((role) => ({ role }));
|
function updateCurrency(value: string) {
|
||||||
});
|
selectedCurrency.value = value;
|
||||||
|
coop.setCurrency(value);
|
||||||
|
emit("save-status", "saved");
|
||||||
|
}
|
||||||
|
|
||||||
function updatePolicy(value: string) {
|
function updatePolicy(value: string) {
|
||||||
selectedPolicy.value = value;
|
coop.setPolicy(value as "equal-pay" | "needs-weighted" | "hours-weighted");
|
||||||
coop.setPolicy(
|
|
||||||
value as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger payroll reallocation after policy change
|
// Trigger payroll reallocation after policy change
|
||||||
const allocatedMembers = coop.allocatePayroll();
|
const allocatedMembers = coop.allocatePayroll();
|
||||||
|
|
@ -154,19 +143,7 @@ function updatePolicy(value: string) {
|
||||||
emit("save-status", "saved");
|
emit("save-status", "saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRoleBands() {
|
// Removed updateRoleBands - no longer needed with simplified policies
|
||||||
coop.setRoleBands(roleBands.value);
|
|
||||||
|
|
||||||
// Trigger payroll reallocation after role bands change
|
|
||||||
if (selectedPolicy.value === "role-banded") {
|
|
||||||
const allocatedMembers = coop.allocatePayroll();
|
|
||||||
allocatedMembers.forEach((m) => {
|
|
||||||
coop.upsertMember(m);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
emit("save-status", "saved");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text input for wage with validation (initialized above)
|
// Text input for wage with validation (initialized above)
|
||||||
|
|
||||||
|
|
@ -188,28 +165,4 @@ function validateAndSaveWage(value: string) {
|
||||||
emit("save-status", "saved");
|
emit("save-status", "saved");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportPolicies() {
|
|
||||||
const exportData = {
|
|
||||||
policies: {
|
|
||||||
selectedPolicy: coop.policy.value?.relationship || selectedPolicy.value,
|
|
||||||
roleBands: coop.policy.value?.roleBands || roleBands.value,
|
|
||||||
equalHourlyWage: store.equalHourlyWage || parseFloat(wageText.value),
|
|
||||||
},
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
section: "policies",
|
|
||||||
};
|
|
||||||
|
|
||||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
|
||||||
type: "application/json",
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `coop-policies-${new Date().toISOString().split("T")[0]}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,11 @@
|
||||||
|
|
||||||
<!-- Removed Tab Navigation - showing streams directly -->
|
<!-- Removed Tab Navigation - showing streams directly -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Export Controls -->
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<UButton
|
|
||||||
variant="outline"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
@click="exportStreams">
|
|
||||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
|
||||||
Export
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-if="streams.length === 0"
|
v-if="streams.length === 0"
|
||||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
class="text-center py-12 border-2 border-dashed border-neutral-300 rounded-xl bg-white shadow-sm">
|
||||||
<h4 class="font-medium text-neutral-900 mb-2">
|
<h4 class="font-medium text-neutral-900 mb-2">
|
||||||
No revenue streams yet
|
No revenue streams yet
|
||||||
</h4>
|
</h4>
|
||||||
|
|
@ -47,56 +36,85 @@
|
||||||
<div
|
<div
|
||||||
v-for="stream in streams"
|
v-for="stream in streams"
|
||||||
:key="stream.id"
|
:key="stream.id"
|
||||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
class="p-6 border-2 border-black rounded-xl bg-white shadow-md">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<!-- First row: Category and Name with delete button -->
|
||||||
<UFormField label="Category" required>
|
<div class="flex gap-4 mb-4">
|
||||||
|
<UFormField label="Category" required class="flex-1">
|
||||||
<USelect
|
<USelect
|
||||||
v-model="stream.category"
|
v-model="stream.category"
|
||||||
:items="categoryOptions"
|
:items="categoryOptions"
|
||||||
size="xl"
|
size="md"
|
||||||
class="text-xl font-bold w-full"
|
class="text-sm font-medium w-full"
|
||||||
@update:model-value="saveStream(stream)" />
|
@update:model-value="saveCategoryChange(stream)" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Revenue source name" required>
|
<UFormField label="Name" required class="flex-1">
|
||||||
<USelectMenu
|
<div class="flex gap-2">
|
||||||
v-model="stream.name"
|
<USelectMenu
|
||||||
:items="nameOptionsByCategory[stream.category] || []"
|
v-model="stream.name"
|
||||||
placeholder="Select or type a source name"
|
:items="nameOptionsByCategory[stream.category] || []"
|
||||||
creatable
|
placeholder="Select or type a source name"
|
||||||
searchable
|
creatable
|
||||||
size="xl"
|
searchable
|
||||||
class="text-xl font-bold w-full"
|
size="md"
|
||||||
@update:model-value="saveStream(stream)" />
|
class="text-sm font-medium w-full"
|
||||||
</UFormField>
|
@update:model-value="saveStream(stream)" />
|
||||||
|
<UButton
|
||||||
<UFormField label="Monthly amount" required>
|
size="md"
|
||||||
<UInput
|
variant="solid"
|
||||||
v-model="stream.targetMonthlyAmount"
|
color="error"
|
||||||
type="text"
|
@click="removeStream(stream.id)"
|
||||||
placeholder="5000"
|
:ui="{
|
||||||
size="xl"
|
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||||
class="text-xl font-black w-full"
|
}">
|
||||||
@update:model-value="validateAndSaveAmount($event, stream)"
|
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||||
@blur="saveStream(stream)">
|
</UButton>
|
||||||
<template #leading>
|
</div>
|
||||||
<span class="text-neutral-500 text-xl">$</span>
|
|
||||||
</template>
|
|
||||||
</UInput>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
|
<!-- Second row: Amount with toggle -->
|
||||||
<UButton
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
size="xs"
|
<UFormField :label="stream.amountType === 'annual' ? 'Annual amount' : 'Monthly amount'" required>
|
||||||
variant="solid"
|
<div class="flex gap-2">
|
||||||
color="error"
|
<UInput
|
||||||
@click="removeStream(stream.id)"
|
:value="stream.amountType === 'annual' ? stream.targetAnnualAmount : stream.targetMonthlyAmount"
|
||||||
:ui="{
|
type="text"
|
||||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
:placeholder="stream.amountType === 'annual' ? '60000' : '5000'"
|
||||||
}">
|
size="md"
|
||||||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
class="text-sm font-medium w-full"
|
||||||
</UButton>
|
@update:model-value="validateAndSaveAmount($event, stream)"
|
||||||
|
@blur="saveStream(stream)">
|
||||||
|
<template #leading>
|
||||||
|
<span class="text-neutral-500">{{ currencySymbol }}</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
<UButtonGroup size="md">
|
||||||
|
<UButton
|
||||||
|
:variant="stream.amountType === 'monthly' ? 'solid' : 'outline'"
|
||||||
|
color="primary"
|
||||||
|
@click="switchAmountType(stream, 'monthly')"
|
||||||
|
class="text-xs">
|
||||||
|
Monthly
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
:variant="stream.amountType === 'annual' ? 'solid' : 'outline'"
|
||||||
|
color="primary"
|
||||||
|
@click="switchAmountType(stream, 'annual')"
|
||||||
|
class="text-xs">
|
||||||
|
Annual
|
||||||
|
</UButton>
|
||||||
|
</UButtonGroup>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-neutral-500 mt-1">
|
||||||
|
<template v-if="stream.amountType === 'annual'">
|
||||||
|
{{ currencySymbol }}{{ Math.round((stream.targetAnnualAmount || 0) / 12) }} per month
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ currencySymbol }}{{ (stream.targetMonthlyAmount || 0) * 12 }} per year
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -115,21 +133,6 @@
|
||||||
Add another stream
|
Add another stream
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="streams.length > 0" class="flex items-center gap-3 justify-end">
|
|
||||||
<UButton
|
|
||||||
@click="addRevenueStream"
|
|
||||||
size="sm"
|
|
||||||
variant="solid"
|
|
||||||
color="success"
|
|
||||||
:ui="{
|
|
||||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
|
||||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
|
||||||
}">
|
|
||||||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
|
||||||
Add stream
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -142,8 +145,9 @@ const emit = defineEmits<{
|
||||||
"save-status": [status: "saving" | "saved" | "error"];
|
"save-status": [status: "saving" | "saved" | "error"];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Store
|
// Store and Currency
|
||||||
const coop = useCoopBuilder();
|
const coop = useCoopBuilder();
|
||||||
|
const { currencySymbol } = useCurrency();
|
||||||
const streams = computed(() =>
|
const streams = computed(() =>
|
||||||
coop.streams.value.map(s => ({
|
coop.streams.value.map(s => ({
|
||||||
// Map store fields to component expectations
|
// Map store fields to component expectations
|
||||||
|
|
@ -151,6 +155,8 @@ const streams = computed(() =>
|
||||||
name: s.label,
|
name: s.label,
|
||||||
category: s.category || 'games',
|
category: s.category || 'games',
|
||||||
targetMonthlyAmount: s.monthly || 0,
|
targetMonthlyAmount: s.monthly || 0,
|
||||||
|
targetAnnualAmount: (s.annual || (s.monthly || 0) * 12),
|
||||||
|
amountType: s.amountType || 'monthly',
|
||||||
subcategory: '',
|
subcategory: '',
|
||||||
targetPct: 0,
|
targetPct: 0,
|
||||||
certainty: s.certainty || 'Aspirational',
|
certainty: s.certainty || 'Aspirational',
|
||||||
|
|
@ -219,7 +225,12 @@ const nameOptionsByCategory: Record<string, string[]> = {
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const totalMonthlyAmount = computed(() =>
|
const totalMonthlyAmount = computed(() =>
|
||||||
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
|
streams.value.reduce((sum, s) => {
|
||||||
|
const monthly = s.amountType === 'annual'
|
||||||
|
? Math.round((s.targetAnnualAmount || 0) / 12)
|
||||||
|
: (s.targetMonthlyAmount || 0);
|
||||||
|
return sum + monthly;
|
||||||
|
}, 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Live-write with debounce
|
// Live-write with debounce
|
||||||
|
|
@ -228,10 +239,16 @@ const debouncedSave = useDebounceFn((stream: any) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert component format back to store format
|
// Convert component format back to store format
|
||||||
|
const monthly = stream.amountType === 'annual'
|
||||||
|
? Math.round((stream.targetAnnualAmount || 0) / 12)
|
||||||
|
: (stream.targetMonthlyAmount || 0);
|
||||||
|
|
||||||
const streamData = {
|
const streamData = {
|
||||||
id: stream.id,
|
id: stream.id,
|
||||||
label: stream.name || '',
|
label: stream.name || '',
|
||||||
monthly: stream.targetMonthlyAmount || 0,
|
monthly: monthly,
|
||||||
|
annual: stream.targetAnnualAmount || monthly * 12,
|
||||||
|
amountType: stream.amountType || 'monthly',
|
||||||
category: stream.category || 'games',
|
category: stream.category || 'games',
|
||||||
certainty: stream.certainty || 'Aspirational'
|
certainty: stream.certainty || 'Aspirational'
|
||||||
};
|
};
|
||||||
|
|
@ -245,23 +262,87 @@ const debouncedSave = useDebounceFn((stream: any) => {
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
function saveStream(stream: any) {
|
function saveStream(stream: any) {
|
||||||
if (stream.name && stream.category && stream.targetMonthlyAmount >= 0) {
|
const hasValidAmount = stream.amountType === 'annual'
|
||||||
|
? stream.targetAnnualAmount >= 0
|
||||||
|
: stream.targetMonthlyAmount >= 0;
|
||||||
|
|
||||||
|
if (stream.name && stream.category && hasValidAmount) {
|
||||||
debouncedSave(stream);
|
debouncedSave(stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save category changes immediately even without a name
|
||||||
|
function saveCategoryChange(stream: any) {
|
||||||
|
// Always save category changes immediately
|
||||||
|
saveStreamImmediate(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate save without debounce for UI responsiveness
|
||||||
|
function saveStreamImmediate(stream: any) {
|
||||||
|
try {
|
||||||
|
// Convert component format back to store format
|
||||||
|
const monthly = stream.amountType === 'annual'
|
||||||
|
? Math.round((stream.targetAnnualAmount || 0) / 12)
|
||||||
|
: (stream.targetMonthlyAmount || 0);
|
||||||
|
|
||||||
|
const streamData = {
|
||||||
|
id: stream.id,
|
||||||
|
label: stream.name || '',
|
||||||
|
monthly: monthly,
|
||||||
|
annual: stream.targetAnnualAmount || monthly * 12,
|
||||||
|
amountType: stream.amountType || 'monthly',
|
||||||
|
category: stream.category || 'games',
|
||||||
|
certainty: stream.certainty || 'Aspirational'
|
||||||
|
};
|
||||||
|
|
||||||
|
coop.upsertStream(streamData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save stream:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validation function for amount
|
// Validation function for amount
|
||||||
function validateAndSaveAmount(value: string, stream: any) {
|
function validateAndSaveAmount(value: string, stream: any) {
|
||||||
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
const numValue = parseFloat(value.replace(/[^\d.]/g, ""));
|
||||||
stream.targetMonthlyAmount = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
const validValue = isNaN(numValue) ? 0 : Math.max(0, numValue);
|
||||||
|
|
||||||
|
if (stream.amountType === 'annual') {
|
||||||
|
stream.targetAnnualAmount = validValue;
|
||||||
|
stream.targetMonthlyAmount = Math.round(validValue / 12);
|
||||||
|
} else {
|
||||||
|
stream.targetMonthlyAmount = validValue;
|
||||||
|
stream.targetAnnualAmount = validValue * 12;
|
||||||
|
}
|
||||||
|
|
||||||
saveStream(stream);
|
saveStream(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to switch between annual and monthly
|
||||||
|
function switchAmountType(stream: any, type: 'annual' | 'monthly') {
|
||||||
|
stream.amountType = type;
|
||||||
|
|
||||||
|
// Recalculate values based on new type
|
||||||
|
if (type === 'annual') {
|
||||||
|
if (!stream.targetAnnualAmount) {
|
||||||
|
stream.targetAnnualAmount = (stream.targetMonthlyAmount || 0) * 12;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!stream.targetMonthlyAmount) {
|
||||||
|
stream.targetMonthlyAmount = Math.round((stream.targetAnnualAmount || 0) / 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save immediately without debounce for instant UI update
|
||||||
|
saveStreamImmediate(stream);
|
||||||
|
}
|
||||||
|
|
||||||
function addRevenueStream() {
|
function addRevenueStream() {
|
||||||
const newStream = {
|
const newStream = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
label: "",
|
label: "",
|
||||||
monthly: 0,
|
monthly: 0,
|
||||||
|
annual: 0,
|
||||||
|
amountType: "monthly",
|
||||||
category: "games",
|
category: "games",
|
||||||
certainty: "Aspirational"
|
certainty: "Aspirational"
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,395 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
|
||||||
<!-- Section Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h3 class="text-2xl font-black text-black dark:text-white mb-2">Review & Complete</h3>
|
|
||||||
<p class="text-neutral-600 dark:text-neutral-400">
|
|
||||||
Review your setup and complete the wizard to start using your co-op
|
|
||||||
tool.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<!-- Members Summary -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="font-medium">Members ({{ members.length }})</h4>
|
|
||||||
<UBadge :color="membersValid ? 'green' : 'red'" variant="subtle">
|
|
||||||
{{ membersValid ? "Valid" : "Incomplete" }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="member in members"
|
|
||||||
:key="member.id"
|
|
||||||
class="flex items-center justify-between text-sm">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">{{
|
|
||||||
member.displayName || "Unnamed Member"
|
|
||||||
}}</span>
|
|
||||||
<span v-if="member.roleFocus" class="text-neutral-500 ml-1"
|
|
||||||
>({{ member.roleFocus }})</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="text-right text-xs text-neutral-500">
|
|
||||||
<div>{{ member.payRelationship || "No relationship set" }}</div>
|
|
||||||
<div>{{ member.capacity?.targetHours || 0 }}h/month</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-3 border-t border-neutral-100">
|
|
||||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-600">Total capacity:</span>
|
|
||||||
<span class="font-medium ml-1">{{ totalCapacity }}h</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-600">Avg external:</span>
|
|
||||||
<span class="font-medium ml-1">{{ avgExternal }}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Policies Summary -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="font-medium">Policies</h4>
|
|
||||||
<UBadge :color="policiesValid ? 'green' : 'red'" variant="subtle">
|
|
||||||
{{ policiesValid ? "Valid" : "Incomplete" }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="space-y-3 text-sm">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-neutral-600">Equal hourly wage:</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>€{{ policies.equalHourlyWage || 0 }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-neutral-600">Payroll on-costs:</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>{{ policies.payrollOncostPct || 0 }}%</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-neutral-600">Savings target:</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>{{ policies.savingsTargetMonths || 0 }} months</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-neutral-600">Cash cushion:</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>€{{ policies.minCashCushionAmount || 0 }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-neutral-600">Deferred cap:</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>{{ policies.deferredCapHoursPerQtr || 0 }}h/qtr</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-neutral-600">Volunteer flows:</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>{{ policies.volunteerScope.allowedFlows.length }} types</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Costs Summary -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="font-medium">
|
|
||||||
Overhead Costs ({{ overheadCosts.length }})
|
|
||||||
</h4>
|
|
||||||
<UBadge color="blue" variant="subtle">Optional</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="overheadCosts.length === 0" class="text-center py-8">
|
|
||||||
<h4 class="font-medium text-neutral-900 mb-1">
|
|
||||||
No overhead costs yet
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm text-neutral-500">Optional - add costs in step 3</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="cost in overheadCosts.slice(0, 3)"
|
|
||||||
:key="cost.id"
|
|
||||||
class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-700">{{ cost.name }}</span>
|
|
||||||
<span class="font-medium">€{{ cost.amount || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="overheadCosts.length > 3" class="text-xs text-neutral-500">
|
|
||||||
+{{ overheadCosts.length - 3 }} more items
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-2 border-t border-neutral-100">
|
|
||||||
<div class="flex justify-between text-sm font-medium">
|
|
||||||
<span>Monthly total:</span>
|
|
||||||
<span>€{{ totalMonthlyCosts }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Revenue Summary -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="font-medium">Revenue Streams ({{ streams.length }})</h4>
|
|
||||||
<UBadge :color="streamsValid ? 'green' : 'red'" variant="subtle">
|
|
||||||
{{ streamsValid ? "Valid" : "Incomplete" }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="streams.length === 0" class="text-center py-8">
|
|
||||||
<h4 class="font-medium text-neutral-900 mb-1">
|
|
||||||
No revenue streams yet
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm text-neutral-500">
|
|
||||||
Required - add streams in step 4
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="stream in streams.slice(0, 3)"
|
|
||||||
:key="stream.id"
|
|
||||||
class="space-y-1">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="font-medium">{{
|
|
||||||
stream.name || "Unnamed Stream"
|
|
||||||
}}</span>
|
|
||||||
<span class="text-neutral-600">{{ stream.targetPct || 0 }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-xs text-neutral-500">
|
|
||||||
<span>{{ stream.category }} • {{ stream.certainty }}</span>
|
|
||||||
<span>€{{ stream.targetMonthlyAmount || 0 }}/mo</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="streams.length > 3" class="text-xs text-neutral-500">
|
|
||||||
+{{ streams.length - 3 }} more streams
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-3 border-t border-neutral-100">
|
|
||||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-600">Target % total:</span>
|
|
||||||
<span
|
|
||||||
class="font-medium ml-1"
|
|
||||||
:class="
|
|
||||||
totalTargetPct === 100 ? 'text-green-600' : 'text-red-600'
|
|
||||||
">
|
|
||||||
{{ totalTargetPct }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-600">Monthly target:</span>
|
|
||||||
<span class="font-medium ml-1">€{{ totalMonthlyTarget }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Team Coverage Summary -->
|
|
||||||
<div class="bg-white border-2 border-black rounded-lg p-4 mb-4">
|
|
||||||
<h4 class="font-medium text-sm mb-3">Team Coverage (min needs)</h4>
|
|
||||||
<div class="flex flex-wrap gap-4 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
:name="teamStats.under100 === 0 ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
|
|
||||||
:class="teamStats.under100 === 0 ? 'text-green-500' : 'text-yellow-500'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
<strong>{{ teamStats.under100 }}</strong> under 100%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="teamStats.median" class="flex items-center gap-1">
|
|
||||||
<span class="text-neutral-600">Median:</span>
|
|
||||||
<strong>{{ Math.round(teamStats.median) }}%</strong>
|
|
||||||
</div>
|
|
||||||
<div v-if="teamStats.gini !== undefined" class="flex items-center gap-1">
|
|
||||||
<span class="text-neutral-600">Gini:</span>
|
|
||||||
<strong>{{ teamStats.gini.toFixed(2) }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="teamStats.under100 > 0" class="mt-3 p-2 bg-yellow-50 rounded text-xs text-yellow-800">
|
|
||||||
Consider more needs-weighting or a smaller headcount to ensure everyone's minimum needs are met.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overall Status -->
|
|
||||||
<div class="bg-neutral-50 rounded-lg p-4">
|
|
||||||
<h4 class="font-medium text-sm mb-3">Setup Status</h4>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
:name="
|
|
||||||
membersValid ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'
|
|
||||||
"
|
|
||||||
:class="membersValid ? 'text-green-500' : 'text-red-500'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span class="text-sm">Members</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
:name="
|
|
||||||
policiesValid
|
|
||||||
? 'i-heroicons-check-circle'
|
|
||||||
: 'i-heroicons-x-circle'
|
|
||||||
"
|
|
||||||
:class="policiesValid ? 'text-green-500' : 'text-red-500'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span class="text-sm">Policies</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
name="i-heroicons-check-circle"
|
|
||||||
class="text-blue-500 w-4 h-4" />
|
|
||||||
<span class="text-sm">Costs (Optional)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
:name="
|
|
||||||
streamsValid ? 'i-heroicons-check-circle' : 'i-heroicons-x-circle'
|
|
||||||
"
|
|
||||||
:class="streamsValid ? 'text-green-500' : 'text-red-500'"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span class="text-sm">Revenue</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!canComplete"
|
|
||||||
class="bg-yellow-100 border border-yellow-200 rounded-md p-3 mb-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
name="i-heroicons-exclamation-triangle"
|
|
||||||
class="text-yellow-600 w-4 h-4" />
|
|
||||||
<span class="text-sm font-medium text-yellow-800"
|
|
||||||
>Complete required sections to finish setup</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<ul class="list-disc list-inside text-xs text-yellow-700 mt-2">
|
|
||||||
<li v-if="!membersValid">
|
|
||||||
Add at least one member with valid details
|
|
||||||
</li>
|
|
||||||
<li v-if="!policiesValid">
|
|
||||||
Set a valid hourly wage and complete policy fields
|
|
||||||
</li>
|
|
||||||
<li v-if="!streamsValid">
|
|
||||||
Add at least one revenue stream with valid details
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex justify-between items-center pt-6 border-t">
|
|
||||||
<UButton variant="ghost" color="red" @click="$emit('reset')">
|
|
||||||
Reset All Data
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<UButton
|
|
||||||
@click="completeSetup"
|
|
||||||
:disabled="!canComplete"
|
|
||||||
size="lg"
|
|
||||||
variant="solid"
|
|
||||||
color="primary">
|
|
||||||
Complete Setup
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const emit = defineEmits<{
|
|
||||||
complete: [];
|
|
||||||
reset: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Store
|
|
||||||
const coop = useCoopBuilder();
|
|
||||||
|
|
||||||
// Computed data
|
|
||||||
const members = computed(() => coop.members.value);
|
|
||||||
const teamStats = computed(() => coop.teamCoverageStats());
|
|
||||||
const policies = computed(() => ({
|
|
||||||
// TODO: Get actual policy data from centralized store
|
|
||||||
equalHourlyWage: 0,
|
|
||||||
payrollOncostPct: 0,
|
|
||||||
savingsTargetMonths: 0,
|
|
||||||
minCashCushionAmount: 0,
|
|
||||||
deferredCapHoursPerQtr: 0,
|
|
||||||
volunteerScope: { allowedFlows: [] },
|
|
||||||
}));
|
|
||||||
const overheadCosts = computed(() => []);
|
|
||||||
const streams = computed(() => coop.streams.value);
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
const membersValid = computed(() => coop.members.value.length > 0);
|
|
||||||
const policiesValid = computed(() => true); // TODO: Add validation
|
|
||||||
const streamsValid = computed(() => coop.streams.value.length > 0);
|
|
||||||
const canComplete = computed(
|
|
||||||
() => membersValid.value && policiesValid.value && streamsValid.value
|
|
||||||
);
|
|
||||||
|
|
||||||
// Summary calculations
|
|
||||||
const totalCapacity = computed(() =>
|
|
||||||
members.value.reduce((sum, m) => sum + (m.capacity?.targetHours || 0), 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const avgExternal = computed(() => {
|
|
||||||
if (members.value.length === 0) return 0;
|
|
||||||
const total = members.value.reduce(
|
|
||||||
(sum, m) => sum + (m.externalCoveragePct || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
return Math.round(total / members.value.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalMonthlyCosts = computed(() =>
|
|
||||||
overheadCosts.value.reduce((sum, c) => sum + (c.amount || 0), 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalTargetPct = computed(() =>
|
|
||||||
coop.streams.value.reduce((sum, s) => sum + (s.targetPct || 0), 0)
|
|
||||||
);
|
|
||||||
const totalMonthlyTarget = computed(() =>
|
|
||||||
Math.round(
|
|
||||||
streams.value.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function completeSetup() {
|
|
||||||
if (canComplete.value) {
|
|
||||||
// Mark setup as complete in some way (could be a store flag)
|
|
||||||
emit("complete");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<template>
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h4 class="font-medium">Scenarios</h4>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<USelect
|
|
||||||
:model-value="scenario"
|
|
||||||
:options="scenarioOptions"
|
|
||||||
@update:model-value="setScenario"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="scenario !== 'current'" class="p-3 bg-blue-50 border border-blue-200 rounded text-sm">
|
|
||||||
<div class="flex items-center gap-2 text-blue-800">
|
|
||||||
<UIcon name="i-heroicons-information-circle" class="w-4 h-4" />
|
|
||||||
<span class="font-medium">Scenario Active</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-blue-700 mt-1">
|
|
||||||
{{ getScenarioDescription(scenario) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { scenario, setScenario } = useCoopBuilder()
|
|
||||||
|
|
||||||
const scenarioOptions = [
|
|
||||||
{ label: 'Current', value: 'current' },
|
|
||||||
{ label: 'Quit Day Jobs', value: 'quit-jobs' },
|
|
||||||
{ label: 'Start Production', value: 'start-production' },
|
|
||||||
{ label: 'Custom', value: 'custom', disabled: true }
|
|
||||||
]
|
|
||||||
|
|
||||||
function getScenarioDescription(scenario: string): string {
|
|
||||||
switch (scenario) {
|
|
||||||
case 'quit-jobs':
|
|
||||||
return 'All external income removed. Shows runway if everyone works full-time for the co-op.'
|
|
||||||
case 'start-production':
|
|
||||||
return 'Service revenue reduced by 30%. Models transition from services to product development.'
|
|
||||||
case 'custom':
|
|
||||||
return 'Custom scenario configuration coming soon.'
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="hidden" data-ui="advanced_accordion_v1" />
|
<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>
|
<template #advanced>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
<!-- Scenarios Panel -->
|
<!-- Scenarios Panel -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h4 class="font-semibold">Scenarios</h4>
|
<h4 class="font-semibold">Scenarios</h4>
|
||||||
<USelect
|
<USelect
|
||||||
v-model="scenario"
|
v-model="scenario"
|
||||||
:options="scenarioOptions"
|
:options="scenarioOptions"
|
||||||
placeholder="Select scenario"
|
placeholder="Select scenario" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stress Test Panel -->
|
<!-- Stress Test Panel -->
|
||||||
|
|
@ -19,15 +20,18 @@
|
||||||
<h4 class="font-semibold">Stress Test</h4>
|
<h4 class="font-semibold">Stress Test</h4>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-600">Revenue Delay (months)</label>
|
<label class="text-xs text-gray-600"
|
||||||
|
>Revenue Delay (months)</label
|
||||||
|
>
|
||||||
<URange
|
<URange
|
||||||
v-model="stress.revenueDelay"
|
v-model="stress.revenueDelay"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="6"
|
:max="6"
|
||||||
:step="1"
|
:step="1"
|
||||||
class="mt-1"
|
class="mt-1" />
|
||||||
/>
|
<div class="text-xs text-gray-500">
|
||||||
<div class="text-xs text-gray-500">{{ stress.revenueDelay }} months</div>
|
{{ stress.revenueDelay }} months
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-600">Cost Shock (%)</label>
|
<label class="text-xs text-gray-600">Cost Shock (%)</label>
|
||||||
|
|
@ -36,14 +40,12 @@
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="30"
|
:max="30"
|
||||||
:step="1"
|
:step="1"
|
||||||
class="mt-1"
|
class="mt-1" />
|
||||||
/>
|
<div class="text-xs text-gray-500">
|
||||||
<div class="text-xs text-gray-500">{{ stress.costShockPct }}%</div>
|
{{ stress.costShockPct }}%
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UCheckbox
|
<UCheckbox v-model="stress.grantLost" label="Grant lost" />
|
||||||
v-model="stress.grantLost"
|
|
||||||
label="Grant lost"
|
|
||||||
/>
|
|
||||||
<div class="text-sm text-gray-600 pt-2 border-t">
|
<div class="text-sm text-gray-600 pt-2 border-t">
|
||||||
Projected runway: {{ projectedRunway }}
|
Projected runway: {{ projectedRunway }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,8 +59,7 @@
|
||||||
<UButton
|
<UButton
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="showMilestoneModal = true"
|
@click="showMilestoneModal = true">
|
||||||
>
|
|
||||||
+ Add
|
+ Add
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,20 +67,22 @@
|
||||||
<div
|
<div
|
||||||
v-for="milestone in milestoneStatus()"
|
v-for="milestone in milestoneStatus()"
|
||||||
:key="milestone.id"
|
: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">
|
<div class="flex items-center gap-2">
|
||||||
<span>{{ milestone.willReach ? '✅' : '⚠️' }}</span>
|
<span>{{ milestone.willReach ? "✅" : "⚠️" }}</span>
|
||||||
<span>{{ milestone.label }}</span>
|
<span>{{ milestone.label }}</span>
|
||||||
</div>
|
</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>
|
||||||
<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
|
No milestones yet
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UAccordion>
|
</UAccordion>
|
||||||
|
|
@ -90,14 +93,16 @@
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="text-lg font-medium">Add Milestone</h3>
|
<h3 class="text-lg font-medium">Add Milestone</h3>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UFormGroup label="Label">
|
<UFormField label="Label">
|
||||||
<UInput v-model="newMilestone.label" placeholder="e.g. Product launch" />
|
<UInput
|
||||||
</UFormGroup>
|
v-model="newMilestone.label"
|
||||||
<UFormGroup label="Date">
|
placeholder="e.g. Product launch" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Date">
|
||||||
<UInput v-model="newMilestone.date" type="date" />
|
<UInput v-model="newMilestone.date" type="date" />
|
||||||
</UFormGroup>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
@ -105,7 +110,9 @@
|
||||||
<UButton variant="ghost" @click="showMilestoneModal = false">
|
<UButton variant="ghost" @click="showMilestoneModal = false">
|
||||||
Cancel
|
Cancel
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton @click="addNewMilestone" :disabled="!newMilestone.label || !newMilestone.date">
|
<UButton
|
||||||
|
@click="addNewMilestone"
|
||||||
|
:disabled="!newMilestone.label || !newMilestone.date">
|
||||||
Add Milestone
|
Add Milestone
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,72 +122,78 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const {
|
const {
|
||||||
scenario,
|
scenario,
|
||||||
setScenario,
|
setScenario,
|
||||||
stress,
|
stress,
|
||||||
updateStress,
|
updateStress,
|
||||||
milestones,
|
milestones,
|
||||||
milestoneStatus,
|
milestoneStatus,
|
||||||
addMilestone,
|
addMilestone,
|
||||||
runwayMonths
|
runwayMonths,
|
||||||
} = useCoopBuilder()
|
} = useCoopBuilder();
|
||||||
|
|
||||||
// Accordion setup
|
// Accordion setup
|
||||||
const accordionItems = [{
|
const accordionItems = [
|
||||||
label: 'Advanced Planning',
|
{
|
||||||
icon: 'i-heroicons-wrench-screwdriver',
|
label: "Advanced Planning",
|
||||||
slot: 'advanced',
|
icon: "i-heroicons-wrench-screwdriver",
|
||||||
defaultOpen: false
|
slot: "advanced",
|
||||||
}]
|
defaultOpen: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Scenarios
|
// Scenarios
|
||||||
const scenarioOptions = [
|
const scenarioOptions = [
|
||||||
{ label: 'Current', value: 'current' },
|
{ label: "Current", value: "current" },
|
||||||
{ label: 'Quit Day Jobs', value: 'quit-jobs' },
|
{ label: "Quit Day Jobs", value: "quit-jobs" },
|
||||||
{ label: 'Start Production', value: 'start-production' },
|
{ label: "Start Production", value: "start-production" },
|
||||||
{ label: 'Custom', value: 'custom' }
|
{ label: "Custom", value: "custom" },
|
||||||
]
|
];
|
||||||
|
|
||||||
// Stress test with live preview
|
// Stress test with live preview
|
||||||
const projectedRunway = computed(() => {
|
const projectedRunway = computed(() => {
|
||||||
const months = runwayMonths(undefined, { useStress: true })
|
const months = runwayMonths(undefined, { useStress: true });
|
||||||
if (!isFinite(months)) return '∞'
|
if (!isFinite(months)) return "∞";
|
||||||
if (months < 1) return '<1m'
|
if (months < 1) return "<1m";
|
||||||
return `${Math.round(months)}m`
|
return `${Math.round(months)}m`;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Milestones modal
|
// Milestones modal
|
||||||
const showMilestoneModal = ref(false)
|
const showMilestoneModal = ref(false);
|
||||||
const newMilestone = reactive({
|
const newMilestone = reactive({
|
||||||
label: '',
|
label: "",
|
||||||
date: ''
|
date: "",
|
||||||
})
|
});
|
||||||
|
|
||||||
function addNewMilestone() {
|
function addNewMilestone() {
|
||||||
if (!newMilestone.label || !newMilestone.date) return
|
if (!newMilestone.label || !newMilestone.date) return;
|
||||||
|
|
||||||
addMilestone(newMilestone.label, newMilestone.date)
|
addMilestone(newMilestone.label, newMilestone.date);
|
||||||
newMilestone.label = ''
|
newMilestone.label = "";
|
||||||
newMilestone.date = ''
|
newMilestone.date = "";
|
||||||
showMilestoneModal.value = false
|
showMilestoneModal.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
year: 'numeric'
|
year: "numeric",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch scenario changes
|
// Watch scenario changes
|
||||||
watch(scenario, (newValue) => {
|
watch(scenario, (newValue) => {
|
||||||
setScenario(newValue)
|
setScenario(newValue);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Watch stress changes
|
// Watch stress changes
|
||||||
watch(stress, (newValue) => {
|
watch(
|
||||||
updateStress(newValue)
|
stress,
|
||||||
}, { deep: true })
|
(newValue) => {
|
||||||
</script>
|
updateStress(newValue);
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,137 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="hidden" data-ui="member_coverage_panel_v1" />
|
<div class="hidden" data-ui="member_coverage_panel_v2" />
|
||||||
<UCard class="shadow-sm rounded-xl">
|
<UCard class="shadow-sm rounded-xl">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="font-semibold">Member needs coverage</h3>
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold">Individual Member Coverage</h3>
|
||||||
|
<UTooltip text="Shows what each member needs from the co-op vs. what we can actually pay them">
|
||||||
|
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" />
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div v-if="allocatedMembers.length > 0" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-for="member in allocatedMembers"
|
v-for="member in allocatedMembers"
|
||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="flex items-center gap-4"
|
class="space-y-2"
|
||||||
>
|
>
|
||||||
<div class="w-20 text-sm font-medium text-gray-700 truncate">
|
<!-- Member name and coverage percentage -->
|
||||||
{{ member.name }}
|
<div class="flex items-center justify-between">
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex-1">
|
<span class="font-medium text-gray-900">{{ member.displayName || member.name || 'Unnamed Member' }}</span>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
<UBadge
|
||||||
<div
|
:color="getCoverageColor(coverage(member).coveragePct)"
|
||||||
class="h-2 rounded-full transition-all"
|
size="xs"
|
||||||
:class="getBarColor(coverage(member).minPct)"
|
:ui="{ base: 'font-medium' }"
|
||||||
:style="{ width: `${Math.min(100, (coverage(member).minPct / 200) * 100)}%` }"
|
>
|
||||||
/>
|
{{ Math.round(coverage(member).coveragePct || 0) }}% covered
|
||||||
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 text-sm font-medium text-right">
|
|
||||||
{{ Math.round(coverage(member).minPct) }}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="allocatedMembers.length === 0" class="text-sm text-gray-600 text-center py-8">
|
<!-- Financial breakdown -->
|
||||||
Add members in Setup → Members to see coverage.
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-gray-600">Needs from co-op</div>
|
||||||
|
<div class="font-medium">{{ formatCurrency(member.minMonthlyNeeds || 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-gray-600">Co-op can pay</div>
|
||||||
|
<div class="font-medium" :class="getAmountColor(member.monthlyPayPlanned, member.minMonthlyNeeds)">
|
||||||
|
{{ formatCurrency(member.monthlyPayPlanned || 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual progress bar -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3 relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-3 rounded-full transition-all duration-300"
|
||||||
|
:class="getBarColor(coverage(member).coveragePct)"
|
||||||
|
:style="{ width: `${Math.min(100, coverage(member).coveragePct || 0)}%` }"
|
||||||
|
/>
|
||||||
|
<!-- 100% marker line -->
|
||||||
|
<div class="absolute top-0 h-3 w-0.5 bg-gray-600 opacity-75" style="left: 100%" v-if="(coverage(member).coveragePct || 0) < 100">
|
||||||
|
<div class="absolute -top-1 -left-1 w-2 h-2 bg-gray-600 rounded-full opacity-75" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
<span>200%+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gap/surplus indicator -->
|
||||||
|
<div v-if="getGapAmount(member) !== 0" class="flex items-center gap-1 text-xs">
|
||||||
|
<UIcon
|
||||||
|
:name="getGapAmount(member) > 0 ? 'i-heroicons-arrow-trending-down' : 'i-heroicons-arrow-trending-up'"
|
||||||
|
class="h-3 w-3"
|
||||||
|
:class="getGapAmount(member) > 0 ? 'text-red-500' : 'text-green-500'"
|
||||||
|
/>
|
||||||
|
<span :class="getGapAmount(member) > 0 ? 'text-red-600' : 'text-green-600'">
|
||||||
|
{{ getGapAmount(member) > 0 ? 'Gap: ' : 'Surplus: ' }}{{ formatCurrency(Math.abs(getGapAmount(member))) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="text-center py-8 text-gray-500">
|
||||||
|
<UIcon name="i-heroicons-users" class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p class="text-sm mb-2">No members added yet</p>
|
||||||
|
<p class="text-xs">Complete setup wizard to add team members</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer v-if="allocatedMembers.length > 0">
|
<template #footer v-if="allocatedMembers.length > 0">
|
||||||
<div class="text-sm text-gray-600 text-center">
|
<!-- Summary Stats -->
|
||||||
Team median {{ Math.round(stats.median) }}% • {{ stats.under100 }} under 100%{{ allCovered ? ' • All covered ✓' : '' }}
|
<div class="flex justify-between items-center text-sm text-gray-600 pb-3 border-b border-gray-200">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span>Median coverage: {{ Math.round(stats.median || 0) }}%</span>
|
||||||
|
<span :class="stats.under100 === 0 ? 'text-green-600' : 'text-amber-600'">
|
||||||
|
{{ stats.under100 === 0 ? 'All covered ✓' : `${stats.under100} need more` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
Total payroll: {{ formatCurrency(totalPayroll) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actionable Insights -->
|
||||||
|
<div class="pt-3">
|
||||||
|
<div v-if="totalGap > 0" class="text-xs">
|
||||||
|
<div class="flex items-center gap-2 text-amber-700">
|
||||||
|
<UIcon name="i-heroicons-light-bulb" class="h-3 w-3" />
|
||||||
|
<span class="font-medium">To cover everyone:</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-gray-600 pl-5">
|
||||||
|
Increase available payroll by <strong>{{ formatCurrency(totalGap) }}</strong>
|
||||||
|
through higher revenue or lower overhead costs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="totalSurplus > 0" class="text-xs">
|
||||||
|
<div class="flex items-center gap-2 text-green-700">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="h-3 w-3" />
|
||||||
|
<span class="font-medium">Healthy position:</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-gray-600 pl-5">
|
||||||
|
You have <strong>{{ formatCurrency(totalSurplus) }}</strong> surplus after covering all member needs.
|
||||||
|
Consider growth opportunities or building reserves.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-xs">
|
||||||
|
<div class="flex items-center gap-2 text-green-700">
|
||||||
|
<UIcon name="i-heroicons-scales" class="h-3 w-3" />
|
||||||
|
<span class="font-medium">Perfect balance:</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-gray-600 pl-5">
|
||||||
|
Available payroll exactly matches member needs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
@ -46,11 +142,61 @@ const { allocatePayroll, coverage, teamCoverageStats } = useCoopBuilder()
|
||||||
|
|
||||||
const allocatedMembers = computed(() => allocatePayroll())
|
const allocatedMembers = computed(() => allocatePayroll())
|
||||||
const stats = computed(() => teamCoverageStats())
|
const stats = computed(() => teamCoverageStats())
|
||||||
const allCovered = computed(() => stats.value.under100 === 0)
|
|
||||||
|
|
||||||
|
// Calculate total payroll
|
||||||
|
const totalPayroll = computed(() =>
|
||||||
|
allocatedMembers.value.reduce((sum, m) => sum + (m.monthlyPayPlanned || 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Color functions for coverage display
|
||||||
function getBarColor(pct: number): string {
|
function getBarColor(pct: number): string {
|
||||||
if (pct >= 100) return 'bg-green-500'
|
if (!pct || pct < 80) return 'bg-red-500'
|
||||||
if (pct >= 80) return 'bg-amber-500'
|
if (pct < 100) return 'bg-amber-500'
|
||||||
return 'bg-red-500'
|
return 'bg-green-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoverageColor(pct: number): string {
|
||||||
|
if (!pct || pct < 80) return 'red'
|
||||||
|
if (pct < 100) return 'amber'
|
||||||
|
return 'green'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAmountColor(planned: number = 0, needed: number = 0): string {
|
||||||
|
if (!needed) return 'text-gray-900'
|
||||||
|
if (planned >= needed) return 'text-green-600'
|
||||||
|
if (planned >= needed * 0.8) return 'text-amber-600'
|
||||||
|
return 'text-red-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate gap between what's needed vs what can be paid
|
||||||
|
function getGapAmount(member: any): number {
|
||||||
|
const planned = member.monthlyPayPlanned || 0
|
||||||
|
const needed = member.minMonthlyNeeds || 0
|
||||||
|
return needed - planned // positive = gap, negative = surplus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total gap/surplus across all members
|
||||||
|
const totalGap = computed(() => {
|
||||||
|
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||||
|
const totalPlanned = totalPayroll.value
|
||||||
|
const gap = totalNeeded - totalPlanned
|
||||||
|
return gap > 0 ? gap : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalSurplus = computed(() => {
|
||||||
|
const totalNeeded = allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||||
|
const totalPlanned = totalPayroll.value
|
||||||
|
const surplus = totalPlanned - totalNeeded
|
||||||
|
return surplus > 0 ? surplus : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Currency formatting
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,37 +1,165 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="hidden" data-ui="needs_coverage_card_v1" />
|
<div class="hidden" data-ui="needs_coverage_card_v2" />
|
||||||
<UCard class="min-h-[140px] shadow-sm rounded-xl">
|
<UCard class="min-h-[140px] shadow-sm rounded-xl">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center justify-between">
|
||||||
<UIcon name="i-heroicons-user-group" class="h-5 w-5" />
|
<div class="flex items-center gap-2">
|
||||||
<h3 class="font-semibold">Members covered</h3>
|
<UIcon name="i-heroicons-user-group" class="h-5 w-5" />
|
||||||
|
<h3 class="font-semibold">Member Needs Coverage</h3>
|
||||||
|
</div>
|
||||||
|
<UTooltip text="Shows how well the co-op can meet each member's stated financial needs">
|
||||||
|
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-help" />
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="text-center space-y-6">
|
<div v-if="hasMembers" class="space-y-4">
|
||||||
<div class="text-2xl font-semibold" :class="statusColor">
|
<!-- Team Summary -->
|
||||||
{{ pctCovered }}%
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-semibold" :class="statusColor">
|
||||||
|
{{ fullyCoveredCount }} of {{ totalMembers }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
members fully covered
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
Median {{ median }}%
|
<!-- Coverage Stats -->
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="font-medium">{{ median }}%</div>
|
||||||
|
<div class="text-gray-600">Median</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="font-medium" :class="underCoveredColor">{{ stats.under100 }}</div>
|
||||||
|
<div class="text-gray-600">Under 100%</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="font-medium">{{ formatCurrency(availablePayroll) }}</div>
|
||||||
|
<div class="text-gray-600">Available</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="stats.under100 > 0" class="flex items-center justify-center gap-1 text-xs text-amber-600 mt-3">
|
|
||||||
<span>⚠</span>
|
<!-- Intelligent Financial Analysis -->
|
||||||
<span>{{ stats.under100 }} under 100%</span>
|
<div v-if="hasMembers" class="space-y-2">
|
||||||
|
<!-- Coverage gap analysis -->
|
||||||
|
<div v-if="stats.under100 > 0" class="text-xs bg-amber-50 p-3 rounded border-l-4 border-amber-400">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="font-medium text-amber-800">Coverage Gap Analysis</p>
|
||||||
|
<p class="text-amber-700">
|
||||||
|
To meet member needs, you need <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements,
|
||||||
|
but you have <strong>{{ formatCurrency(availablePayroll) }}</strong> available for payroll.
|
||||||
|
</p>
|
||||||
|
<p class="text-amber-600">
|
||||||
|
<strong>Shortfall: {{ formatCurrency(Math.max(0, totalNeeds - availablePayroll)) }}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-600 mt-2">
|
||||||
|
💡 Note: This reflects member-stated needs. Check your Budget page for detailed payroll planning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Surplus analysis -->
|
||||||
|
<div v-else-if="availablePayroll > totalNeeds && totalNeeds > 0" class="text-xs bg-green-50 p-3 rounded border-l-4 border-green-400">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="font-medium text-green-800">Healthy Coverage</p>
|
||||||
|
<p class="text-green-700">
|
||||||
|
You have <strong>{{ formatCurrency(availablePayroll) }}</strong> available to cover
|
||||||
|
<strong>{{ formatCurrency(totalNeeds) }}</strong> in member needs.
|
||||||
|
</p>
|
||||||
|
<p class="text-green-600">
|
||||||
|
<strong>Surplus: {{ formatCurrency(availablePayroll - totalNeeds) }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No payroll available -->
|
||||||
|
<div v-else-if="availablePayroll === 0 && totalNeeds > 0" class="text-xs bg-red-50 p-3 rounded border-l-4 border-red-400">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<UIcon name="i-heroicons-x-circle" class="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="font-medium text-red-800">No Funds for Payroll</p>
|
||||||
|
<p class="text-red-700">
|
||||||
|
Member needs total <strong>{{ formatCurrency(totalNeeds) }}</strong> based on their stated requirements,
|
||||||
|
but current revenue minus costs leaves $0 for payroll.
|
||||||
|
</p>
|
||||||
|
<p class="text-red-600">
|
||||||
|
Consider increasing revenue or reducing overhead costs.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-red-600 mt-2">
|
||||||
|
💡 Note: This reflects member-stated needs. Your Budget page may show different payroll amounts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="text-center py-6 text-gray-500">
|
||||||
|
<UIcon name="i-heroicons-users" class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p class="text-sm">Add members in setup to see coverage</p>
|
||||||
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { members, teamCoverageStats } = useCoopBuilder()
|
const { members, teamCoverageStats, allocatePayroll, streams } = useCoopBuilder()
|
||||||
|
const coopStore = useCoopBuilderStore()
|
||||||
|
|
||||||
const stats = computed(() => teamCoverageStats())
|
const stats = computed(() => teamCoverageStats())
|
||||||
const pctCovered = computed(() => Math.round(stats.value.over100Pct || 0))
|
const allocatedMembers = computed(() => allocatePayroll())
|
||||||
const median = computed(() => Math.round(stats.value.median ?? 0))
|
const median = computed(() => Math.round(stats.value.median ?? 0))
|
||||||
|
|
||||||
|
// Team-level calculations
|
||||||
|
const hasMembers = computed(() => members.value.length > 0)
|
||||||
|
const totalMembers = computed(() => members.value.length)
|
||||||
|
const fullyCoveredCount = computed(() => totalMembers.value - stats.value.under100)
|
||||||
|
|
||||||
|
// Financial calculations
|
||||||
|
const totalNeeds = computed(() =>
|
||||||
|
allocatedMembers.value.reduce((sum, m) => sum + (m.minMonthlyNeeds || 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalRevenue = computed(() =>
|
||||||
|
streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const overheadCosts = computed(() =>
|
||||||
|
coopStore.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const availablePayroll = computed(() =>
|
||||||
|
Math.max(0, totalRevenue.value - overheadCosts.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status colors based on coverage
|
||||||
const statusColor = computed(() => {
|
const statusColor = computed(() => {
|
||||||
if (pctCovered.value >= 100) return 'text-green-600'
|
const ratio = fullyCoveredCount.value / Math.max(1, totalMembers.value)
|
||||||
if (pctCovered.value >= 80) return 'text-amber-600'
|
if (ratio === 1) return 'text-green-600'
|
||||||
|
if (ratio >= 0.8) return 'text-amber-600'
|
||||||
return 'text-red-600'
|
return 'text-red-600'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const underCoveredColor = computed(() => {
|
||||||
|
if (stats.value.under100 === 0) return 'text-green-600'
|
||||||
|
if (stats.value.under100 <= Math.ceil(totalMembers.value * 0.2)) return 'text-amber-600'
|
||||||
|
return 'text-red-600'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Currency formatting
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -60,6 +60,24 @@ export function useCoopBuilder() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const currency = computed({
|
||||||
|
get: () => {
|
||||||
|
try {
|
||||||
|
return store.currency || 'EUR'
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error accessing currency:', e)
|
||||||
|
return 'EUR'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (value: string) => {
|
||||||
|
try {
|
||||||
|
store.setCurrency(value)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error setting currency:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const scenario = computed({
|
const scenario = computed({
|
||||||
get: () => store.scenario,
|
get: () => store.scenario,
|
||||||
set: (value) => store.setScenario(value)
|
set: (value) => store.setScenario(value)
|
||||||
|
|
@ -78,12 +96,6 @@ export function useCoopBuilder() {
|
||||||
const baseStreams = [...streams.value]
|
const baseStreams = [...streams.value]
|
||||||
|
|
||||||
switch (scenario.value) {
|
switch (scenario.value) {
|
||||||
case 'quit-jobs':
|
|
||||||
return {
|
|
||||||
members: baseMembers.map(m => ({ ...m, externalMonthlyIncome: 0 })),
|
|
||||||
streams: baseStreams
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'start-production':
|
case 'start-production':
|
||||||
return {
|
return {
|
||||||
members: baseMembers,
|
members: baseMembers,
|
||||||
|
|
@ -154,25 +166,21 @@ export function useCoopBuilder() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage calculation for a single member
|
// Coverage calculation for a single member
|
||||||
function coverage(member: Member): { minPct: number; targetPct: number } {
|
function coverage(member: Member): { coveragePct: number } {
|
||||||
const totalIncome = (member.monthlyPayPlanned || 0) + (member.externalMonthlyIncome || 0)
|
const coopPay = member.monthlyPayPlanned || 0
|
||||||
|
|
||||||
const minPct = member.minMonthlyNeeds > 0
|
const coveragePct = member.minMonthlyNeeds > 0
|
||||||
? Math.min(200, (totalIncome / member.minMonthlyNeeds) * 100)
|
? Math.min(200, (coopPay / member.minMonthlyNeeds) * 100)
|
||||||
: 100
|
|
||||||
|
|
||||||
const targetPct = member.targetMonthlyPay > 0
|
|
||||||
? Math.min(200, (totalIncome / member.targetMonthlyPay) * 100)
|
|
||||||
: 100
|
: 100
|
||||||
|
|
||||||
return { minPct, targetPct }
|
return { coveragePct }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Team coverage statistics
|
// Team coverage statistics
|
||||||
function teamCoverageStats() {
|
function teamCoverageStats() {
|
||||||
try {
|
try {
|
||||||
const allocatedMembers = allocatePayroll() || []
|
const allocatedMembers = allocatePayroll() || []
|
||||||
const coverages = allocatedMembers.map(m => coverage(m).minPct).filter(c => !isNaN(c))
|
const coverages = allocatedMembers.map(m => coverage(m).coveragePct).filter(c => !isNaN(c))
|
||||||
|
|
||||||
if (coverages.length === 0) {
|
if (coverages.length === 0) {
|
||||||
return { median: 0, under100: 0, over100Pct: 0, gini: 0 }
|
return { median: 0, under100: 0, over100Pct: 0, gini: 0 }
|
||||||
|
|
@ -354,6 +362,7 @@ export function useCoopBuilder() {
|
||||||
streams,
|
streams,
|
||||||
policy,
|
policy,
|
||||||
operatingMode,
|
operatingMode,
|
||||||
|
currency,
|
||||||
scenario,
|
scenario,
|
||||||
stress,
|
stress,
|
||||||
milestones,
|
milestones,
|
||||||
|
|
@ -380,6 +389,7 @@ export function useCoopBuilder() {
|
||||||
setPolicy: (relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") => store.setPolicy(relationship),
|
setPolicy: (relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") => store.setPolicy(relationship),
|
||||||
setRoleBands: (bands: Record<string, number>) => store.setRoleBands(bands),
|
setRoleBands: (bands: Record<string, number>) => store.setRoleBands(bands),
|
||||||
setEqualWage: (wage: number) => store.setEqualWage(wage),
|
setEqualWage: (wage: number) => store.setEqualWage(wage),
|
||||||
|
setCurrency: (currency: string) => store.setCurrency(currency),
|
||||||
|
|
||||||
// Testing helpers
|
// Testing helpers
|
||||||
clearAll,
|
clearAll,
|
||||||
|
|
|
||||||
37
composables/useCurrency.ts
Normal file
37
composables/useCurrency.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { getCurrencySymbol } from '~/utils/currency'
|
||||||
|
|
||||||
|
export function useCurrency() {
|
||||||
|
const coop = useCoopBuilder()
|
||||||
|
|
||||||
|
const currencySymbol = computed(() => getCurrencySymbol(coop.currency.value))
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number, options?: { showSymbol?: boolean; precision?: number }) => {
|
||||||
|
const { showSymbol = true, precision = 0 } = options || {}
|
||||||
|
|
||||||
|
const formatted = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: precision,
|
||||||
|
maximumFractionDigits: precision
|
||||||
|
}).format(amount)
|
||||||
|
|
||||||
|
if (showSymbol) {
|
||||||
|
return `${currencySymbol.value}${formatted}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrencyCompact = (amount: number) => {
|
||||||
|
if (amount >= 1000000) {
|
||||||
|
return `${currencySymbol.value}${(amount / 1000000).toFixed(1)}M`
|
||||||
|
} else if (amount >= 1000) {
|
||||||
|
return `${currencySymbol.value}${(amount / 1000).toFixed(1)}k`
|
||||||
|
}
|
||||||
|
return formatCurrency(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currencySymbol,
|
||||||
|
formatCurrency,
|
||||||
|
formatCurrencyCompact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,18 +2,14 @@ import { useMembersStore } from '~/stores/members'
|
||||||
import { usePoliciesStore } from '~/stores/policies'
|
import { usePoliciesStore } from '~/stores/policies'
|
||||||
import { useStreamsStore } from '~/stores/streams'
|
import { useStreamsStore } from '~/stores/streams'
|
||||||
import { useBudgetStore } from '~/stores/budget'
|
import { useBudgetStore } from '~/stores/budget'
|
||||||
import { useScenariosStore } from '~/stores/scenarios'
|
|
||||||
import { useCashStore } from '~/stores/cash'
|
import { useCashStore } from '~/stores/cash'
|
||||||
import { useSessionStore } from '~/stores/session'
|
|
||||||
|
|
||||||
export type AppSnapshot = {
|
export type AppSnapshot = {
|
||||||
members: any[]
|
members: any[]
|
||||||
policies: Record<string, any>
|
policies: Record<string, any>
|
||||||
streams: any[]
|
streams: any[]
|
||||||
budget: Record<string, any>
|
budget: Record<string, any>
|
||||||
scenarios: Record<string, any>
|
|
||||||
cash: Record<string, any>
|
cash: Record<string, any>
|
||||||
session: Record<string, any>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFixtureIO() {
|
export function useFixtureIO() {
|
||||||
|
|
@ -22,9 +18,7 @@ export function useFixtureIO() {
|
||||||
const policies = usePoliciesStore()
|
const policies = usePoliciesStore()
|
||||||
const streams = useStreamsStore()
|
const streams = useStreamsStore()
|
||||||
const budget = useBudgetStore()
|
const budget = useBudgetStore()
|
||||||
const scenarios = useScenariosStore()
|
|
||||||
const cash = useCashStore()
|
const cash = useCashStore()
|
||||||
const session = useSessionStore()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
members: members.members,
|
members: members.members,
|
||||||
|
|
@ -48,23 +42,12 @@ export function useFixtureIO() {
|
||||||
productionCosts: budget.productionCosts,
|
productionCosts: budget.productionCosts,
|
||||||
currentPeriod: budget.currentPeriod
|
currentPeriod: budget.currentPeriod
|
||||||
},
|
},
|
||||||
scenarios: {
|
|
||||||
sliders: scenarios.sliders,
|
|
||||||
activeScenario: scenarios.activeScenario
|
|
||||||
},
|
|
||||||
cash: {
|
cash: {
|
||||||
cashEvents: cash.cashEvents,
|
cashEvents: cash.cashEvents,
|
||||||
paymentQueue: cash.paymentQueue,
|
paymentQueue: cash.paymentQueue,
|
||||||
currentCash: cash.currentCash,
|
currentCash: cash.currentCash,
|
||||||
currentSavings: cash.currentSavings
|
currentSavings: cash.currentSavings
|
||||||
},
|
},
|
||||||
session: {
|
|
||||||
checklist: session.checklist,
|
|
||||||
draftAllocations: session.draftAllocations,
|
|
||||||
rationale: session.rationale,
|
|
||||||
currentSession: session.currentSession,
|
|
||||||
savedRecords: session.savedRecords
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,9 +56,7 @@ export function useFixtureIO() {
|
||||||
const policies = usePoliciesStore()
|
const policies = usePoliciesStore()
|
||||||
const streams = useStreamsStore()
|
const streams = useStreamsStore()
|
||||||
const budget = useBudgetStore()
|
const budget = useBudgetStore()
|
||||||
const scenarios = useScenariosStore()
|
|
||||||
const cash = useCashStore()
|
const cash = useCashStore()
|
||||||
const session = useSessionStore()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Import members
|
// Import members
|
||||||
|
|
@ -98,10 +79,6 @@ export function useFixtureIO() {
|
||||||
budget.$patch(snapshot.budget)
|
budget.$patch(snapshot.budget)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import scenarios
|
|
||||||
if (snapshot.scenarios) {
|
|
||||||
scenarios.$patch(snapshot.scenarios)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import cash
|
// Import cash
|
||||||
if (snapshot.cash) {
|
if (snapshot.cash) {
|
||||||
|
|
@ -118,10 +95,6 @@ export function useFixtureIO() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import session
|
|
||||||
if (snapshot.session) {
|
|
||||||
session.$patch(snapshot.session)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Successfully imported data snapshot')
|
console.log('Successfully imported data snapshot')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import { monthlyPayroll } from '~/types/members'
|
|
||||||
|
|
||||||
export function useScenarios() {
|
|
||||||
const membersStore = useMembersStore()
|
|
||||||
const streamsStore = useStreamsStore()
|
|
||||||
const policiesStore = usePoliciesStore()
|
|
||||||
const budgetStore = useBudgetStore()
|
|
||||||
const cashStore = useCashStore()
|
|
||||||
|
|
||||||
// Base runway calculation
|
|
||||||
function calculateScenarioRunway(
|
|
||||||
members: any[],
|
|
||||||
streams: any[],
|
|
||||||
operatingMode: 'minimum' | 'target' = 'minimum'
|
|
||||||
) {
|
|
||||||
// Calculate payroll for scenario
|
|
||||||
const payrollCost = monthlyPayroll(members, operatingMode)
|
|
||||||
const oncostPct = policiesStore.payrollOncostPct || 0
|
|
||||||
const totalPayroll = payrollCost * (1 + oncostPct / 100)
|
|
||||||
|
|
||||||
// Calculate revenue
|
|
||||||
const totalRevenue = streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
|
|
||||||
|
|
||||||
// Add overhead
|
|
||||||
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
|
|
||||||
|
|
||||||
// Net monthly
|
|
||||||
const monthlyNet = totalRevenue - totalPayroll - overheadCost
|
|
||||||
|
|
||||||
// Cash + savings
|
|
||||||
const cash = cashStore.currentCash || 50000
|
|
||||||
const savings = cashStore.currentSavings || 15000
|
|
||||||
const totalLiquid = cash + savings
|
|
||||||
|
|
||||||
// Runway calculation
|
|
||||||
const monthlyBurn = totalPayroll + overheadCost
|
|
||||||
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : Infinity
|
|
||||||
|
|
||||||
return {
|
|
||||||
runway: Math.max(0, runway),
|
|
||||||
monthlyNet,
|
|
||||||
monthlyBurn,
|
|
||||||
totalRevenue,
|
|
||||||
totalPayroll
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scenario transformations per CLAUDE.md
|
|
||||||
const scenarioTransforms = {
|
|
||||||
current: () => ({
|
|
||||||
members: [...membersStore.members],
|
|
||||||
streams: [...streamsStore.streams]
|
|
||||||
}),
|
|
||||||
|
|
||||||
quitJobs: () => ({
|
|
||||||
// Set external income to 0 for members who have day jobs
|
|
||||||
members: membersStore.members.map(m => ({
|
|
||||||
...m,
|
|
||||||
externalMonthlyIncome: 0 // Assume everyone quits their day job
|
|
||||||
})),
|
|
||||||
streams: [...streamsStore.streams]
|
|
||||||
}),
|
|
||||||
|
|
||||||
startProduction: () => ({
|
|
||||||
members: [...membersStore.members],
|
|
||||||
// Reduce service revenue, increase production costs
|
|
||||||
streams: streamsStore.streams.map(s => {
|
|
||||||
// Reduce service contracts by 30%
|
|
||||||
if (s.category?.toLowerCase().includes('service') || s.name.toLowerCase().includes('service')) {
|
|
||||||
return { ...s, targetMonthlyAmount: (s.targetMonthlyAmount || 0) * 0.7 }
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate all scenarios
|
|
||||||
const scenarios = computed(() => {
|
|
||||||
const currentMode = policiesStore.operatingMode || 'minimum'
|
|
||||||
|
|
||||||
const current = scenarioTransforms.current()
|
|
||||||
const quitJobs = scenarioTransforms.quitJobs()
|
|
||||||
const startProduction = scenarioTransforms.startProduction()
|
|
||||||
|
|
||||||
return {
|
|
||||||
current: {
|
|
||||||
name: 'Operate Current',
|
|
||||||
status: 'Active',
|
|
||||||
...calculateScenarioRunway(current.members, current.streams, currentMode)
|
|
||||||
},
|
|
||||||
quitJobs: {
|
|
||||||
name: 'Quit Day Jobs',
|
|
||||||
status: 'Scenario',
|
|
||||||
...calculateScenarioRunway(quitJobs.members, quitJobs.streams, currentMode)
|
|
||||||
},
|
|
||||||
startProduction: {
|
|
||||||
name: 'Start Production',
|
|
||||||
status: 'Scenario',
|
|
||||||
...calculateScenarioRunway(startProduction.members, startProduction.streams, currentMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
scenarios,
|
|
||||||
calculateScenarioRunway,
|
|
||||||
scenarioTransforms
|
|
||||||
}
|
|
||||||
}
|
|
||||||
79
composables/useSetupState.ts
Normal file
79
composables/useSetupState.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* Setup State Management
|
||||||
|
*
|
||||||
|
* Provides utilities to determine setup completion status and manage
|
||||||
|
* field locking based on setup state
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const useSetupState = () => {
|
||||||
|
const coopStore = useCoopBuilderStore()
|
||||||
|
const membersStore = useMembersStore()
|
||||||
|
const policiesStore = usePoliciesStore()
|
||||||
|
const streamsStore = useStreamsStore()
|
||||||
|
|
||||||
|
// Check if setup is complete using the same logic as middleware
|
||||||
|
const isSetupComplete = computed(() => {
|
||||||
|
// Legacy stores OR new coop builder store (either is enough)
|
||||||
|
const legacyComplete =
|
||||||
|
membersStore.isValid &&
|
||||||
|
policiesStore.isValid &&
|
||||||
|
streamsStore.hasValidStreams
|
||||||
|
|
||||||
|
const coopComplete = Boolean(
|
||||||
|
coopStore &&
|
||||||
|
Array.isArray(coopStore.members) &&
|
||||||
|
coopStore.members.length > 0 &&
|
||||||
|
Array.isArray(coopStore.streams) &&
|
||||||
|
coopStore.streams.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return legacyComplete || coopComplete
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine if revenue and expense fields should be locked
|
||||||
|
const areRevenueFieldsLocked = computed(() => {
|
||||||
|
return isSetupComplete.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const areExpenseFieldsLocked = computed(() => {
|
||||||
|
return isSetupComplete.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine if member management should be in separate interface
|
||||||
|
const shouldUseSeparateMemberInterface = computed(() => {
|
||||||
|
return isSetupComplete.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get setup completion percentage for progress display
|
||||||
|
const setupProgress = computed(() => {
|
||||||
|
let completed = 0
|
||||||
|
let total = 4 // policies, members, revenue, costs
|
||||||
|
|
||||||
|
if (policiesStore.isValid || (coopStore.equalHourlyWage > 0)) completed++
|
||||||
|
if (membersStore.members.length > 0 || coopStore.members.length > 0) completed++
|
||||||
|
if (streamsStore.hasValidStreams || coopStore.streams.length > 0) completed++
|
||||||
|
if (coopStore.overheadCosts.length > 0) completed++
|
||||||
|
|
||||||
|
return Math.round((completed / total) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation helpers
|
||||||
|
const goToSetup = () => {
|
||||||
|
navigateTo('/coop-planner')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToMemberManagement = () => {
|
||||||
|
navigateTo('/settings#members')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSetupComplete,
|
||||||
|
areRevenueFieldsLocked,
|
||||||
|
areExpenseFieldsLocked,
|
||||||
|
shouldUseSeparateMemberInterface,
|
||||||
|
setupProgress,
|
||||||
|
goToSetup,
|
||||||
|
goToMemberManagement,
|
||||||
|
}
|
||||||
|
}
|
||||||
260
composables/useStorSync.ts
Normal file
260
composables/useStorSync.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
/**
|
||||||
|
* Store Synchronization Composable
|
||||||
|
*
|
||||||
|
* Ensures that the legacy stores (streams, members, policies) always stay
|
||||||
|
* synchronized with the new CoopBuilderStore. This makes the setup interface
|
||||||
|
* the single source of truth while maintaining backward compatibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const useStoreSync = () => {
|
||||||
|
const coopStore = useCoopBuilderStore()
|
||||||
|
const streamsStore = useStreamsStore()
|
||||||
|
const membersStore = useMembersStore()
|
||||||
|
const policiesStore = usePoliciesStore()
|
||||||
|
|
||||||
|
// Flags to prevent recursive syncing and duplicate watchers
|
||||||
|
let isSyncing = false
|
||||||
|
let watchersSetup = false
|
||||||
|
|
||||||
|
// Sync CoopBuilder -> Legacy Stores
|
||||||
|
const syncToLegacyStores = () => {
|
||||||
|
if (isSyncing) return
|
||||||
|
isSyncing = true
|
||||||
|
// Sync streams
|
||||||
|
streamsStore.resetStreams()
|
||||||
|
coopStore.streams.forEach((stream: any) => {
|
||||||
|
streamsStore.upsertStream({
|
||||||
|
id: stream.id,
|
||||||
|
name: stream.label,
|
||||||
|
category: stream.category || 'services',
|
||||||
|
targetMonthlyAmount: stream.monthly,
|
||||||
|
certainty: stream.certainty || 'Probable',
|
||||||
|
payoutDelayDays: 30,
|
||||||
|
terms: 'Net 30',
|
||||||
|
targetPct: 0,
|
||||||
|
revenueSharePct: 0,
|
||||||
|
platformFeePct: 0,
|
||||||
|
restrictions: 'General',
|
||||||
|
seasonalityWeights: new Array(12).fill(1),
|
||||||
|
effortHoursPerMonth: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync members
|
||||||
|
membersStore.resetMembers()
|
||||||
|
coopStore.members.forEach((member: any) => {
|
||||||
|
membersStore.upsertMember({
|
||||||
|
id: member.id,
|
||||||
|
displayName: member.name,
|
||||||
|
role: member.role || '',
|
||||||
|
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33),
|
||||||
|
minMonthlyNeeds: member.minMonthlyNeeds || 0,
|
||||||
|
monthlyPayPlanned: member.monthlyPayPlanned || 0,
|
||||||
|
targetMonthlyPay: member.targetMonthlyPay || 0,
|
||||||
|
externalMonthlyIncome: member.externalMonthlyIncome || 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync policies - using individual update calls based on store structure
|
||||||
|
policiesStore.updatePolicy('equalHourlyWage', coopStore.equalHourlyWage)
|
||||||
|
policiesStore.updatePolicy('payrollOncostPct', coopStore.payrollOncostPct)
|
||||||
|
policiesStore.updatePolicy('savingsTargetMonths', coopStore.savingsTargetMonths)
|
||||||
|
policiesStore.updatePolicy('minCashCushionAmount', coopStore.minCashCushion)
|
||||||
|
|
||||||
|
// Reset flag after sync completes
|
||||||
|
nextTick(() => {
|
||||||
|
isSyncing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync Legacy Stores -> CoopBuilder
|
||||||
|
const syncFromLegacyStores = () => {
|
||||||
|
if (isSyncing) return
|
||||||
|
isSyncing = true
|
||||||
|
// Sync streams from legacy store
|
||||||
|
streamsStore.streams.forEach((stream: any) => {
|
||||||
|
coopStore.upsertStream({
|
||||||
|
id: stream.id,
|
||||||
|
label: stream.name,
|
||||||
|
monthly: stream.targetMonthlyAmount,
|
||||||
|
category: stream.category,
|
||||||
|
certainty: stream.certainty
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync members from legacy store
|
||||||
|
membersStore.members.forEach((member: any) => {
|
||||||
|
coopStore.upsertMember({
|
||||||
|
id: member.id,
|
||||||
|
name: member.displayName,
|
||||||
|
role: member.role,
|
||||||
|
hoursPerMonth: Math.round((member.hoursPerWeek || 0) * 4.33),
|
||||||
|
minMonthlyNeeds: member.minMonthlyNeeds,
|
||||||
|
monthlyPayPlanned: member.monthlyPayPlanned,
|
||||||
|
targetMonthlyPay: member.targetMonthlyPay,
|
||||||
|
externalMonthlyIncome: member.externalMonthlyIncome
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync policies from legacy store
|
||||||
|
if (policiesStore.isValid) {
|
||||||
|
coopStore.setEqualWage(policiesStore.equalHourlyWage)
|
||||||
|
coopStore.setOncostPct(policiesStore.payrollOncostPct)
|
||||||
|
coopStore.savingsTargetMonths = policiesStore.savingsTargetMonths
|
||||||
|
coopStore.minCashCushion = policiesStore.minCashCushionAmount
|
||||||
|
if (policiesStore.payPolicy?.relationship) {
|
||||||
|
coopStore.setPolicy(policiesStore.payPolicy.relationship as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset flag after sync completes
|
||||||
|
nextTick(() => {
|
||||||
|
isSyncing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes in CoopBuilder and sync to legacy stores
|
||||||
|
const setupCoopBuilderWatchers = () => {
|
||||||
|
// Watch streams changes
|
||||||
|
watch(() => coopStore.streams, () => {
|
||||||
|
if (!isSyncing) {
|
||||||
|
syncToLegacyStores()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watch members changes
|
||||||
|
watch(() => coopStore.members, () => {
|
||||||
|
if (!isSyncing) {
|
||||||
|
syncToLegacyStores()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watch policy changes
|
||||||
|
watch(() => [
|
||||||
|
coopStore.equalHourlyWage,
|
||||||
|
coopStore.payrollOncostPct,
|
||||||
|
coopStore.savingsTargetMonths,
|
||||||
|
coopStore.minCashCushion,
|
||||||
|
coopStore.currency,
|
||||||
|
coopStore.policy.relationship
|
||||||
|
], () => {
|
||||||
|
if (!isSyncing) {
|
||||||
|
syncToLegacyStores()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes in legacy stores and sync to CoopBuilder
|
||||||
|
const setupLegacyStoreWatchers = () => {
|
||||||
|
// Watch streams store changes
|
||||||
|
watch(() => streamsStore.streams, () => {
|
||||||
|
if (!isSyncing) {
|
||||||
|
syncFromLegacyStores()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watch members store changes
|
||||||
|
watch(() => membersStore.members, () => {
|
||||||
|
if (!isSyncing) {
|
||||||
|
syncFromLegacyStores()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watch policies store changes
|
||||||
|
watch(() => [
|
||||||
|
policiesStore.equalHourlyWage,
|
||||||
|
policiesStore.payrollOncostPct,
|
||||||
|
policiesStore.savingsTargetMonths,
|
||||||
|
policiesStore.minCashCushionAmount,
|
||||||
|
policiesStore.payPolicy?.relationship
|
||||||
|
], () => {
|
||||||
|
if (!isSyncing) {
|
||||||
|
syncFromLegacyStores()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize synchronization
|
||||||
|
const initSync = async () => {
|
||||||
|
// Wait for next tick to ensure stores are mounted
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Force store hydration by accessing $state
|
||||||
|
if (coopStore.$state) {
|
||||||
|
console.log('🔄 CoopBuilder store hydrated')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure localStorage is loaded
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
// Determine which store has data and sync accordingly
|
||||||
|
const coopHasData = coopStore.members.length > 0 || coopStore.streams.length > 0
|
||||||
|
const legacyHasData = streamsStore.streams.length > 0 || membersStore.members.length > 0
|
||||||
|
|
||||||
|
console.log('🔄 InitSync: CoopBuilder data:', coopHasData, 'Legacy data:', legacyHasData)
|
||||||
|
console.log('🔄 CoopBuilder members:', coopStore.members.length, 'streams:', coopStore.streams.length)
|
||||||
|
console.log('🔄 Legacy members:', membersStore.members.length, 'streams:', streamsStore.streams.length)
|
||||||
|
|
||||||
|
if (coopHasData && !legacyHasData) {
|
||||||
|
console.log('🔄 Syncing CoopBuilder → Legacy')
|
||||||
|
syncToLegacyStores()
|
||||||
|
} else if (legacyHasData && !coopHasData) {
|
||||||
|
console.log('🔄 Syncing Legacy → CoopBuilder')
|
||||||
|
syncFromLegacyStores()
|
||||||
|
} else if (coopHasData && legacyHasData) {
|
||||||
|
console.log('🔄 Both have data, keeping in sync')
|
||||||
|
// Both have data, ensure consistency by syncing from CoopBuilder (primary source)
|
||||||
|
syncToLegacyStores()
|
||||||
|
} else {
|
||||||
|
console.log('🔄 No data in either store')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up watchers for ongoing sync (only once)
|
||||||
|
if (!watchersSetup) {
|
||||||
|
setupCoopBuilderWatchers()
|
||||||
|
setupLegacyStoreWatchers()
|
||||||
|
watchersSetup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return promise to allow awaiting
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unified streams data (prioritize CoopBuilder) - make reactive
|
||||||
|
const unifiedStreams = computed(() => {
|
||||||
|
if (coopStore.streams.length > 0) {
|
||||||
|
return coopStore.streams.map(stream => ({
|
||||||
|
...stream,
|
||||||
|
name: stream.label,
|
||||||
|
targetMonthlyAmount: stream.monthly
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return streamsStore.streams
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get unified members data (prioritize CoopBuilder) - make reactive
|
||||||
|
const unifiedMembers = computed(() => {
|
||||||
|
if (coopStore.members.length > 0) {
|
||||||
|
return coopStore.members.map(member => ({
|
||||||
|
...member,
|
||||||
|
displayName: member.name,
|
||||||
|
hoursPerWeek: Math.round((member.hoursPerMonth || 0) / 4.33)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return membersStore.members
|
||||||
|
})
|
||||||
|
|
||||||
|
// Getter functions for backward compatibility
|
||||||
|
const getStreams = () => unifiedStreams.value
|
||||||
|
const getMembers = () => unifiedMembers.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncToLegacyStores,
|
||||||
|
syncFromLegacyStores,
|
||||||
|
initSync,
|
||||||
|
getStreams,
|
||||||
|
getMembers,
|
||||||
|
unifiedStreams,
|
||||||
|
unifiedMembers
|
||||||
|
}
|
||||||
|
}
|
||||||
967
pages/budget.vue
967
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>
|
||||||
101
pages/cash.vue
101
pages/cash.vue
|
|
@ -1,101 +0,0 @@
|
||||||
<template>
|
|
||||||
<section class="py-8 space-y-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-semibold">Cash Calendar</h2>
|
|
||||||
<UBadge v-if="firstBreachWeek" color="red" variant="subtle"
|
|
||||||
>Week {{ firstBreachWeek }} cushion breach</UBadge
|
|
||||||
>
|
|
||||||
<UBadge v-else color="green" variant="subtle"
|
|
||||||
>No cushion breach projected</UBadge
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">13-Week Cash Flow</h3>
|
|
||||||
</template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="text-sm text-neutral-600">
|
|
||||||
Week-by-week cash inflows and outflows with minimum cushion tracking.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-7 gap-2 text-xs font-medium text-neutral-500">
|
|
||||||
<div>Week</div>
|
|
||||||
<div>Inflow</div>
|
|
||||||
<div>Outflow</div>
|
|
||||||
<div>Net</div>
|
|
||||||
<div>Balance</div>
|
|
||||||
<div>Cushion</div>
|
|
||||||
<div>Status</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="week in weeks"
|
|
||||||
:key="week.number"
|
|
||||||
class="grid grid-cols-7 gap-2 text-sm py-2 border-b border-neutral-100"
|
|
||||||
:class="{ 'bg-red-50': week.breachesCushion }">
|
|
||||||
<div class="font-medium">{{ week.number }}</div>
|
|
||||||
<div class="text-green-600">+€{{ week.inflow.toLocaleString() }}</div>
|
|
||||||
<div class="text-red-600">-€{{ week.outflow.toLocaleString() }}</div>
|
|
||||||
<div :class="week.net >= 0 ? 'text-green-600' : 'text-red-600'">
|
|
||||||
{{ week.net >= 0 ? "+" : "" }}€{{ week.net.toLocaleString() }}
|
|
||||||
</div>
|
|
||||||
<div class="font-medium">€{{ week.balance.toLocaleString() }}</div>
|
|
||||||
<div
|
|
||||||
:class="
|
|
||||||
week.breachesCushion
|
|
||||||
? 'text-red-600 font-medium'
|
|
||||||
: 'text-neutral-600'
|
|
||||||
">
|
|
||||||
€{{ week.cushion.toLocaleString() }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<UBadge v-if="week.breachesCushion" color="red" size="xs">
|
|
||||||
Breach
|
|
||||||
</UBadge>
|
|
||||||
<UBadge v-else color="green" size="xs"> OK </UBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 p-3 bg-orange-50 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
name="i-heroicons-exclamation-triangle"
|
|
||||||
class="text-orange-500" />
|
|
||||||
<span class="text-sm font-medium text-orange-800">
|
|
||||||
This week would drop below your minimum cushion.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const cashStore = useCashStore();
|
|
||||||
const { weeklyProjections } = storeToRefs(cashStore);
|
|
||||||
|
|
||||||
const weeks = computed(() => {
|
|
||||||
// If no projections, show empty state
|
|
||||||
if (weeklyProjections.value.length === 0) {
|
|
||||||
return Array.from({ length: 13 }, (_, index) => ({
|
|
||||||
number: index + 1,
|
|
||||||
inflow: 0,
|
|
||||||
outflow: 0,
|
|
||||||
net: 0,
|
|
||||||
balance: 0,
|
|
||||||
cushion: 0,
|
|
||||||
breachesCushion: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return weeklyProjections.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find first week that breaches cushion
|
|
||||||
const firstBreachWeek = computed(() => {
|
|
||||||
const breachWeek = weeks.value.find((week) => week.breachesCushion);
|
|
||||||
return breachWeek ? breachWeek.number : null;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
|
|
||||||
<!-- Vertical Steps Layout -->
|
<!-- Vertical Steps Layout -->
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<!-- Step 1: Members -->
|
<!-- Step 1: Pay Policy -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<!-- Dithered shadow for selected state -->
|
<!-- Dithered shadow for selected state -->
|
||||||
<div
|
<div
|
||||||
|
|
@ -68,12 +68,12 @@
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||||
:class="
|
:class="
|
||||||
membersValid
|
policiesValid
|
||||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
||||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
||||||
">
|
">
|
||||||
<UIcon
|
<UIcon
|
||||||
v-if="membersValid"
|
v-if="policiesValid"
|
||||||
name="i-heroicons-check"
|
name="i-heroicons-check"
|
||||||
class="w-4 h-4" />
|
class="w-4 h-4" />
|
||||||
<span v-else>1</span>
|
<span v-else>1</span>
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||||
Add your team
|
Choose pay approach
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -95,12 +95,12 @@
|
||||||
<div
|
<div
|
||||||
v-if="focusedStep === 1"
|
v-if="focusedStep === 1"
|
||||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||||
<WizardMembersStep @save-status="handleSaveStatus" />
|
<WizardPoliciesStep @save-status="handleSaveStatus" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Wage -->
|
<!-- Step 2: Members -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<!-- Dithered shadow for selected state -->
|
<!-- Dithered shadow for selected state -->
|
||||||
<div
|
<div
|
||||||
|
|
@ -120,12 +120,12 @@
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||||
:class="
|
:class="
|
||||||
policiesValid
|
membersValid
|
||||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
||||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
||||||
">
|
">
|
||||||
<UIcon
|
<UIcon
|
||||||
v-if="policiesValid"
|
v-if="membersValid"
|
||||||
name="i-heroicons-check"
|
name="i-heroicons-check"
|
||||||
class="w-4 h-4" />
|
class="w-4 h-4" />
|
||||||
<span v-else>2</span>
|
<span v-else>2</span>
|
||||||
|
|
@ -133,7 +133,7 @@
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||||
Set your wage
|
Add your team
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,7 +147,7 @@
|
||||||
<div
|
<div
|
||||||
v-if="focusedStep === 2"
|
v-if="focusedStep === 2"
|
||||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
||||||
<WizardPoliciesStep @save-status="handleSaveStatus" />
|
<WizardMembersStep @save-status="handleSaveStatus" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,13 +170,22 @@
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white">
|
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
||||||
<UIcon name="i-heroicons-check" class="w-4 h-4" />
|
:class="
|
||||||
|
costsValid
|
||||||
|
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
||||||
|
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
||||||
|
">
|
||||||
|
<UIcon
|
||||||
|
v-if="costsValid"
|
||||||
|
name="i-heroicons-check"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
<span v-else>3</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||||
Monthly costs
|
Expenses
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -247,60 +256,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 5: Review -->
|
|
||||||
<div class="relative">
|
|
||||||
<!-- Dithered shadow for selected state -->
|
|
||||||
<div
|
|
||||||
v-if="focusedStep === 5"
|
|
||||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
|
|
||||||
focusedStep === 5 ? 'item-selected' : '',
|
|
||||||
]">
|
|
||||||
<div
|
|
||||||
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
|
||||||
@click="setFocusedStep(5)">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
|
|
||||||
:class="
|
|
||||||
canComplete
|
|
||||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
|
|
||||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
|
|
||||||
">
|
|
||||||
<UIcon
|
|
||||||
v-if="canComplete"
|
|
||||||
name="i-heroicons-check"
|
|
||||||
class="w-4 h-4" />
|
|
||||||
<span v-else>5</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
|
||||||
Review & finish
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UIcon
|
|
||||||
name="i-heroicons-chevron-down"
|
|
||||||
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
|
|
||||||
:class="{ 'rotate-180': focusedStep === 5 }" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="focusedStep === 5"
|
|
||||||
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white">
|
|
||||||
<WizardReviewStep
|
|
||||||
@complete="completeWizard"
|
|
||||||
@reset="resetWizard" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress Actions -->
|
<!-- Progress Actions -->
|
||||||
<div class="flex justify-between items-center pt-8">
|
<div class="flex justify-between items-center pt-8">
|
||||||
<button
|
<button
|
||||||
|
|
@ -334,12 +289,25 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- View Dashboard button (when partially complete) -->
|
||||||
<button
|
<button
|
||||||
v-if="canComplete"
|
v-if="hasBasicData && !canComplete"
|
||||||
class="export-btn primary"
|
class="export-btn"
|
||||||
@click="completeWizard">
|
@click="navigateTo('/dashboard')"
|
||||||
Complete Setup
|
>
|
||||||
|
<UIcon name="i-heroicons-chart-bar" class="mr-2" />
|
||||||
|
View Dashboard
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<UTooltip :text="incompleteSectionsText" :prevent="canComplete">
|
||||||
|
<button
|
||||||
|
class="export-btn primary"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': !canComplete }"
|
||||||
|
:disabled="!canComplete"
|
||||||
|
@click="canComplete ? completeWizard() : null">
|
||||||
|
Complete Setup
|
||||||
|
</button>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -357,11 +325,6 @@ const saveStatus = ref("");
|
||||||
const isResetting = ref(false);
|
const isResetting = ref(false);
|
||||||
const isCompleted = ref(false);
|
const isCompleted = ref(false);
|
||||||
|
|
||||||
// Computed validation
|
|
||||||
const canComplete = computed(() => {
|
|
||||||
return coop.members.value.length > 0 && coop.streams.value.length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Local validity flags for step headers
|
// Local validity flags for step headers
|
||||||
const membersValid = computed(() => {
|
const membersValid = computed(() => {
|
||||||
// Valid if at least one member with a name and positive hours
|
// Valid if at least one member with a name and positive hours
|
||||||
|
|
@ -375,7 +338,12 @@ const membersValid = computed(() => {
|
||||||
const policiesValid = computed(() => {
|
const policiesValid = computed(() => {
|
||||||
// Placeholder policy validity; mark true when wage text or policy set exists
|
// Placeholder policy validity; mark true when wage text or policy set exists
|
||||||
// Since policy not persisted yet in this store, consider valid when any member exists
|
// Since policy not persisted yet in this store, consider valid when any member exists
|
||||||
return membersValid.value;
|
return coop.members.value.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const costsValid = computed(() => {
|
||||||
|
// Costs are optional, so always mark as valid for now
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const streamsValid = computed(() => {
|
const streamsValid = computed(() => {
|
||||||
|
|
@ -389,6 +357,34 @@ 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 (
|
||||||
|
policiesValid.value &&
|
||||||
|
membersValid.value &&
|
||||||
|
costsValid.value &&
|
||||||
|
streamsValid.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate tooltip text for incomplete sections
|
||||||
|
const incompleteSectionsText = computed(() => {
|
||||||
|
if (canComplete.value) return "";
|
||||||
|
|
||||||
|
const incomplete = [];
|
||||||
|
if (!policiesValid.value) incomplete.push("Choose pay approach");
|
||||||
|
if (!membersValid.value) incomplete.push("Add team members");
|
||||||
|
if (!costsValid.value) incomplete.push("Add monthly costs");
|
||||||
|
if (!streamsValid.value) incomplete.push("Add revenue streams");
|
||||||
|
|
||||||
|
return `Complete these sections: ${incomplete.join(", ")}`;
|
||||||
|
});
|
||||||
|
|
||||||
// Save status handler
|
// Save status handler
|
||||||
function handleSaveStatus(status: "saving" | "saved" | "error") {
|
function handleSaveStatus(status: "saving" | "saved" | "error") {
|
||||||
saveStatus.value = status;
|
saveStatus.value = status;
|
||||||
|
|
@ -404,12 +400,14 @@ function handleSaveStatus(status: "saving" | "saved" | "error") {
|
||||||
|
|
||||||
// Step management
|
// Step management
|
||||||
function setFocusedStep(step: number) {
|
function setFocusedStep(step: number) {
|
||||||
|
console.log("Setting focused step to:", step, "Current:", focusedStep.value);
|
||||||
// Toggle if clicking on already focused step
|
// Toggle if clicking on already focused step
|
||||||
if (focusedStep.value === step) {
|
if (focusedStep.value === step) {
|
||||||
focusedStep.value = 0; // Close the section
|
focusedStep.value = 0; // Close the section
|
||||||
} else {
|
} else {
|
||||||
focusedStep.value = step; // Open the section
|
focusedStep.value = step; // Open the section
|
||||||
}
|
}
|
||||||
|
console.log("Focused step is now:", focusedStep.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeWizard() {
|
function completeWizard() {
|
||||||
|
|
|
||||||
|
|
@ -43,31 +43,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<!-- Value Accounting Section -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-medium">Value Accounting</h3>
|
|
||||||
<UBadge color="blue" variant="subtle">January 2024</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 mb-2">
|
|
||||||
Next Value Session due January 2024
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<UProgress :value="50" :max="100" color="blue" class="w-32" />
|
|
||||||
<span class="text-sm text-gray-600">2/4 prep steps done</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UButton color="primary" @click="navigateTo('/session')">
|
|
||||||
Start Session
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,166 @@
|
||||||
<template>
|
<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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
<h2 class="text-2xl font-semibold">Compensation</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm text-gray-600">Min</span>
|
<span class="text-sm">Mode:</span>
|
||||||
<UToggle
|
<button
|
||||||
:model-value="operatingMode === 'target'"
|
@click="setOperatingMode('min')"
|
||||||
@update:model-value="(value) => setOperatingMode(value ? 'target' : 'min')"
|
class="px-3 py-1 text-sm font-bold border-2 border-black"
|
||||||
/>
|
:class="coopStore.operatingMode === 'min' ? 'bg-black text-white' : 'bg-white'">
|
||||||
<span class="text-sm text-gray-600">Target</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Core Metrics -->
|
<!-- Simple Policy Display -->
|
||||||
<DashboardCoreMetrics />
|
<div class="border-2 border-black bg-white p-4">
|
||||||
|
<div class="text-lg font-bold mb-2">
|
||||||
<!-- Member Coverage -->
|
{{ getPolicyName() }} Policy
|
||||||
<MemberCoveragePanel />
|
|
||||||
|
|
||||||
<!-- Advanced Tools -->
|
|
||||||
<AdvancedAccordion />
|
|
||||||
|
|
||||||
<!-- Next Session -->
|
|
||||||
<UCard class="shadow-sm rounded-xl">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h3 class="font-semibold">Next Value Session</h3>
|
|
||||||
<p class="text-sm text-gray-600">Review contributions and distribute surplus</p>
|
|
||||||
</div>
|
|
||||||
<UButton color="primary" @click="navigateTo('/session')">
|
|
||||||
Start Session
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
<div class="text-2xl font-mono">
|
||||||
</div>
|
{{ 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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Import components explicitly to avoid auto-import issues
|
const { $format } = useNuxtApp();
|
||||||
import DashboardCoreMetrics from '~/components/dashboard/DashboardCoreMetrics.vue'
|
const coopStore = useCoopBuilderStore();
|
||||||
import MemberCoveragePanel from '~/components/dashboard/MemberCoveragePanel.vue'
|
|
||||||
import AdvancedAccordion from '~/components/dashboard/AdvancedAccordion.vue'
|
|
||||||
|
|
||||||
// Access composable data
|
// Calculate member pay based on policy
|
||||||
const { operatingMode, setOperatingMode } = useCoopBuilder()
|
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>
|
</script>
|
||||||
|
|
@ -57,12 +57,6 @@ const glossaryTerms = ref([
|
||||||
"Month-by-month plan of money in and money out. Not exact dates.",
|
"Month-by-month plan of money in and money out. Not exact dates.",
|
||||||
example: "January budget shows €12,000 revenue and €9,900 costs",
|
example: "January budget shows €12,000 revenue and €9,900 costs",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "cash-flow",
|
|
||||||
term: "Cash Flow",
|
|
||||||
definition: "The actual dates money moves. Shows timing risk.",
|
|
||||||
example: "Client pays Net 30, so January work arrives in February",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "concentration",
|
id: "concentration",
|
||||||
term: "Concentration",
|
term: "Concentration",
|
||||||
|
|
@ -143,13 +137,6 @@ const glossaryTerms = ref([
|
||||||
definition: "Money left over after all costs are paid.",
|
definition: "Money left over after all costs are paid.",
|
||||||
example: "€12,000 revenue - €9,900 costs = €2,100 surplus",
|
example: "€12,000 revenue - €9,900 costs = €2,100 surplus",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "value-accounting",
|
|
||||||
term: "Value Accounting",
|
|
||||||
definition:
|
|
||||||
"Monthly process to review contributions and distribute surplus.",
|
|
||||||
example: "January session: review work, repay deferred pay, fund training",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter terms based on search
|
// Filter terms based on search
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Monthly vs Annual Planning</h3>
|
<h3 class="text-xl font-semibold mb-3">Monthly vs Annual Planning</h3>
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
[Add content about balancing monthly cash flow with annual strategic planning]
|
[Add content about balancing monthly budgets with annual strategic planning]
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Setting Realistic Goals</h3>
|
<h3 class="text-xl font-semibold mb-3">Setting Realistic Goals</h3>
|
||||||
|
|
|
||||||
675
pages/index.vue
675
pages/index.vue
|
|
@ -2,40 +2,32 @@
|
||||||
<section class="py-8 space-y-6">
|
<section class="py-8 space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div class="flex items-center gap-2 mt-1">
|
||||||
<UBadge
|
<span class="px-2 py-1 border border-black bg-white text-xs font-bold uppercase">
|
||||||
:color="policiesStore.operatingMode === 'target' ? 'primary' : 'gray'"
|
{{ policiesStore.operatingMode === 'target' ? 'Target Mode' : 'Min Mode' }}
|
||||||
size="xs"
|
</span>
|
||||||
>
|
<span class="text-xs font-mono">
|
||||||
{{ policiesStore.operatingMode === 'target' ? '🎯 Target Mode' : '⚡ Min Mode' }}
|
|
||||||
</UBadge>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
Runway: {{ Math.round(metrics.runway) }}mo
|
Runway: {{ Math.round(metrics.runway) }}mo
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<UButton
|
<button
|
||||||
icon="i-heroicons-arrow-down-tray"
|
|
||||||
color="gray"
|
|
||||||
@click="onExport"
|
@click="onExport"
|
||||||
>Export 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
|
||||||
<UButton icon="i-heroicons-arrow-up-tray" color="gray" @click="onImport"
|
</button>
|
||||||
>Import JSON</UButton
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Key Metrics Row -->
|
<!-- Key Metrics Row -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<RunwayMeter
|
|
||||||
:months="metrics.runway"
|
|
||||||
:description="`You have ${$format.number(
|
|
||||||
metrics.runway
|
|
||||||
)} months of runway with current spending.`" />
|
|
||||||
|
|
||||||
<CoverageMeter
|
<CoverageMeter
|
||||||
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
|
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
|
||||||
:target-hours="metrics.totalTargetHours"
|
:target-hours="metrics.totalTargetHours"
|
||||||
|
|
@ -46,390 +38,152 @@
|
||||||
:savings-target-months="savingsProgress.targetMonths"
|
:savings-target-months="savingsProgress.targetMonths"
|
||||||
:monthly-burn="getMonthlyBurn()"
|
:monthly-burn="getMonthlyBurn()"
|
||||||
:description="`${savingsProgress.progressPct.toFixed(0)}% of savings target reached. ${savingsProgress.gap > 0 ? 'Gap: ' + $format.currency(savingsProgress.gap) : 'Target achieved!'}`" />
|
: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>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Wins Dashboard Components -->
|
<!-- Dashboard Components with Wizard Styling -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Needs Coverage Bars -->
|
<!-- Needs Coverage Bars -->
|
||||||
<UCard>
|
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||||
<template #header>
|
<div class="border-b-2 border-black p-4">
|
||||||
<h3 class="text-lg font-medium">Member Coverage</h3>
|
<h3 class="text-lg font-bold uppercase">Member Coverage</h3>
|
||||||
</template>
|
</div>
|
||||||
<NeedsCoverageBars />
|
<div class="p-4">
|
||||||
</UCard>
|
<NeedsCoverageBars />
|
||||||
|
</div>
|
||||||
<!-- Revenue Mix -->
|
</div>
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Revenue Mix</h3>
|
|
||||||
</template>
|
|
||||||
<RevenueMixTable />
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Milestone-Runway Overlay -->
|
<!-- Milestone-Runway Overlay -->
|
||||||
<UCard>
|
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||||
<template #header>
|
<div class="border-b-2 border-black p-4">
|
||||||
<h3 class="text-lg font-medium">Runway vs Milestones</h3>
|
<h3 class="text-lg font-bold uppercase">Runway vs Milestones</h3>
|
||||||
</template>
|
</div>
|
||||||
<MilestoneRunwayOverlay />
|
<div class="p-4">
|
||||||
</UCard>
|
<MilestoneRunwayOverlay />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alerts Section -->
|
<!-- Alerts Section with Wizard Styling -->
|
||||||
<UCard>
|
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||||
<template #header>
|
<div class="border-b-2 border-black p-4">
|
||||||
<h3 class="text-lg font-medium">Alerts</h3>
|
<h3 class="text-lg font-bold uppercase">Alerts</h3>
|
||||||
</template>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="p-4 space-y-4">
|
||||||
<!-- Concentration Risk Alert -->
|
<!-- Concentration Risk Alert -->
|
||||||
<UAlert
|
<div
|
||||||
v-if="topSourcePct > 50"
|
v-if="topSourcePct > 50"
|
||||||
color="red"
|
class="border-2 border-red-600 bg-red-50 p-4">
|
||||||
variant="subtle"
|
<div class="flex items-start gap-3">
|
||||||
icon="i-heroicons-exclamation-triangle"
|
<span class="text-red-600 font-bold text-xl">!</span>
|
||||||
title="Revenue Concentration Risk"
|
<div class="flex-1">
|
||||||
:description="`${topStreamName} = ${topSourcePct}% of total → consider balancing`"
|
<h4 class="font-bold uppercase mb-1">Revenue Concentration Risk</h4>
|
||||||
:actions="[
|
<p class="text-sm mb-2">{{ topStreamName }} = {{ topSourcePct }}% of total → consider balancing</p>
|
||||||
{ label: 'Plan Mix', click: () => handleAlertNavigation('/mix', 'concentration') },
|
<button
|
||||||
{ label: 'Scenarios', click: () => handleAlertNavigation('/scenarios', 'diversification') }
|
@click="handleAlertNavigation('/dashboard', 'concentration')"
|
||||||
]" />
|
class="text-sm underline font-bold">
|
||||||
|
VIEW DETAILS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cushion Breach Alert -->
|
<!-- Cushion Breach Alert -->
|
||||||
<UAlert
|
<div
|
||||||
v-if="alerts.cushionBreach"
|
v-if="alerts.cushionBreach"
|
||||||
color="orange"
|
class="border-2 border-orange-600 bg-orange-50 p-4">
|
||||||
variant="subtle"
|
<div class="flex items-start gap-3">
|
||||||
icon="i-heroicons-calendar"
|
<span class="text-orange-600 font-bold text-xl">!</span>
|
||||||
title="Cash Cushion Breach Forecast"
|
<div class="flex-1">
|
||||||
:description="`Projected to breach minimum cushion in week ${cushionForecast.firstBreachWeek || 'unknown'}`"
|
<h4 class="font-bold uppercase mb-1">Cash Cushion Breach Forecast</h4>
|
||||||
:actions="[
|
<p class="text-sm mb-2">Projected to breach minimum cushion in week {{ cushionForecast.firstBreachWeek || 'unknown' }}</p>
|
||||||
{ label: 'View Calendar', click: () => handleAlertNavigation('/cash', 'breach-forecast') },
|
<div class="flex gap-4">
|
||||||
{ label: 'Adjust Budget', click: () => handleAlertNavigation('/budget', 'expenses') }
|
<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 -->
|
<!-- Savings Below Target Alert -->
|
||||||
<UAlert
|
<div
|
||||||
v-if="alerts.savingsBelowTarget"
|
v-if="alerts.savingsBelowTarget"
|
||||||
color="yellow"
|
class="border-2 border-yellow-600 bg-yellow-50 p-4">
|
||||||
variant="subtle"
|
<div class="flex items-start gap-3">
|
||||||
icon="i-heroicons-banknotes"
|
<span class="text-yellow-600 font-bold text-xl">!</span>
|
||||||
title="Savings Below Target"
|
<div class="flex-1">
|
||||||
:description="`${savingsProgress.progressPct.toFixed(0)}% of target reached. Build savings before increasing paid hours.`"
|
<h4 class="font-bold uppercase mb-1">Savings Below Target</h4>
|
||||||
:actions="[
|
<p class="text-sm mb-2">{{ savingsProgress.progressPct.toFixed(0) }}% of target reached. Build savings before increasing paid hours.</p>
|
||||||
{ label: 'View Progress', click: () => handleAlertNavigation('/budget', 'savings') },
|
<div class="flex gap-4">
|
||||||
{ label: 'Adjust Policies', click: () => handleAlertNavigation('/coop-builder', 'policies') }
|
<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 -->
|
<!-- Over-Deferred Member Alert -->
|
||||||
<UAlert
|
<div
|
||||||
v-if="deferredAlert.show"
|
v-if="deferredAlert.show"
|
||||||
color="purple"
|
class="border-2 border-purple-600 bg-purple-50 p-4">
|
||||||
variant="subtle"
|
<div class="flex items-start gap-3">
|
||||||
icon="i-heroicons-user-group"
|
<span class="text-purple-600 font-bold text-xl">!</span>
|
||||||
title="Member Over-Deferred"
|
<div class="flex-1">
|
||||||
:description="deferredAlert.description"
|
<h4 class="font-bold uppercase mb-1">Member Over-Deferred</h4>
|
||||||
:actions="[
|
<p class="text-sm mb-2">{{ deferredAlert.description }}</p>
|
||||||
{ label: 'Review Members', click: () => handleAlertNavigation('/coop-builder', 'members') },
|
<button
|
||||||
{ label: 'Value Session', click: () => handleAlertNavigation('/session', 'distributions') }
|
@click="handleAlertNavigation('/coop-builder', 'members')"
|
||||||
]" />
|
class="text-sm underline font-bold">
|
||||||
|
REVIEW MEMBERS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Success message when no alerts -->
|
<!-- Success message when no alerts -->
|
||||||
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
|
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
|
||||||
class="text-center py-8 text-gray-500">
|
class="text-center py-8">
|
||||||
<UIcon name="i-heroicons-check-circle" class="w-8 h-8 mx-auto mb-2 text-green-500" />
|
<span class="text-4xl font-bold">✓</span>
|
||||||
<p class="font-medium">All systems looking good!</p>
|
<p class="font-bold uppercase mt-2">All systems looking good!</p>
|
||||||
<p class="text-sm">No critical alerts at this time.</p>
|
<p class="text-sm mt-1">No critical alerts at this time.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</div>
|
||||||
|
|
||||||
<!-- Scenario Snapshots -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Scenario Snapshots</h3>
|
|
||||||
</template>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<!-- Current Scenario -->
|
|
||||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h4 class="font-medium text-sm">{{ scenarios.current.name }}</h4>
|
|
||||||
<UBadge color="green" variant="subtle" size="xs">{{ scenarios.current.status }}</UBadge>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.current.runway)">
|
|
||||||
{{ Math.round(scenarios.current.runway * 10) / 10 }} months
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-600">
|
|
||||||
Net: {{ $format.currency(scenarios.current.monthlyNet) }}/mo
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quit Jobs Scenario -->
|
|
||||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h4 class="font-medium text-sm">{{ scenarios.quitJobs.name }}</h4>
|
|
||||||
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.quitJobs.status }}</UBadge>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.quitJobs.runway)">
|
|
||||||
{{ Math.round(scenarios.quitJobs.runway * 10) / 10 }} months
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-600">
|
|
||||||
Net: {{ $format.currency(scenarios.quitJobs.monthlyNet) }}/mo
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Start Production Scenario -->
|
|
||||||
<div class="p-4 border border-neutral-200 rounded-lg">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h4 class="font-medium text-sm">{{ scenarios.startProduction.name }}</h4>
|
|
||||||
<UBadge color="gray" variant="subtle" size="xs">{{ scenarios.startProduction.status }}</UBadge>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold mb-1" :class="getRunwayColor(scenarios.startProduction.runway)">
|
|
||||||
{{ Math.round(scenarios.startProduction.runway * 10) / 10 }} months
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-600">
|
|
||||||
Net: {{ $format.currency(scenarios.startProduction.monthlyNet) }}/mo
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<UButton variant="outline" @click="navigateTo('/scenarios')">
|
|
||||||
Compare All Scenarios
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Next Value Accounting Session -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-medium">Next Value Accounting Session</h3>
|
|
||||||
<UBadge color="blue" variant="subtle">January 2024</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium mb-3">Session Preparation</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
|
||||||
<span class="text-sm">Month closed & reviewed</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
|
||||||
<span class="text-sm">Contributions logged</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
|
|
||||||
<span class="text-sm text-neutral-600">Surplus calculated</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UIcon name="i-heroicons-x-circle" class="text-neutral-400" />
|
|
||||||
<span class="text-sm text-neutral-600"
|
|
||||||
>Member needs reviewed</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<UProgress value="50" :max="100" color="blue" />
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">2 of 4 items complete</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<!-- Quick Actions with Wizard Styling -->
|
||||||
<h4 class="font-medium mb-3">Available for Distribution</h4>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<button
|
||||||
<div class="flex justify-between text-sm">
|
@click="navigateTo('/cash-flow')"
|
||||||
<span class="text-neutral-600">Surplus</span>
|
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||||
<span class="font-medium text-green-600">{{
|
<div class="font-bold uppercase mb-1">Cash Flow Analysis</div>
|
||||||
$format.currency(metrics.finances.surplus || 0)
|
<div class="text-sm">Detailed runway & one-time events</div>
|
||||||
}}</span>
|
</button>
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Deferred owed</span>
|
|
||||||
<span class="font-medium text-orange-600">{{
|
|
||||||
$format.currency(
|
|
||||||
metrics.finances.deferredLiabilities.totalDeferred
|
|
||||||
)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Savings gap</span>
|
|
||||||
<span class="font-medium text-blue-600">{{
|
|
||||||
$format.currency(metrics.finances.savingsGap || 0)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<UButton color="primary" @click="navigateTo('/session')">
|
|
||||||
Start Session
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Advanced Planning Panel -->
|
<button
|
||||||
<UCard>
|
@click="navigateTo('/budget')"
|
||||||
<template #header>
|
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="flex items-center justify-between">
|
<div class="font-bold uppercase mb-1">Budget Planning</div>
|
||||||
<h3 class="text-lg font-medium">Advanced Planning</h3>
|
<div class="text-sm">Manage expenses & savings</div>
|
||||||
<UButton
|
</button>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="showAdvanced = !showAdvanced"
|
|
||||||
:icon="showAdvanced ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
|
|
||||||
>
|
|
||||||
{{ showAdvanced ? 'Hide' : 'Show' }} Advanced
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-show="showAdvanced" class="space-y-6">
|
|
||||||
<!-- Stress Tests -->
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<h4 class="font-medium mb-3">Stress Tests</h4>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Revenue Delay (months)</label>
|
|
||||||
<UInput
|
|
||||||
v-model="stressTests.revenueDelay"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="6"
|
|
||||||
size="sm"
|
|
||||||
@input="updateStressTest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Cost Shock (%)</label>
|
|
||||||
<UInput
|
|
||||||
v-model="stressTests.costShockPct"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
size="sm"
|
|
||||||
@input="updateStressTest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Major Grant Lost</label>
|
|
||||||
<UToggle
|
|
||||||
v-model="stressTests.grantLost"
|
|
||||||
@update:model-value="updateStressTest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stress Test Results -->
|
|
||||||
<div v-if="hasStressTest" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h5 class="font-medium text-yellow-800">Stress Test Results</h5>
|
|
||||||
<p class="text-sm text-yellow-700">
|
|
||||||
Runway under stress: {{ Math.round(stressedRunway * 10) / 10 }} months
|
|
||||||
({{ Math.round((stressedRunway - metrics.runway) * 10) / 10 }} month change)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<UButton size="xs" @click="applyStressTest">Apply to Plan</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Policy Sandbox -->
|
|
||||||
<div class="border rounded-lg p-4">
|
|
||||||
<h4 class="font-medium mb-3">Policy Sandbox</h4>
|
|
||||||
<p class="text-sm text-gray-600 mb-3">
|
|
||||||
Try different pay relationships without overwriting your current plan.
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Test Pay Policy</label>
|
|
||||||
<USelect
|
|
||||||
v-model="sandboxPolicy"
|
|
||||||
:options="policyOptions"
|
|
||||||
size="sm"
|
|
||||||
@update:model-value="updateSandboxPolicy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="sandboxRunway">
|
|
||||||
<label class="text-sm font-medium text-gray-700 mb-1 block">Projected Runway</label>
|
|
||||||
<div class="text-lg font-bold" :class="getRunwayColor(sandboxRunway)">
|
|
||||||
{{ Math.round(sandboxRunway * 10) / 10 }} months
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
block
|
|
||||||
variant="ghost"
|
|
||||||
class="justify-start h-auto p-4"
|
|
||||||
@click="navigateTo('/cash')">
|
|
||||||
<div class="text-left">
|
|
||||||
<div class="font-medium">Cash Calendar</div>
|
|
||||||
<div class="text-xs text-neutral-500">13-week cash flow</div>
|
|
||||||
</div>
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
block
|
|
||||||
variant="ghost"
|
|
||||||
class="justify-start h-auto p-4"
|
|
||||||
@click="navigateTo('/scenarios')">
|
|
||||||
<div class="text-left">
|
|
||||||
<div class="font-medium">Scenarios</div>
|
|
||||||
<div class="text-xs text-neutral-500">What-if analysis</div>
|
|
||||||
</div>
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
block
|
|
||||||
color="primary"
|
|
||||||
class="justify-start h-auto p-4"
|
|
||||||
@click="navigateTo('/session')">
|
|
||||||
<div class="text-left">
|
|
||||||
<div class="font-medium">Next Session</div>
|
|
||||||
<div class="text-xs">Value Accounting</div>
|
|
||||||
</div>
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -451,27 +205,6 @@ const { getDualModeRunway, getMonthlyBurn } = useRunway();
|
||||||
// Cushion forecast and savings progress
|
// Cushion forecast and savings progress
|
||||||
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
|
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
|
||||||
|
|
||||||
// Scenario calculations
|
|
||||||
const { scenarios } = useScenarios();
|
|
||||||
|
|
||||||
// Advanced panel state
|
|
||||||
const showAdvanced = ref(false);
|
|
||||||
|
|
||||||
// Stress testing
|
|
||||||
const stressTests = ref({
|
|
||||||
revenueDelay: 0,
|
|
||||||
costShockPct: 0,
|
|
||||||
grantLost: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Policy sandbox
|
|
||||||
const sandboxPolicy = ref('equal-pay');
|
|
||||||
const policyOptions = [
|
|
||||||
{ label: 'Equal Pay', value: 'equal-pay' },
|
|
||||||
{ label: 'Needs Weighted', value: 'needs-weighted' },
|
|
||||||
{ label: 'Hours Weighted', value: 'hours-weighted' },
|
|
||||||
{ label: 'Role Banded', value: 'role-banded' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Calculate metrics from real store data
|
// Calculate metrics from real store data
|
||||||
const metrics = computed(() => {
|
const metrics = computed(() => {
|
||||||
|
|
@ -573,21 +306,6 @@ function getRunwayColor(months: number): string {
|
||||||
return 'text-red-600'
|
return 'text-red-600'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate scenario metrics
|
|
||||||
const scenarioMetrics = computed(() => {
|
|
||||||
const baseRunway = metrics.value.runway;
|
|
||||||
return {
|
|
||||||
current: {
|
|
||||||
runway: Math.round(baseRunway * 100) / 100 || 0,
|
|
||||||
},
|
|
||||||
quitJobs: {
|
|
||||||
runway: Math.round(baseRunway * 0.7 * 100) / 100 || 0, // Shorter runway due to higher costs
|
|
||||||
},
|
|
||||||
startProduction: {
|
|
||||||
runway: Math.round(baseRunway * 0.8 * 100) / 100 || 0, // Moderate impact
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cash breach description
|
// Cash breach description
|
||||||
const cashBreachDescription = computed(() => {
|
const cashBreachDescription = computed(() => {
|
||||||
|
|
@ -629,135 +347,6 @@ const onImport = async () => {
|
||||||
|
|
||||||
const { exportAll, importAll } = useFixtureIO();
|
const { exportAll, importAll } = useFixtureIO();
|
||||||
|
|
||||||
// Advanced panel computed properties and methods
|
|
||||||
const hasStressTest = computed(() => {
|
|
||||||
return stressTests.value.revenueDelay > 0 ||
|
|
||||||
stressTests.value.costShockPct > 0 ||
|
|
||||||
stressTests.value.grantLost;
|
|
||||||
});
|
|
||||||
|
|
||||||
const stressedRunway = computed(() => {
|
|
||||||
if (!hasStressTest.value) return metrics.value.runway;
|
|
||||||
|
|
||||||
const cash = cashStore.currentCash || 50000;
|
|
||||||
const savings = cashStore.currentSavings || 15000;
|
|
||||||
|
|
||||||
// Apply stress test adjustments
|
|
||||||
let adjustedRevenue = streamsStore.streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0);
|
|
||||||
let adjustedCosts = getMonthlyBurn();
|
|
||||||
|
|
||||||
// Revenue delay impact (reduce revenue by delay percentage)
|
|
||||||
if (stressTests.value.revenueDelay > 0) {
|
|
||||||
adjustedRevenue *= Math.max(0, 1 - (stressTests.value.revenueDelay / 12));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cost shock impact
|
|
||||||
if (stressTests.value.costShockPct > 0) {
|
|
||||||
adjustedCosts *= (1 + stressTests.value.costShockPct / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant lost (remove largest revenue stream if it's a grant)
|
|
||||||
if (stressTests.value.grantLost) {
|
|
||||||
const grantStreams = streamsStore.streams.filter(s =>
|
|
||||||
s.category?.toLowerCase().includes('grant') ||
|
|
||||||
s.name.toLowerCase().includes('grant')
|
|
||||||
);
|
|
||||||
if (grantStreams.length > 0) {
|
|
||||||
const largestGrant = Math.max(...grantStreams.map(s => s.targetMonthlyAmount || 0));
|
|
||||||
adjustedRevenue -= largestGrant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const netMonthly = adjustedRevenue - adjustedCosts;
|
|
||||||
const burnRate = netMonthly < 0 ? Math.abs(netMonthly) : adjustedCosts;
|
|
||||||
|
|
||||||
return burnRate > 0 ? (cash + savings) / burnRate : Infinity;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sandboxRunway = computed(() => {
|
|
||||||
if (!sandboxPolicy.value || sandboxPolicy.value === policiesStore.payPolicy?.relationship) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate runway with sandbox policy
|
|
||||||
const cash = cashStore.currentCash || 50000;
|
|
||||||
const savings = cashStore.currentSavings || 15000;
|
|
||||||
|
|
||||||
// Create sandbox policy object
|
|
||||||
const testPolicy = {
|
|
||||||
relationship: sandboxPolicy.value,
|
|
||||||
equalHourlyWage: policiesStore.equalHourlyWage,
|
|
||||||
roleBands: policiesStore.payPolicy?.roleBands || []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use scenario calculation with sandbox policy
|
|
||||||
const { calculateScenarioRunway } = useScenarios();
|
|
||||||
const result = calculateScenarioRunway(membersStore.members, streamsStore.streams);
|
|
||||||
|
|
||||||
// Apply simple adjustment based on policy type
|
|
||||||
let policyMultiplier = 1;
|
|
||||||
switch (sandboxPolicy.value) {
|
|
||||||
case 'needs-weighted':
|
|
||||||
policyMultiplier = 0.9; // Slightly higher costs
|
|
||||||
break;
|
|
||||||
case 'role-banded':
|
|
||||||
policyMultiplier = 0.85; // Higher costs due to senior roles
|
|
||||||
break;
|
|
||||||
case 'hours-weighted':
|
|
||||||
policyMultiplier = 0.95; // Moderate increase
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.runway * policyMultiplier;
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateStressTest() {
|
|
||||||
// Reactive computed will handle updates automatically
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSandboxPolicy() {
|
|
||||||
// Reactive computed will handle updates automatically
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyStressTest() {
|
|
||||||
// Apply stress test adjustments to the actual plan
|
|
||||||
if (stressTests.value.revenueDelay > 0) {
|
|
||||||
// Reduce all stream targets by delay impact
|
|
||||||
streamsStore.streams.forEach(stream => {
|
|
||||||
const reduction = (stressTests.value.revenueDelay / 12) * (stream.targetMonthlyAmount || 0);
|
|
||||||
streamsStore.updateStream(stream.id, {
|
|
||||||
targetMonthlyAmount: Math.max(0, (stream.targetMonthlyAmount || 0) - reduction)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stressTests.value.costShockPct > 0) {
|
|
||||||
// Increase overhead costs
|
|
||||||
const shockMultiplier = 1 + (stressTests.value.costShockPct / 100);
|
|
||||||
budgetStore.overheadCosts.forEach(cost => {
|
|
||||||
budgetStore.updateOverheadCost(cost.id, {
|
|
||||||
amount: (cost.amount || 0) * shockMultiplier
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stressTests.value.grantLost) {
|
|
||||||
// Remove or reduce grant streams
|
|
||||||
const grantStreams = streamsStore.streams.filter(s =>
|
|
||||||
s.category?.toLowerCase().includes('grant') ||
|
|
||||||
s.name.toLowerCase().includes('grant')
|
|
||||||
);
|
|
||||||
if (grantStreams.length > 0) {
|
|
||||||
const largestGrant = grantStreams.reduce((prev, current) =>
|
|
||||||
(prev.targetMonthlyAmount || 0) > (current.targetMonthlyAmount || 0) ? prev : current
|
|
||||||
);
|
|
||||||
streamsStore.updateStream(largestGrant.id, { targetMonthlyAmount: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset stress tests
|
|
||||||
stressTests.value = { revenueDelay: 0, costShockPct: 0, grantLost: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deferred alert logic
|
// Deferred alert logic
|
||||||
const deferredAlert = computed(() => {
|
const deferredAlert = computed(() => {
|
||||||
|
|
|
||||||
291
pages/mix.vue
291
pages/mix.vue
|
|
@ -1,291 +0,0 @@
|
||||||
<template>
|
|
||||||
<section class="py-8 space-y-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-semibold">Revenue Mix Planner</h2>
|
|
||||||
<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 real store data instead of fixtures
|
|
||||||
const streamsStore = useStreamsStore();
|
|
||||||
const { streams } = storeToRefs(streamsStore);
|
|
||||||
|
|
||||||
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(() => streamsStore.totalTargetPct);
|
|
||||||
const totalMonthlyAmount = computed(() => streamsStore.totalMonthlyAmount);
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const stream = streams.value.find((s) => s.id === id);
|
|
||||||
if (stream) {
|
|
||||||
stream[field] = Number(value) || value;
|
|
||||||
streamsStore.upsertStream(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStream() {
|
|
||||||
const newStream = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
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(newStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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>
|
|
||||||
|
|
@ -1,575 +0,0 @@
|
||||||
<template>
|
|
||||||
<section class="py-8 space-y-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-semibold">Scenarios & Runway</h2>
|
|
||||||
<UButton
|
|
||||||
variant="outline"
|
|
||||||
color="red"
|
|
||||||
size="sm"
|
|
||||||
@click="restartWizard"
|
|
||||||
:disabled="isResetting">
|
|
||||||
<UIcon name="i-heroicons-arrow-path" class="mr-1" />
|
|
||||||
Restart Setup (Testing)
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 6-Month Preset Card -->
|
|
||||||
<UCard class="bg-blue-50 border-blue-200">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-medium text-blue-900">
|
|
||||||
6-Month Plan Analysis
|
|
||||||
</h3>
|
|
||||||
<UBadge color="info" variant="solid">Recommended</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl font-bold text-blue-600 mb-2">
|
|
||||||
{{ sixMonthScenario.runway }} months
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-neutral-600 mb-3">Extended runway</div>
|
|
||||||
<UProgress value="91" color="info" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium mb-3">Key Changes</h4>
|
|
||||||
<ul class="text-sm text-neutral-600 space-y-1">
|
|
||||||
<li>• Diversify revenue mix</li>
|
|
||||||
<li>• Build 6-month savings buffer</li>
|
|
||||||
<li>• Gradual capacity scaling</li>
|
|
||||||
<li>• Risk mitigation focus</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium mb-3">Feasibility Gates</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
|
||||||
<span class="text-sm">Savings target achievable</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
|
||||||
<span class="text-sm">Cash floor maintained</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
name="i-heroicons-exclamation-triangle"
|
|
||||||
class="text-yellow-500" />
|
|
||||||
<span class="text-sm">Requires 2 new streams</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Scenario Comparison -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
||||||
<UCard class="border-green-200 bg-green-50">
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="font-medium text-sm">Operate Current</h4>
|
|
||||||
<UBadge color="success" variant="solid" size="xs">Active</UBadge>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold text-orange-600">
|
|
||||||
{{ currentScenario.runway }} months
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-neutral-600">Baseline scenario</div>
|
|
||||||
<UButton size="xs" variant="ghost" @click="setScenario('current')">
|
|
||||||
<UIcon name="i-heroicons-play" class="mr-1" />
|
|
||||||
Continue
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="font-medium text-sm">Quit Day Jobs</h4>
|
|
||||||
<UBadge color="error" variant="subtle" size="xs">High Risk</UBadge>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold text-red-600">
|
|
||||||
{{ quitDayJobsScenario.runway }} months
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-neutral-600">Full-time co-op work</div>
|
|
||||||
<UButton
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
@click="setScenario('quitDayJobs')">
|
|
||||||
<UIcon name="i-heroicons-briefcase" class="mr-1" />
|
|
||||||
Analyze
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="font-medium text-sm">Start Production</h4>
|
|
||||||
<UBadge color="warning" variant="subtle" size="xs"
|
|
||||||
>Medium Risk</UBadge
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold text-yellow-600">
|
|
||||||
{{ startProductionScenario.runway }} months
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-neutral-600">Launch development</div>
|
|
||||||
<UButton
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
@click="setScenario('startProduction')">
|
|
||||||
<UIcon name="i-heroicons-rocket-launch" class="mr-1" />
|
|
||||||
Analyze
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<UCard class="border-blue-200">
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="font-medium text-sm">6-Month Plan</h4>
|
|
||||||
<UBadge color="info" variant="solid" size="xs">Planned</UBadge>
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold text-blue-600">
|
|
||||||
{{ sixMonthScenario.runway }} months
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-neutral-600">Extended planning</div>
|
|
||||||
<UButton size="xs" color="primary" @click="setScenario('sixMonth')">
|
|
||||||
<UIcon name="i-heroicons-calendar" class="mr-1" />
|
|
||||||
Plan
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feasibility Analysis -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Feasibility Analysis</h3>
|
|
||||||
</template>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium mb-3">Gate Checks</h4>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm">Savings Target Reached</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-heroicons-x-circle" class="text-red-500" />
|
|
||||||
<span class="text-sm text-neutral-600">€5,200 short</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm">Cash Floor Maintained</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-heroicons-check-circle" class="text-green-500" />
|
|
||||||
<span class="text-sm text-neutral-600">Week 4+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm">Revenue Diversification</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon
|
|
||||||
name="i-heroicons-exclamation-triangle"
|
|
||||||
class="text-yellow-500" />
|
|
||||||
<span class="text-sm text-neutral-600"
|
|
||||||
>Top: {{ topSourcePct }}%</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium mb-3">Key Dates</h4>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-sm text-neutral-600">Savings gate clear:</span>
|
|
||||||
<span class="text-sm font-medium">{{
|
|
||||||
keyDates.savingsGate || "Not projected"
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-sm text-neutral-600">First cash breach:</span>
|
|
||||||
<span class="text-sm font-medium text-red-600">{{
|
|
||||||
keyDates.firstBreach || "None projected"
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-sm text-neutral-600">Deferred cap reset:</span>
|
|
||||||
<span class="text-sm font-medium">{{
|
|
||||||
keyDates.deferredReset || "Not scheduled"
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- What-If Sliders -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-medium">What-If Analysis</h3>
|
|
||||||
<UButton size="sm" variant="ghost" @click="resetSliders">
|
|
||||||
Reset
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="revenue-slider" class="block text-sm font-medium mb-2">
|
|
||||||
<GlossaryTooltip
|
|
||||||
term="Monthly Revenue"
|
|
||||||
term-id="revenue"
|
|
||||||
definition="Total money earned from all streams in one month." />:
|
|
||||||
{{ $format.currency(revenue) }}
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<URange
|
|
||||||
id="revenue-slider"
|
|
||||||
v-model="revenue"
|
|
||||||
:min="5000"
|
|
||||||
:max="20000"
|
|
||||||
:step="500"
|
|
||||||
:aria-label="`Monthly revenue: ${$format.currency(revenue)}`"
|
|
||||||
class="flex-1" />
|
|
||||||
<UInput
|
|
||||||
v-model="revenue"
|
|
||||||
type="number"
|
|
||||||
:min="5000"
|
|
||||||
:max="20000"
|
|
||||||
:step="500"
|
|
||||||
class="w-24"
|
|
||||||
size="xs"
|
|
||||||
aria-label="Monthly revenue input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="hours-slider" class="block text-sm font-medium mb-2">
|
|
||||||
Paid Hours: {{ paidHours }}h/month
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<URange
|
|
||||||
id="hours-slider"
|
|
||||||
v-model="paidHours"
|
|
||||||
:min="100"
|
|
||||||
:max="600"
|
|
||||||
:step="20"
|
|
||||||
:aria-label="`Paid hours: ${paidHours} per month`"
|
|
||||||
class="flex-1" />
|
|
||||||
<UInput
|
|
||||||
v-model="paidHours"
|
|
||||||
type="number"
|
|
||||||
:min="100"
|
|
||||||
:max="600"
|
|
||||||
:step="20"
|
|
||||||
class="w-20"
|
|
||||||
size="xs"
|
|
||||||
aria-label="Paid hours input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="winrate-slider" class="block text-sm font-medium mb-2">
|
|
||||||
Win Rate: {{ winRate }}%
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<URange
|
|
||||||
id="winrate-slider"
|
|
||||||
v-model="winRate"
|
|
||||||
:min="40"
|
|
||||||
:max="95"
|
|
||||||
:step="5"
|
|
||||||
:aria-label="`Win rate: ${winRate} percent`"
|
|
||||||
class="flex-1" />
|
|
||||||
<UInput
|
|
||||||
v-model="winRate"
|
|
||||||
type="number"
|
|
||||||
:min="40"
|
|
||||||
:max="95"
|
|
||||||
:step="5"
|
|
||||||
class="w-16"
|
|
||||||
size="xs"
|
|
||||||
aria-label="Win rate input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="bg-neutral-50 rounded-lg p-4">
|
|
||||||
<h4 class="font-medium text-sm mb-3">Impact on Runway</h4>
|
|
||||||
<div class="text-center">
|
|
||||||
<div
|
|
||||||
class="text-2xl font-bold"
|
|
||||||
:class="getRunwayColor(calculatedRunway)">
|
|
||||||
{{ calculatedRunway }} months
|
|
||||||
</div>
|
|
||||||
<UProgress
|
|
||||||
:value="Math.min(calculatedRunway * 10, 100)"
|
|
||||||
:max="100"
|
|
||||||
:color="getProgressColor(calculatedRunway)"
|
|
||||||
class="mt-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Monthly burn:</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>€{{ monthlyBurn.toLocaleString() }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Coverage ratio:</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>{{ Math.round((paidHours / 400) * 100) }}%</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { $format } = useNuxtApp();
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const scenariosStore = useScenariosStore();
|
|
||||||
|
|
||||||
// Restart wizard functionality
|
|
||||||
const membersStore = useMembersStore();
|
|
||||||
const policiesStore = usePoliciesStore();
|
|
||||||
const streamsStore = useStreamsStore();
|
|
||||||
const budgetStore = useBudgetStore();
|
|
||||||
const cashStore = useCashStore();
|
|
||||||
const sessionStore = useSessionStore();
|
|
||||||
const coopBuilderStore = useCoopBuilderStore();
|
|
||||||
|
|
||||||
const isResetting = ref(false);
|
|
||||||
|
|
||||||
// Get initial values from stores
|
|
||||||
const initialRevenue = computed(() => streamsStore.totalMonthlyAmount || 0);
|
|
||||||
const initialHours = computed(
|
|
||||||
() => membersStore.capacityTotals.targetHours || 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const revenue = ref(initialRevenue.value || 0);
|
|
||||||
const paidHours = ref(initialHours.value || 0);
|
|
||||||
const winRate = ref(0);
|
|
||||||
|
|
||||||
// Watch for store changes and update sliders
|
|
||||||
watch(initialRevenue, (newVal) => {
|
|
||||||
if (newVal > 0) revenue.value = newVal;
|
|
||||||
});
|
|
||||||
watch(initialHours, (newVal) => {
|
|
||||||
if (newVal > 0) paidHours.value = newVal;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate dynamic metrics from real store data
|
|
||||||
const monthlyBurn = computed(() => {
|
|
||||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
|
||||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
|
||||||
const payroll = paidHours.value * hourlyWage * (1 + oncostPct / 100);
|
|
||||||
const overhead =
|
|
||||||
budgetStore.overheadCosts.reduce(
|
|
||||||
(sum, cost) => sum + (cost.amount || 0),
|
|
||||||
0
|
|
||||||
) || 0;
|
|
||||||
const production =
|
|
||||||
budgetStore.productionCosts.reduce(
|
|
||||||
(sum, cost) => sum + (cost.amount || 0),
|
|
||||||
0
|
|
||||||
) || 0;
|
|
||||||
return payroll + overhead + production;
|
|
||||||
});
|
|
||||||
|
|
||||||
const calculatedRunway = computed(() => {
|
|
||||||
const totalCash = cashStore.currentCash + cashStore.currentSavings;
|
|
||||||
const adjustedRevenue = revenue.value * (winRate.value / 100);
|
|
||||||
const netPerMonth = adjustedRevenue - monthlyBurn.value;
|
|
||||||
|
|
||||||
if (netPerMonth >= 0)
|
|
||||||
return monthlyBurn.value > 0
|
|
||||||
? Math.round((totalCash / monthlyBurn.value) * 100) / 100
|
|
||||||
: 0;
|
|
||||||
return Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((totalCash / Math.abs(netPerMonth)) * 100) / 100
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scenario calculations based on real data
|
|
||||||
const totalCash = computed(
|
|
||||||
() => cashStore.currentCash + cashStore.currentSavings
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseRunway = computed(() => {
|
|
||||||
const baseBurn = monthlyBurn.value;
|
|
||||||
return baseBurn > 0
|
|
||||||
? Math.round((totalCash.value / baseBurn) * 100) / 100
|
|
||||||
: 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentScenario = computed(() => ({
|
|
||||||
runway: baseRunway.value || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const quitDayJobsScenario = computed(() => ({
|
|
||||||
runway:
|
|
||||||
monthlyBurn.value > 0
|
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((totalCash.value / (monthlyBurn.value * 1.8)) * 100) / 100
|
|
||||||
)
|
|
||||||
: 0, // Higher burn rate
|
|
||||||
}));
|
|
||||||
|
|
||||||
const startProductionScenario = computed(() => ({
|
|
||||||
runway:
|
|
||||||
monthlyBurn.value > 0
|
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((totalCash.value / (monthlyBurn.value * 1.4)) * 100) / 100
|
|
||||||
)
|
|
||||||
: 0, // Medium higher burn
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sixMonthScenario = computed(() => ({
|
|
||||||
runway:
|
|
||||||
monthlyBurn.value > 0
|
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((totalCash.value / (monthlyBurn.value * 0.6)) * 100) / 100
|
|
||||||
)
|
|
||||||
: 0, // Lower burn with optimization
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Calculate concentration from real data
|
|
||||||
const topSourcePct = computed(() => {
|
|
||||||
if (streamsStore.streams.length === 0) return 0;
|
|
||||||
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
|
|
||||||
const total = amounts.reduce((sum, amt) => sum + amt, 0);
|
|
||||||
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate key dates from real data
|
|
||||||
const keyDates = computed(() => {
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
// Calculate savings gate clear date based on current savings and target
|
|
||||||
const savingsNeeded =
|
|
||||||
(policiesStore.savingsTargetMonths || 0) * monthlyBurn.value;
|
|
||||||
const currentSavings = cashStore.currentSavings;
|
|
||||||
const monthlyNet = revenue.value - monthlyBurn.value;
|
|
||||||
|
|
||||||
let savingsGate = null;
|
|
||||||
if (savingsNeeded > 0 && currentSavings < savingsNeeded && monthlyNet > 0) {
|
|
||||||
const monthsToTarget = Math.ceil(
|
|
||||||
(savingsNeeded - currentSavings) / monthlyNet
|
|
||||||
);
|
|
||||||
const targetDate = new Date(currentDate);
|
|
||||||
targetDate.setMonth(targetDate.getMonth() + monthsToTarget);
|
|
||||||
savingsGate = targetDate.toLocaleDateString("en-US", {
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// First cash breach from cash store projections
|
|
||||||
const firstBreachWeek = cashStore.firstBreachWeek;
|
|
||||||
let firstBreach = null;
|
|
||||||
if (firstBreachWeek) {
|
|
||||||
const breachDate = new Date(currentDate);
|
|
||||||
breachDate.setDate(breachDate.getDate() + firstBreachWeek * 7);
|
|
||||||
firstBreach = `Week ${firstBreachWeek} (${breachDate.toLocaleDateString(
|
|
||||||
"en-US",
|
|
||||||
{ month: "short", day: "numeric" }
|
|
||||||
)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deferred cap reset - quarterly (every 3 months)
|
|
||||||
let deferredReset = null;
|
|
||||||
if (policiesStore.deferredCapHoursPerQtr > 0) {
|
|
||||||
const nextQuarter = new Date(currentDate);
|
|
||||||
const currentMonth = nextQuarter.getMonth();
|
|
||||||
const quarterStartMonth = Math.floor(currentMonth / 3) * 3;
|
|
||||||
nextQuarter.setMonth(quarterStartMonth + 3, 1);
|
|
||||||
deferredReset = nextQuarter.toLocaleDateString("en-US", {
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
savingsGate,
|
|
||||||
firstBreach,
|
|
||||||
deferredReset,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function getRunwayColor(months: number) {
|
|
||||||
if (months >= 6) return "text-green-600";
|
|
||||||
if (months >= 3) return "text-blue-600";
|
|
||||||
if (months >= 2) return "text-yellow-600";
|
|
||||||
return "text-red-600";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProgressColor(months: number) {
|
|
||||||
if (months >= 6) return "success";
|
|
||||||
if (months >= 3) return "info";
|
|
||||||
if (months >= 2) return "warning";
|
|
||||||
return "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setScenario(scenario: string) {
|
|
||||||
scenariosStore.setActiveScenario(scenario);
|
|
||||||
router.replace({ query: { ...route.query, scenario } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSliders() {
|
|
||||||
revenue.value = initialRevenue.value || 0;
|
|
||||||
paidHours.value = initialHours.value || 0;
|
|
||||||
winRate.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restartWizard() {
|
|
||||||
isResetting.value = true;
|
|
||||||
|
|
||||||
// Clear all localStorage persistence
|
|
||||||
if (typeof localStorage !== "undefined") {
|
|
||||||
localStorage.removeItem("urgent-tools-members");
|
|
||||||
localStorage.removeItem("urgent-tools-policies");
|
|
||||||
localStorage.removeItem("urgent-tools-streams");
|
|
||||||
localStorage.removeItem("urgent-tools-budget");
|
|
||||||
localStorage.removeItem("urgent-tools-cash");
|
|
||||||
localStorage.removeItem("urgent-tools-session");
|
|
||||||
localStorage.removeItem("urgent-tools-scenarios");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset all stores
|
|
||||||
membersStore.resetMembers();
|
|
||||||
policiesStore.resetPolicies();
|
|
||||||
streamsStore.resetStreams();
|
|
||||||
budgetStore.resetBudgetOverhead();
|
|
||||||
sessionStore.resetSession();
|
|
||||||
|
|
||||||
// Reset wizard state
|
|
||||||
coopBuilderStore.reset();
|
|
||||||
|
|
||||||
// Small delay for UX
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
isResetting.value = false;
|
|
||||||
|
|
||||||
// Navigate to coop planner
|
|
||||||
await navigateTo("/coop-planner");
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const q = route.query.scenario;
|
|
||||||
if (typeof q === "string") {
|
|
||||||
scenariosStore.setActiveScenario(q);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
<template>
|
|
||||||
<section class="py-8 space-y-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-semibold">Value Accounting Session</h2>
|
|
||||||
<UBadge color="primary" variant="subtle">January 2024</UBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Checklist</h3>
|
|
||||||
</template>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UCheckbox v-model="checklist.monthClosed" />
|
|
||||||
<span class="text-sm">Month closed & reviewed</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UCheckbox v-model="checklist.contributionsLogged" />
|
|
||||||
<span class="text-sm">Contributions logged</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UCheckbox v-model="checklist.surplusCalculated" />
|
|
||||||
<span class="text-sm">Surplus calculated</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UCheckbox v-model="checklist.needsReviewed" />
|
|
||||||
<span class="text-sm">Member needs reviewed</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Available</h3>
|
|
||||||
</template>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Surplus</span>
|
|
||||||
<span class="font-medium text-green-600"
|
|
||||||
>€{{ availableAmounts.surplus.toLocaleString() }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Deferred owed</span>
|
|
||||||
<span class="font-medium text-orange-600"
|
|
||||||
>€{{ availableAmounts.deferredOwed.toLocaleString() }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Savings gap</span>
|
|
||||||
<span class="font-medium text-blue-600"
|
|
||||||
>€{{ availableAmounts.savingsNeeded.toLocaleString() }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Distribution</h3>
|
|
||||||
</template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">Deferred Repay</label>
|
|
||||||
<UInput
|
|
||||||
v-model.number="draftAllocations.deferredRepay"
|
|
||||||
type="number"
|
|
||||||
size="sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">Savings</label>
|
|
||||||
<UInput
|
|
||||||
v-model.number="draftAllocations.savings"
|
|
||||||
type="number"
|
|
||||||
size="sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">Training</label>
|
|
||||||
<UInput
|
|
||||||
v-model.number="draftAllocations.training"
|
|
||||||
type="number"
|
|
||||||
size="sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">Retained</label>
|
|
||||||
<UInput
|
|
||||||
v-model.number="draftAllocations.retained"
|
|
||||||
type="number"
|
|
||||||
size="sm"
|
|
||||||
readonly />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Decision Record</h3>
|
|
||||||
</template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<UTextarea
|
|
||||||
v-model="rationale"
|
|
||||||
placeholder="Brief rationale for this month's distribution decisions..."
|
|
||||||
rows="3" />
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<UButton variant="ghost"> Save Draft </UButton>
|
|
||||||
<UButton color="primary" :disabled="!allChecklistComplete">
|
|
||||||
Complete Session
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// Use stores
|
|
||||||
const sessionStore = useSessionStore();
|
|
||||||
const membersStore = useMembersStore();
|
|
||||||
const policiesStore = usePoliciesStore();
|
|
||||||
const budgetStore = useBudgetStore();
|
|
||||||
const streamsStore = useStreamsStore();
|
|
||||||
|
|
||||||
// Use store refs
|
|
||||||
const { checklist, draftAllocations, rationale, availableAmounts } =
|
|
||||||
storeToRefs(sessionStore);
|
|
||||||
|
|
||||||
const allChecklistComplete = computed(() => {
|
|
||||||
return Object.values(checklist.value).every(Boolean);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate available amounts from real data
|
|
||||||
const calculatedAvailableAmounts = computed(() => {
|
|
||||||
// Calculate surplus from budget metrics
|
|
||||||
const totalRevenue = streamsStore.totalMonthlyAmount || 0;
|
|
||||||
const totalHours = membersStore.capacityTotals.targetHours || 0;
|
|
||||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
|
||||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
|
||||||
|
|
||||||
const totalPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
|
|
||||||
const totalOverhead = budgetStore.overheadCosts.reduce(
|
|
||||||
(sum, cost) => sum + (cost.amount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const surplus = Math.max(0, totalRevenue - totalPayroll - totalOverhead);
|
|
||||||
|
|
||||||
// Calculate deferred owed
|
|
||||||
const deferredOwed = membersStore.members.reduce((sum, member) => {
|
|
||||||
return sum + (member.deferredHours || 0) * hourlyWage;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Calculate savings gap
|
|
||||||
const savingsTarget =
|
|
||||||
(policiesStore.savingsTargetMonths || 0) * (totalPayroll + totalOverhead);
|
|
||||||
const savingsNeeded = Math.max(0, savingsTarget);
|
|
||||||
|
|
||||||
return {
|
|
||||||
surplus,
|
|
||||||
deferredOwed,
|
|
||||||
savingsNeeded,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update store available amounts when calculated values change
|
|
||||||
watch(
|
|
||||||
calculatedAvailableAmounts,
|
|
||||||
(newAmounts) => {
|
|
||||||
sessionStore.updateAvailableAmounts(newAmounts);
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
@ -121,6 +121,53 @@
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Member Management Section -->
|
||||||
|
<UCard id="members">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium">Team Members</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UBadge v-if="isSetupComplete" color="green" variant="subtle" size="xs">
|
||||||
|
Synchronized with Setup
|
||||||
|
</UBadge>
|
||||||
|
<UButton variant="ghost" size="xs" @click="goToSetup">
|
||||||
|
Edit in Setup
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="members.length === 0" class="text-center py-8 text-neutral-500">
|
||||||
|
<p class="mb-4">No team members found.</p>
|
||||||
|
<UButton @click="goToSetup" variant="outline">
|
||||||
|
Add Members in Setup
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="member in members"
|
||||||
|
:key="member.id"
|
||||||
|
class="p-4 border border-neutral-200 rounded-lg bg-neutral-50">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium">{{ member.displayName || member.name }}</h4>
|
||||||
|
<div class="text-sm text-neutral-600 space-y-1">
|
||||||
|
<div v-if="member.role">Role: {{ member.role }}</div>
|
||||||
|
<div>Monthly Target: {{ $format.currency(member.monthlyPayPlanned || 0) }}</div>
|
||||||
|
<div v-if="member.minMonthlyNeeds">Min Needs: {{ $format.currency(member.minMonthlyNeeds) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-sm text-neutral-600">
|
||||||
|
<div>{{ Math.round((member.hoursPerMonth || member.hoursPerWeek * 4.33)) }} hrs/month</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<UButton color="primary"> Save Policies </UButton>
|
<UButton color="primary"> Save Policies </UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -128,13 +175,37 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const policies = ref({
|
// Store sync and setup state
|
||||||
hourlyWage: 20,
|
const { initSync, getMembers, unifiedMembers } = useStoreSync();
|
||||||
payrollOncost: 25,
|
const { isSetupComplete, goToSetup } = useSetupState();
|
||||||
savingsTargetMonths: 3,
|
const coopStore = useCoopBuilderStore();
|
||||||
minCashCushion: 3000,
|
const { $format } = useNuxtApp();
|
||||||
deferredCapHours: 240,
|
|
||||||
deferredSunsetMonths: 12,
|
// Initialize synchronization on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
await initSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get reactive synchronized member data
|
||||||
|
const members = unifiedMembers;
|
||||||
|
|
||||||
|
// Get synchronized policy data from setup
|
||||||
|
const policies = computed({
|
||||||
|
get: () => ({
|
||||||
|
hourlyWage: coopStore.equalHourlyWage,
|
||||||
|
payrollOncost: coopStore.payrollOncostPct,
|
||||||
|
savingsTargetMonths: coopStore.savingsTargetMonths,
|
||||||
|
minCashCushion: coopStore.minCashCushion,
|
||||||
|
deferredCapHours: 240, // These fields might not be in coop store yet
|
||||||
|
deferredSunsetMonths: 12,
|
||||||
|
}),
|
||||||
|
set: (newPolicies) => {
|
||||||
|
// Update the CoopBuilder store when policies change
|
||||||
|
coopStore.setEqualWage(newPolicies.hourlyWage);
|
||||||
|
coopStore.setOncostPct(newPolicies.payrollOncost);
|
||||||
|
coopStore.savingsTargetMonths = newPolicies.savingsTargetMonths;
|
||||||
|
coopStore.minCashCushion = newPolicies.minCashCushion;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const distributionOrder = ref([
|
const distributionOrder = ref([
|
||||||
|
|
|
||||||
|
|
@ -172,15 +172,16 @@
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
Any person who:
|
Any person who:
|
||||||
</p>
|
</p>
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
<UFormField label="Member Requirements" class="form-group-large">
|
||||||
<li>Shares our values and purpose</li>
|
<UTextarea
|
||||||
<li>
|
v-model="formData.memberRequirements"
|
||||||
Contributes labour to the cooperative (by doing actual work,
|
:rows="4"
|
||||||
not just investing money)
|
placeholder="Enter member requirements"
|
||||||
</li>
|
size="xl"
|
||||||
<li>Commits to collective decision-making</li>
|
class="large-field"
|
||||||
<li>Participates in governance responsibilities</li>
|
@input="debouncedAutoSave"
|
||||||
</ul>
|
@change="autoSave" />
|
||||||
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -413,43 +414,102 @@
|
||||||
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">
|
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
|
Paying Ourselves
|
||||||
</h3>
|
</h3>
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
<!-- Pay Policy Selection -->
|
||||||
Base rate: $<UInput
|
<UFormField label="Pay Policy" class="form-group-large mb-4">
|
||||||
v-model="formData.baseRate"
|
<USelect
|
||||||
type="number"
|
v-model="formData.payPolicy"
|
||||||
placeholder="25"
|
:items="payPolicyOptions"
|
||||||
class="inline-field number-field"
|
placeholder="Select pay policy"
|
||||||
@change="autoSave" />/hour for all members
|
size="xl"
|
||||||
</li>
|
class="w-full"
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
@change="autoSave" />
|
||||||
Or: Equal monthly draw of $<UInput
|
</UFormField>
|
||||||
v-model="formData.monthlyDraw"
|
|
||||||
type="number"
|
<!-- Equal Pay Policy -->
|
||||||
placeholder="2000"
|
<div v-if="formData.payPolicy === 'equal-pay'" class="space-y-3">
|
||||||
class="inline-field number-field"
|
<p class="content-paragraph">All members receive equal compensation regardless of role or hours worked.</p>
|
||||||
@change="autoSave" />
|
<ul class="content-list my-2 pl-6 list-disc">
|
||||||
per member
|
<li class="flex items-baseline gap-2 flex-wrap">
|
||||||
</li>
|
Base rate: $<UInput
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
v-model="formData.baseRate"
|
||||||
Paid on the
|
type="number"
|
||||||
<USelect
|
placeholder="25"
|
||||||
v-model="formData.paymentDay"
|
class="inline-field number-field"
|
||||||
:items="dayOptions"
|
@change="autoSave" />/hour for all members
|
||||||
placeholder="15"
|
</li>
|
||||||
class="inline-field"
|
<li class="flex items-baseline gap-2 flex-wrap">
|
||||||
@change="autoSave" />
|
Or: Equal monthly draw of $<UInput
|
||||||
of each month
|
v-model="formData.monthlyDraw"
|
||||||
</li>
|
type="number"
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
placeholder="2000"
|
||||||
Surplus (profit) distributed equally every
|
class="inline-field number-field"
|
||||||
<UInput
|
@change="autoSave" />
|
||||||
v-model="formData.surplusFrequency"
|
per member
|
||||||
placeholder="quarter"
|
</li>
|
||||||
class="inline-field"
|
</ul>
|
||||||
@change="autoSave" />
|
</div>
|
||||||
</li>
|
|
||||||
</ul>
|
<!-- 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="15th"
|
||||||
|
arrow
|
||||||
|
class="inline-field"
|
||||||
|
@change="autoSave" />
|
||||||
|
of each month
|
||||||
|
</li>
|
||||||
|
<li class="flex items-baseline gap-2 flex-wrap">
|
||||||
|
Surplus (profit) distributed equally every
|
||||||
|
<UInput
|
||||||
|
v-model="formData.surplusFrequency"
|
||||||
|
placeholder="quarter"
|
||||||
|
class="inline-field"
|
||||||
|
@change="autoSave" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
|
@ -522,15 +582,16 @@
|
||||||
@change="autoSave" />
|
@change="autoSave" />
|
||||||
months. Current roles include:
|
months. Current roles include:
|
||||||
</p>
|
</p>
|
||||||
<ul class="content-list">
|
<UFormField label="Rotating Roles" class="form-group-large">
|
||||||
<li>
|
<UTextarea
|
||||||
Financial coordinator (handles bookkeeping, not financial
|
v-model="formData.rotatingRoles"
|
||||||
decisions)
|
:rows="4"
|
||||||
</li>
|
placeholder="List rotating operational roles"
|
||||||
<li>Meeting facilitator</li>
|
size="xl"
|
||||||
<li>External communications</li>
|
class="large-field"
|
||||||
<li>Others</li>
|
@input="debouncedAutoSave"
|
||||||
</ul>
|
@change="autoSave" />
|
||||||
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -541,11 +602,16 @@
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
All members participate in:
|
All members participate in:
|
||||||
</p>
|
</p>
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
<UFormField label="Shared Responsibilities" class="form-group-large">
|
||||||
<li>Governance and decision-making</li>
|
<UTextarea
|
||||||
<li>Strategic planning</li>
|
v-model="formData.sharedResponsibilities"
|
||||||
<li>Mutual support and care</li>
|
:rows="3"
|
||||||
</ul>
|
placeholder="List shared responsibilities for all members"
|
||||||
|
size="xl"
|
||||||
|
class="large-field"
|
||||||
|
@input="debouncedAutoSave"
|
||||||
|
@change="autoSave" />
|
||||||
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -690,47 +756,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -768,13 +793,36 @@ const monthOptions = [
|
||||||
"December",
|
"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({
|
const formData = ref({
|
||||||
cooperativeName: "",
|
cooperativeName: "",
|
||||||
dateEstablished: "",
|
dateEstablished: "",
|
||||||
purpose: "",
|
purpose: "",
|
||||||
coreValues: "",
|
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: "" }],
|
members: [{ name: "", email: "", joinDate: "", role: "" }],
|
||||||
trialPeriodMonths: 3,
|
trialPeriodMonths: 3,
|
||||||
buyInAmount: "",
|
buyInAmount: "",
|
||||||
|
|
@ -787,20 +835,24 @@ const formData = ref({
|
||||||
majorDebtThreshold: 5000,
|
majorDebtThreshold: 5000,
|
||||||
meetingFrequency: "weekly",
|
meetingFrequency: "weekly",
|
||||||
emergencyNoticeHours: 24,
|
emergencyNoticeHours: 24,
|
||||||
|
// Pay policy settings
|
||||||
|
payPolicy: "equal-pay",
|
||||||
baseRate: 25,
|
baseRate: 25,
|
||||||
monthlyDraw: "",
|
monthlyDraw: "",
|
||||||
|
hourlyRate: 25,
|
||||||
|
minGuaranteedPay: 1000,
|
||||||
paymentDay: 15,
|
paymentDay: 15,
|
||||||
surplusFrequency: "quarter",
|
surplusFrequency: "quarter",
|
||||||
targetHours: 40,
|
targetHours: 40,
|
||||||
roleRotationMonths: 6,
|
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",
|
reviewFrequency: "year",
|
||||||
assetDonationTarget: "",
|
assetDonationTarget: "",
|
||||||
legalStructure: "",
|
legalStructure: "",
|
||||||
registeredLocation: "",
|
registeredLocation: "",
|
||||||
fiscalYearEndMonth: "December",
|
fiscalYearEndMonth: "December",
|
||||||
fiscalYearEndDay: 31,
|
fiscalYearEndDay: 31,
|
||||||
lastUpdated: new Date().toISOString().split("T")[0],
|
|
||||||
nextReview: "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load saved data immediately (before watchers)
|
// Load saved data immediately (before watchers)
|
||||||
|
|
@ -1017,28 +1069,12 @@ const handlePrint = () => {
|
||||||
|
|
||||||
// Export data for the ExportOptions component
|
// Export data for the ExportOptions component
|
||||||
const exportData = computed(() => ({
|
const exportData = computed(() => ({
|
||||||
|
// Pass the complete formData object - this is what the export functions use
|
||||||
formData: formData.value,
|
formData: formData.value,
|
||||||
|
// Also provide direct access to key fields for backward compatibility
|
||||||
cooperativeName: formData.value.cooperativeName || "Worker Cooperative",
|
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",
|
section: "membership-agreement",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
229
stores/budget.ts
229
stores/budget.ts
|
|
@ -319,15 +319,22 @@ export const useBudgetStore = defineStore(
|
||||||
if (!isInitialized.value) return;
|
if (!isInitialized.value) return;
|
||||||
|
|
||||||
const coopStore = useCoopBuilderStore();
|
const coopStore = useCoopBuilderStore();
|
||||||
const payrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll");
|
const basePayrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-base");
|
||||||
|
const oncostIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-oncosts");
|
||||||
|
|
||||||
if (payrollIndex === -1) return; // No existing payroll entry
|
// If no split payroll entries exist, look for legacy single entry
|
||||||
|
const legacyIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll");
|
||||||
|
|
||||||
|
if (basePayrollIndex === -1 && legacyIndex === -1) return; // No existing payroll entries
|
||||||
|
|
||||||
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
||||||
const hourlyWage = coopStore.equalHourlyWage || 0;
|
const hourlyWage = coopStore.equalHourlyWage || 0;
|
||||||
const oncostPct = coopStore.payrollOncostPct || 0;
|
const oncostPct = coopStore.payrollOncostPct || 0;
|
||||||
const basePayrollBudget = totalHours * hourlyWage;
|
const basePayrollBudget = totalHours * hourlyWage;
|
||||||
|
|
||||||
|
// Declare today once for the entire function
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
||||||
// Use policy-driven allocation
|
// Use policy-driven allocation
|
||||||
const payPolicy = {
|
const payPolicy = {
|
||||||
|
|
@ -340,63 +347,118 @@ export const useBudgetStore = defineStore(
|
||||||
displayName: m.name,
|
displayName: m.name,
|
||||||
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
||||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||||
targetMonthlyPay: m.targetMonthlyPay || 0,
|
|
||||||
role: m.role || '',
|
|
||||||
hoursPerMonth: m.hoursPerMonth || 0
|
hoursPerMonth: m.hoursPerMonth || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
||||||
|
|
||||||
// Sum with operating mode consideration
|
// Sum the allocated payroll amounts
|
||||||
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
||||||
const planned = m.monthlyPayPlanned || 0;
|
return sum + (m.monthlyPayPlanned || 0);
|
||||||
if (coopStore.operatingMode === 'min' && m.minMonthlyNeeds) {
|
|
||||||
return sum + Math.min(planned, m.minMonthlyNeeds);
|
|
||||||
}
|
|
||||||
return sum + planned;
|
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
|
// Update monthly values for base payroll
|
||||||
|
|
||||||
// Update monthly values
|
if (basePayrollIndex !== -1) {
|
||||||
const today = new Date();
|
// Update base payroll entry
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
budgetWorksheet.value.expenses[payrollIndex].monthlyValues[monthKey] = monthlyPayroll;
|
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = totalAllocatedPayroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update annual values for base payroll
|
||||||
|
budgetWorksheet.value.expenses[basePayrollIndex].values = {
|
||||||
|
year1: { best: totalAllocatedPayroll * 12, worst: totalAllocatedPayroll * 8, mostLikely: totalAllocatedPayroll * 12 },
|
||||||
|
year2: { best: totalAllocatedPayroll * 14, worst: totalAllocatedPayroll * 10, mostLikely: totalAllocatedPayroll * 13 },
|
||||||
|
year3: { best: totalAllocatedPayroll * 16, worst: totalAllocatedPayroll * 12, mostLikely: totalAllocatedPayroll * 15 }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update annual values
|
if (oncostIndex !== -1) {
|
||||||
budgetWorksheet.value.expenses[payrollIndex].values = {
|
// Update oncost entry
|
||||||
year1: { best: monthlyPayroll * 12, worst: monthlyPayroll * 8, mostLikely: monthlyPayroll * 12 },
|
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
|
||||||
year2: { best: monthlyPayroll * 14, worst: monthlyPayroll * 10, mostLikely: monthlyPayroll * 13 },
|
|
||||||
year3: { best: monthlyPayroll * 16, worst: monthlyPayroll * 12, mostLikely: monthlyPayroll * 15 }
|
for (let i = 0; i < 12; i++) {
|
||||||
};
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = oncostAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update name with current percentage
|
||||||
|
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
|
||||||
|
|
||||||
|
// Update annual values for oncosts
|
||||||
|
budgetWorksheet.value.expenses[oncostIndex].values = {
|
||||||
|
year1: { best: oncostAmount * 12, worst: oncostAmount * 8, mostLikely: oncostAmount * 12 },
|
||||||
|
year2: { best: oncostAmount * 14, worst: oncostAmount * 10, mostLikely: oncostAmount * 13 },
|
||||||
|
year3: { best: oncostAmount * 16, worst: oncostAmount * 12, mostLikely: oncostAmount * 15 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy single payroll entry (update to combined amount for backwards compatibility)
|
||||||
|
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
||||||
|
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
|
||||||
|
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = monthlyPayroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetWorksheet.value.expenses[legacyIndex].values = {
|
||||||
|
year1: { best: monthlyPayroll * 12, worst: monthlyPayroll * 8, mostLikely: monthlyPayroll * 12 },
|
||||||
|
year2: { best: monthlyPayroll * 14, worst: monthlyPayroll * 10, mostLikely: monthlyPayroll * 13 },
|
||||||
|
year3: { best: monthlyPayroll * 16, worst: monthlyPayroll * 12, mostLikely: monthlyPayroll * 15 }
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force reinitialize - always reload from wizard data
|
||||||
|
async function forceInitializeFromWizardData() {
|
||||||
|
console.log("=== FORCE BUDGET INITIALIZATION ===");
|
||||||
|
isInitialized.value = false;
|
||||||
|
budgetWorksheet.value.revenue = [];
|
||||||
|
budgetWorksheet.value.expenses = [];
|
||||||
|
await initializeFromWizardData();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize worksheet from wizard data
|
// Initialize worksheet from wizard data
|
||||||
async function initializeFromWizardData() {
|
async function initializeFromWizardData() {
|
||||||
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
|
console.log("=== BUDGET INITIALIZATION DEBUG ===");
|
||||||
console.log("Already initialized with data, skipping...");
|
console.log("Is already initialized:", isInitialized.value);
|
||||||
|
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 - 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Initializing budget from wizard data...");
|
console.log("No existing budget data found, initializing from wizard data...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the new coopBuilder store instead of the old stores
|
// Use the new coopBuilder store instead of the old stores
|
||||||
const coopStore = useCoopBuilderStore();
|
const coopStore = useCoopBuilderStore();
|
||||||
|
|
||||||
console.log("Streams:", coopStore.streams.length, "streams");
|
console.log("CoopStore Data:");
|
||||||
console.log("Members:", coopStore.members.length, "members");
|
console.log("- Streams:", coopStore.streams.length, coopStore.streams);
|
||||||
console.log("Equal wage:", coopStore.equalHourlyWage || "No wage set");
|
console.log("- Members:", coopStore.members.length, coopStore.members);
|
||||||
console.log("Overhead costs:", coopStore.overheadCosts.length, "costs");
|
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.revenue = [];
|
||||||
budgetWorksheet.value.expenses = [];
|
budgetWorksheet.value.expenses = [];
|
||||||
|
|
||||||
|
// Declare today once for the entire function
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
// Add revenue streams from wizard (but don't auto-load fixtures)
|
// Add revenue streams from wizard (but don't auto-load fixtures)
|
||||||
// Note: We don't auto-load fixtures anymore, but wizard data should still work
|
// Note: We don't auto-load fixtures anymore, but wizard data should still work
|
||||||
|
|
||||||
|
|
@ -423,7 +485,6 @@ export const useBudgetStore = defineStore(
|
||||||
|
|
||||||
// Create monthly values - split the annual target evenly across 12 months
|
// Create monthly values - split the annual target evenly across 12 months
|
||||||
const monthlyValues: Record<string, number> = {};
|
const monthlyValues: Record<string, number> = {};
|
||||||
const today = new Date();
|
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
const monthKey = `${date.getFullYear()}-${String(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
|
|
@ -470,8 +531,16 @@ export const useBudgetStore = defineStore(
|
||||||
const hourlyWage = coopStore.equalHourlyWage || 0;
|
const hourlyWage = coopStore.equalHourlyWage || 0;
|
||||||
const oncostPct = coopStore.payrollOncostPct || 0;
|
const oncostPct = coopStore.payrollOncostPct || 0;
|
||||||
|
|
||||||
|
console.log("=== PAYROLL CALCULATION DEBUG ===");
|
||||||
|
console.log("Total hours:", totalHours);
|
||||||
|
console.log("Hourly wage:", hourlyWage);
|
||||||
|
console.log("Oncost %:", oncostPct);
|
||||||
|
console.log("Operating mode:", coopStore.operatingMode);
|
||||||
|
console.log("Policy relationship:", coopStore.policy.relationship);
|
||||||
|
|
||||||
// Calculate total payroll budget using policy allocation
|
// Calculate total payroll budget using policy allocation
|
||||||
const basePayrollBudget = totalHours * hourlyWage;
|
const basePayrollBudget = totalHours * hourlyWage;
|
||||||
|
console.log("Base payroll budget:", basePayrollBudget);
|
||||||
|
|
||||||
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
||||||
// Use policy-driven allocation to get actual member pay amounts
|
// Use policy-driven allocation to get actual member pay amounts
|
||||||
|
|
@ -487,30 +556,28 @@ export const useBudgetStore = defineStore(
|
||||||
// Ensure all required fields exist
|
// Ensure all required fields exist
|
||||||
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
||||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||||
targetMonthlyPay: m.targetMonthlyPay || 0,
|
|
||||||
role: m.role || '',
|
|
||||||
hoursPerMonth: m.hoursPerMonth || 0
|
hoursPerMonth: m.hoursPerMonth || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Allocate payroll based on policy
|
// Allocate payroll based on policy
|
||||||
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
||||||
|
console.log("Allocated members:", allocatedMembers.map(m => ({ name: m.name, planned: m.monthlyPayPlanned, needs: m.minMonthlyNeeds })));
|
||||||
|
|
||||||
// Sum the allocated amounts for total payroll, respecting operating mode
|
// Sum the allocated amounts for total payroll
|
||||||
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
||||||
const planned = m.monthlyPayPlanned || 0;
|
const planned = m.monthlyPayPlanned || 0;
|
||||||
// In "minimum" mode, cap at min needs to show a lean runway scenario
|
console.log(`Member ${m.name}: planned ${planned}`);
|
||||||
if (coopStore.operatingMode === 'min' && m.minMonthlyNeeds) {
|
|
||||||
return sum + Math.min(planned, m.minMonthlyNeeds);
|
|
||||||
}
|
|
||||||
return sum + planned;
|
return sum + planned;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
console.log("Total allocated payroll:", totalAllocatedPayroll);
|
||||||
|
|
||||||
// Apply oncosts to the policy-allocated total
|
// Apply oncosts to the policy-allocated total
|
||||||
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
|
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
|
||||||
|
console.log("Monthly payroll with oncosts:", monthlyPayroll);
|
||||||
|
|
||||||
// Create monthly values for payroll
|
// Create monthly values for payroll
|
||||||
const monthlyValues: Record<string, number> = {};
|
const monthlyValues: Record<string, number> = {};
|
||||||
const today = new Date();
|
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
const monthKey = `${date.getFullYear()}-${String(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
|
|
@ -519,31 +586,77 @@ export const useBudgetStore = defineStore(
|
||||||
monthlyValues[monthKey] = monthlyPayroll;
|
monthlyValues[monthKey] = monthlyPayroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("Creating split payroll budget items with monthly values:", Object.keys(monthlyValues).length, "months");
|
||||||
|
|
||||||
|
// Create base payroll monthly values (without oncosts)
|
||||||
|
const baseMonthlyValues: Record<string, number> = {};
|
||||||
|
const oncostMonthlyValues: Record<string, number> = {};
|
||||||
|
// Reuse the today variable from above
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
|
date.getMonth() + 1
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
baseMonthlyValues[monthKey] = totalAllocatedPayroll;
|
||||||
|
oncostMonthlyValues[monthKey] = totalAllocatedPayroll * (oncostPct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base payroll item
|
||||||
budgetWorksheet.value.expenses.push({
|
budgetWorksheet.value.expenses.push({
|
||||||
id: "expense-payroll",
|
id: "expense-payroll-base",
|
||||||
name: "Payroll",
|
name: "Payroll",
|
||||||
mainCategory: "Salaries & Benefits",
|
mainCategory: "Salaries & Benefits",
|
||||||
subcategory: "Base wages and benefits",
|
subcategory: "Base wages and benefits",
|
||||||
source: "wizard",
|
source: "wizard",
|
||||||
monthlyValues,
|
monthlyValues: baseMonthlyValues,
|
||||||
values: {
|
values: {
|
||||||
year1: {
|
year1: {
|
||||||
best: monthlyPayroll * 12,
|
best: totalAllocatedPayroll * 12,
|
||||||
worst: monthlyPayroll * 8,
|
worst: totalAllocatedPayroll * 8,
|
||||||
mostLikely: monthlyPayroll * 12,
|
mostLikely: totalAllocatedPayroll * 12,
|
||||||
},
|
},
|
||||||
year2: {
|
year2: {
|
||||||
best: monthlyPayroll * 14,
|
best: totalAllocatedPayroll * 14,
|
||||||
worst: monthlyPayroll * 10,
|
worst: totalAllocatedPayroll * 10,
|
||||||
mostLikely: monthlyPayroll * 13,
|
mostLikely: totalAllocatedPayroll * 13,
|
||||||
},
|
},
|
||||||
year3: {
|
year3: {
|
||||||
best: monthlyPayroll * 16,
|
best: totalAllocatedPayroll * 16,
|
||||||
worst: monthlyPayroll * 12,
|
worst: totalAllocatedPayroll * 12,
|
||||||
mostLikely: monthlyPayroll * 15,
|
mostLikely: totalAllocatedPayroll * 15,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add payroll oncosts item (if oncost percentage > 0)
|
||||||
|
if (oncostPct > 0) {
|
||||||
|
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
|
||||||
|
budgetWorksheet.value.expenses.push({
|
||||||
|
id: "expense-payroll-oncosts",
|
||||||
|
name: `Payroll Taxes & Benefits (${oncostPct}%)`,
|
||||||
|
mainCategory: "Salaries & Benefits",
|
||||||
|
subcategory: "Payroll taxes and benefits",
|
||||||
|
source: "wizard",
|
||||||
|
monthlyValues: oncostMonthlyValues,
|
||||||
|
values: {
|
||||||
|
year1: {
|
||||||
|
best: oncostAmount * 12,
|
||||||
|
worst: oncostAmount * 8,
|
||||||
|
mostLikely: oncostAmount * 12,
|
||||||
|
},
|
||||||
|
year2: {
|
||||||
|
best: oncostAmount * 14,
|
||||||
|
worst: oncostAmount * 10,
|
||||||
|
mostLikely: oncostAmount * 13,
|
||||||
|
},
|
||||||
|
year3: {
|
||||||
|
best: oncostAmount * 16,
|
||||||
|
worst: oncostAmount * 12,
|
||||||
|
mostLikely: oncostAmount * 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add overhead costs from wizard
|
// Add overhead costs from wizard
|
||||||
|
|
@ -563,7 +676,6 @@ export const useBudgetStore = defineStore(
|
||||||
|
|
||||||
// Create monthly values for overhead costs
|
// Create monthly values for overhead costs
|
||||||
const monthlyValues: Record<string, number> = {};
|
const monthlyValues: Record<string, number> = {};
|
||||||
const today = new Date();
|
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
const monthKey = `${date.getFullYear()}-${String(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
|
|
@ -696,7 +808,6 @@ export const useBudgetStore = defineStore(
|
||||||
if (!item.monthlyValues) {
|
if (!item.monthlyValues) {
|
||||||
console.log("Migrating item to monthly values:", item.name);
|
console.log("Migrating item to monthly values:", item.name);
|
||||||
item.monthlyValues = {};
|
item.monthlyValues = {};
|
||||||
const today = new Date();
|
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
const monthKey = `${date.getFullYear()}-${String(
|
const monthKey = `${date.getFullYear()}-${String(
|
||||||
|
|
@ -743,13 +854,22 @@ export const useBudgetStore = defineStore(
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMonthlyValue(category, itemId, monthKey, value) {
|
function updateMonthlyValue(category, itemId, monthKey, value) {
|
||||||
|
console.log('updateMonthlyValue called:', { category, itemId, monthKey, value });
|
||||||
const items = budgetWorksheet.value[category];
|
const items = budgetWorksheet.value[category];
|
||||||
const item = items.find((i) => i.id === itemId);
|
const item = items.find((i) => i.id === itemId);
|
||||||
if (item) {
|
if (item) {
|
||||||
if (!item.monthlyValues) {
|
if (!item.monthlyValues) {
|
||||||
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})) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -856,6 +976,7 @@ export const useBudgetStore = defineStore(
|
||||||
groupedExpenses,
|
groupedExpenses,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
initializeFromWizardData,
|
initializeFromWizardData,
|
||||||
|
forceInitializeFromWizardData,
|
||||||
refreshPayrollInBudget,
|
refreshPayrollInBudget,
|
||||||
updateBudgetValue,
|
updateBudgetValue,
|
||||||
updateMonthlyValue,
|
updateMonthlyValue,
|
||||||
|
|
|
||||||
193
stores/cash.ts
193
stores/cash.ts
|
|
@ -1,8 +1,28 @@
|
||||||
import { defineStore } from "pinia";
|
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", () => {
|
export const useCashStore = defineStore("cash", () => {
|
||||||
// 13-week cash flow events
|
// 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
|
// Payment queue - staged payments within policy
|
||||||
const paymentQueue = ref([]);
|
const paymentQueue = ref([]);
|
||||||
|
|
@ -18,33 +38,123 @@ export const useCashStore = defineStore("cash", () => {
|
||||||
const weeklyProjections = computed(() => {
|
const weeklyProjections = computed(() => {
|
||||||
const weeks = [];
|
const weeks = [];
|
||||||
let runningBalance = currentCash.value;
|
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++) {
|
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 weekEvents = cashEvents.value.filter((e) => e.week === week);
|
||||||
const weekInflow = weekEvents
|
weekInflow += weekEvents
|
||||||
.filter((e) => e.type === "Influx")
|
.filter((e) => e.type === "Influx")
|
||||||
.reduce((sum, e) => sum + e.amount, 0);
|
.reduce((sum, e) => sum + e.amount, 0);
|
||||||
const weekOutflow = weekEvents
|
weekOutflow += weekEvents
|
||||||
.filter((e) => e.type === "Outflow")
|
.filter((e) => e.type === "Outflow")
|
||||||
.reduce((sum, e) => sum + e.amount, 0);
|
.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;
|
const net = weekInflow - weekOutflow;
|
||||||
runningBalance += net;
|
runningBalance += net;
|
||||||
|
|
||||||
weeks.push({
|
weeks.push({
|
||||||
number: week,
|
number: week,
|
||||||
inflow: weekInflow,
|
inflow: Math.round(weekInflow),
|
||||||
outflow: weekOutflow,
|
outflow: Math.round(weekOutflow),
|
||||||
net,
|
net: Math.round(net),
|
||||||
balance: runningBalance,
|
balance: Math.round(runningBalance),
|
||||||
cushion: runningBalance, // Will be calculated properly later
|
cushion: Math.round(runningBalance),
|
||||||
breachesCushion: false, // Will be calculated properly later
|
breachesCushion: runningBalance < 0,
|
||||||
|
weekStart: weekStart.toISOString().split('T')[0],
|
||||||
|
weekEnd: weekEnd.toISOString().split('T')[0],
|
||||||
|
oneOffEvents: weekOneOffs
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return weeks;
|
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
|
// Actions
|
||||||
function addCashEvent(event) {
|
function addCashEvent(event) {
|
||||||
cashEvents.value.push({
|
cashEvents.value.push({
|
||||||
|
|
@ -97,19 +207,79 @@ export const useCashStore = defineStore("cash", () => {
|
||||||
currentSavings.value = savings;
|
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 {
|
return {
|
||||||
cashEvents: readonly(cashEvents),
|
cashEvents: readonly(cashEvents),
|
||||||
|
oneOffEvents: readonly(oneOffEvents),
|
||||||
paymentQueue: readonly(paymentQueue),
|
paymentQueue: readonly(paymentQueue),
|
||||||
firstBreachWeek: readonly(firstBreachWeek),
|
firstBreachWeek: readonly(firstBreachWeek),
|
||||||
currentCash: readonly(currentCash),
|
currentCash: readonly(currentCash),
|
||||||
currentSavings: readonly(currentSavings),
|
currentSavings: readonly(currentSavings),
|
||||||
weeklyProjections,
|
weeklyProjections,
|
||||||
|
monthlyProjections,
|
||||||
addCashEvent,
|
addCashEvent,
|
||||||
updateCashEvent,
|
updateCashEvent,
|
||||||
removeCashEvent,
|
removeCashEvent,
|
||||||
addToPaymentQueue,
|
addToPaymentQueue,
|
||||||
stagePayment,
|
stagePayment,
|
||||||
updateCurrentBalances,
|
updateCurrentBalances,
|
||||||
|
// One-off events
|
||||||
|
addOneOffEvent,
|
||||||
|
updateOneOffEvent,
|
||||||
|
removeOneOffEvent,
|
||||||
|
getEventsByMonth,
|
||||||
|
addCashEventFromOneOff
|
||||||
};
|
};
|
||||||
}, {
|
}, {
|
||||||
persist: {
|
persist: {
|
||||||
|
|
@ -118,7 +288,8 @@ export const useCashStore = defineStore("cash", () => {
|
||||||
"currentCash",
|
"currentCash",
|
||||||
"currentSavings",
|
"currentSavings",
|
||||||
"cashEvents",
|
"cashEvents",
|
||||||
"paymentQueue"
|
"paymentQueue",
|
||||||
|
"oneOffEvents"
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,17 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
operatingMode: "min" as "min" | "target",
|
operatingMode: "min" as "min" | "target",
|
||||||
|
|
||||||
|
// Currency preference
|
||||||
|
currency: "EUR" as string,
|
||||||
|
|
||||||
// Flag to track if data was intentionally cleared
|
// Flag to track if data was intentionally cleared
|
||||||
_wasCleared: false,
|
_wasCleared: false,
|
||||||
|
|
||||||
members: [] as Array<{
|
members: [] as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
|
||||||
hoursPerMonth?: number;
|
hoursPerMonth?: number;
|
||||||
minMonthlyNeeds: number;
|
minMonthlyNeeds: number;
|
||||||
targetMonthlyPay: number;
|
|
||||||
externalMonthlyIncome: number;
|
|
||||||
monthlyPayPlanned: number;
|
monthlyPayPlanned: number;
|
||||||
}>,
|
}>,
|
||||||
|
|
||||||
|
|
@ -22,6 +22,8 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
monthly: number;
|
monthly: number;
|
||||||
|
annual?: number;
|
||||||
|
amountType?: 'monthly' | 'annual';
|
||||||
category?: string;
|
category?: string;
|
||||||
certainty?: string;
|
certainty?: string;
|
||||||
}>,
|
}>,
|
||||||
|
|
@ -35,7 +37,6 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
// Scenario and stress test state
|
// Scenario and stress test state
|
||||||
scenario: "current" as
|
scenario: "current" as
|
||||||
| "current"
|
| "current"
|
||||||
| "quit-jobs"
|
|
||||||
| "start-production"
|
| "start-production"
|
||||||
| "custom",
|
| "custom",
|
||||||
stress: {
|
stress: {
|
||||||
|
|
@ -46,8 +47,7 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
|
|
||||||
// Policy settings
|
// Policy settings
|
||||||
policy: {
|
policy: {
|
||||||
relationship: "equal-pay" as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded",
|
relationship: "equal-pay" as "equal-pay" | "needs-weighted" | "hours-weighted",
|
||||||
roleBands: {} as Record<string, number>,
|
|
||||||
},
|
},
|
||||||
equalHourlyWage: 50,
|
equalHourlyWage: 50,
|
||||||
payrollOncostPct: 25,
|
payrollOncostPct: 25,
|
||||||
|
|
@ -63,6 +63,8 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
annualAmount?: number;
|
||||||
|
amountType?: 'monthly' | 'annual';
|
||||||
category?: string;
|
category?: string;
|
||||||
}>,
|
}>,
|
||||||
}),
|
}),
|
||||||
|
|
@ -110,10 +112,19 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
// Stream actions
|
// Stream actions
|
||||||
upsertStream(s: any) {
|
upsertStream(s: any) {
|
||||||
const i = this.streams.findIndex((x) => x.id === s.id);
|
const i = this.streams.findIndex((x) => x.id === s.id);
|
||||||
|
|
||||||
|
// Calculate monthly value based on amount type
|
||||||
|
let monthlyValue = s.monthly || s.targetMonthlyAmount || 0;
|
||||||
|
if (s.amountType === 'annual' && s.annual) {
|
||||||
|
monthlyValue = Math.round(s.annual / 12);
|
||||||
|
}
|
||||||
|
|
||||||
const withDefaults = {
|
const withDefaults = {
|
||||||
id: s.id || Date.now().toString(),
|
id: s.id || Date.now().toString(),
|
||||||
label: s.label || s.name || "",
|
label: s.label || s.name || "",
|
||||||
monthly: s.monthly || s.targetMonthlyAmount || 0,
|
monthly: monthlyValue,
|
||||||
|
annual: s.annual || s.targetAnnualAmount || monthlyValue * 12,
|
||||||
|
amountType: s.amountType || 'monthly',
|
||||||
category: s.category ?? "",
|
category: s.category ?? "",
|
||||||
certainty: s.certainty ?? "Probable",
|
certainty: s.certainty ?? "Probable",
|
||||||
};
|
};
|
||||||
|
|
@ -148,7 +159,7 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
|
|
||||||
// Scenario
|
// Scenario
|
||||||
setScenario(
|
setScenario(
|
||||||
scenario: "current" | "quit-jobs" | "start-production" | "custom"
|
scenario: "current" | "start-production" | "custom"
|
||||||
) {
|
) {
|
||||||
this.scenario = scenario;
|
this.scenario = scenario;
|
||||||
},
|
},
|
||||||
|
|
@ -159,14 +170,10 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Policy updates
|
// Policy updates
|
||||||
setPolicy(relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") {
|
setPolicy(relationship: "equal-pay" | "needs-weighted" | "hours-weighted") {
|
||||||
this.policy.relationship = relationship;
|
this.policy.relationship = relationship;
|
||||||
},
|
},
|
||||||
|
|
||||||
setRoleBands(bands: Record<string, number>) {
|
|
||||||
this.policy.roleBands = bands;
|
|
||||||
},
|
|
||||||
|
|
||||||
setEqualWage(wage: number) {
|
setEqualWage(wage: number) {
|
||||||
this.equalHourlyWage = wage;
|
this.equalHourlyWage = wage;
|
||||||
},
|
},
|
||||||
|
|
@ -175,12 +182,24 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
this.payrollOncostPct = pct;
|
this.payrollOncostPct = pct;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCurrency(currency: string) {
|
||||||
|
this.currency = currency;
|
||||||
|
},
|
||||||
|
|
||||||
// Overhead costs
|
// Overhead costs
|
||||||
addOverheadCost(cost: any) {
|
addOverheadCost(cost: any) {
|
||||||
|
// Calculate monthly value based on amount type
|
||||||
|
let monthlyValue = cost.amount || 0;
|
||||||
|
if (cost.amountType === 'annual' && cost.annualAmount) {
|
||||||
|
monthlyValue = Math.round(cost.annualAmount / 12);
|
||||||
|
}
|
||||||
|
|
||||||
const withDefaults = {
|
const withDefaults = {
|
||||||
id: cost.id || Date.now().toString(),
|
id: cost.id || Date.now().toString(),
|
||||||
name: cost.name || "",
|
name: cost.name || "",
|
||||||
amount: cost.amount || 0,
|
amount: monthlyValue,
|
||||||
|
annualAmount: cost.annualAmount || monthlyValue * 12,
|
||||||
|
amountType: cost.amountType || 'monthly',
|
||||||
category: cost.category ?? "",
|
category: cost.category ?? "",
|
||||||
};
|
};
|
||||||
this.overheadCosts.push(withDefaults);
|
this.overheadCosts.push(withDefaults);
|
||||||
|
|
@ -188,10 +207,19 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
|
|
||||||
upsertOverheadCost(cost: any) {
|
upsertOverheadCost(cost: any) {
|
||||||
const i = this.overheadCosts.findIndex((c) => c.id === cost.id);
|
const i = this.overheadCosts.findIndex((c) => c.id === cost.id);
|
||||||
|
|
||||||
|
// Calculate monthly value based on amount type
|
||||||
|
let monthlyValue = cost.amount || 0;
|
||||||
|
if (cost.amountType === 'annual' && cost.annualAmount) {
|
||||||
|
monthlyValue = Math.round(cost.annualAmount / 12);
|
||||||
|
}
|
||||||
|
|
||||||
const withDefaults = {
|
const withDefaults = {
|
||||||
id: cost.id || Date.now().toString(),
|
id: cost.id || Date.now().toString(),
|
||||||
name: cost.name || "",
|
name: cost.name || "",
|
||||||
amount: cost.amount || 0,
|
amount: monthlyValue,
|
||||||
|
annualAmount: cost.annualAmount || monthlyValue * 12,
|
||||||
|
amountType: cost.amountType || 'monthly',
|
||||||
category: cost.category ?? "",
|
category: cost.category ?? "",
|
||||||
};
|
};
|
||||||
if (i === -1) {
|
if (i === -1) {
|
||||||
|
|
@ -218,6 +246,7 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
// Reset ALL state to initial empty values
|
// Reset ALL state to initial empty values
|
||||||
this._wasCleared = true;
|
this._wasCleared = true;
|
||||||
this.operatingMode = "min";
|
this.operatingMode = "min";
|
||||||
|
this.currency = "EUR";
|
||||||
this.members = [];
|
this.members = [];
|
||||||
this.streams = [];
|
this.streams = [];
|
||||||
this.milestones = [];
|
this.milestones = [];
|
||||||
|
|
@ -229,7 +258,6 @@ export const useCoopBuilderStore = defineStore("coop", {
|
||||||
};
|
};
|
||||||
this.policy = {
|
this.policy = {
|
||||||
relationship: "equal-pay",
|
relationship: "equal-pay",
|
||||||
roleBands: {},
|
|
||||||
};
|
};
|
||||||
this.equalHourlyWage = 0;
|
this.equalHourlyWage = 0;
|
||||||
this.payrollOncostPct = 0;
|
this.payrollOncostPct = 0;
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@ export const useMembersStore = defineStore(
|
||||||
const normalized = {
|
const normalized = {
|
||||||
id: raw.id || Date.now().toString(),
|
id: raw.id || Date.now().toString(),
|
||||||
displayName: typeof raw.displayName === "string" ? raw.displayName : "",
|
displayName: typeof raw.displayName === "string" ? raw.displayName : "",
|
||||||
roleFocus: typeof raw.roleFocus === "string" ? raw.roleFocus : "",
|
|
||||||
role: raw.role || raw.roleFocus || "",
|
|
||||||
hoursPerWeek: hoursPerWeek,
|
hoursPerWeek: hoursPerWeek,
|
||||||
payRelationship: raw.payRelationship || "FullyPaid",
|
payRelationship: raw.payRelationship || "FullyPaid",
|
||||||
capacity: {
|
capacity: {
|
||||||
|
|
@ -57,10 +55,8 @@ export const useMembersStore = defineStore(
|
||||||
privacyNeeds: raw.privacyNeeds || "aggregate_ok",
|
privacyNeeds: raw.privacyNeeds || "aggregate_ok",
|
||||||
deferredHours: Number(raw.deferredHours ?? 0),
|
deferredHours: Number(raw.deferredHours ?? 0),
|
||||||
quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240),
|
quarterlyDeferredCap: Number(raw.quarterlyDeferredCap ?? 240),
|
||||||
// NEW fields for needs coverage
|
// Simplified - only minimum needs for allocation
|
||||||
minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0,
|
minMonthlyNeeds: Number(raw.minMonthlyNeeds) || 0,
|
||||||
targetMonthlyPay: Number(raw.targetMonthlyPay) || 0,
|
|
||||||
externalMonthlyIncome: Number(raw.externalMonthlyIncome) || 0,
|
|
||||||
monthlyPayPlanned: Number(raw.monthlyPayPlanned) || 0,
|
monthlyPayPlanned: Number(raw.monthlyPayPlanned) || 0,
|
||||||
...raw,
|
...raw,
|
||||||
};
|
};
|
||||||
|
|
@ -145,6 +141,16 @@ export const useMembersStore = defineStore(
|
||||||
const member = members.value.find((m) => m.id === memberId);
|
const member = members.value.find((m) => m.id === memberId);
|
||||||
if (member) {
|
if (member) {
|
||||||
member.capacity = { ...member.capacity, ...capacity };
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,20 +207,47 @@ export const useMembersStore = defineStore(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage calculations for individual members
|
// Coverage calculations for individual members
|
||||||
function getMemberCoverage(memberId) {
|
function getMemberCoverage(memberId, equalWage = 25) {
|
||||||
const member = members.value.find((m) => m.id === memberId);
|
const member = members.value.find((m) => m.id === memberId);
|
||||||
if (!member) return { minPct: undefined, targetPct: undefined };
|
if (!member) return { minPct: 0, targetPct: 0 };
|
||||||
|
|
||||||
return coverage(
|
// Calculate what they're getting paid based on their hours
|
||||||
member.minMonthlyNeeds || 0,
|
const minHours = member.capacity?.minHours || 0;
|
||||||
member.targetMonthlyPay || 0,
|
const targetHours = member.capacity?.targetHours || 0;
|
||||||
member.monthlyPayPlanned || 0,
|
|
||||||
member.externalMonthlyIncome || 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
|
// Team-wide coverage statistics - accepts equalWage parameter
|
||||||
const teamStats = computed(() => teamCoverageStats(members.value));
|
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
|
// Pay policy configuration
|
||||||
const payPolicy = ref({
|
const payPolicy = ref({
|
||||||
|
|
@ -222,26 +255,19 @@ export const useMembersStore = defineStore(
|
||||||
notes: '',
|
notes: '',
|
||||||
equalBase: 0,
|
equalBase: 0,
|
||||||
needsWeight: 0.5,
|
needsWeight: 0.5,
|
||||||
roleBands: {},
|
|
||||||
hoursRate: 0,
|
hoursRate: 0,
|
||||||
customFormula: ''
|
customFormula: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setters for new fields
|
// Setter for minimum needs only
|
||||||
function setMonthlyNeeds(memberId, minNeeds, targetPay) {
|
function setMonthlyNeeds(memberId, minNeeds) {
|
||||||
const member = members.value.find((m) => m.id === memberId);
|
const member = members.value.find((m) => m.id === memberId);
|
||||||
if (member) {
|
if (member) {
|
||||||
member.minMonthlyNeeds = Number(minNeeds) || 0;
|
member.minMonthlyNeeds = Number(minNeeds) || 0;
|
||||||
member.targetMonthlyPay = Number(targetPay) || 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setExternalIncome(memberId, income) {
|
// Removed setExternalIncome - no longer needed
|
||||||
const member = members.value.find((m) => m.id === memberId);
|
|
||||||
if (member) {
|
|
||||||
member.externalMonthlyIncome = Number(income) || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPlannedPay(memberId, planned) {
|
function setPlannedPay(memberId, planned) {
|
||||||
const member = members.value.find((m) => m.id === memberId);
|
const member = members.value.find((m) => m.id === memberId);
|
||||||
|
|
@ -269,9 +295,10 @@ export const useMembersStore = defineStore(
|
||||||
resetMembers,
|
resetMembers,
|
||||||
// New coverage actions
|
// New coverage actions
|
||||||
setMonthlyNeeds,
|
setMonthlyNeeds,
|
||||||
setExternalIncome,
|
|
||||||
setPlannedPay,
|
setPlannedPay,
|
||||||
getMemberCoverage,
|
getMemberCoverage,
|
||||||
|
getTeamStats,
|
||||||
|
recalculateMemberPay,
|
||||||
// Legacy actions
|
// Legacy actions
|
||||||
addMember,
|
addMember,
|
||||||
updateMember,
|
updateMember,
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { defineStore } from "pinia";
|
|
||||||
|
|
||||||
export const useScenariosStore = defineStore("scenarios", () => {
|
|
||||||
// Scenario presets
|
|
||||||
const presets = ref({
|
|
||||||
current: {
|
|
||||||
name: "Operate Current Plan",
|
|
||||||
description: "Continue with existing revenue and capacity",
|
|
||||||
settings: {},
|
|
||||||
},
|
|
||||||
quitDayJobs: {
|
|
||||||
name: "Quit Day Jobs",
|
|
||||||
description: "Members leave external work, increase co-op hours",
|
|
||||||
settings: {
|
|
||||||
targetHoursMultiplier: 1.5,
|
|
||||||
externalCoverageReduction: 0.8,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startProduction: {
|
|
||||||
name: "Start Production",
|
|
||||||
description: "Launch product development phase",
|
|
||||||
settings: {
|
|
||||||
productionCostsIncrease: 2000,
|
|
||||||
effortHoursIncrease: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sixMonth: {
|
|
||||||
name: "6-Month Plan",
|
|
||||||
description: "Extended planning horizon",
|
|
||||||
settings: {
|
|
||||||
timeHorizonMonths: 6,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// What-if sliders state
|
|
||||||
const sliders = ref({
|
|
||||||
monthlyRevenue: 0,
|
|
||||||
paidHoursPerMonth: 0,
|
|
||||||
winRatePct: 0,
|
|
||||||
avgPayoutDelayDays: 0,
|
|
||||||
hourlyWageAdjust: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Selected scenario
|
|
||||||
const activeScenario = ref("current");
|
|
||||||
|
|
||||||
// Computed scenario results (will be calculated by composables)
|
|
||||||
const scenarioResults = computed(() => ({
|
|
||||||
runway: 0,
|
|
||||||
monthlyCosts: 0,
|
|
||||||
cashflowRisk: "low",
|
|
||||||
recommendations: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
function setActiveScenario(scenarioKey) {
|
|
||||||
activeScenario.value = scenarioKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSlider(key, value) {
|
|
||||||
if (key in sliders.value) {
|
|
||||||
sliders.value[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSliders() {
|
|
||||||
sliders.value = {
|
|
||||||
monthlyRevenue: 0,
|
|
||||||
paidHoursPerMonth: 0,
|
|
||||||
winRatePct: 0,
|
|
||||||
avgPayoutDelayDays: 0,
|
|
||||||
hourlyWageAdjust: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveCustomScenario(name, settings) {
|
|
||||||
presets.value[name.toLowerCase().replace(/\s+/g, "")] = {
|
|
||||||
name,
|
|
||||||
description: "Custom scenario",
|
|
||||||
settings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
presets: readonly(presets),
|
|
||||||
sliders,
|
|
||||||
activeScenario: readonly(activeScenario),
|
|
||||||
scenarioResults,
|
|
||||||
setActiveScenario,
|
|
||||||
updateSlider,
|
|
||||||
resetSliders,
|
|
||||||
saveCustomScenario,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import { defineStore } from "pinia";
|
|
||||||
|
|
||||||
export const useSessionStore = defineStore("session", () => {
|
|
||||||
// Value Accounting session checklist state
|
|
||||||
const checklist = ref({
|
|
||||||
monthClosed: false,
|
|
||||||
contributionsLogged: false,
|
|
||||||
surplusCalculated: false,
|
|
||||||
needsReviewed: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draft distribution allocations
|
|
||||||
const draftAllocations = ref({
|
|
||||||
deferredRepay: 0,
|
|
||||||
savings: 0,
|
|
||||||
hardship: 0,
|
|
||||||
training: 0,
|
|
||||||
patronage: 0,
|
|
||||||
retained: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Session rationale text
|
|
||||||
const rationale = ref("");
|
|
||||||
|
|
||||||
// Current session period - use current month/year
|
|
||||||
const currentDate = new Date();
|
|
||||||
const currentYear = currentDate.getFullYear();
|
|
||||||
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
|
|
||||||
const currentSession = ref(`${currentYear}-${currentMonth}`);
|
|
||||||
|
|
||||||
// Saved distribution records
|
|
||||||
const savedRecords = ref([]);
|
|
||||||
|
|
||||||
// Available amounts for distribution
|
|
||||||
const availableAmounts = ref({
|
|
||||||
surplus: 0,
|
|
||||||
deferredOwed: 0,
|
|
||||||
savingsNeeded: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Computed total allocated
|
|
||||||
const totalAllocated = computed(() =>
|
|
||||||
Object.values(draftAllocations.value).reduce(
|
|
||||||
(sum, amount) => sum + amount,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Computed checklist completion
|
|
||||||
const checklistComplete = computed(() =>
|
|
||||||
Object.values(checklist.value).every(Boolean)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
function updateChecklistItem(key, value) {
|
|
||||||
if (key in checklist.value) {
|
|
||||||
checklist.value[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAllocation(key, amount) {
|
|
||||||
if (key in draftAllocations.value) {
|
|
||||||
draftAllocations.value[key] = Number(amount) || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAllocations() {
|
|
||||||
Object.keys(draftAllocations.value).forEach((key) => {
|
|
||||||
draftAllocations.value[key] = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRationale(text) {
|
|
||||||
rationale.value = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSession() {
|
|
||||||
const record = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
period: currentSession.value,
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
allocations: { ...draftAllocations.value },
|
|
||||||
rationale: rationale.value,
|
|
||||||
checklist: { ...checklist.value },
|
|
||||||
};
|
|
||||||
|
|
||||||
savedRecords.value.push(record);
|
|
||||||
|
|
||||||
// Reset for next session
|
|
||||||
resetAllocations();
|
|
||||||
rationale.value = "";
|
|
||||||
Object.keys(checklist.value).forEach((key) => {
|
|
||||||
checklist.value[key] = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSession(period) {
|
|
||||||
const record = savedRecords.value.find((r) => r.period === period);
|
|
||||||
if (record) {
|
|
||||||
currentSession.value = period;
|
|
||||||
Object.assign(draftAllocations.value, record.allocations);
|
|
||||||
rationale.value = record.rationale;
|
|
||||||
Object.assign(checklist.value, record.checklist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCurrentSession(period) {
|
|
||||||
currentSession.value = period;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAvailableAmounts(amounts) {
|
|
||||||
Object.assign(availableAmounts.value, amounts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSession() {
|
|
||||||
// Reset checklist
|
|
||||||
Object.keys(checklist.value).forEach((key) => {
|
|
||||||
checklist.value[key] = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset allocations
|
|
||||||
Object.keys(draftAllocations.value).forEach((key) => {
|
|
||||||
draftAllocations.value[key] = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset other values
|
|
||||||
rationale.value = "";
|
|
||||||
savedRecords.value = [];
|
|
||||||
|
|
||||||
// Reset available amounts
|
|
||||||
availableAmounts.value = {
|
|
||||||
surplus: 0,
|
|
||||||
deferredOwed: 0,
|
|
||||||
savingsNeeded: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
checklist,
|
|
||||||
draftAllocations,
|
|
||||||
rationale,
|
|
||||||
currentSession: readonly(currentSession),
|
|
||||||
savedRecords: readonly(savedRecords),
|
|
||||||
availableAmounts: readonly(availableAmounts),
|
|
||||||
totalAllocated,
|
|
||||||
checklistComplete,
|
|
||||||
updateChecklistItem,
|
|
||||||
updateAllocation,
|
|
||||||
resetAllocations,
|
|
||||||
updateRationale,
|
|
||||||
saveSession,
|
|
||||||
loadSession,
|
|
||||||
setCurrentSession,
|
|
||||||
updateAvailableAmounts,
|
|
||||||
resetSession,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -48,8 +48,8 @@ export const useStreamsStore = defineStore(
|
||||||
} else {
|
} else {
|
||||||
const newStream = {
|
const newStream = {
|
||||||
id: stream.id || Date.now().toString(),
|
id: stream.id || Date.now().toString(),
|
||||||
name: stream.name,
|
|
||||||
category: stream.category,
|
category: stream.category,
|
||||||
|
name: stream.name,
|
||||||
subcategory: stream.subcategory || "",
|
subcategory: stream.subcategory || "",
|
||||||
targetPct: stream.targetPct || 0,
|
targetPct: stream.targetPct || 0,
|
||||||
targetMonthlyAmount: stream.targetMonthlyAmount || 0,
|
targetMonthlyAmount: stream.targetMonthlyAmount || 0,
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
export type PayRelationship =
|
export type PayRelationship =
|
||||||
| 'equal-pay'
|
| 'equal-pay'
|
||||||
| 'needs-weighted'
|
| 'needs-weighted'
|
||||||
| 'role-banded'
|
|
||||||
| 'hours-weighted'
|
| 'hours-weighted'
|
||||||
| 'custom-formula';
|
| 'custom-formula';
|
||||||
|
|
||||||
export interface Member {
|
export interface Member {
|
||||||
id: string
|
id: string
|
||||||
displayName: string
|
displayName: string
|
||||||
roleFocus?: string
|
|
||||||
role?: string
|
|
||||||
hoursPerWeek?: number
|
hoursPerWeek?: number
|
||||||
hoursPerMonth?: number
|
hoursPerMonth?: number
|
||||||
capacity?: {
|
capacity?: {
|
||||||
|
|
@ -21,10 +18,8 @@ export interface Member {
|
||||||
// Existing/planned
|
// Existing/planned
|
||||||
monthlyPayPlanned?: number
|
monthlyPayPlanned?: number
|
||||||
|
|
||||||
// NEW - early-stage friendly, defaults-safe
|
// Simplified - only minimum needs for needs-weighted allocation
|
||||||
minMonthlyNeeds?: number
|
minMonthlyNeeds?: number
|
||||||
targetMonthlyPay?: number
|
|
||||||
externalMonthlyIncome?: number
|
|
||||||
|
|
||||||
// Compatibility with existing store
|
// Compatibility with existing store
|
||||||
payRelationship?: string
|
payRelationship?: string
|
||||||
|
|
@ -35,8 +30,7 @@ export interface Member {
|
||||||
quarterlyDeferredCap?: number
|
quarterlyDeferredCap?: number
|
||||||
|
|
||||||
// UI-only derivations
|
// UI-only derivations
|
||||||
coverageMinPct?: number
|
coveragePct?: number
|
||||||
coverageTargetPct?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PayPolicy {
|
export interface PayPolicy {
|
||||||
|
|
@ -44,22 +38,19 @@ export interface PayPolicy {
|
||||||
notes?: string
|
notes?: string
|
||||||
equalBase?: number
|
equalBase?: number
|
||||||
needsWeight?: number
|
needsWeight?: number
|
||||||
roleBands?: Record<string, number>
|
|
||||||
hoursRate?: number
|
hoursRate?: number
|
||||||
customFormula?: string
|
customFormula?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage calculation helpers
|
// Simplified coverage calculation
|
||||||
export function coverage(minNeeds = 0, target = 0, planned = 0, external = 0) {
|
export function coverage(minNeeds = 0, planned = 0) {
|
||||||
const base = planned + external
|
const coveragePct = minNeeds > 0 ? Math.min(200, (planned / minNeeds) * 100) : undefined
|
||||||
const min = minNeeds > 0 ? Math.min(200, (base / minNeeds) * 100) : undefined
|
return { coveragePct }
|
||||||
const tgt = target > 0 ? Math.min(200, (base / target) * 100) : undefined
|
|
||||||
return { minPct: min, targetPct: tgt }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function teamCoverageStats(members: Member[]) {
|
export function teamCoverageStats(members: Member[]) {
|
||||||
const vals = members
|
const vals = members
|
||||||
.map(m => coverage(m.minMonthlyNeeds, m.targetMonthlyPay, m.monthlyPayPlanned, m.externalMonthlyIncome).minPct)
|
.map(m => coverage(m.minMonthlyNeeds, m.monthlyPayPlanned).coveragePct)
|
||||||
.filter((v): v is number => typeof v === 'number')
|
.filter((v): v is number => typeof v === 'number')
|
||||||
|
|
||||||
if (!vals.length) return { under100: 0, median: undefined, range: undefined, gini: undefined }
|
if (!vals.length) return { under100: 0, median: undefined, range: undefined, gini: undefined }
|
||||||
|
|
@ -104,12 +95,7 @@ export function allocatePayroll(members: Member[], policy: PayPolicy, payrollBud
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
if (policy.relationship === 'role-banded' && policy.roleBands) {
|
// Removed role-banded allocation - no longer supported
|
||||||
const bands = result.map(m => policy.roleBands![m.role ?? ''] ?? 0)
|
|
||||||
const sum = bands.reduce((a, b) => a + b, 0) || 1
|
|
||||||
result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (bands[i] / sum))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if (policy.relationship === 'hours-weighted') {
|
if (policy.relationship === 'hours-weighted') {
|
||||||
const hours = result.map(m => m.hoursPerMonth ?? (m.hoursPerWeek ? m.hoursPerWeek * 4 : 0) ?? (m.capacity?.targetHours ?? 0))
|
const hours = result.map(m => m.hoursPerMonth ?? (m.hoursPerWeek ? m.hoursPerWeek * 4 : 0) ?? (m.capacity?.targetHours ?? 0))
|
||||||
|
|
@ -124,7 +110,7 @@ export function allocatePayroll(members: Member[], policy: PayPolicy, payrollBud
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monthly payroll calculation for runway and cashflow
|
// Monthly payroll calculation for runway and budgets
|
||||||
export function monthlyPayroll(members: Member[], mode: 'minimum' | 'target' = 'minimum'): number {
|
export function monthlyPayroll(members: Member[], mode: 'minimum' | 'target' = 'minimum'): number {
|
||||||
return members.reduce((sum, m) => {
|
return members.reduce((sum, m) => {
|
||||||
const planned = m.monthlyPayPlanned ?? 0
|
const planned = m.monthlyPayPlanned ?? 0
|
||||||
|
|
|
||||||
23
utils/currency.ts
Normal file
23
utils/currency.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
export interface CurrencyOption {
|
||||||
|
code: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currencyOptions: CurrencyOption[] = [
|
||||||
|
{ code: 'USD', symbol: '$', name: 'US Dollar' },
|
||||||
|
{ code: 'EUR', symbol: '€', name: 'Euro' },
|
||||||
|
{ code: 'GBP', symbol: '£', name: 'British Pound' },
|
||||||
|
{ code: 'CAD', symbol: 'C$', name: 'Canadian Dollar' },
|
||||||
|
{ code: 'AUD', symbol: 'A$', name: 'Australian Dollar' },
|
||||||
|
{ code: 'CHF', symbol: 'CHF', name: 'Swiss Franc' },
|
||||||
|
{ code: 'JPY', symbol: '¥', name: 'Japanese Yen' },
|
||||||
|
{ code: 'SEK', symbol: 'kr', name: 'Swedish Krona' },
|
||||||
|
{ code: 'NOK', symbol: 'kr', name: 'Norwegian Krone' },
|
||||||
|
{ code: 'DKK', symbol: 'kr', name: 'Danish Krone' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCurrencySymbol(currencyCode: string): string {
|
||||||
|
const currency = currencyOptions.find(c => c.code === currencyCode);
|
||||||
|
return currency?.symbol || currencyCode;
|
||||||
|
}
|
||||||
94
utils/testDataConsistency.ts
Normal file
94
utils/testDataConsistency.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* Test Data Consistency Utilities
|
||||||
|
*
|
||||||
|
* Functions to verify that data is consistent across all interfaces
|
||||||
|
* after setup completion and synchronization
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const testDataConsistency = () => {
|
||||||
|
const coopStore = useCoopBuilderStore()
|
||||||
|
const streamsStore = useStreamsStore()
|
||||||
|
const membersStore = useMembersStore()
|
||||||
|
const policiesStore = usePoliciesStore()
|
||||||
|
|
||||||
|
const issues: string[] = []
|
||||||
|
|
||||||
|
// Test streams consistency
|
||||||
|
if (coopStore.streams.length > 0 && streamsStore.streams.length > 0) {
|
||||||
|
if (coopStore.streams.length !== streamsStore.streams.length) {
|
||||||
|
issues.push(`Stream count mismatch: CoopBuilder(${coopStore.streams.length}) vs Legacy(${streamsStore.streams.length})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if stream data matches
|
||||||
|
coopStore.streams.forEach(coopStream => {
|
||||||
|
const legacyStream = streamsStore.streams.find(s => s.id === coopStream.id)
|
||||||
|
if (!legacyStream) {
|
||||||
|
issues.push(`CoopBuilder stream "${coopStream.label}" not found in legacy store`)
|
||||||
|
} else {
|
||||||
|
if (legacyStream.name !== coopStream.label) {
|
||||||
|
issues.push(`Stream name mismatch for ${coopStream.id}: "${legacyStream.name}" vs "${coopStream.label}"`)
|
||||||
|
}
|
||||||
|
if (legacyStream.targetMonthlyAmount !== coopStream.monthly) {
|
||||||
|
issues.push(`Stream amount mismatch for ${coopStream.id}: ${legacyStream.targetMonthlyAmount} vs ${coopStream.monthly}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test members consistency
|
||||||
|
if (coopStore.members.length > 0 && membersStore.members.length > 0) {
|
||||||
|
if (coopStore.members.length !== membersStore.members.length) {
|
||||||
|
issues.push(`Member count mismatch: CoopBuilder(${coopStore.members.length}) vs Legacy(${membersStore.members.length})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if member data matches
|
||||||
|
coopStore.members.forEach(coopMember => {
|
||||||
|
const legacyMember = membersStore.members.find(m => m.id === coopMember.id)
|
||||||
|
if (!legacyMember) {
|
||||||
|
issues.push(`CoopBuilder member "${coopMember.name}" not found in legacy store`)
|
||||||
|
} else {
|
||||||
|
if (legacyMember.displayName !== coopMember.name) {
|
||||||
|
issues.push(`Member name mismatch for ${coopMember.id}: "${legacyMember.displayName}" vs "${coopMember.name}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test policies consistency
|
||||||
|
if (coopStore.equalHourlyWage > 0 && policiesStore.equalHourlyWage > 0) {
|
||||||
|
if (coopStore.equalHourlyWage !== policiesStore.equalHourlyWage) {
|
||||||
|
issues.push(`Hourly wage mismatch: CoopBuilder(${coopStore.equalHourlyWage}) vs Legacy(${policiesStore.equalHourlyWage})`)
|
||||||
|
}
|
||||||
|
if (coopStore.payrollOncostPct !== policiesStore.payrollOncostPct) {
|
||||||
|
issues.push(`Oncost percentage mismatch: CoopBuilder(${coopStore.payrollOncostPct}) vs Legacy(${policiesStore.payrollOncostPct})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConsistent: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
summary: issues.length === 0
|
||||||
|
? 'All data is consistent across interfaces'
|
||||||
|
: `${issues.length} consistency issues found`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logDataConsistency = () => {
|
||||||
|
const result = testDataConsistency()
|
||||||
|
|
||||||
|
console.group('🔄 Data Consistency Check')
|
||||||
|
console.log('Status:', result.isConsistent ? '✅ Consistent' : '❌ Issues Found')
|
||||||
|
console.log('Summary:', result.summary)
|
||||||
|
|
||||||
|
if (result.issues.length > 0) {
|
||||||
|
console.group('Issues:')
|
||||||
|
result.issues.forEach((issue, index) => {
|
||||||
|
console.log(`${index + 1}. ${issue}`)
|
||||||
|
})
|
||||||
|
console.groupEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue