Compare commits
No commits in common. "983aeca2dc1a17b78faa55c1131506d3bb894cff" and "4cea1f71feb16db4754c5eff22e3487524f9de93" have entirely different histories.
983aeca2dc
...
4cea1f71fe
33 changed files with 27533 additions and 1842 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
14
.env.example
Normal file
14
.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Co-op Pay & Value Tool Configuration
|
||||||
|
|
||||||
|
# Currency and localization
|
||||||
|
APP_CURRENCY=CAD
|
||||||
|
APP_LOCALE=en-CA
|
||||||
|
APP_DECIMAL_PLACES=2
|
||||||
|
|
||||||
|
# Application settings
|
||||||
|
APP_NAME="Urgent Tools"
|
||||||
|
APP_ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Optional overrides
|
||||||
|
# APP_DATE_FORMAT=short
|
||||||
|
# APP_NUMBER_FORMAT=standard
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
CLAUDE.md
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
FROM node:22.12-bullseye
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN corepack enable && yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
CMD ["yarn", "start"]
|
||||||
75
README.md
Normal file
75
README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
42
app.config.ts
Normal file
42
app.config.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: "zinc",
|
||||||
|
neutral: "neutral",
|
||||||
|
},
|
||||||
|
|
||||||
|
global: {
|
||||||
|
body: "bg-white dark:bg-neutral-950",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
base: "mx-auto",
|
||||||
|
padding: "px-4 sm:px-6 lg:px-8",
|
||||||
|
constrained: "max-w-7xl",
|
||||||
|
background: "",
|
||||||
|
},
|
||||||
|
// Spacious card styling
|
||||||
|
card: {
|
||||||
|
base: "overflow-hidden",
|
||||||
|
background: "bg-white dark:bg-neutral-950",
|
||||||
|
divide: "divide-y divide-neutral-200 dark:divide-neutral-800",
|
||||||
|
ring: "ring-1 ring-neutral-200 dark:ring-neutral-800",
|
||||||
|
rounded: "rounded-lg",
|
||||||
|
shadow: "shadow",
|
||||||
|
body: {
|
||||||
|
base: "",
|
||||||
|
background: "",
|
||||||
|
padding: "px-6 py-5 sm:p-6",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
base: "",
|
||||||
|
background: "",
|
||||||
|
padding: "px-6 py-4 sm:px-6",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
base: "",
|
||||||
|
background: "",
|
||||||
|
padding: "px-6 py-4 sm:px-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
10
app.vue
10
app.vue
|
|
@ -54,15 +54,6 @@
|
||||||
>
|
>
|
||||||
Wizards
|
Wizards
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
|
||||||
to="/resources"
|
|
||||||
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
|
||||||
:class="{
|
|
||||||
'bg-neutral-100 dark:bg-neutral-800': $route.path === '/resources',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
More Resources & Templates
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div class="py-8">
|
<div class="py-8">
|
||||||
<CoopBuilderSubnav v-if="isCoopBuilderSection" />
|
<CoopBuilderSubnav v-if="isCoopBuilderSection" />
|
||||||
|
|
@ -91,7 +82,6 @@ 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 === "/scenarios" ||
|
route.path === "/scenarios" ||
|
||||||
route.path === "/cash" ||
|
route.path === "/cash" ||
|
||||||
route.path === "/session" ||
|
route.path === "/session" ||
|
||||||
|
|
|
||||||
BIN
assets/.DS_Store
vendored
BIN
assets/.DS_Store
vendored
Binary file not shown.
BIN
components/.DS_Store
vendored
BIN
components/.DS_Store
vendored
Binary file not shown.
|
|
@ -1,339 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="space-y-8">
|
|
||||||
<!-- Annual Budget Overview -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h2 class="text-2xl font-bold">Annual Budget Overview</h2>
|
|
||||||
|
|
||||||
<div class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
|
||||||
<table class="w-full border-collapse text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b-2 border-black bg-gray-100">
|
|
||||||
<th class="border-r-2 border-black px-4 py-3 text-left font-bold">Category</th>
|
|
||||||
<th class="border-r border-gray-400 px-4 py-3 text-right font-bold">Planned</th>
|
|
||||||
<th class="px-4 py-3 text-right font-bold">%</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<!-- Revenue Section -->
|
|
||||||
<tr class="bg-black text-white">
|
|
||||||
<td class="px-4 py-2 font-bold" colspan="3">REVENUE</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Revenue Categories -->
|
|
||||||
<tr v-for="(category, index) in revenueCategories"
|
|
||||||
:key="`rev-${index}`"
|
|
||||||
class="border-t border-gray-200"
|
|
||||||
v-show="category.planned > 0">
|
|
||||||
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
|
|
||||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
|
||||||
{{ formatCurrency(category.planned) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2 text-right">
|
|
||||||
{{ category.percentage }}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Total Revenue -->
|
|
||||||
<tr class="border-t-2 border-black font-semibold bg-gray-50">
|
|
||||||
<td class="border-r-2 border-black px-4 py-2">Total Revenue</td>
|
|
||||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
|
||||||
{{ formatCurrency(totalRevenuePlanned) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2 text-right">100%</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Revenue Diversification Guidance -->
|
|
||||||
<tr :class="guidanceBackgroundClass">
|
|
||||||
<td colspan="3" class="border-t border-gray-300 px-4 py-3">
|
|
||||||
<div class="text-sm">
|
|
||||||
<p class="font-medium mb-2">{{ diversificationGuidance }}</p>
|
|
||||||
<p class="text-gray-600 mb-2" v-if="suggestedCategories.length > 0">
|
|
||||||
Consider developing: {{ suggestedCategories.join(', ') }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs">
|
|
||||||
<NuxtLink
|
|
||||||
to="/help#revenue-diversification"
|
|
||||||
class="text-blue-600 hover:text-blue-800 underline"
|
|
||||||
>
|
|
||||||
Learn how to develop these revenue streams →
|
|
||||||
</NuxtLink>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Spacer -->
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="h-2"></td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Expenses Section -->
|
|
||||||
<tr class="bg-black text-white">
|
|
||||||
<td class="px-4 py-2 font-bold" colspan="3">EXPENSES</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Expense Categories -->
|
|
||||||
<tr v-for="(category, index) in expenseCategories"
|
|
||||||
:key="`exp-${index}`"
|
|
||||||
class="border-t border-gray-200"
|
|
||||||
v-show="category.planned > 0">
|
|
||||||
<td class="border-r-2 border-black px-4 py-2">{{ category.name }}</td>
|
|
||||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
|
||||||
{{ formatCurrency(category.planned) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2 text-right">
|
|
||||||
{{ category.percentage }}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Total Expenses -->
|
|
||||||
<tr class="border-t-2 border-black font-semibold bg-gray-50">
|
|
||||||
<td class="border-r-2 border-black px-4 py-2">Total Expenses</td>
|
|
||||||
<td class="border-r border-gray-400 px-4 py-2 text-right">
|
|
||||||
{{ formatCurrency(totalExpensesPlanned) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2 text-right">100%</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Net Total -->
|
|
||||||
<tr class="border-t-2 border-black font-bold text-lg" :class="netTotalClass">
|
|
||||||
<td class="border-r-2 border-black px-4 py-3">NET TOTAL</td>
|
|
||||||
<td class="border-r border-gray-400 px-4 py-3 text-right">
|
|
||||||
{{ formatCurrency(netTotal) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-right">-</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
orgId: string;
|
|
||||||
year: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
year: () => new Date().getFullYear()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get budget data from store
|
|
||||||
const budgetStore = useBudgetStore();
|
|
||||||
|
|
||||||
// Revenue categories with calculations
|
|
||||||
const revenueCategories = computed(() => {
|
|
||||||
const categories = [
|
|
||||||
{ key: 'gamesProducts', name: 'Games & Products', planned: 0, percentage: 0 },
|
|
||||||
{ key: 'servicesContracts', name: 'Services & Contracts', planned: 0, percentage: 0 },
|
|
||||||
{ key: 'grantsFunding', name: 'Grants & Funding', planned: 0, percentage: 0 },
|
|
||||||
{ key: 'communitySupport', name: 'Community Support', planned: 0, percentage: 0 },
|
|
||||||
{ key: 'partnerships', name: 'Partnerships', planned: 0, percentage: 0 },
|
|
||||||
{ key: 'investmentIncome', name: 'Investment Income', planned: 0, percentage: 0 },
|
|
||||||
{ key: 'inKindContributions', name: 'In-Kind Contributions', planned: 0, percentage: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Calculate planned amounts for each category
|
|
||||||
budgetStore.budgetWorksheet.revenue.forEach(item => {
|
|
||||||
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
|
|
||||||
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
|
|
||||||
if (categoryIndex !== -1) {
|
|
||||||
categories[categoryIndex].planned += annualPlanned;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate percentages
|
|
||||||
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
|
|
||||||
categories.forEach(cat => {
|
|
||||||
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return categories;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Expense categories with calculations
|
|
||||||
const expenseCategories = computed(() => {
|
|
||||||
const categories = [
|
|
||||||
{ name: 'Salaries & Benefits', planned: 0, percentage: 0 },
|
|
||||||
{ name: 'Development Costs', planned: 0, percentage: 0 },
|
|
||||||
{ name: 'Equipment & Technology', planned: 0, percentage: 0 },
|
|
||||||
{ name: 'Marketing & Outreach', planned: 0, percentage: 0 },
|
|
||||||
{ name: 'Office & Operations', planned: 0, percentage: 0 },
|
|
||||||
{ name: 'Legal & Professional', planned: 0, percentage: 0 },
|
|
||||||
{ name: 'Other Expenses', planned: 0, percentage: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Calculate planned amounts for each category
|
|
||||||
budgetStore.budgetWorksheet.expenses.forEach(item => {
|
|
||||||
const annualPlanned = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + (val || 0), 0);
|
|
||||||
const categoryIndex = categories.findIndex(cat => cat.name === item.mainCategory);
|
|
||||||
if (categoryIndex !== -1) {
|
|
||||||
categories[categoryIndex].planned += annualPlanned;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate percentages
|
|
||||||
const total = categories.reduce((sum, cat) => sum + cat.planned, 0);
|
|
||||||
categories.forEach(cat => {
|
|
||||||
cat.percentage = total > 0 ? Math.round((cat.planned / total) * 100) : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return categories;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Totals
|
|
||||||
const totalRevenuePlanned = computed(() =>
|
|
||||||
revenueCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalExpensesPlanned = computed(() =>
|
|
||||||
expenseCategories.value.reduce((sum, cat) => sum + cat.planned, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const netTotal = computed(() =>
|
|
||||||
totalRevenuePlanned.value - totalExpensesPlanned.value
|
|
||||||
);
|
|
||||||
|
|
||||||
const netTotalClass = computed(() => {
|
|
||||||
if (netTotal.value > 0) return 'bg-green-50';
|
|
||||||
if (netTotal.value < 0) return 'bg-red-50';
|
|
||||||
return 'bg-gray-50';
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Diversification guidance
|
|
||||||
const diversificationGuidance = computed(() => {
|
|
||||||
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
|
|
||||||
const topCategory = categoriesWithRevenue.reduce((max, cat) => cat.percentage > max.percentage ? cat : max, { percentage: 0, name: '' });
|
|
||||||
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
|
|
||||||
|
|
||||||
let guidance = "";
|
|
||||||
|
|
||||||
// Concentration Risk
|
|
||||||
if (topCategory.percentage >= 70) {
|
|
||||||
guidance += `Very high concentration risk: most of your revenue is from ${topCategory.name} (${topCategory.percentage}%). `;
|
|
||||||
} else if (topCategory.percentage >= 50) {
|
|
||||||
guidance += `High concentration risk: ${topCategory.name} makes up ${topCategory.percentage}% of your revenue. `;
|
|
||||||
} else {
|
|
||||||
guidance += "No single category dominates your revenue. ";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diversification Benchmark
|
|
||||||
if (categoriesAbove20 >= 3) {
|
|
||||||
guidance += "Your mix is reasonably balanced across multiple sources.";
|
|
||||||
} else if (categoriesAbove20 === 2) {
|
|
||||||
guidance += "Your mix is split, but still reliant on just two sources.";
|
|
||||||
} else {
|
|
||||||
guidance += "Your revenue is concentrated; aim to grow at least 2–3 other categories.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional Positive Nudges
|
|
||||||
const grantsCategory = categoriesWithRevenue.find(cat => cat.name === 'Grants & Funding');
|
|
||||||
const servicesCategory = categoriesWithRevenue.find(cat => cat.name === 'Services & Contracts');
|
|
||||||
const productsCategory = categoriesWithRevenue.find(cat => cat.name === 'Games & Products');
|
|
||||||
|
|
||||||
if (grantsCategory && grantsCategory.percentage >= 20) {
|
|
||||||
guidance += " You've secured meaningful support from grants — consider pairing this with services or product revenue for stability.";
|
|
||||||
} else if (servicesCategory && servicesCategory.percentage >= 20 && productsCategory && productsCategory.percentage >= 20) {
|
|
||||||
guidance += " Strong foundation in both services and products — this balance helps smooth cash flow.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return guidance;
|
|
||||||
});
|
|
||||||
|
|
||||||
const guidanceBackgroundClass = computed(() => {
|
|
||||||
const topCategory = revenueCategories.value.reduce((max, cat) => cat.percentage > max.percentage ? cat : max, { percentage: 0 });
|
|
||||||
|
|
||||||
if (topCategory.percentage >= 70) {
|
|
||||||
return 'bg-red-50';
|
|
||||||
} else if (topCategory.percentage >= 50) {
|
|
||||||
return 'bg-red-50';
|
|
||||||
} else {
|
|
||||||
const categoriesAbove20 = revenueCategories.value.filter(cat => cat.percentage >= 20).length;
|
|
||||||
if (categoriesAbove20 >= 3) {
|
|
||||||
return 'bg-green-50';
|
|
||||||
} else {
|
|
||||||
return 'bg-yellow-50';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Suggested categories to develop
|
|
||||||
const suggestedCategories = computed(() => {
|
|
||||||
const categoriesWithRevenue = revenueCategories.value.filter(cat => cat.percentage > 0);
|
|
||||||
const categoriesWithoutRevenue = revenueCategories.value.filter(cat => cat.percentage === 0);
|
|
||||||
const categoriesAbove20 = categoriesWithRevenue.filter(cat => cat.percentage >= 20).length;
|
|
||||||
|
|
||||||
// If we have fewer than 3 categories above 20%, suggest developing others
|
|
||||||
if (categoriesAbove20 < 3) {
|
|
||||||
// Prioritize categories that complement existing strengths
|
|
||||||
const suggestions = [];
|
|
||||||
|
|
||||||
// If they have services, suggest products for balance
|
|
||||||
if (categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts') &&
|
|
||||||
!categoriesWithRevenue.some(cat => cat.name === 'Games & Products')) {
|
|
||||||
suggestions.push('Games & Products');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If they have products, suggest services for stability
|
|
||||||
if (categoriesWithRevenue.some(cat => cat.name === 'Games & Products') &&
|
|
||||||
!categoriesWithRevenue.some(cat => cat.name === 'Services & Contracts')) {
|
|
||||||
suggestions.push('Services & Contracts');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always suggest grants if not present
|
|
||||||
if (!categoriesWithRevenue.some(cat => cat.name === 'Grants & Funding')) {
|
|
||||||
suggestions.push('Grants & Funding');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add community support for stability
|
|
||||||
if (!categoriesWithRevenue.some(cat => cat.name === 'Community Support')) {
|
|
||||||
suggestions.push('Community Support');
|
|
||||||
}
|
|
||||||
|
|
||||||
return suggestions.slice(0, 3); // Limit to 3 suggestions
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Utility functions
|
|
||||||
function formatCurrency(amount: number): string {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPercentageClass(percentage: number): string {
|
|
||||||
if (percentage > 50) return 'text-red-600 font-bold';
|
|
||||||
if (percentage > 35) return 'text-yellow-600 font-semibold';
|
|
||||||
if (percentage > 20) return 'text-black font-medium';
|
|
||||||
return 'text-gray-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
onMounted(() => {
|
|
||||||
console.log(`Annual budget view for org: ${props.orgId}, year: ${props.year}`);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Remove number input spinners */
|
|
||||||
input[type="number"]::-webkit-inner-spin-button,
|
|
||||||
input[type="number"]::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"] {
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -46,11 +46,6 @@ const coopBuilderItems = [
|
||||||
name: "Revenue Mix",
|
name: "Revenue Mix",
|
||||||
path: "/mix",
|
path: "/mix",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "runway-lite",
|
|
||||||
name: "Runway Lite",
|
|
||||||
path: "/runway-lite",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "scenarios",
|
id: "scenarios",
|
||||||
name: "Scenarios",
|
name: "Scenarios",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -3,13 +3,19 @@
|
||||||
<!-- Section Header with Export Controls -->
|
<!-- Section Header with Export Controls -->
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-2xl font-black text-black mb-2">Set your wage & pay policy</h3>
|
<h3 class="text-2xl font-black text-black mb-2">
|
||||||
|
Set your wage & pay policy
|
||||||
|
</h3>
|
||||||
<p class="text-neutral-600">
|
<p class="text-neutral-600">
|
||||||
Choose how to allocate payroll among members and set the base hourly rate.
|
Choose how to allocate payroll among members and set the base hourly rate.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<UButton variant="outline" color="neutral" size="sm" @click="exportPolicies">
|
<UButton
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
@click="exportPolicies">
|
||||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
||||||
Export
|
Export
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
@ -20,11 +26,7 @@
|
||||||
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
<div class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||||
<h4 class="font-bold mb-4">Pay Allocation Policy</h4>
|
<h4 class="font-bold mb-4">Pay Allocation Policy</h4>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<label
|
<label v-for="option in policyOptions" :key="option.value" class="flex items-start gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded-lg transition-colors">
|
||||||
v-for="option in policyOptions"
|
|
||||||
:key="option.value"
|
|
||||||
class="flex items-start gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
|
|
@ -35,17 +37,13 @@
|
||||||
<span class="text-sm flex-1">{{ option.label }}</span>
|
<span class="text-sm flex-1">{{ option.label }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role bands editor if role-banded is selected -->
|
<!-- Role bands editor if role-banded is selected -->
|
||||||
<div v-if="selectedPolicy === 'role-banded'" class="mt-4 p-4 bg-gray-50 rounded-lg">
|
<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>
|
<h5 class="text-sm font-medium mb-3">Role Bands (monthly € or weight)</h5>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div
|
<div v-for="member in uniqueRoles" :key="member.role" class="flex items-center gap-2">
|
||||||
v-for="member in uniqueRoles"
|
<span class="text-sm w-32">{{ member.role || 'No role' }}</span>
|
||||||
:key="member.role"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span class="text-sm w-32">{{ member.role || "No role" }}</span>
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="roleBands[member.role || '']"
|
v-model="roleBands[member.role || '']"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -57,7 +55,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UAlert
|
<UAlert
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -69,7 +67,7 @@
|
||||||
</template>
|
</template>
|
||||||
</UAlert>
|
</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-3 border-black rounded-xl bg-white shadow-md">
|
||||||
<h4 class="font-bold mb-4">Base Hourly Wage</h4>
|
<h4 class="font-bold mb-4">Base Hourly Wage</h4>
|
||||||
|
|
@ -80,8 +78,7 @@
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
size="xl"
|
size="xl"
|
||||||
class="text-4xl font-black w-full h-20"
|
class="text-4xl font-black w-full h-20"
|
||||||
@update:model-value="validateAndSaveWage"
|
@update:model-value="validateAndSaveWage">
|
||||||
>
|
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<span class="text-neutral-500 text-3xl">€</span>
|
<span class="text-neutral-500 text-3xl">€</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -101,9 +98,9 @@ const coop = useCoopBuilder();
|
||||||
const store = useCoopBuilderStore();
|
const store = useCoopBuilderStore();
|
||||||
|
|
||||||
// Initialize from store
|
// Initialize from store
|
||||||
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 || ''))
|
||||||
|
|
||||||
function parseNumberInput(val: unknown): number {
|
function parseNumberInput(val: unknown): number {
|
||||||
if (typeof val === "number") return val;
|
if (typeof val === "number") return val;
|
||||||
|
|
@ -117,54 +114,43 @@ function parseNumberInput(val: unknown): number {
|
||||||
|
|
||||||
// Pay policy options
|
// Pay policy options
|
||||||
const policyOptions = [
|
const policyOptions = [
|
||||||
{
|
{ value: 'equal-pay', label: 'Equal pay - Everyone gets the same monthly amount' },
|
||||||
value: "equal-pay",
|
{ value: 'needs-weighted', label: 'Needs-weighted - Allocate based on minimum needs' },
|
||||||
label: "Equal pay - Everyone gets the same monthly amount",
|
{ value: 'hours-weighted', label: 'Hours-weighted - Allocate based on hours worked' },
|
||||||
},
|
{ value: 'role-banded', label: 'Role-banded - Different amounts per role' }
|
||||||
{
|
]
|
||||||
value: "needs-weighted",
|
|
||||||
label: "Needs-weighted - Allocate based on minimum needs",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "hours-weighted",
|
|
||||||
label: "Hours-weighted - Allocate based on hours worked",
|
|
||||||
},
|
|
||||||
{ value: "role-banded", label: "Role-banded - Different amounts per role" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Already initialized above with store values
|
// Already initialized above with store values
|
||||||
|
|
||||||
const uniqueRoles = computed(() => {
|
const uniqueRoles = computed(() => {
|
||||||
const roles = new Set(coop.members.value.map((m) => m.role || ""));
|
const roles = new Set(coop.members.value.map(m => m.role || ''))
|
||||||
return Array.from(roles).map((role) => ({ role }));
|
return Array.from(roles).map(role => ({ role }))
|
||||||
});
|
})
|
||||||
|
|
||||||
function updatePolicy(value: string) {
|
function updatePolicy(value: string) {
|
||||||
selectedPolicy.value = value;
|
selectedPolicy.value = value
|
||||||
coop.setPolicy(
|
coop.setPolicy(value as "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded")
|
||||||
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()
|
||||||
allocatedMembers.forEach((m) => {
|
allocatedMembers.forEach(m => {
|
||||||
coop.upsertMember(m);
|
coop.upsertMember(m)
|
||||||
});
|
})
|
||||||
|
|
||||||
emit("save-status", "saved");
|
emit("save-status", "saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRoleBands() {
|
function updateRoleBands() {
|
||||||
coop.setRoleBands(roleBands.value);
|
coop.setRoleBands(roleBands.value)
|
||||||
|
|
||||||
// Trigger payroll reallocation after role bands change
|
// Trigger payroll reallocation after role bands change
|
||||||
if (selectedPolicy.value === "role-banded") {
|
if (selectedPolicy.value === 'role-banded') {
|
||||||
const allocatedMembers = coop.allocatePayroll();
|
const allocatedMembers = coop.allocatePayroll()
|
||||||
allocatedMembers.forEach((m) => {
|
allocatedMembers.forEach(m => {
|
||||||
coop.upsertMember(m);
|
coop.upsertMember(m)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("save-status", "saved");
|
emit("save-status", "saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,14 +163,14 @@ function validateAndSaveWage(value: string) {
|
||||||
wageText.value = cleanValue;
|
wageText.value = cleanValue;
|
||||||
|
|
||||||
if (!isNaN(numValue) && numValue >= 0) {
|
if (!isNaN(numValue) && numValue >= 0) {
|
||||||
coop.setEqualWage(numValue);
|
coop.setEqualWage(numValue)
|
||||||
|
|
||||||
// Trigger payroll reallocation after wage change
|
// Trigger payroll reallocation after wage change
|
||||||
const allocatedMembers = coop.allocatePayroll();
|
const allocatedMembers = coop.allocatePayroll()
|
||||||
allocatedMembers.forEach((m) => {
|
allocatedMembers.forEach(m => {
|
||||||
coop.upsertMember(m);
|
coop.upsertMember(m)
|
||||||
});
|
})
|
||||||
|
|
||||||
emit("save-status", "saved");
|
emit("save-status", "saved");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import type { BudgetLine, OrgYearSettings } from '~/types/budget'
|
|
||||||
|
|
||||||
export function useBudget(orgId: string = 'default', year: number = new Date().getFullYear()) {
|
|
||||||
const budgetStore = useBudgetStore()
|
|
||||||
const cashStore = useCashStore()
|
|
||||||
const { analyzeConcentration } = useConcentration()
|
|
||||||
|
|
||||||
// Initialize budget from wizard data if needed
|
|
||||||
const initialized = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!budgetStore.isInitialized) {
|
|
||||||
await budgetStore.initializeFromWizardData()
|
|
||||||
}
|
|
||||||
initialized.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get starting cash from cash store
|
|
||||||
const startingCash = computed(() => {
|
|
||||||
const cash = cashStore.currentCash || 0
|
|
||||||
const savings = cashStore.currentSavings || 0
|
|
||||||
return cash + savings
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build revenue and expense arrays for the 12 months
|
|
||||||
const revenuePlanned = computed(() => {
|
|
||||||
const revenue = Array(12).fill(0)
|
|
||||||
|
|
||||||
if (!budgetStore.budgetWorksheet?.revenue) {
|
|
||||||
return revenue
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetStore.budgetWorksheet.revenue.forEach(item => {
|
|
||||||
// Get monthly values for the current year
|
|
||||||
const today = new Date()
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
const date = new Date(year, i, 1)
|
|
||||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
|
||||||
revenue[i] += item.monthlyValues?.[monthKey] || 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return revenue
|
|
||||||
})
|
|
||||||
|
|
||||||
const expensePlanned = computed(() => {
|
|
||||||
const expenses = Array(12).fill(0)
|
|
||||||
|
|
||||||
if (!budgetStore.budgetWorksheet?.expenses) {
|
|
||||||
return expenses
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetStore.budgetWorksheet.expenses.forEach(item => {
|
|
||||||
// Get monthly values for the current year
|
|
||||||
const today = new Date()
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
const date = new Date(year, i, 1)
|
|
||||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
|
||||||
expenses[i] += item.monthlyValues?.[monthKey] || 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return expenses
|
|
||||||
})
|
|
||||||
|
|
||||||
// Calculate diversification percentage by category
|
|
||||||
const diversification = computed(() => {
|
|
||||||
const revTotalsByCat: Record<string, number> = {}
|
|
||||||
|
|
||||||
if (!budgetStore.budgetWorksheet?.revenue) {
|
|
||||||
return {
|
|
||||||
byCategoryPct: {},
|
|
||||||
guidance: 'No revenue data available'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sum annual revenue by category
|
|
||||||
budgetStore.budgetWorksheet.revenue.forEach(item => {
|
|
||||||
const category = item.mainCategory || 'Other'
|
|
||||||
const annualAmount = Object.values(item.monthlyValues || {}).reduce((sum, val) => sum + val, 0)
|
|
||||||
revTotalsByCat[category] = (revTotalsByCat[category] || 0) + annualAmount
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalRev = Object.values(revTotalsByCat).reduce((a, b) => a + b, 0) || 1
|
|
||||||
const byCategoryPct = Object.fromEntries(
|
|
||||||
Object.entries(revTotalsByCat).map(([k, v]) => [k, (v / totalRev) * 100])
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use existing concentration analysis
|
|
||||||
const revenueStreams = Object.entries(byCategoryPct).map(([category, pct]) => ({
|
|
||||||
targetPct: pct
|
|
||||||
}))
|
|
||||||
|
|
||||||
const analysis = analyzeConcentration(revenueStreams)
|
|
||||||
|
|
||||||
return {
|
|
||||||
byCategoryPct,
|
|
||||||
guidance: analysis.message
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
startingCash,
|
|
||||||
revenuePlanned,
|
|
||||||
expensePlanned,
|
|
||||||
diversification
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { allocatePayroll as allocatePayrollImpl, monthlyPayroll, type Member, type PayPolicy } from '~/types/members'
|
import { allocatePayroll as allocatePayrollImpl, monthlyPayroll, type Member, type PayPolicy } from '~/types/members'
|
||||||
import { useCoopBuilderStore } from '~/stores/coopBuilder'
|
|
||||||
|
|
||||||
export function useCoopBuilder() {
|
export function useCoopBuilder() {
|
||||||
// Use the centralized Pinia store
|
// Use the centralized Pinia store
|
||||||
|
|
@ -324,30 +323,6 @@ export function useCoopBuilder() {
|
||||||
store.loadDefaultData()
|
store.loadDefaultData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for policy and operating mode changes to refresh budget payroll
|
|
||||||
// This needs to be after computed values are defined
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const budgetStore = useBudgetStore()
|
|
||||||
|
|
||||||
// Watch for policy changes
|
|
||||||
watch(() => [policy.value.relationship, policy.value.roleBands, operatingMode.value, store.equalHourlyWage, store.payrollOncostPct], () => {
|
|
||||||
if (budgetStore.isInitialized) {
|
|
||||||
nextTick(() => {
|
|
||||||
budgetStore.refreshPayrollInBudget()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Watch for member changes
|
|
||||||
watch(() => store.members, () => {
|
|
||||||
if (budgetStore.isInitialized) {
|
|
||||||
nextTick(() => {
|
|
||||||
budgetStore.refreshPayrollInBudget()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
members,
|
members,
|
||||||
|
|
|
||||||
81
nuxt.config.ts
Normal file
81
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { defineNuxtConfig } from "nuxt/config";
|
||||||
|
// Tailwind v4 is configured via postcss.config.mjs
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: "2025-07-15",
|
||||||
|
devtools: { enabled: true },
|
||||||
|
devServer: {
|
||||||
|
port: 3004,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disable SSR to avoid hydration mismatches during wizard work
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
// Strict TypeScript
|
||||||
|
typescript: {
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Global CSS (use srcDir-relative path)
|
||||||
|
css: ["~/assets/css/main.css"],
|
||||||
|
|
||||||
|
// PostCSS configured separately
|
||||||
|
|
||||||
|
// Vite plugin not required for basics; rely on PostCSS and @nuxt/ui
|
||||||
|
|
||||||
|
modules: ["@pinia/nuxt", "@nuxtjs/color-mode", "@nuxt/ui"],
|
||||||
|
|
||||||
|
// Redirect old Templates landing to Wizards (keep detail routes working)
|
||||||
|
routeRules: {
|
||||||
|
"/templates": { redirect: { to: "/wizards", statusCode: 301 } },
|
||||||
|
"/wizard": { redirect: { to: "/coop-planner", statusCode: 301 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
colorMode: {
|
||||||
|
preference: "system",
|
||||||
|
fallback: "light",
|
||||||
|
classSuffix: "",
|
||||||
|
dataValue: "dark",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Google Fonts
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
link: [
|
||||||
|
{
|
||||||
|
rel: "preconnect",
|
||||||
|
href: "https://fonts.googleapis.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "preconnect",
|
||||||
|
href: "https://fonts.gstatic.com",
|
||||||
|
crossorigin: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500;700&display=swap",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;500;600;700&display=swap",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Runtime configuration for formatting
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
appCurrency: process.env.APP_CURRENCY || "EUR",
|
||||||
|
appLocale: process.env.APP_LOCALE || "en-CA",
|
||||||
|
appDecimalPlaces: process.env.APP_DECIMAL_PLACES || "2",
|
||||||
|
appName: process.env.APP_NAME || "Urgent Tools",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Nuxt UI minimal theme customizations live in app.config.ts
|
||||||
|
});
|
||||||
17882
package-lock.json
generated
Normal file
17882
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
package.json
Normal file
39
package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "nuxt-app",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"start": "node .output/server/index.mjs",
|
||||||
|
"prestart": "node -e \"process.exit(require('fs').existsSync('.output/server/index.mjs')?0:1)\" || yarn build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"test": "vitest --run",
|
||||||
|
"test:ui": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/ui": "^3.3.0",
|
||||||
|
"@nuxtjs/color-mode": "^3.5.2",
|
||||||
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"html2pdf.js": "^0.10.3",
|
||||||
|
"jspdf": "^3.0.1",
|
||||||
|
"nuxt": "^4.0.3",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"pinia-plugin-persistedstate": "^4.4.1",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/eslint-config": "^1.8.0",
|
||||||
|
"@playwright/test": "^1.54.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
pages/.DS_Store
vendored
BIN
pages/.DS_Store
vendored
Binary file not shown.
466
pages/budget.vue
466
pages/budget.vue
|
|
@ -2,36 +2,8 @@
|
||||||
<div>
|
<div>
|
||||||
<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 class="flex items-center gap-4">
|
<h2 class="text-2xl font-bold">Budget Worksheet</h2>
|
||||||
<h2 class="text-2xl font-bold">Budget Worksheet</h2>
|
|
||||||
<div class="flex border-2 border-black bg-white">
|
|
||||||
<button
|
|
||||||
@click="activeView = 'monthly'"
|
|
||||||
:class="[
|
|
||||||
'px-4 py-2 font-medium transition-none',
|
|
||||||
activeView === 'monthly' ? 'bg-black text-white' : 'bg-white text-black hover:bg-gray-100'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
Monthly
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="activeView = 'annual'"
|
|
||||||
:class="[
|
|
||||||
'px-4 py-2 font-medium border-l-2 border-black transition-none',
|
|
||||||
activeView === 'annual' ? 'bg-black text-white' : 'bg-white text-black hover:bg-gray-100'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
Annual
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
|
||||||
to="/help"
|
|
||||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 border-2 border-blue-200 hover:border-blue-300 bg-blue-50 hover:bg-blue-100 transition-colors"
|
|
||||||
>
|
|
||||||
Help & Guides
|
|
||||||
</NuxtLink>
|
|
||||||
<UButton
|
<UButton
|
||||||
@click="exportBudget"
|
@click="exportBudget"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -57,8 +29,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Monthly View -->
|
<!-- Budget Table -->
|
||||||
<div v-if="activeView === 'monthly'" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
<div class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full border-collapse text-sm">
|
<table class="w-full border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -123,12 +95,15 @@
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between group">
|
<div class="flex items-center justify-between group">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-left w-full">
|
<button
|
||||||
|
@click="openHelperForItem(item)"
|
||||||
|
class="text-left hover:underline focus:outline-none focus:underline w-full"
|
||||||
|
>
|
||||||
<div class="font-medium">{{ item.name }}</div>
|
<div class="font-medium">{{ item.name }}</div>
|
||||||
<div class="text-xs text-gray-600">
|
<div class="text-xs text-gray-600">
|
||||||
{{ item.subcategory }}
|
{{ item.subcategory }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<UButton
|
<UButton
|
||||||
@click="removeItem('revenue', item.id)"
|
@click="removeItem('revenue', item.id)"
|
||||||
|
|
@ -229,12 +204,15 @@
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between group">
|
<div class="flex items-center justify-between group">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-left w-full">
|
<button
|
||||||
|
@click="openHelperForItem(item)"
|
||||||
|
class="text-left hover:underline focus:outline-none focus:underline w-full"
|
||||||
|
>
|
||||||
<div class="font-medium">{{ item.name }}</div>
|
<div class="font-medium">{{ item.name }}</div>
|
||||||
<div class="text-xs text-gray-600">
|
<div class="text-xs text-gray-600">
|
||||||
{{ item.subcategory }}
|
{{ item.subcategory }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<UButton
|
<UButton
|
||||||
@click="removeItem('expenses', item.id)"
|
@click="removeItem('expenses', item.id)"
|
||||||
|
|
@ -303,14 +281,6 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Annual View -->
|
|
||||||
<div v-if="activeView === 'annual'">
|
|
||||||
<AnnualBudget
|
|
||||||
:orgId="'default'"
|
|
||||||
:year="new Date().getFullYear()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Add Revenue Modal -->
|
<!-- Add Revenue Modal -->
|
||||||
|
|
@ -338,20 +308,11 @@
|
||||||
<UFormGroup label="Category" required>
|
<UFormGroup label="Category" required>
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="newRevenue.category"
|
v-model="newRevenue.category"
|
||||||
:items="revenueCategories"
|
:options="revenueCategories"
|
||||||
placeholder="Select a category"
|
placeholder="Select a category"
|
||||||
/>
|
/>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<UFormGroup label="Subcategory" :required="false">
|
|
||||||
<USelectMenu
|
|
||||||
v-model="newRevenue.subcategory"
|
|
||||||
:items="revenueSubcategories"
|
|
||||||
placeholder="Select a subcategory"
|
|
||||||
:disabled="!newRevenue.category"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup label="Revenue Name" required>
|
<UFormGroup label="Revenue Name" required>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="newRevenue.name"
|
v-model="newRevenue.name"
|
||||||
|
|
@ -360,59 +321,20 @@
|
||||||
/>
|
/>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<div class="border-t-2 border-gray-200 pt-5">
|
<UFormGroup
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Initial Values</h4>
|
label="Initial Monthly Amount"
|
||||||
|
description="Starting amount for each month"
|
||||||
<UTabs v-model="revenueInitialTab" :items="revenueHelperTabs" class="w-full">
|
>
|
||||||
<template #content="{ item }">
|
<UInput
|
||||||
<!-- Annual Distribution -->
|
v-model.number="newRevenue.initialAmount"
|
||||||
<div v-if="item.key === 'annual'" class="pt-4 space-y-4">
|
type="number"
|
||||||
<UFormGroup label="Annual Total Amount">
|
placeholder="0.00"
|
||||||
<UInput
|
>
|
||||||
v-model.number="newRevenue.annualAmount"
|
<template #leading>
|
||||||
type="number"
|
<span class="text-gray-500">$</span>
|
||||||
placeholder="Enter annual amount (e.g., 12000)"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<span class="text-gray-500">$</span>
|
|
||||||
</template>
|
|
||||||
</UInput>
|
|
||||||
</UFormGroup>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
This will divide ${{ newRevenue.annualAmount || 0 }} equally across all 12 months
|
|
||||||
(${{ newRevenue.annualAmount ? Math.round(newRevenue.annualAmount / 12) : 0 }} per month)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Monthly Amount -->
|
|
||||||
<div v-else-if="item.key === 'monthly'" class="pt-4 space-y-4">
|
|
||||||
<UFormGroup label="Monthly Amount">
|
|
||||||
<UInput
|
|
||||||
v-model.number="newRevenue.monthlyAmount"
|
|
||||||
type="number"
|
|
||||||
placeholder="Enter monthly amount (e.g., 1000)"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<span class="text-gray-500">$</span>
|
|
||||||
</template>
|
|
||||||
</UInput>
|
|
||||||
</UFormGroup>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
This will set ${{ newRevenue.monthlyAmount || 0 }} for all months
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Start Empty -->
|
|
||||||
<div v-else class="pt-4">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
The revenue item will be created with no initial values. You can fill them in later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</UTabs>
|
</UInput>
|
||||||
</div>
|
</UFormGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -434,6 +356,79 @@
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Helper Modal -->
|
||||||
|
<UModal
|
||||||
|
v-model:open="showHelperModal"
|
||||||
|
:ui="{ wrapper: 'sm:max-w-lg', footer: 'justify-end' }"
|
||||||
|
title="Quick Entry Tools"
|
||||||
|
:description="selectedItemDetails?.label || 'Budget item'"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="isolate">
|
||||||
|
<UTabs v-model="activeHelperTab" :items="helperTabs" class="w-full">
|
||||||
|
<template #content="{ item }">
|
||||||
|
<!-- Annual Distribution Content -->
|
||||||
|
<div v-if="item.key === 'annual'" class="pt-4 space-y-4">
|
||||||
|
<UFormGroup label="Annual Total Amount">
|
||||||
|
<UInput
|
||||||
|
v-model.number="helperConfig.annualAmount"
|
||||||
|
type="number"
|
||||||
|
placeholder="Enter annual amount (e.g., 12000)"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<span class="text-gray-500">$</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</UFormGroup>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
This will divide the amount equally across all 12 months
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly Amount Content -->
|
||||||
|
<div v-else-if="item.key === 'monthly'" class="pt-4 space-y-4">
|
||||||
|
<UFormGroup label="Monthly Amount">
|
||||||
|
<UInput
|
||||||
|
v-model.number="helperConfig.monthlyAmount"
|
||||||
|
type="number"
|
||||||
|
placeholder="Enter monthly amount (e.g., 1000)"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<span class="text-gray-500">$</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</UFormGroup>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
This will set the same value for all months
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ close }">
|
||||||
|
<UButton @click="close" variant="outline" color="neutral"> Cancel </UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="activeHelperTab === 0"
|
||||||
|
@click="distributeAnnualAmount"
|
||||||
|
:disabled="!helperConfig.annualAmount || helperConfig.annualAmount <= 0"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Distribute Amount
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
@click="setAllMonths"
|
||||||
|
:disabled="!helperConfig.monthlyAmount || helperConfig.monthlyAmount <= 0"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Apply to All Months
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
<!-- Add Expense Modal -->
|
<!-- Add Expense Modal -->
|
||||||
<UModal v-model:open="showAddExpenseModal">
|
<UModal v-model:open="showAddExpenseModal">
|
||||||
|
|
@ -458,7 +453,7 @@
|
||||||
<UFormGroup label="Category" required>
|
<UFormGroup label="Category" required>
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="newExpense.category"
|
v-model="newExpense.category"
|
||||||
:items="expenseCategories"
|
:options="expenseCategories"
|
||||||
placeholder="Select a category"
|
placeholder="Select a category"
|
||||||
/>
|
/>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
@ -471,59 +466,20 @@
|
||||||
/>
|
/>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<div class="border-t-2 border-gray-200 pt-5">
|
<UFormGroup
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Initial Values</h4>
|
label="Initial Monthly Amount"
|
||||||
|
description="Starting amount for each month"
|
||||||
<UTabs v-model="expenseInitialTab" :items="expenseHelperTabs" class="w-full">
|
>
|
||||||
<template #content="{ item }">
|
<UInput
|
||||||
<!-- Annual Distribution -->
|
v-model.number="newExpense.initialAmount"
|
||||||
<div v-if="item.key === 'annual'" class="pt-4 space-y-4">
|
type="number"
|
||||||
<UFormGroup label="Annual Total Amount">
|
placeholder="0.00"
|
||||||
<UInput
|
>
|
||||||
v-model.number="newExpense.annualAmount"
|
<template #leading>
|
||||||
type="number"
|
<span class="text-gray-500">$</span>
|
||||||
placeholder="Enter annual amount (e.g., 12000)"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<span class="text-gray-500">$</span>
|
|
||||||
</template>
|
|
||||||
</UInput>
|
|
||||||
</UFormGroup>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
This will divide ${{ newExpense.annualAmount || 0 }} equally across all 12 months
|
|
||||||
(${{ newExpense.annualAmount ? Math.round(newExpense.annualAmount / 12) : 0 }} per month)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Monthly Amount -->
|
|
||||||
<div v-else-if="item.key === 'monthly'" class="pt-4 space-y-4">
|
|
||||||
<UFormGroup label="Monthly Amount">
|
|
||||||
<UInput
|
|
||||||
v-model.number="newExpense.monthlyAmount"
|
|
||||||
type="number"
|
|
||||||
placeholder="Enter monthly amount (e.g., 1000)"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<span class="text-gray-500">$</span>
|
|
||||||
</template>
|
|
||||||
</UInput>
|
|
||||||
</UFormGroup>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
This will set ${{ newExpense.monthlyAmount || 0 }} for all months
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Start Empty -->
|
|
||||||
<div v-else class="pt-4">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
The expense item will be created with no initial values. You can fill them in later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</UTabs>
|
</UInput>
|
||||||
</div>
|
</UFormGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -555,9 +511,9 @@ const membersStore = useMembersStore();
|
||||||
const policiesStore = usePoliciesStore();
|
const policiesStore = usePoliciesStore();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const activeView = ref('monthly');
|
|
||||||
const showAddRevenueModal = ref(false);
|
const showAddRevenueModal = ref(false);
|
||||||
const showAddExpenseModal = ref(false);
|
const showAddExpenseModal = ref(false);
|
||||||
|
const showHelperModal = ref(false);
|
||||||
const activeTab = ref(0);
|
const activeTab = ref(0);
|
||||||
const highlightedItemId = ref<string | null>(null);
|
const highlightedItemId = ref<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -567,8 +523,6 @@ const newRevenue = ref({
|
||||||
subcategory: "",
|
subcategory: "",
|
||||||
name: "",
|
name: "",
|
||||||
initialAmount: 0,
|
initialAmount: 0,
|
||||||
annualAmount: 0,
|
|
||||||
monthlyAmount: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const newExpense = ref({
|
const newExpense = ref({
|
||||||
|
|
@ -576,14 +530,27 @@ const newExpense = ref({
|
||||||
subcategory: "",
|
subcategory: "",
|
||||||
name: "",
|
name: "",
|
||||||
initialAmount: 0,
|
initialAmount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper config
|
||||||
|
const helperConfig = ref({
|
||||||
|
selectedItem: null as string | null,
|
||||||
annualAmount: 0,
|
annualAmount: 0,
|
||||||
monthlyAmount: 0,
|
monthlyAmount: 0,
|
||||||
|
startAmount: 0,
|
||||||
|
percentChange: 0,
|
||||||
|
baseAmount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Selected item details for helper modal
|
||||||
|
const selectedItemDetails = computed(() => {
|
||||||
|
if (!helperConfig.value.selectedItem) return null;
|
||||||
|
return allBudgetItems.value.find((item) => item.id === helperConfig.value.selectedItem);
|
||||||
|
});
|
||||||
|
|
||||||
// New modal helper tabs
|
// Helper tabs configuration
|
||||||
const revenueInitialTab = ref(0);
|
const activeHelperTab = ref(0); // UTabs uses index, not key
|
||||||
const revenueHelperTabs = [
|
const helperTabs = [
|
||||||
{
|
{
|
||||||
key: "annual",
|
key: "annual",
|
||||||
label: "Annual Distribution",
|
label: "Annual Distribution",
|
||||||
|
|
@ -594,49 +561,12 @@ const revenueHelperTabs = [
|
||||||
label: "Set All Months",
|
label: "Set All Months",
|
||||||
icon: "i-heroicons-squares-2x2",
|
icon: "i-heroicons-squares-2x2",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "empty",
|
|
||||||
label: "Start Empty",
|
|
||||||
icon: "i-heroicons-minus",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const expenseInitialTab = ref(0);
|
// Data from store
|
||||||
const expenseHelperTabs = [
|
|
||||||
{
|
|
||||||
key: "annual",
|
|
||||||
label: "Annual Distribution",
|
|
||||||
icon: "i-heroicons-calendar",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "monthly",
|
|
||||||
label: "Set All Months",
|
|
||||||
icon: "i-heroicons-squares-2x2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "empty",
|
|
||||||
label: "Start Empty",
|
|
||||||
icon: "i-heroicons-minus",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Data from store - just use the string arrays directly
|
|
||||||
const revenueCategories = computed(() => budgetStore.revenueCategories);
|
const revenueCategories = computed(() => budgetStore.revenueCategories);
|
||||||
const expenseCategories = computed(() => budgetStore.expenseCategories);
|
const expenseCategories = computed(() => budgetStore.expenseCategories);
|
||||||
|
|
||||||
// Revenue subcategories based on selected category
|
|
||||||
const revenueSubcategories = computed(() => {
|
|
||||||
if (!newRevenue.value.category) return [];
|
|
||||||
return budgetStore.revenueSubcategories[newRevenue.value.category] || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear subcategory when category changes
|
|
||||||
watch(() => newRevenue.value.category, (newCategory, oldCategory) => {
|
|
||||||
if (newCategory !== oldCategory) {
|
|
||||||
newRevenue.value.subcategory = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate monthly headers
|
// Generate monthly headers
|
||||||
const monthlyHeaders = computed(() => {
|
const monthlyHeaders = computed(() => {
|
||||||
const headers = [];
|
const headers = [];
|
||||||
|
|
@ -661,11 +591,41 @@ const groupedRevenue = computed(() => budgetStore.groupedRevenue);
|
||||||
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
|
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
|
||||||
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
|
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
|
||||||
|
|
||||||
|
// All budget items for helper dropdown
|
||||||
|
const allBudgetItems = computed(() => {
|
||||||
|
const items: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: "revenue" | "expenses";
|
||||||
|
data: any;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
budgetStore.budgetWorksheet.revenue.forEach((item: any) => {
|
||||||
|
items.push({
|
||||||
|
id: item.id,
|
||||||
|
label: `[Revenue] ${item.name}`,
|
||||||
|
type: "revenue",
|
||||||
|
data: item,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
budgetStore.budgetWorksheet.expenses.forEach((item: any) => {
|
||||||
|
items.push({
|
||||||
|
id: item.id,
|
||||||
|
label: `[Expense] ${item.name}`,
|
||||||
|
type: "expenses",
|
||||||
|
data: item,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Only initialize if not already done (preserve persisted data)
|
// Always re-initialize to get latest wizard data
|
||||||
|
budgetStore.isInitialized = false;
|
||||||
await budgetStore.initializeFromWizardData();
|
await budgetStore.initializeFromWizardData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing budget page:", error);
|
console.error("Error initializing budget page:", error);
|
||||||
|
|
@ -677,36 +637,20 @@ function addRevenueItem() {
|
||||||
const id = budgetStore.addBudgetItem(
|
const id = budgetStore.addBudgetItem(
|
||||||
"revenue",
|
"revenue",
|
||||||
newRevenue.value.name,
|
newRevenue.value.name,
|
||||||
newRevenue.value.category,
|
newRevenue.value.category
|
||||||
newRevenue.value.subcategory
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply helper values based on selected tab
|
// Set initial amount for all months if provided
|
||||||
const activeTab = revenueHelperTabs[revenueInitialTab.value];
|
if (newRevenue.value.initialAmount > 0) {
|
||||||
|
|
||||||
if (activeTab?.key === 'annual' && Number(newRevenue.value.annualAmount) > 0) {
|
|
||||||
// Annual distribution
|
|
||||||
const monthlyAmount = Math.round(newRevenue.value.annualAmount / 12);
|
|
||||||
monthlyHeaders.value.forEach((month) => {
|
monthlyHeaders.value.forEach((month) => {
|
||||||
budgetStore.updateMonthlyValue(
|
budgetStore.updateMonthlyValue(
|
||||||
"revenue",
|
"revenue",
|
||||||
id,
|
id,
|
||||||
month.key,
|
month.key,
|
||||||
monthlyAmount.toString()
|
newRevenue.value.initialAmount.toString()
|
||||||
);
|
|
||||||
});
|
|
||||||
} else if (activeTab?.key === 'monthly' && Number(newRevenue.value.monthlyAmount) > 0) {
|
|
||||||
// Set all months
|
|
||||||
monthlyHeaders.value.forEach((month) => {
|
|
||||||
budgetStore.updateMonthlyValue(
|
|
||||||
"revenue",
|
|
||||||
id,
|
|
||||||
month.key,
|
|
||||||
newRevenue.value.monthlyAmount.toString()
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// If tab === 2 (empty), don't set any values
|
|
||||||
|
|
||||||
// Reset form and close modal
|
// Reset form and close modal
|
||||||
newRevenue.value = {
|
newRevenue.value = {
|
||||||
|
|
@ -714,10 +658,7 @@ function addRevenueItem() {
|
||||||
subcategory: "",
|
subcategory: "",
|
||||||
name: "",
|
name: "",
|
||||||
initialAmount: 0,
|
initialAmount: 0,
|
||||||
annualAmount: 0,
|
|
||||||
monthlyAmount: 0,
|
|
||||||
};
|
};
|
||||||
revenueInitialTab.value = 0;
|
|
||||||
showAddRevenueModal.value = false;
|
showAddRevenueModal.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -729,32 +670,17 @@ function addExpenseItem() {
|
||||||
newExpense.value.category
|
newExpense.value.category
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply helper values based on selected tab
|
// Set initial amount for all months if provided
|
||||||
const activeExpenseTab = expenseHelperTabs[expenseInitialTab.value];
|
if (newExpense.value.initialAmount > 0) {
|
||||||
|
|
||||||
if (activeExpenseTab?.key === 'annual' && Number(newExpense.value.annualAmount) > 0) {
|
|
||||||
// Annual distribution
|
|
||||||
const monthlyAmount = Math.round(newExpense.value.annualAmount / 12);
|
|
||||||
monthlyHeaders.value.forEach((month) => {
|
monthlyHeaders.value.forEach((month) => {
|
||||||
budgetStore.updateMonthlyValue(
|
budgetStore.updateMonthlyValue(
|
||||||
"expenses",
|
"expenses",
|
||||||
id,
|
id,
|
||||||
month.key,
|
month.key,
|
||||||
monthlyAmount.toString()
|
newExpense.value.initialAmount.toString()
|
||||||
);
|
|
||||||
});
|
|
||||||
} else if (activeExpenseTab?.key === 'monthly' && Number(newExpense.value.monthlyAmount) > 0) {
|
|
||||||
// Set all months
|
|
||||||
monthlyHeaders.value.forEach((month) => {
|
|
||||||
budgetStore.updateMonthlyValue(
|
|
||||||
"expenses",
|
|
||||||
id,
|
|
||||||
month.key,
|
|
||||||
newExpense.value.monthlyAmount.toString()
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// If tab === 2 (empty), don't set any values
|
|
||||||
|
|
||||||
// Reset form and close modal
|
// Reset form and close modal
|
||||||
newExpense.value = {
|
newExpense.value = {
|
||||||
|
|
@ -762,10 +688,7 @@ function addExpenseItem() {
|
||||||
subcategory: "",
|
subcategory: "",
|
||||||
name: "",
|
name: "",
|
||||||
initialAmount: 0,
|
initialAmount: 0,
|
||||||
annualAmount: 0,
|
|
||||||
monthlyAmount: 0,
|
|
||||||
};
|
};
|
||||||
expenseInitialTab.value = 0;
|
|
||||||
showAddExpenseModal.value = false;
|
showAddExpenseModal.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -803,6 +726,14 @@ function handleEnter(event: KeyboardEvent) {
|
||||||
input.blur();
|
input.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open helper modal for specific item
|
||||||
|
function openHelperForItem(item: any) {
|
||||||
|
helperConfig.value.selectedItem = item.id;
|
||||||
|
helperConfig.value.annualAmount = 0;
|
||||||
|
helperConfig.value.monthlyAmount = 0;
|
||||||
|
activeTab.value = 0; // Reset to first tab
|
||||||
|
showHelperModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight row after changes
|
// Highlight row after changes
|
||||||
function highlightRow(itemId: string) {
|
function highlightRow(itemId: string) {
|
||||||
|
|
@ -812,6 +743,47 @@ function highlightRow(itemId: string) {
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function distributeAnnualAmount() {
|
||||||
|
if (!helperConfig.value.selectedItem || !helperConfig.value.annualAmount) return;
|
||||||
|
|
||||||
|
const item = allBudgetItems.value.find((i) => i.id === helperConfig.value.selectedItem);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const monthlyAmount = Math.round(helperConfig.value.annualAmount / 12);
|
||||||
|
monthlyHeaders.value.forEach((month) => {
|
||||||
|
budgetStore.updateMonthlyValue(
|
||||||
|
item.type,
|
||||||
|
item.id,
|
||||||
|
month.key,
|
||||||
|
monthlyAmount.toString()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
helperConfig.value.annualAmount = 0;
|
||||||
|
highlightRow(item.id);
|
||||||
|
showHelperModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAllMonths() {
|
||||||
|
if (!helperConfig.value.selectedItem || !helperConfig.value.monthlyAmount) return;
|
||||||
|
|
||||||
|
const item = allBudgetItems.value.find((i) => i.id === helperConfig.value.selectedItem);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
monthlyHeaders.value.forEach((month) => {
|
||||||
|
budgetStore.updateMonthlyValue(
|
||||||
|
item.type,
|
||||||
|
item.id,
|
||||||
|
month.key,
|
||||||
|
helperConfig.value.monthlyAmount.toString()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
helperConfig.value.monthlyAmount = 0;
|
||||||
|
highlightRow(item.id);
|
||||||
|
showHelperModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset worksheet
|
// Reset worksheet
|
||||||
function resetWorksheet() {
|
function resetWorksheet() {
|
||||||
|
|
|
||||||
191
pages/help.vue
191
pages/help.vue
|
|
@ -1,191 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50 py-8">
|
|
||||||
<div class="container mx-auto max-w-4xl px-4">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8 text-center">
|
|
||||||
<h1 class="text-4xl font-bold mb-4">Budget Planning Help</h1>
|
|
||||||
<p class="text-xl text-gray-600">Learn how to build a sustainable financial plan for your co-op or studio</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
|
||||||
<div class="p-4">
|
|
||||||
<h2 class="text-xl font-bold mb-4">Quick Navigation</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<a href="#revenue-diversification" class="block p-3 bg-blue-50 border-2 border-blue-200 rounded hover:bg-blue-100 transition-colors">
|
|
||||||
<span class="font-semibold">Revenue Diversification</span>
|
|
||||||
<p class="text-sm text-gray-600">How to develop multiple income streams</p>
|
|
||||||
</a>
|
|
||||||
<a href="#budget-categories" class="block p-3 bg-green-50 border-2 border-green-200 rounded hover:bg-green-100 transition-colors">
|
|
||||||
<span class="font-semibold">Budget Categories</span>
|
|
||||||
<p class="text-sm text-gray-600">Understanding revenue and expense types</p>
|
|
||||||
</a>
|
|
||||||
<a href="#planning-tips" class="block p-3 bg-yellow-50 border-2 border-yellow-200 rounded hover:bg-yellow-100 transition-colors">
|
|
||||||
<span class="font-semibold">Planning Tips</span>
|
|
||||||
<p class="text-sm text-gray-600">Best practices for financial planning</p>
|
|
||||||
</a>
|
|
||||||
<a href="#getting-started" class="block p-3 bg-purple-50 border-2 border-purple-200 rounded hover:bg-purple-100 transition-colors">
|
|
||||||
<span class="font-semibold">Getting Started</span>
|
|
||||||
<p class="text-sm text-gray-600">Step-by-step setup guide</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Sections -->
|
|
||||||
<div class="space-y-8">
|
|
||||||
|
|
||||||
<!-- Revenue Diversification -->
|
|
||||||
<section id="revenue-diversification" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
|
||||||
<div class="bg-blue-500 text-white px-6 py-4">
|
|
||||||
<h2 class="text-2xl font-bold">Revenue Diversification</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="prose max-w-none">
|
|
||||||
<p class="text-lg mb-4">
|
|
||||||
Building multiple revenue streams reduces risk and creates more stable income for your co-op.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">How to Develop Games & Products</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Placeholder text - Add your content here about developing games and product revenue streams]
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">How to Develop Services & Contracts</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Placeholder text - Add your content here about developing service-based revenue streams]
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">How to Secure Grants & Funding</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Placeholder text - Add your content here about finding and securing grants]
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Building Community Support</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Placeholder text - Add your content here about building community-supported revenue]
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Budget Categories -->
|
|
||||||
<section id="budget-categories" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
|
||||||
<div class="bg-green-500 text-white px-6 py-4">
|
|
||||||
<h2 class="text-2xl font-bold">Budget Categories</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="prose max-w-none">
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Revenue Categories</h3>
|
|
||||||
<ul class="mb-6 space-y-2">
|
|
||||||
<li><strong>Games & Products:</strong> [Add description]</li>
|
|
||||||
<li><strong>Services & Contracts:</strong> [Add description]</li>
|
|
||||||
<li><strong>Grants & Funding:</strong> [Add description]</li>
|
|
||||||
<li><strong>Community Support:</strong> [Add description]</li>
|
|
||||||
<li><strong>Partnerships:</strong> [Add description]</li>
|
|
||||||
<li><strong>Investment Income:</strong> [Add description]</li>
|
|
||||||
<li><strong>In-Kind Contributions:</strong> [Add description]</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Expense Categories</h3>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li><strong>Salaries & Benefits:</strong> [Add description]</li>
|
|
||||||
<li><strong>Development Costs:</strong> [Add description]</li>
|
|
||||||
<li><strong>Equipment & Technology:</strong> [Add description]</li>
|
|
||||||
<li><strong>Marketing & Outreach:</strong> [Add description]</li>
|
|
||||||
<li><strong>Office & Operations:</strong> [Add description]</li>
|
|
||||||
<li><strong>Legal & Professional:</strong> [Add description]</li>
|
|
||||||
<li><strong>Other Expenses:</strong> [Add description]</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Planning Tips -->
|
|
||||||
<section id="planning-tips" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
|
||||||
<div class="bg-yellow-500 text-white px-6 py-4">
|
|
||||||
<h2 class="text-2xl font-bold">Planning Tips</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="prose max-w-none">
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Concentration Risk</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Add content about managing concentration risk and why diversification matters]
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Monthly vs Annual Planning</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Add content about balancing monthly cash flow with annual strategic planning]
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Setting Realistic Goals</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Add content about setting achievable revenue and expense targets]
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Getting Started -->
|
|
||||||
<section id="getting-started" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
|
|
||||||
<div class="bg-purple-500 text-white px-6 py-4">
|
|
||||||
<h2 class="text-2xl font-bold">Getting Started</h2>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="prose max-w-none">
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Step 1: Set Up Your Basic Budget</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Add step-by-step instructions for initial budget setup]
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Step 2: Plan Your Revenue Streams</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Add guidance on planning initial revenue streams]
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-3">Step 3: Track and Adjust</h3>
|
|
||||||
<p class="mb-4">
|
|
||||||
[Add content about ongoing budget management]
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Back to Budget -->
|
|
||||||
<div class="mt-8 text-center">
|
|
||||||
<NuxtLink
|
|
||||||
to="/budget"
|
|
||||||
class="inline-block px-6 py-3 bg-black text-white font-bold border-4 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
|
|
||||||
>
|
|
||||||
Back to Budget
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// Page meta
|
|
||||||
useHead({
|
|
||||||
title: 'Budget Planning Help - Urgent Tools',
|
|
||||||
meta: [
|
|
||||||
{ name: 'description', content: 'Learn how to build sustainable financial plans and develop diverse revenue streams for your co-op or studio.' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Smooth scrolling for anchor links */
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure anchor links account for any fixed headers */
|
|
||||||
section[id] {
|
|
||||||
scroll-margin-top: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
|
|
||||||
>
|
|
||||||
<div class="max-w-6xl mx-auto px-4 relative">
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
|
||||||
More Resources & Templates
|
|
||||||
</h1>
|
|
||||||
<p class="text-neutral-700 dark:text-neutral-200">
|
|
||||||
Additional tools, templates, and resources to support your cooperative journey.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-8">
|
|
||||||
<!-- External Templates Section -->
|
|
||||||
<section>
|
|
||||||
<h2 class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
|
|
||||||
External Templates
|
|
||||||
</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<!-- Miro Template -->
|
|
||||||
<div class="template-card">
|
|
||||||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
|
||||||
<div
|
|
||||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6"
|
|
||||||
>
|
|
||||||
<h3 class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
|
|
||||||
Goals & Values Exercise
|
|
||||||
</h3>
|
|
||||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
|
||||||
A Miro template to help your team align on shared goals and values through collaborative exercises.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://miro.com/miroverse/goals-values-exercise/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
Open in Miro
|
|
||||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CommunityRule Templates -->
|
|
||||||
<div class="template-card">
|
|
||||||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
|
||||||
<div
|
|
||||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6"
|
|
||||||
>
|
|
||||||
<h3 class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
|
|
||||||
CommunityRule Governance Templates
|
|
||||||
</h3>
|
|
||||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
|
||||||
A collection of governance templates and patterns for democratic organizations and communities.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://communityrule.info/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
Browse Templates
|
|
||||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002 2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- PDF Downloads Section -->
|
|
||||||
<section>
|
|
||||||
<h2 class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
|
|
||||||
Wizard PDF Downloads
|
|
||||||
</h2>
|
|
||||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
|
||||||
Download PDF versions of our wizards for offline use or printing.
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<div
|
|
||||||
v-for="pdf in pdfDownloads"
|
|
||||||
:key="pdf.id"
|
|
||||||
class="template-card"
|
|
||||||
>
|
|
||||||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
|
||||||
<div
|
|
||||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<h3 class="text-lg font-semibold text-neutral-900 dark:text-white">
|
|
||||||
{{ pdf.name }}
|
|
||||||
</h3>
|
|
||||||
<span class="text-xs text-neutral-600 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-2 py-1 rounded">
|
|
||||||
PDF
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
|
|
||||||
{{ pdf.description }}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
:disabled="!pdf.available"
|
|
||||||
:class="[
|
|
||||||
'inline-flex items-center px-4 py-2 font-medium transition-opacity',
|
|
||||||
pdf.available
|
|
||||||
? 'bg-black dark:bg-white text-white dark:text-black hover:opacity-90'
|
|
||||||
: 'bg-neutral-200 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-500 cursor-not-allowed'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
{{ pdf.available ? 'Download' : 'Coming Soon' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// PDF downloads list with placeholder data
|
|
||||||
const pdfDownloads = [
|
|
||||||
{
|
|
||||||
id: 'bylaws',
|
|
||||||
name: 'Bylaws Wizard',
|
|
||||||
description: 'Create comprehensive bylaws for your cooperative',
|
|
||||||
available: false,
|
|
||||||
url: '#'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'operating-agreement',
|
|
||||||
name: 'Operating Agreement Wizard',
|
|
||||||
description: 'Draft an operating agreement for your LLC cooperative',
|
|
||||||
available: false,
|
|
||||||
url: '#'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'articles',
|
|
||||||
name: 'Articles of Incorporation',
|
|
||||||
description: 'Template for articles of incorporation',
|
|
||||||
available: false,
|
|
||||||
url: '#'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'membership',
|
|
||||||
name: 'Membership Agreement',
|
|
||||||
description: 'Define membership terms and conditions',
|
|
||||||
available: false,
|
|
||||||
url: '#'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'patronage',
|
|
||||||
name: 'Patronage Policy',
|
|
||||||
description: 'Structure your patronage distribution system',
|
|
||||||
available: false,
|
|
||||||
url: '#'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'conflict',
|
|
||||||
name: 'Conflict Resolution Process',
|
|
||||||
description: 'Establish clear conflict resolution procedures',
|
|
||||||
available: false,
|
|
||||||
url: '#'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.template-card {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dither-shadow {
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent,
|
|
||||||
transparent 2px,
|
|
||||||
currentColor 2px,
|
|
||||||
currentColor 4px
|
|
||||||
);
|
|
||||||
opacity: 0.1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dither-tag {
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
135deg,
|
|
||||||
transparent,
|
|
||||||
transparent 1px,
|
|
||||||
currentColor 1px,
|
|
||||||
currentColor 2px
|
|
||||||
);
|
|
||||||
background-size: 4px 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -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>
|
|
||||||
77
playwright-report/index.html
Normal file
77
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
34
playwright.config.ts
Normal file
34
playwright.config.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3002',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
testIgnore: [
|
||||||
|
'tests/e2e/conflict-resolution-edge-cases.spec.ts',
|
||||||
|
'tests/e2e/conflict-resolution-parity.spec.ts',
|
||||||
|
'tests/e2e/conflict-resolution-form.spec.ts',
|
||||||
|
'tests/e2e/example.spec.ts'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 3002,
|
||||||
|
timeout: 60_000,
|
||||||
|
reuseExistingServer: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
8
server/api/fixtures.get.ts
Normal file
8
server/api/fixtures.get.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineEventHandler } from "h3";
|
||||||
|
import { useFixtureIO } from "~/composables/useFixtureIO";
|
||||||
|
|
||||||
|
export default defineEventHandler(() => {
|
||||||
|
// Export snapshot of in-memory state
|
||||||
|
const { exportAll } = useFixtureIO();
|
||||||
|
return exportAll();
|
||||||
|
});
|
||||||
9
server/api/fixtures.post.ts
Normal file
9
server/api/fixtures.post.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineEventHandler, readBody } from "h3";
|
||||||
|
import { useFixtureIO, type AppSnapshot } from "~/composables/useFixtureIO";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = (await readBody(event)) as AppSnapshot;
|
||||||
|
const { importAll } = useFixtureIO();
|
||||||
|
importAll(body);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
105
stores/budget.ts
105
stores/budget.ts
|
|
@ -314,67 +314,6 @@ export const useBudgetStore = defineStore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh payroll in budget when policy or operating mode changes
|
|
||||||
function refreshPayrollInBudget() {
|
|
||||||
if (!isInitialized.value) return;
|
|
||||||
|
|
||||||
const coopStore = useCoopBuilderStore();
|
|
||||||
const payrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll");
|
|
||||||
|
|
||||||
if (payrollIndex === -1) return; // No existing payroll entry
|
|
||||||
|
|
||||||
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
|
||||||
const hourlyWage = coopStore.equalHourlyWage || 0;
|
|
||||||
const oncostPct = coopStore.payrollOncostPct || 0;
|
|
||||||
const basePayrollBudget = totalHours * hourlyWage;
|
|
||||||
|
|
||||||
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
|
||||||
// Use policy-driven allocation
|
|
||||||
const payPolicy = {
|
|
||||||
relationship: coopStore.policy.relationship,
|
|
||||||
roleBands: coopStore.policy.roleBands
|
|
||||||
};
|
|
||||||
|
|
||||||
const membersForAllocation = coopStore.members.map(m => ({
|
|
||||||
...m,
|
|
||||||
displayName: m.name,
|
|
||||||
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
|
||||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
|
||||||
targetMonthlyPay: m.targetMonthlyPay || 0,
|
|
||||||
role: m.role || '',
|
|
||||||
hoursPerMonth: m.hoursPerMonth || 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
|
||||||
|
|
||||||
// Sum with operating mode consideration
|
|
||||||
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
|
||||||
const planned = m.monthlyPayPlanned || 0;
|
|
||||||
if (coopStore.operatingMode === 'min' && m.minMonthlyNeeds) {
|
|
||||||
return sum + Math.min(planned, m.minMonthlyNeeds);
|
|
||||||
}
|
|
||||||
return sum + planned;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
|
|
||||||
|
|
||||||
// Update monthly values
|
|
||||||
const today = new Date();
|
|
||||||
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[payrollIndex].monthlyValues[monthKey] = monthlyPayroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update annual values
|
|
||||||
budgetWorksheet.value.expenses[payrollIndex].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 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize worksheet from wizard data
|
// Initialize worksheet from wizard data
|
||||||
async function initializeFromWizardData() {
|
async function initializeFromWizardData() {
|
||||||
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
|
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
|
||||||
|
|
@ -465,48 +404,17 @@ export const useBudgetStore = defineStore(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add payroll from wizard data using the policy-driven allocation system
|
// Add payroll from wizard data using the allocatePayroll function
|
||||||
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;
|
||||||
|
|
||||||
// Calculate total payroll budget using policy allocation
|
// Calculate total payroll budget (before oncosts)
|
||||||
const basePayrollBudget = totalHours * hourlyWage;
|
const basePayrollBudget = totalHours * hourlyWage;
|
||||||
|
|
||||||
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
||||||
// Use policy-driven allocation to get actual member pay amounts
|
// Calculate total with oncosts
|
||||||
const payPolicy = {
|
const monthlyPayroll = basePayrollBudget * (1 + oncostPct / 100);
|
||||||
relationship: coopStore.policy.relationship,
|
|
||||||
roleBands: coopStore.policy.roleBands
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert coopStore members to the format expected by allocatePayroll
|
|
||||||
const membersForAllocation = coopStore.members.map(m => ({
|
|
||||||
...m,
|
|
||||||
displayName: m.name,
|
|
||||||
// Ensure all required fields exist
|
|
||||||
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
|
||||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
|
||||||
targetMonthlyPay: m.targetMonthlyPay || 0,
|
|
||||||
role: m.role || '',
|
|
||||||
hoursPerMonth: m.hoursPerMonth || 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Allocate payroll based on policy
|
|
||||||
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
|
||||||
|
|
||||||
// Sum the allocated amounts for total payroll, respecting operating mode
|
|
||||||
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
|
||||||
const planned = m.monthlyPayPlanned || 0;
|
|
||||||
// In "minimum" mode, cap at min needs to show a lean runway scenario
|
|
||||||
if (coopStore.operatingMode === 'min' && m.minMonthlyNeeds) {
|
|
||||||
return sum + Math.min(planned, m.minMonthlyNeeds);
|
|
||||||
}
|
|
||||||
return sum + planned;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Apply oncosts to the policy-allocated total
|
|
||||||
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
|
|
||||||
|
|
||||||
// Create monthly values for payroll
|
// Create monthly values for payroll
|
||||||
const monthlyValues: Record<string, number> = {};
|
const monthlyValues: Record<string, number> = {};
|
||||||
|
|
@ -753,7 +661,7 @@ export const useBudgetStore = defineStore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBudgetItem(category, name, selectedCategory = "", subcategory = "") {
|
function addBudgetItem(category, name, selectedCategory = "") {
|
||||||
const id = `${category}-${Date.now()}`;
|
const id = `${category}-${Date.now()}`;
|
||||||
|
|
||||||
// Create empty monthly values for next 12 months
|
// Create empty monthly values for next 12 months
|
||||||
|
|
@ -773,7 +681,7 @@ export const useBudgetStore = defineStore(
|
||||||
mainCategory:
|
mainCategory:
|
||||||
selectedCategory ||
|
selectedCategory ||
|
||||||
(category === "revenue" ? "Games & Products" : "Other Expenses"),
|
(category === "revenue" ? "Games & Products" : "Other Expenses"),
|
||||||
subcategory: subcategory || "",
|
subcategory: "", // Will be set by user via dropdown
|
||||||
source: "user",
|
source: "user",
|
||||||
monthlyValues,
|
monthlyValues,
|
||||||
values: {
|
values: {
|
||||||
|
|
@ -856,7 +764,6 @@ export const useBudgetStore = defineStore(
|
||||||
groupedExpenses,
|
groupedExpenses,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
initializeFromWizardData,
|
initializeFromWizardData,
|
||||||
refreshPayrollInBudget,
|
|
||||||
updateBudgetValue,
|
updateBudgetValue,
|
||||||
updateMonthlyValue,
|
updateMonthlyValue,
|
||||||
addBudgetItem,
|
addBudgetItem,
|
||||||
|
|
|
||||||
13
tailwind.config.ts
Normal file
13
tailwind.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./app.vue',
|
||||||
|
'./pages/**/*.vue',
|
||||||
|
'./components/**/*.{vue,js,ts}',
|
||||||
|
'./composables/**/*.{js,ts}',
|
||||||
|
'./layouts/**/*.vue',
|
||||||
|
'./plugins/**/*.{js,ts}',
|
||||||
|
],
|
||||||
|
darkMode: "class",
|
||||||
|
} satisfies Config;
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
export type BudgetLine = {
|
|
||||||
orgId: string
|
|
||||||
year: number
|
|
||||||
month: 1|2|3|4|5|6|7|8|9|10|11|12
|
|
||||||
type: 'revenue' | 'expense'
|
|
||||||
category: 'Games'|'Services'|'Grants'|'Other'|string
|
|
||||||
planned: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OrgYearSettings = {
|
|
||||||
orgId: string
|
|
||||||
year: number
|
|
||||||
startingCash: number
|
|
||||||
}
|
|
||||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue