This commit is contained in:
Jennie Robinson Faber 2025-09-04 10:42:03 +01:00
parent fc2d9ed56b
commit 983aeca2dc
32 changed files with 1570 additions and 27266 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,14 +0,0 @@
# 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
View file

@ -1,25 +0,0 @@
# 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

View file

@ -1,11 +0,0 @@
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"]

View file

@ -1,75 +0,0 @@
# 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.

View file

@ -1,42 +0,0 @@
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
View file

@ -54,6 +54,15 @@
>
Wizards
</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>
<div class="py-8">
<CoopBuilderSubnav v-if="isCoopBuilderSection" />
@ -82,6 +91,7 @@ const isCoopBuilderSection = computed(
route.path === "/dashboard" ||
route.path === "/mix" ||
route.path === "/budget" ||
route.path === "/runway-lite" ||
route.path === "/scenarios" ||
route.path === "/cash" ||
route.path === "/session" ||

BIN
assets/.DS_Store vendored Normal file

Binary file not shown.

BIN
components/.DS_Store vendored Normal file

Binary file not shown.

339
components/AnnualBudget.vue Normal file
View file

@ -0,0 +1,339 @@
<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 23 other categories.";
}
// Optional Positive Nudges
const grantsCategory = categoriesWithRevenue.find(cat => cat.name === 'Grants & Funding');
const servicesCategory = categoriesWithRevenue.find(cat => cat.name === 'Services & Contracts');
const productsCategory = categoriesWithRevenue.find(cat => cat.name === 'Games & Products');
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>

View file

@ -46,6 +46,11 @@ const coopBuilderItems = [
name: "Revenue Mix",
path: "/mix",
},
{
id: "runway-lite",
name: "Runway Lite",
path: "/runway-lite",
},
{
id: "scenarios",
name: "Scenarios",

513
components/RunwayLite.vue Normal file
View file

@ -0,0 +1,513 @@
<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 36 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>

108
composables/useBudget.ts Normal file
View file

@ -0,0 +1,108 @@
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
}
}

View file

@ -1,4 +1,5 @@
import { allocatePayroll as allocatePayrollImpl, monthlyPayroll, type Member, type PayPolicy } from '~/types/members'
import { useCoopBuilderStore } from '~/stores/coopBuilder'
export function useCoopBuilder() {
// Use the centralized Pinia store
@ -323,6 +324,30 @@ export function useCoopBuilder() {
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 {
// State
members,

View file

@ -1,81 +0,0 @@
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

File diff suppressed because it is too large Load diff

View file

@ -1,39 +0,0 @@
{
"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 Normal file

Binary file not shown.

View file

@ -2,8 +2,36 @@
<div>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<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">
<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
@click="exportBudget"
variant="outline"
@ -29,8 +57,8 @@
</div>
</div>
<!-- Budget Table -->
<div class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<!-- Monthly View -->
<div v-if="activeView === 'monthly'" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
@ -275,6 +303,14 @@
</table>
</div>
</div>
<!-- Annual View -->
<div v-if="activeView === 'annual'">
<AnnualBudget
:orgId="'default'"
:year="new Date().getFullYear()"
/>
</div>
</section>
<!-- Add Revenue Modal -->
@ -519,6 +555,7 @@ const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
// State
const activeView = ref('monthly');
const showAddRevenueModal = ref(false);
const showAddExpenseModal = ref(false);
const activeTab = ref(0);

191
pages/help.vue Normal file
View file

@ -0,0 +1,191 @@
<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>

205
pages/resources.vue Normal file
View file

@ -0,0 +1,205 @@
<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>

23
pages/runway-lite.vue Normal file
View file

@ -0,0 +1,23 @@
<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>

File diff suppressed because one or more lines are too long

View file

@ -1,34 +0,0 @@
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
}
})

View file

@ -1,8 +0,0 @@
import { defineEventHandler } from "h3";
import { useFixtureIO } from "~/composables/useFixtureIO";
export default defineEventHandler(() => {
// Export snapshot of in-memory state
const { exportAll } = useFixtureIO();
return exportAll();
});

View file

@ -1,9 +0,0 @@
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 };
});

View file

@ -314,6 +314,67 @@ 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
async function initializeFromWizardData() {
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
@ -404,17 +465,48 @@ export const useBudgetStore = defineStore(
});
});
// Add payroll from wizard data using the allocatePayroll function
// Add payroll from wizard data using the policy-driven allocation system
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
const hourlyWage = coopStore.equalHourlyWage || 0;
const oncostPct = coopStore.payrollOncostPct || 0;
// Calculate total payroll budget (before oncosts)
// Calculate total payroll budget using policy allocation
const basePayrollBudget = totalHours * hourlyWage;
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Calculate total with oncosts
const monthlyPayroll = basePayrollBudget * (1 + oncostPct / 100);
// Use policy-driven allocation to get actual member pay amounts
const payPolicy = {
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
const monthlyValues: Record<string, number> = {};
@ -764,6 +856,7 @@ export const useBudgetStore = defineStore(
groupedExpenses,
isInitialized,
initializeFromWizardData,
refreshPayrollInBudget,
updateBudgetValue,
updateMonthlyValue,
addBudgetItem,

View file

@ -1,13 +0,0 @@
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;

View file

@ -1,18 +0,0 @@
{
// 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"
}
]
}

14
types/budget.ts Normal file
View file

@ -0,0 +1,14 @@
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
}

View file

@ -1,12 +0,0 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom'
}
})

8919
yarn.lock

File diff suppressed because it is too large Load diff