refactor: replace Wizard with CoopBuilder in navigation, enhance budget store structure, and streamline template components for improved user experience

This commit is contained in:
Jennie Robinson Faber 2025-08-17 17:25:04 +01:00
parent eede87a273
commit f67b138d95
33 changed files with 4970 additions and 2451 deletions

90
app.vue
View file

@ -9,12 +9,11 @@
<div class="relative flex items-center justify-center"> <div class="relative flex items-center justify-center">
<NuxtLink <NuxtLink
to="/" to="/"
class="flex items-center gap-2 hover:opacity-80 transition-opacity"> class="flex items-center gap-2 hover:opacity-80 transition-opacity"
<UIcon >
name="i-heroicons-rocket-launch"
class="text-primary-500" />
<h1 <h1
class="font-semibold text-black dark:text-white text-center"> class="text-black dark:text-white text-center text-2xl font-mono uppercase font-bold"
>
Urgent Tools Urgent Tools
</h1> </h1>
</NuxtLink> </NuxtLink>
@ -25,49 +24,41 @@
<nav <nav
class="mt-4 flex items-center justify-center gap-1" class="mt-4 flex items-center justify-center gap-1"
role="navigation" role="navigation"
aria-label="Main navigation"> aria-label="Main navigation"
>
<NuxtLink <NuxtLink
to="/coop-planner" to="/coop-planner"
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="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="{ :class="{
'bg-neutral-100 dark:bg-neutral-800': isCoopSection, 'bg-neutral-100 dark:bg-neutral-800': isCoopBuilderSection,
}"> }"
Co-Op in 6 Months >
Co-Op Builder
</NuxtLink> </NuxtLink>
<!-- Coach feature - hidden for now -->
<!-- <NuxtLink
to="/coach/skills-to-offers"
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.startsWith('/coach'),
}"
>
Coach
</NuxtLink> -->
<NuxtLink <NuxtLink
to="/wizards" to="/wizards"
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="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="{ :class="{
'bg-neutral-100 dark:bg-neutral-800': 'bg-neutral-100 dark:bg-neutral-800': $route.path === '/wizards',
$route.path === '/wizards', }"
}"> >
Wizards Wizards
</NuxtLink> </NuxtLink>
<UButton
color="gray"
variant="ghost"
disabled
class="px-3 py-2 text-sm text-black dark:text-white rounded-md opacity-60 cursor-not-allowed">
Downloads
</UButton>
</nav>
<nav
v-if="isCoopSection"
class="mt-2 flex items-center justify-center gap-1"
role="navigation"
aria-label="Co-Op in 6 Months sub navigation">
<NuxtLink
v-for="item in coopMenu[0]"
:key="item.to"
:to="item.to"
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 === item.to,
}">
{{ item.label }}
</NuxtLink>
</nav> </nav>
<div class="py-8">
<CoopBuilderSubnav v-if="isCoopBuilderSection" />
<WizardSubnav v-if="isWizardSection" />
</div>
</header> </header>
<NuxtPage /> <NuxtPage />
</UContainer> </UContainer>
@ -82,17 +73,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const coopMenu = [
[
{ label: "Dashboard", to: "/" },
{ label: "Revenue Mix", to: "/mix" },
{ label: "Budget", to: "/budget" },
{ label: "Scenarios", to: "/scenarios" },
{ label: "Cash", to: "/cash" },
{ label: "Glossary", to: "/glossary" },
],
];
const route = useRoute(); const route = useRoute();
const isCoopSection = computed(() => route.path === "/coop-planner"); const isCoopBuilderSection = computed(
() =>
route.path === "/coop-planner" ||
route.path === "/coop-builder" ||
route.path === "/" ||
route.path === "/mix" ||
route.path === "/budget" ||
route.path === "/scenarios" ||
route.path === "/cash" ||
route.path === "/glossary"
);
const isWizardSection = computed(
() => route.path === "/wizards" || route.path.startsWith("/templates/")
);
</script> </script>

View file

@ -1,29 +1,25 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
@import "tailwindcss"; @import "tailwindcss";
@import "@nuxt/ui"; @import "@nuxt/ui";
/* Ubuntu font import */ @theme {
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap"); --font-body: "Ubuntu", "Inter", sans-serif;
--font-mono: "Ubuntu Mono", monospace;
[data-theme="dark"] {
html { @apply bg-white text-neutral-900; }
html.dark { @apply bg-neutral-950 text-neutral-100; }
} }
/* Disable all animations, transitions, and smooth scrolling app-wide */
html, html,
body { body {
scroll-behavior: auto !important; @apply font-body bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100;
} }
*, /* All headers use Inter font */
*::before, h1, h2, h3, h4, h5, h6 {
*::after { font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
animation: none !important;
transition: none !important;
} }
/* ========================= /* =========================
TEMPLATE DOCUMENT LAYOUT TEMPLATE DOCUMENT LAYOUT
========================= */ ========================= */
@ -47,7 +43,7 @@ body {
========================= */ ========================= */
.section-card { .section-card {
@apply border border-neutral-200 dark:border-neutral-800 rounded-lg p-5 mb-8; @apply border border-neutral-200 dark:border-neutral-800 rounded-lg p-5 mb-8 relative;
} }
.section-card::before { .section-card::before {
@ -57,27 +53,24 @@ body {
left: 4px; left: 4px;
right: -4px; right: -4px;
bottom: -4px; bottom: -4px;
background: black; @apply bg-black dark:bg-white;
background-image: radial-gradient(white 1px, transparent 1px); background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px; background-size: 2px 2px;
z-index: -1; z-index: -1;
} }
html.dark .section-card::before {
background-image: radial-gradient(black 1px, transparent 1px);
}
.section-title { .section-title {
font-size: 1.75rem; @apply text-3xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4;
font-weight: 800; font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
color: inherit;
margin: 0 0 1rem 0;
} }
.subsection-title { .subsection-title {
font-size: 1.25rem; @apply text-xl font-semibold text-neutral-700 dark:text-neutral-300 mb-3 no-underline border-b border-neutral-200 dark:border-neutral-700 pb-1;
font-weight: 600; font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
color: #374151;
margin: 0 0 0.75rem 0;
text-decoration: none;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.25rem;
} }
/* ========================= /* =========================
@ -121,55 +114,36 @@ body {
} }
.form-group-large .large-field { .form-group-large .large-field {
@apply block w-full mt-2 text-lg rounded-md border-none transition-colors duration-150 ease-in-out; @apply block w-full mt-2 text-lg rounded-md border-none transition-colors duration-150 ease-in-out bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100;
} }
.form-group-large .large-field:focus { .form-group-large .large-field:focus {
background: #f3f4f6; @apply bg-neutral-100 dark:bg-neutral-700 outline-2 outline-blue-500 -outline-offset-2;
box-shadow: none; box-shadow: none;
outline: 2px solid #3b82f6;
outline-offset: -2px;
} }
.inline-field { .inline-field {
display: inline-block; @apply inline-block mx-1 min-w-[120px] border-none bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-2 py-1 rounded;
margin: 0 0.25rem;
min-width: 120px;
border: none;
background: #f9fafb;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
} }
.inline-field:focus { .inline-field:focus {
background: #f3f4f6; @apply bg-neutral-100 dark:bg-neutral-700 outline-1 outline-blue-500 -outline-offset-1;
outline: 1px solid #3b82f6;
outline-offset: -1px;
} }
.number-field { .number-field {
min-width: 80px !important; @apply min-w-[80px] text-center;
text-align: center;
} }
.wide-field { .wide-field {
min-width: 250px !important; @apply min-w-[250px];
} }
.form-group-block .block-field { .form-group-block .block-field {
display: block; @apply block w-full mt-1 border-none bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 p-2 rounded;
width: 100%;
margin-top: 0.25rem;
border: none;
background: #f9fafb;
padding: 0.5rem;
border-radius: 0.25rem;
} }
.form-group-block .block-field:focus { .form-group-block .block-field:focus {
background: #f3f4f6; @apply bg-neutral-100 dark:bg-neutral-700 outline-1 outline-blue-500 -outline-offset-1;
outline: 1px solid #3b82f6;
outline-offset: -1px;
} }
/* ========================= /* =========================
@ -263,16 +237,75 @@ body {
========================= */ ========================= */
.dither-shadow { .dither-shadow {
background: black; @apply bg-black dark:bg-white;
background-image: radial-gradient(white 1px, transparent 1px); background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px; background-size: 2px 2px;
} }
html.dark .dither-shadow { html.dark .dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px); background-image: radial-gradient(black 1px, transparent 1px);
} }
/* =========================
SELECTED ITEM PATTERN
========================= */
/* Pattern for selected items with dithered shadow and patterned background */
.item-selected {
@apply relative bg-white dark:bg-neutral-950;
}
.item-selected::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
black 1px,
black 2px
);
opacity: 0.1;
pointer-events: none;
z-index: 0;
}
html.dark .item-selected::after {
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
white 1px,
white 2px
);
}
.item-selected > * {
position: relative;
z-index: 1;
}
/* Text background for better readability on selected items */
.item-label-bg {
@apply bg-white/85 dark:bg-neutral-950/85 rounded;
}
.item-label-bg.selected {
@apply bg-white/95 dark:bg-neutral-950/95;
}
.item-text-bg {
@apply bg-white/90 dark:bg-neutral-950/90;
}
.item-text-bg.selected {
@apply bg-white/95 dark:bg-neutral-950/95;
}
/* ========================= /* =========================
BUTTON STYLING BUTTON STYLING
========================= */ ========================= */
@ -330,123 +363,71 @@ html.dark .export-btn.primary:hover {
@apply bg-white text-black; @apply bg-white text-black;
} }
/* General buttons with bitmap styling */ /* Bitmap button base styling - more targeted approach */
button:not(.export-btn) { .bitmap-style {
background: white !important; @apply bg-white dark:bg-neutral-950 border border-black dark:border-white text-black dark:text-white font-mono uppercase font-bold tracking-wide cursor-pointer;
border: 1px solid black !important;
color: black !important;
font-family: "Ubuntu Mono", monospace !important;
text-transform: uppercase !important;
font-weight: bold !important;
letter-spacing: 0.5px !important;
cursor: pointer !important;
} }
button:not(.export-btn):hover { .bitmap-style:hover {
background: black !important; @apply bg-black dark:bg-white text-white dark:text-black;
color: white !important; transform: translateY(-1px) translateX(-1px);
transform: translateY(-1px) translateX(-1px) !important;
} }
/* Dark mode buttons */ /* Bitmap button styling for template cards */
html.dark button:not(.export-btn) { .bitmap-button {
background: #0a0a0a !important; @apply font-mono uppercase font-bold tracking-wider relative;
border: 1px solid white !important;
color: white !important;
} }
html.dark button:not(.export-btn):hover { .bitmap-button:hover {
background: white !important; transform: translateY(-1px) translateX(-1px);
color: black !important; transition: transform 0.1s ease;
}
.bitmap-button:hover::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: -1px;
bottom: -1px;
@apply border border-black dark:border-white bg-white dark:bg-neutral-950;
z-index: -1;
}
/* Constraint button selected styling */
.constraint-selected {
@apply bg-black dark:bg-white text-white dark:text-black;
}
.constraint-selected:hover {
@apply bg-black dark:bg-white text-white dark:text-black;
} }
/* ========================= /* =========================
BITMAP AESTHETIC OVERRIDES BITMAP AESTHETIC OVERRIDES
========================= */ ========================= */
/* Remove all rounded corners */
* { /* Bitmap form field styling - applied selectively */
border-radius: 0 !important; .bitmap-input {
font-family: "Ubuntu", monospace !important; @apply border border-black dark:border-white bg-white dark:bg-neutral-950 text-black dark:text-white font-mono;
} }
/* Form fields with bitmap styling */ .bitmap-input:focus {
input, @apply outline-2 outline-black dark:outline-white -outline-offset-2 bg-white dark:bg-neutral-950;
textarea,
select {
border: 1px solid black !important;
background: white !important;
color: black !important;
font-family: "Ubuntu Mono", monospace !important;
} }
input:focus, /* Checkbox and radio button styling for bitmap theme */
textarea:focus, .bitmap-checkbox {
select:focus { @apply border-2 border-black dark:border-white bg-white dark:bg-neutral-950;
outline: 2px solid black !important;
outline-offset: -2px !important;
background: white !important;
} }
/* Dark mode form fields */ .bitmap-checkbox:checked {
html.dark input, @apply bg-black dark:bg-white text-white dark:text-black;
html.dark textarea,
html.dark select {
border: 1px solid white !important;
background: #0a0a0a !important;
color: white !important;
} }
html.dark input:focus,
html.dark textarea:focus,
html.dark select:focus {
outline: 2px solid white !important;
background: #0a0a0a !important;
}
/* Checkbox and radio button styling */
input[type="checkbox"],
input[type="radio"] {
border: 2px solid black !important;
background: white !important;
}
input[type="checkbox"]:checked,
input[type="radio"]:checked {
background: black !important;
color: white !important;
}
html.dark input[type="checkbox"],
html.dark input[type="radio"] {
border: 2px solid white !important;
background: #0a0a0a !important;
}
html.dark input[type="checkbox"]:checked,
html.dark input[type="radio"]:checked {
background: white !important;
color: black !important;
}
/* Document titles */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Ubuntu", monospace !important;
color: inherit !important;
}
/* All text */
p,
span,
div {
color: inherit !important;
font-family: "Ubuntu", monospace !important;
}
/* ========================= /* =========================
HIDE ELEMENTS FROM PRINT HIDE ELEMENTS FROM PRINT
@ -523,6 +504,85 @@ div {
} }
} }
/* =========================
VALIDATION STYLES
========================= */
.validation-error {
@apply text-red-500 dark:text-red-400 text-sm font-medium mt-2 flex items-center gap-1;
}
/* =========================
TEMPLATE CARD STYLES
========================= */
.template-card {
@apply relative font-mono;
}
.help-section {
@apply relative;
}
.coming-soon {
@apply opacity-70;
}
.dither-tag {
@apply relative bg-white dark:bg-neutral-950;
}
.dither-tag::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
black 1px,
black 2px
);
opacity: 0.1;
pointer-events: none;
}
html.dark .dither-tag::before {
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
white 1px,
white 2px
);
}
.disabled-button {
@apply opacity-60 cursor-not-allowed;
}
/* =========================
ANIMATIONS
========================= */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out;
}
/* ========================= /* =========================
MOBILE RESPONSIVENESS MOBILE RESPONSIVENESS
========================= */ ========================= */

View file

@ -0,0 +1,67 @@
<template>
<select
v-model="selectedCategory"
@change="handleSelection(selectedCategory)"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option v-for="option in options" :key="option" :value="option">
{{ option }}
</option>
</select>
</template>
<script setup lang="ts">
interface Props {
modelValue: string;
type: 'revenue' | 'expenses';
mainCategory?: string;
placeholder?: string;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select subcategory'
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const budgetStore = useBudgetStore();
const selectedCategory = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const options = computed(() => {
if (props.type === 'revenue' && props.mainCategory) {
// Show subcategories for the revenue main category
return budgetStore.revenueSubcategories[props.mainCategory] || [];
} else if (props.type === 'expenses' && props.mainCategory) {
// For expenses, we don't have predefined subcategories, so show common ones or empty
const expenseSubcategories = {
'Salaries & Benefits': ['Base wages and benefits', 'Health insurance', 'Retirement contributions', 'Payroll taxes'],
'Development Costs': ['Software tools', 'Development kits', 'Contractor fees', 'Testing costs'],
'Equipment & Technology': ['Hardware', 'Software licenses', 'Cloud services', 'IT support'],
'Marketing & Outreach': ['Advertising', 'Content creation', 'Event costs', 'PR services'],
'Office & Operations': ['Rent', 'Utilities', 'Insurance', 'Office supplies'],
'Legal & Professional': ['Legal fees', 'Accounting', 'Consulting', 'Professional services'],
'Other Expenses': ['Miscellaneous', 'Travel', 'Training', 'Other']
};
return expenseSubcategories[props.mainCategory] || ['Miscellaneous'];
} else {
// Fallback to main categories if no mainCategory provided
return props.type === 'revenue'
? budgetStore.revenueCategories
: budgetStore.expenseCategories;
}
});
function handleSelection(value: string) {
// If it's a new category (not in existing list), add it
if (value && !options.value.includes(value)) {
budgetStore.addCustomCategory(props.type, value);
}
emit('update:modelValue', value);
}
</script>

View file

@ -0,0 +1,56 @@
<template>
<div class="mb-12">
<div class="w-full mx-auto">
<nav
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
>
<NuxtLink
v-for="item in coopBuilderItems"
:key="item.id"
:to="item.path"
class="inline-flex items-center px-3 py-2 text-sm font-bold transition-colors whitespace-nowrap underline"
:class="
isActive(item.path)
? 'bg-black text-white dark:bg-white dark:text-black no-underline'
: ''
"
>
{{ item.name }}
</NuxtLink>
</nav>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const coopBuilderItems = [
{
id: "coop-builder",
name: "Setup Wizard",
path: "/coop-builder",
},
{
id: "budget",
name: "Budget",
path: "/budget",
},
];
function isActive(path: string): boolean {
return route.path === path;
}
</script>
<style scoped>
/* Ensure horizontal scroll on mobile */
nav {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
nav::-webkit-scrollbar {
display: none; /* WebKit */
}
</style>

View file

@ -2,9 +2,8 @@
<div class="export-options" :class="containerClass"> <div class="export-options" :class="containerClass">
<div class="export-content"> <div class="export-content">
<div class="export-section"> <div class="export-section">
<h3 class="export-title">Export Options:</h3>
<div class="export-buttons"> <div class="export-buttons">
<button <UButton
@click="copyToClipboard" @click="copyToClipboard"
class="export-btn" class="export-btn"
:disabled="isProcessing" :disabled="isProcessing"
@ -13,9 +12,9 @@
<UIcon name="i-heroicons-clipboard" /> <UIcon name="i-heroicons-clipboard" />
<span>Copy as Text</span> <span>Copy as Text</span>
<UIcon v-if="showCopySuccess" name="i-heroicons-check" class="success-icon" /> <UIcon v-if="showCopySuccess" name="i-heroicons-check" class="success-icon" />
</button> </UButton>
<button <UButton
@click="downloadAsMarkdown" @click="downloadAsMarkdown"
class="export-btn" class="export-btn"
:disabled="isProcessing" :disabled="isProcessing"
@ -28,7 +27,7 @@
name="i-heroicons-check" name="i-heroicons-check"
class="success-icon" class="success-icon"
/> />
</button> </UButton>
</div> </div>
</div> </div>
</div> </div>
@ -582,6 +581,7 @@ const downloadFile = (content: string, filename: string, type: string) => {
} }
.export-buttons { .export-buttons {
@apply font-mono;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;

View file

@ -1,132 +1,137 @@
<template> <template>
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header with Export Controls --> <!-- Section Header -->
<div class="flex items-center justify-between mb-8"> <div class="mb-8">
<div> <h3 class="text-2xl font-black text-black mb-2">
<h3 class="text-2xl font-black text-black mb-2"> Where will your money come from?
Where will your money come from? </h3>
</h3> <p class="text-neutral-600">
<p class="text-neutral-600"> Add sources like client work, grants, product sales, or donations.
Add sources like client work, grants, product sales, or donations. </p>
</p>
</div>
<div class="flex items-center gap-3">
<UButton
variant="outline"
color="gray"
size="sm"
@click="exportStreams">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export
</UButton>
<UButton
v-if="streams.length > 0"
@click="addRevenueStream"
size="sm"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-1" />
Add stream
</UButton>
</div>
</div> </div>
<div class="space-y-3"> <!-- Removed Tab Navigation - showing streams directly -->
<div <div class="space-y-6">
v-if="streams.length === 0" <!-- Export Controls -->
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg"> <div class="flex justify-end">
<h4 class="font-medium text-neutral-900 mb-2"> <UButton
No revenue streams yet variant="outline"
</h4> color="gray"
<p class="text-sm text-neutral-500 mb-4"> size="sm"
Get started by adding your first revenue source. @click="exportStreams">
</p> <UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
<UButton Export
@click="addRevenueStream" </UButton>
size="lg" </div>
variant="solid"
color="primary">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add your first revenue stream
</UButton>
</div>
<div <div class="space-y-3">
v-for="stream in streams" <div
:key="stream.id" v-if="streams.length === 0"
class="p-6 border-3 border-black rounded-xl bg-white shadow-md"> class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <h4 class="font-medium text-neutral-900 mb-2">
<UFormField label="Category" required> No revenue streams yet
<USelect </h4>
v-model="stream.category" <p class="text-sm text-neutral-500 mb-4">
:items="categoryOptions" Get started by adding your first revenue source.
size="xl" </p>
class="text-xl font-bold w-full" <UButton
@update:model-value="saveStream(stream)" /> @click="addRevenueStream"
</UFormField> size="lg"
variant="solid"
color="primary">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add your first revenue stream
</UButton>
</div>
<UFormField label="Revenue source name" required> <div
<USelectMenu v-for="stream in streams"
v-model="stream.name" :key="stream.id"
:items="nameOptionsByCategory[stream.category] || []" class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
placeholder="Select or type a source name" <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
creatable <UFormField label="Category" required>
searchable <USelect
size="xl" v-model="stream.category"
class="text-xl font-bold w-full" :items="categoryOptions"
@update:model-value="saveStream(stream)" /> size="xl"
</UFormField> class="text-xl font-bold w-full"
@update:model-value="saveStream(stream)" />
</UFormField>
<UFormField label="Monthly amount" required> <UFormField label="Revenue source name" required>
<UInput <USelectMenu
v-model="stream.targetMonthlyAmount" v-model="stream.name"
type="text" :items="nameOptionsByCategory[stream.category] || []"
placeholder="5000" placeholder="Select or type a source name"
size="xl" creatable
class="text-xl font-black w-full" searchable
@update:model-value="validateAndSaveAmount($event, stream)" size="xl"
@blur="saveStream(stream)"> class="text-xl font-bold w-full"
<template #leading> @update:model-value="saveStream(stream)" />
<span class="text-neutral-500 text-xl">$</span> </UFormField>
</template>
</UInput> <UFormField label="Monthly amount" required>
</UFormField> <UInput
v-model="stream.targetMonthlyAmount"
type="text"
placeholder="5000"
size="xl"
class="text-xl font-black w-full"
@update:model-value="validateAndSaveAmount($event, stream)"
@blur="saveStream(stream)">
<template #leading>
<span class="text-neutral-500 text-xl">$</span>
</template>
</UInput>
</UFormField>
</div>
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
<UButton
size="xs"
variant="solid"
color="error"
@click="removeStream(stream.id)"
:ui="{
base: 'cursor-pointer hover:opacity-90 transition-opacity',
}">
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton>
</div>
</div>
<!-- Add Stream Button (when items exist) -->
<div v-if="streams.length > 0" class="flex justify-center">
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add another stream
</UButton>
</div>
<div v-if="streams.length > 0" class="flex items-center gap-3 justify-end">
<UButton
@click="addRevenueStream"
size="sm"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-1" />
Add stream
</UButton>
</div>
</div>
</div> </div>
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
<UButton
size="xs"
variant="solid"
color="error"
@click="removeStream(stream.id)"
:ui="{
base: 'cursor-pointer hover:opacity-90 transition-opacity',
}">
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
</UButton>
</div>
</div>
<!-- Add Stream Button (when items exist) -->
<div v-if="streams.length > 0" class="flex justify-center">
<UButton
@click="addRevenueStream"
size="lg"
variant="solid"
color="success"
:ui="{
base: 'cursor-pointer hover:scale-105 transition-transform',
leadingIcon: 'hover:rotate-90 transition-transform',
}">
<UIcon name="i-heroicons-plus" class="mr-2" />
Add another stream
</UButton>
</div>
</div>
</div> </div>
</template> </template>

View file

@ -1,20 +1,12 @@
<template> <template>
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<!-- Section Header with Export Controls --> <!-- Section Header -->
<div class="flex items-center justify-between mb-8"> <div class="mb-8">
<div> <h3 class="text-2xl font-black text-black dark:text-white mb-2">Review & Complete</h3>
<h3 class="text-2xl font-black text-black mb-2">Review & Complete</h3> <p class="text-neutral-600 dark:text-neutral-400">
<p class="text-neutral-600"> Review your setup and complete the wizard to start using your co-op
Review your setup and complete the wizard to start using your co-op tool.
tool. </p>
</p>
</div>
<div class="flex items-center gap-3">
<UButton variant="outline" color="gray" size="sm" @click="exportSetup">
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
Export All
</UButton>
</div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@ -371,28 +363,4 @@ function completeSetup() {
} }
} }
function exportSetup() {
// Create export data
const setupData = {
members: members.value,
policies: policies.value,
overheadCosts: overheadCosts.value,
streams: streams.value,
exportedAt: new Date().toISOString(),
version: "1.0",
};
// Download as JSON
const blob = new Blob([JSON.stringify(setupData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coop-setup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script> </script>

View file

@ -1,52 +1,22 @@
<template> <template>
<div <div class="mb-12">
class="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900"> <div class="w-full mx-auto">
<div class="max-w-4xl mx-auto px-4 py-3"> <nav
<nav class="flex items-center space-x-1 overflow-x-auto"> class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
<!-- Main Setup Wizard --> >
<NuxtLink
to="/wizard"
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap"
:class="
isActive('/wizard')
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
">
<UIcon name="i-heroicons-cog-6-tooth" class="w-4 h-4 mr-2" />
Setup Wizard
</NuxtLink>
<!-- Divider -->
<div class="h-6 w-px bg-neutral-300 dark:bg-neutral-600 mx-2"></div>
<!-- Template Wizards -->
<NuxtLink <NuxtLink
v-for="wizard in templateWizards" v-for="wizard in templateWizards"
:key="wizard.id" :key="wizard.id"
:to="wizard.path" :to="wizard.path"
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap" class="inline-flex items-center px-3 py-2 text-sm font-bold transition-colors whitespace-nowrap underline"
:class=" :class="
isActive(wizard.path) isActive(wizard.path)
? 'bg-black text-white dark:bg-white dark:text-black' ? 'bg-black text-white dark:bg-white dark:text-black no-underline'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800' : ''
"> "
<UIcon :name="wizard.icon" class="w-4 h-4 mr-2" /> >
{{ wizard.name }} {{ wizard.name }}
</NuxtLink> </NuxtLink>
<!-- All Wizards Link -->
<div class="h-6 w-px bg-neutral-300 dark:bg-neutral-600 mx-2"></div>
<NuxtLink
to="/wizards"
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap"
:class="
isActive('/wizards')
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
">
<UIcon name="i-heroicons-squares-plus" class="w-4 h-4 mr-2" />
All Wizards
</NuxtLink>
</nav> </nav>
</div> </div>
</div> </div>
@ -55,30 +25,25 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute(); const route = useRoute();
// Template wizards data - matches the wizards.vue page
const templateWizards = [ const templateWizards = [
{ {
id: "membership-agreement", id: "membership-agreement",
name: "Membership Agreement", name: "Membership Agreement",
icon: "i-heroicons-user-group",
path: "/templates/membership-agreement", path: "/templates/membership-agreement",
}, },
{ {
id: "conflict-resolution-framework", id: "conflict-resolution-framework",
name: "Conflict Resolution", name: "Conflict Resolution",
icon: "i-heroicons-scale",
path: "/templates/conflict-resolution-framework", path: "/templates/conflict-resolution-framework",
}, },
{ {
id: "tech-charter", id: "tech-charter",
name: "Tech Charter", name: "Tech Charter",
icon: "i-heroicons-cog-6-tooth",
path: "/templates/tech-charter", path: "/templates/tech-charter",
}, },
{ {
id: "decision-framework", id: "decision-framework",
name: "Decision Helper", name: "Decision Framework",
icon: "i-heroicons-light-bulb",
path: "/templates/decision-framework", path: "/templates/decision-framework",
}, },
]; ];

View file

@ -58,7 +58,7 @@ export const useFixtures = () => {
category: 'Services', category: 'Services',
subcategory: 'Development', subcategory: 'Development',
targetPct: 65, targetPct: 65,
targetMonthlyAmount: 0, targetMonthlyAmount: 13000,
certainty: 'Committed', certainty: 'Committed',
payoutDelayDays: 30, payoutDelayDays: 30,
terms: 'Net 30', terms: 'Net 30',
@ -73,7 +73,7 @@ export const useFixtures = () => {
category: 'Product', category: 'Product',
subcategory: 'Digital Tools', subcategory: 'Digital Tools',
targetPct: 20, targetPct: 20,
targetMonthlyAmount: 0, targetMonthlyAmount: 4000,
certainty: 'Probable', certainty: 'Probable',
payoutDelayDays: 14, payoutDelayDays: 14,
terms: 'Platform payout', terms: 'Platform payout',
@ -88,7 +88,7 @@ export const useFixtures = () => {
category: 'Grant', category: 'Grant',
subcategory: 'Government', subcategory: 'Government',
targetPct: 10, targetPct: 10,
targetMonthlyAmount: 0, targetMonthlyAmount: 2000,
certainty: 'Committed', certainty: 'Committed',
payoutDelayDays: 45, payoutDelayDays: 45,
terms: 'Quarterly disbursement', terms: 'Quarterly disbursement',
@ -103,7 +103,7 @@ export const useFixtures = () => {
category: 'Donation', category: 'Donation',
subcategory: 'Individual', subcategory: 'Individual',
targetPct: 3, targetPct: 3,
targetMonthlyAmount: 0, targetMonthlyAmount: 600,
certainty: 'Aspirational', certainty: 'Aspirational',
payoutDelayDays: 3, payoutDelayDays: 3,
terms: 'Immediate', terms: 'Immediate',
@ -118,7 +118,7 @@ export const useFixtures = () => {
category: 'Other', category: 'Other',
subcategory: 'Professional Services', subcategory: 'Professional Services',
targetPct: 2, targetPct: 2,
targetMonthlyAmount: 0, targetMonthlyAmount: 400,
certainty: 'Probable', certainty: 'Probable',
payoutDelayDays: 21, payoutDelayDays: 21,
terms: 'Net 21', terms: 'Net 21',

View file

@ -0,0 +1,256 @@
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
import { offerTemplates, matchTemplateToInput, getTemplateHours } from '~/data/offerTemplates';
interface SuggestOffersInput {
members: Member[];
selectedSkillsByMember: Record<string, string[]>;
selectedProblems: string[];
}
interface Catalogs {
skills: SkillTag[];
problems: ProblemTag[];
}
export interface OfferTemplate {
id: string;
name: string;
type: 'workshop' | 'clinic' | 'sprint' | 'retainer';
skillRequirements: string[];
problemTargets: string[];
scope: string[];
defaultDays: number;
defaultHours?: Record<string, number>;
whyThisTemplate: string[];
riskTemplate: string;
}
export function useOfferSuggestor() {
// Payout delay defaults by offer type
const payoutDelays = {
workshop: 14,
clinic: 30,
sprint: 45,
retainer: 30
};
function suggestOffers(input: SuggestOffersInput, catalogs: Catalogs): Offer[] {
const { members, selectedSkillsByMember, selectedProblems } = input;
// Get all selected skills across all members
const allSelectedSkills = new Set<string>();
Object.values(selectedSkillsByMember).forEach(skills => {
skills.forEach(skill => allSelectedSkills.add(skill));
});
const selectedSkillsArray = Array.from(allSelectedSkills);
const offers: Offer[] = [];
// Try to match templates
const matchingTemplates = findMatchingTemplates(selectedSkillsArray, selectedProblems);
// Generate offers from matching templates (max 2)
for (const template of matchingTemplates.slice(0, 2)) {
const offer = generateOfferFromTemplate(template, input);
if (offer) {
offers.push(offer);
}
}
// If we have fewer than 2 offers, add default "1-Week Dev Sprint"
if (offers.length < 2) {
const devSprintOffer = generateDevSprintOffer(input);
if (devSprintOffer) {
offers.push(devSprintOffer);
}
}
// If no template matches at all, ensure we always have at least the dev sprint
if (offers.length === 0) {
const devSprintOffer = generateDevSprintOffer(input);
if (devSprintOffer) {
offers.push(devSprintOffer);
}
}
// Limit to 3 offers max
return offers.slice(0, 3);
}
function findMatchingTemplates(selectedSkills: string[], selectedProblems: string[]): OfferTemplate[] {
// Use the lightweight matching rules from the data file
return matchTemplateToInput(selectedSkills, selectedProblems);
}
function generateOfferFromTemplate(template: OfferTemplate, input: SuggestOffersInput): Offer | null {
const { members, selectedSkillsByMember } = input;
// Create skill-to-member mapping
const skillToMemberMap = new Map<string, Member[]>();
members.forEach(member => {
const memberSkills = selectedSkillsByMember[member.id] || [];
memberSkills.forEach(skill => {
if (!skillToMemberMap.has(skill)) {
skillToMemberMap.set(skill, []);
}
skillToMemberMap.get(skill)!.push(member);
});
});
// Generate hours allocation based on template's defaultHours
const hoursByMember: Array<{ memberId: string; hours: number }> = [];
if (template.defaultHours) {
// Use specific hour allocations from template
Object.entries(template.defaultHours).forEach(([skill, hours]) => {
const availableMembers = skillToMemberMap.get(skill) || [];
if (availableMembers.length > 0) {
// Assign to member with highest availability for this skill
const bestMember = availableMembers.sort((a, b) => b.availableHrs - a.availableHrs)[0];
// Check if this member already has hours assigned
const existingAllocation = hoursByMember.find(h => h.memberId === bestMember.id);
if (existingAllocation) {
existingAllocation.hours += hours;
} else {
hoursByMember.push({ memberId: bestMember.id, hours });
}
}
});
} else {
// Fallback to old method if no defaultHours specified
const relevantMembers = members.filter(member => {
const memberSkills = selectedSkillsByMember[member.id] || [];
return template.skillRequirements.some(skill => memberSkills.includes(skill));
});
if (relevantMembers.length === 0) return null;
const totalHours = template.defaultDays * 8; // 8 hours per day
hoursByMember.push(...distributeHours(relevantMembers, totalHours));
}
if (hoursByMember.length === 0) return null;
// Calculate pricing
const pricing = calculatePricing(hoursByMember, members);
return {
id: `${template.id}-${Date.now()}`,
name: `${template.name} (${template.defaultDays} ${template.defaultDays === 1 ? 'day' : 'days'})`,
scope: template.scope,
hoursByMember,
price: {
baseline: pricing.baseline,
stretch: pricing.stretch,
calcNote: pricing.calcNote
},
payoutDelayDays: payoutDelays[template.type],
whyThis: template.whyThisTemplate,
riskNotes: [template.riskTemplate]
};
}
function generateDevSprintOffer(input: SuggestOffersInput): Offer | null {
const { members, selectedSkillsByMember } = input;
// Find members with highest availability (if no skills selected, use all members)
const membersWithSkills = members
.filter(member => (selectedSkillsByMember[member.id] || []).length > 0);
const availableMembers = membersWithSkills.length > 0
? membersWithSkills.sort((a, b) => b.availableHrs - a.availableHrs)
: members.sort((a, b) => b.availableHrs - a.availableHrs);
if (availableMembers.length === 0) return null;
// Use top 2-3 highest availability members
const selectedMembers = availableMembers.slice(0, Math.min(3, availableMembers.length));
// 1 week = 40 hours, distributed among selected members
const hoursByMember = distributeHours(selectedMembers, 40);
const pricing = calculatePricing(hoursByMember, members);
// Get selected skill names for why this
const allSelectedSkills = new Set<string>();
Object.values(selectedSkillsByMember).forEach(skills => {
skills.forEach(skill => allSelectedSkills.add(skill));
});
return {
id: `dev-sprint-${Date.now()}`,
name: 'Development Sprint (1 week)',
scope: [
'Implement specific features or fixes',
'Code review and quality assurance',
'Documentation and handoff'
],
hoursByMember,
price: {
baseline: pricing.baseline,
stretch: pricing.stretch,
calcNote: pricing.calcNote
},
payoutDelayDays: payoutDelays.sprint,
whyThis: [
`Leverages your ${Array.from(allSelectedSkills).slice(0, 2).join(' and ')} skills`,
'Time-boxed sprint reduces risk',
'Fits within your available capacity'
],
riskNotes: ['Scope creep is common in open-ended development work']
};
}
function distributeHours(members: Member[], totalHours: number): Array<{ memberId: string; hours: number }> {
if (members.length === 0) return [];
// Simple distribution based on availability
const totalAvailability = members.reduce((sum, m) => sum + m.availableHrs, 0);
return members.map(member => {
const proportion = member.availableHrs / totalAvailability;
const allocatedHours = Math.round(totalHours * proportion);
return {
memberId: member.id,
hours: Math.min(allocatedHours, member.availableHrs)
};
});
}
function calculatePricing(
hoursByMember: Array<{ memberId: string; hours: number }>,
members: Member[]
): { baseline: number; stretch: number; calcNote: string } {
// Create member lookup
const memberMap = new Map(members.map(m => [m.id, m]));
// Calculate cost-plus pricing
let totalCost = 0;
let totalHours = 0;
for (const allocation of hoursByMember) {
const member = memberMap.get(allocation.memberId);
if (member) {
// memberHours * hourly * 1.25 (markup)
totalCost += allocation.hours * member.hourly * 1.25;
totalHours += allocation.hours;
}
}
// Apply 1.10 multiplier to total
const baseline = Math.round(totalCost * 1.10);
const stretch = Math.round(baseline * 1.2);
const avgRate = totalHours > 0 ? Math.round(totalCost / totalHours) : 0;
const calcNote = `${totalHours} hours at ~$${avgRate}/hr blended rate with markup`;
return { baseline, stretch, calcNote };
}
return {
suggestOffers
};
}

178
data/offerTemplates.ts Normal file
View file

@ -0,0 +1,178 @@
import type { OfferTemplate } from '~/composables/useOfferSuggestor';
export const offerTemplates: OfferTemplate[] = [
{
id: 'pitch-polish',
name: 'Pitch Polish',
type: 'clinic',
skillRequirements: ['writing', 'design'],
problemTargets: ['unclear-pitch', 'grant-budget-help'],
scope: [
'Comprehensive deck review and analysis',
'Rewrite key sections for clarity and impact',
'90-minute live presentation coaching session',
'Final edit pass with visual polish'
],
defaultDays: 2,
defaultHours: {
writing: 6,
design: 6,
pm: 2
},
whyThisTemplate: [
'Combines writing expertise with design polish',
'Time-boxed format keeps scope manageable',
'Live coaching session builds confidence',
'Immediate impact on funding success'
],
riskTemplate: 'Client may resist feedback on core concept or messaging'
},
{
id: 'brand-store-page-sprint',
name: 'Brand/Store Page Sprint',
type: 'sprint',
skillRequirements: ['design', 'dev'],
problemTargets: ['need-landing-store-page', 'marketing-assets'],
scope: [
'Develop clear messaging and brand voice',
'Design and build one-page marketing site',
'Create store page assets and layout',
'Implement responsive design and basic SEO'
],
defaultDays: 7,
defaultHours: {
design: 12,
writing: 6,
dev: 10,
pm: 4
},
whyThisTemplate: [
'Full-stack approach from concept to deployment',
'Combines brand strategy with technical execution',
'Creates immediate market presence',
'Scalable foundation for future marketing'
],
riskTemplate: 'Scope creep around additional pages or complex integrations'
},
{
id: 'dev-sprint',
name: 'Dev Sprint',
type: 'sprint',
skillRequirements: ['dev'],
problemTargets: ['vertical-slice', 'tech-debt'],
scope: [
'Backlog triage and feature prioritization',
'Implement 1-2 focused features or fixes',
'Create demo build for stakeholder review',
'Document changes and deployment process'
],
defaultDays: 7,
defaultHours: {
dev: 24,
qa: 4,
pm: 4
},
whyThisTemplate: [
'Focused development with clear deliverables',
'Includes quality assurance and project management',
'Demo build provides immediate feedback opportunity',
'Manageable scope reduces technical risk'
],
riskTemplate: 'Technical complexity may exceed initial estimates'
},
{
id: 'maintenance-retainer',
name: 'Maintenance Retainer',
type: 'retainer',
skillRequirements: ['dev', 'pm'],
problemTargets: ['launch-checklist'],
scope: [
'Handle small fixes and bug reports',
'Apply security and dependency updates',
'Provide technical support and guidance',
'Monthly progress reports and recommendations'
],
defaultDays: 30, // Monthly
defaultHours: {
dev: 6,
pm: 2
},
whyThisTemplate: [
'Predictable monthly income stream',
'Builds long-term client relationships',
'Low-risk work with defined boundaries',
'Efficient use of development skills'
],
riskTemplate: 'Client expectations may exceed allocated hours'
}
];
/**
* Lightweight matching rules for offer templates
*/
export function matchTemplateToInput(
selectedSkills: string[],
selectedProblems: string[]
): OfferTemplate[] {
const matches: OfferTemplate[] = [];
// Pitch Polish: Writing + Design + pitch/funding problems
if (selectedSkills.includes('writing') && selectedSkills.includes('design')) {
if (selectedProblems.includes('unclear-pitch') || selectedProblems.includes('grant-budget-help')) {
matches.push(offerTemplates[0]); // Pitch Polish
}
}
// Brand/Store Page: Design + Dev + website/marketing problems
if (selectedSkills.includes('design') && selectedSkills.includes('dev')) {
if (selectedProblems.includes('need-landing-store-page') || selectedProblems.includes('marketing-assets')) {
matches.push(offerTemplates[1]); // Brand/Store Page Sprint
}
}
// Dev Sprint: Dev + development-related problems
if (selectedSkills.includes('dev')) {
if (selectedProblems.includes('vertical-slice') || selectedProblems.includes('tech-debt')) {
matches.push(offerTemplates[2]); // Dev Sprint
}
}
// Maintenance Retainer: Dev + PM + launch/maintenance problems
if (selectedSkills.includes('dev') && selectedSkills.includes('pm')) {
if (selectedProblems.includes('launch-checklist') || matches.length === 0) { // Also use as fallback
matches.push(offerTemplates[3]); // Maintenance Retainer
}
}
return matches;
}
/**
* Get default hour allocation for a template based on available members
*/
export function getTemplateHours(
template: OfferTemplate,
availableSkills: string[]
): Array<{ skill: string; hours: number }> {
const allocations: Array<{ skill: string; hours: number }> = [];
// Convert template hours to skill-based allocations
if (template.defaultHours) {
Object.entries(template.defaultHours).forEach(([skill, hours]) => {
if (availableSkills.includes(skill)) {
allocations.push({ skill, hours });
}
});
}
return allocations;
}
/**
* Calculate total hours for a template
*/
export function getTemplateTotalHours(template: OfferTemplate): number {
if (!template.defaultHours) return template.defaultDays * 8; // Fallback: 8 hours per day
return Object.values(template.defaultHours).reduce((sum, hours) => sum + hours, 0);
}

157
data/skillsProblems.ts Normal file
View file

@ -0,0 +1,157 @@
import type { SkillTag, ProblemTag } from "~/types/coaching";
/**
* Standardized skills catalog for co-op offer generation
*/
export const skillsCatalog: SkillTag[] = [
{ id: "design", label: "Design" },
{ id: "writing", label: "Writing" },
{ id: "dev", label: "Dev" },
{ id: "pm", label: "PM" },
{ id: "qa", label: "QA" },
{ id: "teaching", label: "Teaching" },
{ id: "community", label: "Community" },
{ id: "marketing", label: "Marketing" },
{ id: "audio", label: "Audio" },
{ id: "art", label: "Art" },
{ id: "facilitation", label: "Facilitation" },
{ id: "ops", label: "Ops" }
];
/**
* Core problems that co-ops commonly solve, with realistic client examples
*/
export const problemsCatalog: ProblemTag[] = [
{
id: "unclear-pitch",
label: "Unclear pitch",
examples: [
"Our deck isn't landing with investors",
"Publishers don't get our concept",
"Feedback says the vision is confusing"
]
},
{
id: "need-landing-store-page",
label: "Need landing/store page",
examples: [
"We have no website presence",
"Need Steam page copy and layout",
"Want a simple marketing landing page"
]
},
{
id: "vertical-slice",
label: "Vertical slice",
examples: [
"Need a demo for potential funders",
"Want to test core mechanics with users",
"Prototype for investor meetings"
]
},
{
id: "community-plan",
label: "Community plan",
examples: [
"Our Discord server is inactive",
"We have no social media posting plan",
"Need a community building strategy"
]
},
{
id: "marketing-assets",
label: "Marketing assets",
examples: [
"Need trailer stills and screenshots",
"Press kit is completely missing",
"Want social media content templates"
]
},
{
id: "grant-budget-help",
label: "Grant budget help",
examples: [
"Need a budget narrative for arts council",
"Don't know how to price development time",
"Grant application requires detailed financials"
]
},
{
id: "launch-checklist",
label: "Launch checklist",
examples: [
"Need final QA pass before release",
"Store assets aren't ready",
"Want a pre-launch timeline and tasks"
]
},
{
id: "tech-debt",
label: "Tech debt",
examples: [
"Build process is broken and slow",
"Tooling needs major updates",
"Performance issues need addressing"
]
}
];
/**
* Skills grouped by common combinations for template matching
*/
export const skillCombinations = {
creative: ['design', 'art', 'writing'],
technical: ['dev', 'qa', 'ops'],
business: ['pm', 'marketing', 'facilitation'],
community: ['community', 'teaching', 'marketing'],
production: ['pm', 'qa', 'ops']
};
/**
* Problems grouped by solution type
*/
export const problemCategories = {
communication: ['unclear-pitch', 'grant-budget-help'],
marketing: ['need-landing-store-page', 'marketing-assets', 'community-plan'],
development: ['vertical-slice', 'tech-debt'],
operations: ['launch-checklist']
};
/**
* Helper function to get skills by category
*/
export function getSkillsByCategory(category: keyof typeof skillCombinations): SkillTag[] {
const skillIds = skillCombinations[category];
return skillsCatalog.filter(skill => skillIds.includes(skill.id));
}
/**
* Helper function to get problems by category
*/
export function getProblemsByCategory(category: keyof typeof problemCategories): ProblemTag[] {
const problemIds = problemCategories[category];
return problemsCatalog.filter(problem => problemIds.includes(problem.id));
}
/**
* Check if a skill combination is commonly used together
*/
export function areSkillsComplementary(skills: string[]): boolean {
// Check if skills fall within the same or complementary categories
const categories = Object.entries(skillCombinations);
for (const [category, categorySkills] of categories) {
const overlap = skills.filter(skill => categorySkills.includes(skill));
if (overlap.length >= 2) {
return true; // Found 2+ skills in same category
}
}
// Check cross-category combinations that work well together
const hasCreative = skills.some(s => skillCombinations.creative.includes(s));
const hasTechnical = skills.some(s => skillCombinations.technical.includes(s));
const hasBusiness = skills.some(s => skillCombinations.business.includes(s));
// Creative + Technical, Technical + Business, etc. are good combinations
return (hasCreative && hasTechnical) || (hasTechnical && hasBusiness) || (hasCreative && hasBusiness);
}

View file

@ -1,368 +1,320 @@
<template> <template>
<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">
<h2 class="text-2xl font-semibold">Operating Plan</h2> <h2 class="text-2xl font-semibold">Budget Worksheet</h2>
<USelect <div class="flex items-center gap-4">
v-model="selectedMonth" <UButton @click="forceReinitialize" variant="outline" size="sm" color="orange">Force Re-init</UButton>
:options="months" <UButton @click="resetWorksheet" variant="outline" size="sm">Reset All</UButton>
placeholder="Select month" /> <UButton @click="exportBudget" variant="outline" size="sm">Export</UButton>
</div>
</div> </div>
<!-- Cash Waterfall Summary --> <!-- Budget Worksheet Table -->
<UCard> <UCard>
<template #header> <div class="overflow-x-auto">
<h3 class="text-lg font-medium"> <table class="w-full border-collapse border border-gray-300 text-sm">
Cash Waterfall - {{ selectedMonth }} <thead>
</h3> <tr class="bg-gray-50">
</template> <th class="border border-gray-300 px-3 py-2 text-left min-w-40 sticky left-0 bg-gray-50 z-10">Category</th>
<div <!-- Monthly columns -->
class="flex items-center justify-between py-4 border-b border-neutral-200"> <th v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-center min-w-20">{{ month.label }}</th>
<div class="flex items-center gap-8"> </tr>
<div class="text-center"> </thead>
<div class="text-2xl font-bold text-blue-600"> <tbody>
{{ budgetMetrics.grossRevenue.toLocaleString() }} <!-- Revenue Section -->
</div> <tr class="bg-blue-50 font-medium">
<div class="text-xs text-neutral-600">Gross Revenue</div> <td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-50 z-10">
</div> <div class="flex items-center justify-between">
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" /> <span>Revenue</span>
<div class="text-center"> <UButton @click="addRevenueLine" size="xs" variant="soft">+</UButton>
<div class="text-2xl font-bold text-red-600"> </div>
-{{ budgetMetrics.totalFees.toLocaleString() }} </td>
</div> <td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
<div class="text-xs text-neutral-600">Fees</div> </tr>
</div>
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" /> <!-- Revenue by Category -->
<div class="text-center"> <template v-for="(category, categoryName) in budgetStore.groupedRevenue" :key="`revenue-${categoryName}`">
<div class="text-2xl font-bold text-green-600"> <tr v-if="category.length > 0" class="bg-blue-100 font-medium">
{{ budgetMetrics.netRevenue.toLocaleString() }} <td class="border border-gray-300 px-4 py-1 sticky left-0 bg-blue-100 z-10 text-sm text-blue-700">
</div> {{ categoryName }} ({{ category.length }} items)
<div class="text-xs text-neutral-600">Net Revenue</div> </td>
</div> <td class="border border-gray-300 px-2 py-1" :colspan="monthlyHeaders.length"></td>
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" /> </tr>
<div class="text-center"> <tr v-for="item in category" :key="item.id">
<div class="text-2xl font-bold text-blue-600"> <td class="border border-gray-300 px-3 py-2 sticky left-0 bg-white z-10">
{{ Math.round(budgetMetrics.savingsAmount).toLocaleString() }} <div class="space-y-2">
</div> <div class="flex items-center justify-between">
<div class="text-xs text-neutral-600">To Savings</div> <input
</div> v-model="item.name"
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" /> @blur="saveWorksheet"
<div class="text-center"> class="bg-transparent border-none outline-none w-full font-medium"
<div class="text-2xl font-bold text-purple-600"> :class="{ 'italic text-gray-500': item.name === 'New Revenue Item' }"
{{ Math.round(budgetMetrics.totalPayroll).toLocaleString() }} />
</div> <UButton @click="removeItem('revenue', item.id)" size="xs" variant="ghost" color="error">×</UButton>
<div class="text-xs text-neutral-600">Payroll</div> </div>
</div> <div class="flex items-center gap-2">
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" /> <BudgetCategorySelector
<div class="text-center"> v-model="item.subcategory"
<div class="text-2xl font-bold text-orange-600"> type="revenue"
{{ budgetMetrics.totalOverhead.toLocaleString() }} :main-category="item.mainCategory"
</div> placeholder="Subcategory"
<div class="text-xs text-neutral-600">Overhead</div> @update:model-value="saveWorksheet"
</div> />
</div> </div>
</div> </div>
<div class="pt-4"> </td>
<div class="flex items-center justify-between"> <!-- Monthly columns -->
<span class="text-lg font-medium">Available for Operations</span> <td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-1 py-1">
<span class="text-2xl font-bold text-green-600" <input
>{{ type="number"
Math.round(budgetMetrics.availableForOps).toLocaleString() :value="item.monthlyValues?.[month.key] || 0"
}}</span @input="updateMonthlyValue('revenue', item.id, month.key, $event.target.value)"
> class="w-full text-right border-none outline-none bg-transparent"
</div> placeholder="0"
/>
</td>
</tr>
</template>
<!-- Total Revenue Row -->
<tr class="bg-blue-100 font-bold">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-100 z-10">Total Revenue</td>
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right">
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.revenue || 0) }}
</td>
</tr>
<!-- Expenses Section -->
<tr class="bg-red-50 font-medium">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-red-50 z-10">
<div class="flex items-center justify-between">
<span>Expenses</span>
<UButton @click="addExpenseLine" size="xs" variant="soft">+</UButton>
</div>
</td>
<td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
</tr>
<!-- Expenses by Category -->
<template v-for="(category, categoryName) in budgetStore.groupedExpenses" :key="`expense-${categoryName}`">
<tr v-if="category.length > 0" class="bg-red-100 font-medium">
<td class="border border-gray-300 px-4 py-1 sticky left-0 bg-red-100 z-10 text-sm text-red-700">
{{ categoryName }}
</td>
<td class="border border-gray-300 px-2 py-1" :colspan="monthlyHeaders.length"></td>
</tr>
<tr v-for="item in category" :key="item.id">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-white z-10">
<div class="space-y-2">
<div class="flex items-center justify-between">
<input
v-model="item.name"
@blur="saveWorksheet"
class="bg-transparent border-none outline-none w-full font-medium"
:class="{ 'italic text-gray-500': item.name === 'New Expense Item' }"
/>
<UButton @click="removeItem('expenses', item.id)" size="xs" variant="ghost" color="error">×</UButton>
</div>
<div class="flex items-center gap-2">
<BudgetCategorySelector
v-model="item.subcategory"
type="expenses"
:main-category="item.mainCategory"
placeholder="Subcategory"
@update:model-value="saveWorksheet"
/>
</div>
</div>
</td>
<!-- Monthly columns -->
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-1 py-1">
<input
type="number"
:value="item.monthlyValues?.[month.key] || 0"
@input="updateMonthlyValue('expenses', item.id, month.key, $event.target.value)"
class="w-full text-right border-none outline-none bg-transparent"
placeholder="0"
/>
</td>
</tr>
</template>
<!-- Total Expenses Row -->
<tr class="bg-red-100 font-bold">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-red-100 z-10">Total Expenses</td>
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right">
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.expenses || 0) }}
</td>
</tr>
<!-- Net Income Row -->
<tr class="bg-green-100 font-bold text-lg">
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-green-100 z-10">Net Income</td>
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right"
:class="getNetIncomeClass(budgetStore.monthlyTotals[month.key]?.net || 0)">
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.net || 0) }}
</td>
</tr>
</tbody>
</table>
</div> </div>
</UCard> </UCard>
<!-- Monthly Revenue Table -->
<UCard>
<template #header>
<h3 class="text-lg font-medium">Revenue by Stream</h3>
</template>
<UTable :rows="revenueStreams" :columns="revenueColumns">
<template #name-data="{ row }">
<div class="flex items-center gap-2">
<span class="font-medium">{{ row.name }}</span>
<RestrictionChip :restriction="row.restrictions" size="xs" />
</div>
</template>
<template #target-data="{ row }">
<span class="font-medium">{{ row.target.toLocaleString() }}</span>
</template>
<template #committed-data="{ row }">
<span class="font-medium text-green-600"
>{{ row.committed.toLocaleString() }}</span
>
</template>
<template #actual-data="{ row }">
<span
class="font-medium"
:class="
row.actual >= row.committed ? 'text-green-600' : 'text-orange-600'
">
{{ row.actual.toLocaleString() }}
</span>
</template>
<template #variance-data="{ row }">
<span :class="row.variance >= 0 ? 'text-green-600' : 'text-red-600'">
{{ row.variance >= 0 ? "+" : "" }}{{
row.variance.toLocaleString()
}}
</span>
</template>
</UTable>
</UCard>
<!-- Costs Breakdown -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<UCard>
<template #header>
<h3 class="text-lg font-medium">Costs</h3>
</template>
<div class="space-y-4">
<div>
<h4 class="font-medium text-sm mb-2">Payroll</h4>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600"
>Wages ({{ budgetMetrics.totalHours }}h @ {{
budgetMetrics.hourlyWage
}})</span
>
<span class="font-medium"
>{{
Math.round(budgetMetrics.grossWages).toLocaleString()
}}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600"
>On-costs ({{ budgetMetrics.oncostPct }}%)</span
>
<span class="font-medium"
>{{
Math.round(budgetMetrics.oncosts).toLocaleString()
}}</span
>
</div>
<div
class="flex justify-between text-sm font-medium border-t pt-2">
<span>Total Payroll</span>
<span
>{{
Math.round(budgetMetrics.totalPayroll).toLocaleString()
}}</span
>
</div>
</div>
</div>
<div>
<h4 class="font-medium text-sm mb-2">Overhead</h4>
<div class="space-y-2">
<div
v-if="budgetStore.overheadCosts.length === 0"
class="text-sm text-neutral-500 italic">
No overhead costs added yet
</div>
<div
v-for="cost in budgetStore.overheadCosts"
:key="cost.id"
class="flex justify-between text-sm">
<span class="text-neutral-600">{{ cost.name }}</span>
<span class="font-medium"
>{{ (cost.amount || 0).toLocaleString() }}</span
>
</div>
<div
class="flex justify-between text-sm font-medium border-t pt-2">
<span>Total Overhead</span>
<span>{{ budgetMetrics.totalOverhead.toLocaleString() }}</span>
</div>
</div>
</div>
<div>
<h4 class="font-medium text-sm mb-2">Production</h4>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Dev kits</span>
<span class="font-medium">500</span>
</div>
<div
class="flex justify-between text-sm font-medium border-t pt-2">
<span>Total Production</span>
<span>500</span>
</div>
</div>
</div>
</div>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Net Impact on Savings</h3>
</template>
<div class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Net Revenue</span>
<span class="font-medium text-green-600"
>{{ budgetMetrics.netRevenue.toLocaleString() }}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Total Costs</span>
<span class="font-medium text-red-600"
>-{{
Math.round(budgetMetrics.totalCosts).toLocaleString()
}}</span
>
</div>
<div class="flex justify-between text-lg font-bold border-t pt-3">
<span>Net</span>
<span
:class="
budgetMetrics.monthlyNet >= 0
? 'text-green-600'
: 'text-red-600'
"
>{{ budgetMetrics.monthlyNet >= 0 ? "+" : "" }}{{
Math.round(budgetMetrics.monthlyNet).toLocaleString()
}}</span
>
</div>
</div>
<div class="bg-neutral-50 rounded-lg p-4">
<h4 class="font-medium text-sm mb-3">Allocation</h4>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-neutral-600">To Savings</span>
<span class="font-medium"
>{{
Math.round(budgetMetrics.savingsAmount).toLocaleString()
}}</span
>
</div>
<div class="flex justify-between text-sm">
<span class="text-neutral-600">Available</span>
<span class="font-medium"
>{{
Math.round(
budgetMetrics.availableAfterSavings
).toLocaleString()
}}</span
>
</div>
</div>
</div>
<div class="text-xs text-neutral-600 space-y-1">
<p>
<RestrictionChip restriction="Restricted" size="xs" /> funds can
only be used for approved purposes.
</p>
<p>
<RestrictionChip restriction="General" size="xs" /> funds have no
restrictions.
</p>
</div>
</div>
</UCard>
</div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// Use real store data // Import components explicitly
const membersStore = useMembersStore(); import BudgetCategorySelector from '~/components/BudgetCategorySelector.vue';
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore(); // Use budget worksheet store
const budgetStore = useBudgetStore(); const budgetStore = useBudgetStore();
const cashStore = useCashStore();
const selectedMonth = ref("2024-01"); // Generate monthly headers for the next 12 months
const months = ref([ const monthlyHeaders = computed(() => {
{ label: "January 2024", value: "2024-01" }, const headers = [];
{ label: "February 2024", value: "2024-02" }, const today = new Date();
{ label: "March 2024", value: "2024-03" },
]); for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
// Calculate budget values from real data const monthName = date.toLocaleString('default', { month: 'short' });
const budgetMetrics = computed(() => { const year = date.getFullYear();
const totalHours = membersStore.capacityTotals.targetHours || 0;
const hourlyWage = policiesStore.equalHourlyWage || 0; headers.push({
const oncostPct = policiesStore.payrollOncostPct || 0; key: `${year}-${String(date.getMonth() + 1).padStart(2, '0')}`,
label: `${monthName} ${year}`
const grossWages = totalHours * hourlyWage; });
const oncosts = grossWages * (oncostPct / 100); }
const totalPayroll = grossWages + oncosts;
return headers;
const totalOverhead = budgetStore.overheadCosts.reduce(
(sum, cost) => sum + (cost.amount || 0),
0
);
const grossRevenue = streamsStore.totalMonthlyAmount || 0;
// Calculate fees from streams with platform fees
const totalFees = streamsStore.streams.reduce((sum, stream) => {
const revenue = stream.targetMonthlyAmount || 0;
const platformFee = (stream.platformFeePct || 0) / 100;
const revShareFee = (stream.revenueSharePct || 0) / 100;
return sum + revenue * platformFee + revenue * revShareFee;
}, 0);
const netRevenue = grossRevenue - totalFees;
const totalCosts = totalPayroll + totalOverhead;
const monthlyNet = netRevenue - totalCosts;
const savingsAmount = Math.max(0, monthlyNet * 0.3); // Save 30% of positive net if possible
const availableAfterSavings = Math.max(0, monthlyNet - savingsAmount);
const availableForOps = Math.max(
0,
netRevenue - totalPayroll - totalOverhead - savingsAmount
);
return {
grossRevenue,
totalFees,
netRevenue,
totalCosts,
monthlyNet,
savingsAmount,
availableAfterSavings,
totalPayroll,
grossWages,
oncosts,
totalOverhead,
availableForOps,
totalHours,
hourlyWage,
oncostPct,
};
}); });
// Convert streams to budget table format // Initialize from wizard data on first load
const revenueStreams = computed(() => onMounted(async () => {
streamsStore.streams.map((stream) => ({ console.log('Budget page mounted, initializing...');
id: stream.id, if (!budgetStore.isInitialized) {
name: stream.name, await budgetStore.initializeFromWizardData();
target: stream.targetMonthlyAmount || 0, }
committed: Math.round((stream.targetMonthlyAmount || 0) * 0.8), // 80% committed assumption console.log('Budget worksheet:', budgetStore.budgetWorksheet);
actual: Math.round((stream.targetMonthlyAmount || 0) * 0.9), // 90% actual assumption console.log('Grouped revenue:', budgetStore.groupedRevenue);
variance: Math.round((stream.targetMonthlyAmount || 0) * 0.1), // 10% positive variance console.log('Grouped expenses:', budgetStore.groupedExpenses);
restrictions: stream.restrictions || "General", });
}))
);
const revenueColumns = [ // Budget worksheet functions
{ id: "name", key: "name", label: "Stream" }, function updateValue(category: string, itemId: string, year: string, scenario: string, value: string) {
{ id: "target", key: "target", label: "Target" }, budgetStore.updateBudgetValue(category, itemId, year, scenario, value);
{ id: "committed", key: "committed", label: "Committed" }, }
{ id: "actual", key: "actual", label: "Actual" },
{ id: "variance", key: "variance", label: "Variance" }, function updateMonthlyValue(category: string, itemId: string, monthKey: string, value: string) {
]; budgetStore.updateMonthlyValue(category, itemId, monthKey, value);
}
function addRevenueLine() {
console.log('Adding revenue line...');
budgetStore.addBudgetItem('revenue', 'New Revenue Item');
}
function addExpenseLine() {
console.log('Adding expense line...');
budgetStore.addBudgetItem('expenses', 'New Expense Item');
}
function removeItem(category: string, itemId: string) {
budgetStore.removeBudgetItem(category, itemId);
}
function saveWorksheet() {
// Auto-save is handled by the store persistence
console.log('Worksheet saved');
}
function resetWorksheet() {
if (confirm('Are you sure you want to reset all budget data? This cannot be undone.')) {
budgetStore.resetBudgetWorksheet();
// Force re-initialization
budgetStore.isInitialized = false;
budgetStore.initializeFromWizardData();
}
}
async function forceReinitialize() {
console.log('Force re-initializing budget...');
// Clear all persistent data
localStorage.removeItem('urgent-tools-budget');
localStorage.removeItem('urgent-tools-streams');
localStorage.removeItem('urgent-tools-members');
localStorage.removeItem('urgent-tools-policies');
// Reset the store state completely
budgetStore.isInitialized = false;
budgetStore.budgetWorksheet.revenue = [];
budgetStore.budgetWorksheet.expenses = [];
// Reset categories to defaults
budgetStore.revenueCategories = [
'Games & Products',
'Services & Contracts',
'Grants & Funding',
'Community Support',
'Partnerships',
'Investment Income',
'In-Kind Contributions'
];
budgetStore.expenseCategories = [
'Salaries & Benefits',
'Development Costs',
'Equipment & Technology',
'Marketing & Outreach',
'Office & Operations',
'Legal & Professional',
'Other Expenses'
];
// Force re-initialization
await budgetStore.initializeFromWizardData();
console.log('Re-initialization complete');
}
function exportBudget() {
const data = {
worksheet: budgetStore.budgetWorksheet,
totals: budgetStore.budgetTotals,
exportedAt: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `budget-worksheet-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Helper functions
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount || 0);
}
function getNetIncomeClass(amount: number): string {
if (amount > 0) return 'text-green-600';
if (amount < 0) return 'text-red-600';
return 'text-gray-600';
}
// SEO
useSeoMeta({
title: "Budget Worksheet - Plan Your Co-op's Financial Future",
description: "Interactive budget planning tool with multiple scenarios and multi-year projections for worker cooperatives.",
});
</script> </script>

View file

@ -0,0 +1,684 @@
<template>
<div class="min-h-screen bg-neutral-50 pb-24">
<div class="max-w-4xl mx-auto p-6">
<!-- Header -->
<div class="mb-8">
<div class="flex items-start justify-between">
<div>
<h1 class="text-3xl font-black text-black mb-2">
Turn skills into fair, sellable offers
</h1>
<p class="text-neutral-600">
Tell us what you're good at and who you help. We'll suggest offers that match your co-op's shared capacity.
</p>
</div>
<div class="flex items-center gap-3">
<button
@click="skipCoach"
class="px-4 py-2 text-sm bg-neutral-50 border-2 border-neutral-300 rounded-lg text-neutral-700 hover:bg-neutral-100 hover:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
:aria-label="'Skip coach and go to streams tab'"
>
Skip coach Streams
</button>
<button
@click="loadSampleData"
class="px-4 py-2 text-sm bg-blue-50 border-2 border-blue-200 rounded-lg text-blue-700 hover:bg-blue-100 hover:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
:aria-label="'Load sample data to see example offers'"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Load sample data
</div>
</button>
</div>
</div>
</div>
<!-- Section A: Name your strengths -->
<section class="mb-8" aria-labelledby="strengths-heading">
<div class="flex items-center gap-2 mb-4">
<h2 id="strengths-heading" class="text-xl font-bold text-black">
A) Name your strengths
</h2>
<div class="relative group">
<button
class="w-4 h-4 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-full"
aria-label="Why limit to 3 skills per member?"
>
<svg fill="currentColor" viewBox="0 0 20 20" class="w-4 h-4">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</button>
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-black text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Focus keeps offers shippable
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black"></div>
</div>
</div>
</div>
<p class="text-neutral-600 mb-6">
Pick what you can reliably do as a team. We'll keep it simple.
</p>
<div class="space-y-6">
<div
v-for="member in members"
:key="member.id"
class="p-6 bg-white border-2 border-neutral-200 rounded-xl shadow-sm"
>
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-bold text-black">{{ member.name }}</h3>
<p v-if="member.role" class="text-sm text-neutral-600">{{ member.role }}</p>
</div>
<div class="text-sm text-neutral-500">
{{ getSelectedSkillsCount(member.id) }}/3 skills selected
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="skill in availableSkills"
:key="skill.id"
@click="toggleSkill(member.id, skill.id)"
:disabled="!canSelectSkill(member.id, skill.id)"
:class="[
'px-3 py-1.5 text-sm rounded-full border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
isSkillSelected(member.id, skill.id)
? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700'
: canSelectSkill(member.id, skill.id)
? 'bg-white text-neutral-700 border-neutral-300 hover:border-blue-400 hover:text-blue-600'
: 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
]"
:aria-pressed="isSkillSelected(member.id, skill.id)"
:aria-label="`${isSkillSelected(member.id, skill.id) ? 'Remove' : 'Add'} ${skill.label} skill for ${member.name}`"
>
{{ skill.label }}
</button>
</div>
</div>
</div>
</section>
<!-- Section B: Who do you help? -->
<section class="mb-8" aria-labelledby="problems-heading">
<div class="flex items-center gap-2 mb-4">
<h2 id="problems-heading" class="text-xl font-bold text-black">
B) Who do you help?
</h2>
<div class="relative group">
<button
class="w-4 h-4 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded-full"
aria-label="Why limit to 2 problem types?"
>
<svg fill="currentColor" viewBox="0 0 20 20" class="w-4 h-4">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</button>
<div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-black text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
Focus keeps offers shippable
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black"></div>
</div>
</div>
</div>
<p class="text-neutral-600 mb-6">
Choose the problems you can solve this month. We'll suggest time-boxed offers.
</p>
<div class="flex flex-wrap gap-3">
<div
v-for="problem in availableProblems"
:key="problem.id"
class="relative"
>
<button
@click="toggleProblem(problem.id)"
:disabled="!canSelectProblem(problem.id)"
:class="[
'px-4 py-2 text-sm rounded-lg border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
isProblemSelected(problem.id)
? 'bg-green-600 text-white border-green-600 hover:bg-green-700'
: canSelectProblem(problem.id)
? 'bg-white text-neutral-700 border-neutral-300 hover:border-green-400 hover:text-green-600'
: 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
]"
:aria-pressed="isProblemSelected(problem.id)"
:aria-label="`${isProblemSelected(problem.id) ? 'Remove' : 'Add'} ${problem.label} problem type`"
>
{{ problem.label }}
</button>
<!-- Examples popover trigger -->
<button
@click="toggleExamples(problem.id)"
@keydown.escape="hideExamples"
class="ml-1 text-xs text-neutral-500 hover:text-neutral-700 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
:aria-label="`See examples for ${problem.label}`"
:aria-expanded="showExamples === problem.id"
>
see examples
</button>
<!-- Examples popover -->
<div
v-if="showExamples === problem.id"
class="absolute z-10 mt-2 p-3 bg-white border-2 border-neutral-200 rounded-lg shadow-lg min-w-64 max-w-sm"
role="tooltip"
:aria-label="`Examples for ${problem.label}`"
>
<div class="text-sm">
<p class="font-medium text-black mb-2">Examples:</p>
<ul class="space-y-1 text-neutral-700">
<li v-for="example in problem.examples" :key="example" class="flex items-start">
<span class="text-neutral-400 mr-2"></span>
<span>{{ example }}</span>
</li>
</ul>
</div>
<button
@click="hideExamples"
class="mt-2 text-xs text-blue-600 hover:text-blue-800 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Close examples"
>
Close
</button>
</div>
</div>
</div>
<div class="mt-4 text-sm text-neutral-500">
{{ selectedProblems.length }}/2 problem types selected
</div>
</section>
<!-- Section C: Suggested offers -->
<section class="mb-8" aria-labelledby="offers-heading">
<h2 id="offers-heading" class="text-xl font-bold text-black mb-4">
C) Suggested offers
</h2>
<!-- Loading state -->
<div
v-if="loading"
class="text-center py-12 bg-white border-2 border-dashed border-blue-200 rounded-xl"
>
<div class="max-w-md mx-auto">
<div class="w-16 h-16 mx-auto mb-4 bg-blue-50 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<h3 class="font-medium text-blue-900 mb-2">Generating offers...</h3>
<p class="text-blue-700">
Creating personalized revenue suggestions based on your selections.
</p>
</div>
</div>
<!-- Empty state -->
<div
v-else-if="suggestedOffers.length === 0"
class="text-center py-12 bg-white border-2 border-dashed border-neutral-300 rounded-xl"
>
<div class="max-w-md mx-auto">
<div class="w-16 h-16 mx-auto mb-4 bg-neutral-100 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 class="font-medium text-neutral-900 mb-2">No offers yet</h3>
<p class="text-neutral-600 mb-4">
Pick a few skills and a problemwe'll suggest something you can sell this month.
</p>
<p class="text-sm text-neutral-500">
We need at least one shared skill and one problem type to suggest offers.
</p>
</div>
</div>
<!-- Offer cards -->
<div v-else class="grid gap-6 md:grid-cols-2">
<div
v-for="offer in suggestedOffers"
:key="offer.id"
class="p-6 bg-white border-2 border-neutral-200 rounded-xl shadow-sm hover:shadow-md transition-shadow"
role="article"
:aria-label="`Offer: ${offer.name}`"
>
<h3 class="font-bold text-black mb-3">{{ offer.name }}</h3>
<!-- Offer chips -->
<div class="flex flex-wrap gap-2 mb-4">
<span class="inline-flex items-center px-2 py-1 text-xs bg-green-50 text-green-700 border border-green-200 rounded-full">
Covers ~{{ calculateMonthlyCoverage(offer) }}% of monthly needs at baseline
</span>
<span class="inline-flex items-center px-2 py-1 text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded-full">
Typical payout: {{ getPayoutDaysRange(offer) }}
</span>
<span class="inline-flex items-center px-2 py-1 text-xs bg-purple-50 text-purple-700 border border-purple-200 rounded-full">
Why this
</span>
</div>
<!-- Scope -->
<div class="mb-4">
<p class="text-sm font-medium text-neutral-700 mb-2">Scope:</p>
<ul class="space-y-1">
<li
v-for="item in offer.scope"
:key="item"
class="text-sm text-neutral-600 flex items-start"
>
<span class="text-neutral-400 mr-2"></span>
<span>{{ item }}</span>
</li>
</ul>
</div>
<!-- Price range -->
<div class="mb-4 p-3 bg-neutral-50 rounded-lg">
<div class="flex justify-between items-center mb-1">
<span class="text-sm font-medium text-neutral-700">Baseline:</span>
<span class="font-bold text-black">${{ offer.price.baseline.toLocaleString() }}</span>
</div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-neutral-700">Stretch:</span>
<span class="font-bold text-green-600">${{ offer.price.stretch.toLocaleString() }}</span>
</div>
<p class="text-xs text-neutral-500">{{ offer.price.calcNote }}</p>
</div>
<!-- Payout delay -->
<div class="mb-4 flex items-center justify-between text-sm">
<span class="text-neutral-600">Payment timing:</span>
<span class="font-medium text-black">{{ offer.payoutDelayDays }} days</span>
</div>
<!-- Why this works -->
<div class="mb-4">
<p class="text-sm font-medium text-neutral-700 mb-2">Why this works for your co-op:</p>
<ul class="space-y-1">
<li
v-for="reason in offer.whyThis"
:key="reason"
class="text-sm text-neutral-600 flex items-start"
>
<span class="text-green-500 mr-2"></span>
<span>{{ updateLanguageToCoopTerms(reason) }}</span>
</li>
</ul>
</div>
<!-- Risk notes (if any) -->
<div v-if="offer.riskNotes.length > 0" class="border-t border-neutral-200 pt-3">
<p class="text-sm font-medium text-amber-700 mb-2">Consider:</p>
<ul class="space-y-1">
<li
v-for="risk in offer.riskNotes"
:key="risk"
class="text-sm text-amber-600 flex items-start"
>
<span class="text-amber-500 mr-2"></span>
<span>{{ risk }}</span>
</li>
</ul>
</div>
</div>
</div>
</section>
</div>
<!-- Sticky Footer -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-neutral-200 shadow-lg">
<div class="max-w-4xl mx-auto p-4">
<div class="flex items-center justify-between">
<button
@click="goBack"
class="px-4 py-2 text-neutral-700 hover:text-black focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-lg transition-colors"
aria-label="Go back to previous page"
>
Back
</button>
<div class="flex items-center gap-3">
<button
@click="regenerateOffers"
:disabled="!canRegenerate"
:class="[
'px-4 py-2 rounded-lg border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
canRegenerate
? 'border-neutral-300 text-neutral-700 hover:border-blue-400 hover:text-blue-600'
: 'border-neutral-200 text-neutral-400 cursor-not-allowed'
]"
:aria-label="canRegenerate ? 'Regenerate offers with current selections' : 'Cannot regenerate - select skills and problems first'"
>
🔄 Regenerate
</button>
<button
@click="useOffers"
:disabled="suggestedOffers.length === 0"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
suggestedOffers.length > 0
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-neutral-200 text-neutral-400 cursor-not-allowed'
]"
:aria-label="suggestedOffers.length > 0 ? 'Add these offers to cover co-op needs' : 'No offers to use - generate offers first'"
>
Add to plan
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Member, SkillTag, ProblemTag, Offer } from "~/types/coaching";
import { useDebounceFn } from "@vueuse/core";
import {
membersSample,
skillsCatalogSample,
problemsCatalogSample,
sampleSelections
} from "~/sample/skillsToOffersSamples";
// Store integration
const planStore = usePlanStore();
// Initialize with default data
const members = ref<Member[]>([
{ id: "1", name: "Alex Chen", role: "Game Designer", hourly: 75, availableHrs: 30 },
{ id: "2", name: "Jordan Smith", role: "Developer", hourly: 80, availableHrs: 35 },
{ id: "3", name: "Sam Rodriguez", role: "Artist", hourly: 70, availableHrs: 25 }
]);
const availableSkills = ref<SkillTag[]>([
{ id: "unity", label: "Unity Development" },
{ id: "art", label: "2D/3D Art" },
{ id: "design", label: "Game Design" },
{ id: "audio", label: "Audio Design" },
{ id: "writing", label: "Narrative Writing" },
{ id: "marketing", label: "Marketing" },
{ id: "business", label: "Business Strategy" },
{ id: "web", label: "Web Development" },
{ id: "mobile", label: "Mobile Development" },
{ id: "consulting", label: "Technical Consulting" }
]);
const availableProblems = ref<ProblemTag[]>([
{
id: "indie-games",
label: "Indie game development",
examples: [
"Small studios needing extra development capacity",
"Solo developers wanting art/audio support",
"Teams needing game design consultation"
]
},
{
id: "corporate-training",
label: "Corporate training games",
examples: [
"Companies wanting engaging employee training",
"HR departments needing onboarding tools",
"Safety training for industrial workers"
]
},
{
id: "educational",
label: "Educational technology",
examples: [
"Schools needing interactive learning tools",
"Universities wanting research simulations",
"Non-profits creating awareness campaigns"
]
},
{
id: "prototypes",
label: "Rapid prototyping",
examples: [
"Startups validating game concepts",
"Publishers testing market fit",
"Researchers creating proof-of-concepts"
]
}
]);
// Set members in store on component mount
onMounted(() => {
planStore.setMembers(members.value);
});
// Reactive state
const selectedSkills = ref<Record<string, string[]>>({});
const selectedProblems = ref<string[]>([]);
const showExamples = ref<string | null>(null);
const offers = ref<Offer[] | null>(null);
const loading = ref(false);
// Use offer suggestor composable
const { suggestOffers } = useOfferSuggestor();
// Catalogs for the suggestor
const catalogs = computed(() => ({
skills: availableSkills.value,
problems: availableProblems.value
}));
// Computed for suggested offers (for backward compatibility)
const suggestedOffers = computed(() => offers.value || []);
// Helper functions for offer chips
function calculateMonthlyCoverage(offer: Offer): number {
// Estimate monthly burn (simplified calculation)
const totalMemberHours = members.value.reduce((sum, m) => sum + m.availableHrs, 0);
const avgHourlyRate = members.value.reduce((sum, m) => sum + m.hourly, 0) / members.value.length;
const estimatedMonthlyBurn = totalMemberHours * avgHourlyRate * 1.25; // Add on-costs
return Math.round((offer.price.baseline / estimatedMonthlyBurn) * 100);
}
function getPayoutDaysRange(offer: Offer): string {
const days = offer.payoutDelayDays;
if (days <= 15) return "015 days";
if (days <= 30) return "1530 days";
if (days <= 45) return "3045 days";
return `${days} days`;
}
function updateLanguageToCoopTerms(text: string): string {
return text
.replace(/maximize|maximiz/gi, 'cover needs with')
.replace(/optimize|optimiz/gi, 'improve')
.replace(/competitive advantage/gi, 'shared capacity')
.replace(/market position/gi, 'community standing')
.replace(/profit/gi, 'surplus')
.replace(/revenue growth/gi, 'sustainable income')
.replace(/scale/gi, 'grow together')
.replace(/efficiency gains/gi, 'reduce risk')
.replace(/leverages/gi, 'uses')
.replace(/expertise/gi, 'shared skills')
.replace(/builds reputation/gi, 'builds trust in community')
.replace(/high-impact/gi, 'meaningful')
.replace(/productivity/gi, 'shared capacity');
}
// Sample data loading
function loadSampleData() {
// Replace data with samples
members.value = [...membersSample];
availableSkills.value = [...skillsCatalogSample];
availableProblems.value = [...problemsCatalogSample];
// Set pre-selected skills and problems
selectedSkills.value = { ...sampleSelections.selectedSkillsByMember };
selectedProblems.value = [...sampleSelections.selectedProblems];
// Update store with new members
planStore.setMembers(members.value);
// Trigger offer generation immediately
nextTick(() => {
debouncedGenerateOffers();
});
}
// Debounced offer generation
const debouncedGenerateOffers = useDebounceFn(async () => {
const hasSkills = Object.values(selectedSkills.value).some(skills => skills.length > 0);
const hasProblems = selectedProblems.value.length > 0;
if (!hasSkills || !hasProblems) {
offers.value = null;
return;
}
loading.value = true;
try {
const input = {
members: members.value,
selectedSkillsByMember: selectedSkills.value,
selectedProblems: selectedProblems.value
};
const suggestedOffers = suggestOffers(input, catalogs.value);
offers.value = suggestedOffers;
} catch (error) {
console.error('Failed to generate offers:', error);
offers.value = null;
} finally {
loading.value = false;
}
}, 300);
// Skill management
function toggleSkill(memberId: string, skillId: string) {
if (!selectedSkills.value[memberId]) {
selectedSkills.value[memberId] = [];
}
const memberSkills = selectedSkills.value[memberId];
const index = memberSkills.indexOf(skillId);
if (index >= 0) {
memberSkills.splice(index, 1);
} else {
memberSkills.push(skillId);
}
debouncedGenerateOffers();
}
function isSkillSelected(memberId: string, skillId: string): boolean {
return selectedSkills.value[memberId]?.includes(skillId) || false;
}
function canSelectSkill(memberId: string, skillId: string): boolean {
if (isSkillSelected(memberId, skillId)) return true;
return getSelectedSkillsCount(memberId) < 3;
}
function getSelectedSkillsCount(memberId: string): number {
return selectedSkills.value[memberId]?.length || 0;
}
// Problem management
function toggleProblem(problemId: string) {
const index = selectedProblems.value.indexOf(problemId);
if (index >= 0) {
selectedProblems.value.splice(index, 1);
} else {
selectedProblems.value.push(problemId);
}
debouncedGenerateOffers();
}
function isProblemSelected(problemId: string): boolean {
return selectedProblems.value.includes(problemId);
}
function canSelectProblem(problemId: string): boolean {
if (isProblemSelected(problemId)) return true;
return selectedProblems.value.length < 2;
}
// Examples popover
function toggleExamples(problemId: string) {
showExamples.value = showExamples.value === problemId ? null : problemId;
}
function hideExamples() {
showExamples.value = null;
}
// Footer actions
const canRegenerate = computed(() => {
const hasSkills = Object.values(selectedSkills.value).some(skills => skills.length > 0);
const hasProblems = selectedProblems.value.length > 0;
return hasSkills && hasProblems;
});
function goBack() {
// Navigate back - would typically use router
window.history.back();
}
function regenerateOffers() {
if (canRegenerate.value) {
// Re-call suggestOffers with same inputs
debouncedGenerateOffers();
}
}
function useOffers() {
if (offers.value && offers.value.length > 0) {
// Add offers to plan store as streams
planStore.addStreamsFromOffers(offers.value);
// Navigate back to wizard with success message
const router = useRouter();
// Show success notification
console.log(`Added ${offers.value.length} offers as revenue streams to your plan.`);
// Navigate to wizard revenue step - adjust path as needed for your routing
router.push('/wizards'); // This would need to be the correct wizard path
// Note: The Streams tab activation would be handled by the wizard component
// when it detects new streams in the store
}
}
function skipCoach() {
// Navigate directly to wizard streams without adding offers
const router = useRouter();
router.push('/wizards'); // Navigate to wizard - streams tab would be activated there
}
// Close examples on click outside
onMounted(() => {
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest('[role="tooltip"]') && !target.closest('button[aria-expanded]')) {
showExamples.value = null;
}
};
document.addEventListener('click', handleClickOutside);
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
});
</script>

468
pages/coop-builder.vue Normal file
View file

@ -0,0 +1,468 @@
<template>
<div>
<!-- No WizardSubnav for co-op setup tool -->
<section class="py-8 max-w-4xl mx-auto font-mono">
<!-- Header -->
<div class="mb-10 text-center">
<h1
class="text-3xl font-black text-black dark:text-white mb-4 leading-tight uppercase tracking-wide border-t-2 border-b-2 border-black dark:border-white py-4"
>
Co-op Builder
</h1>
</div>
<!-- Completed State -->
<div v-if="isCompleted" class="text-center py-12 relative">
<!-- Dithered shadow background -->
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8"
>
<div
class="w-16 h-16 bg-black dark:bg-white border-2 border-black dark:border-white flex items-center justify-center mx-auto mb-4"
>
<UIcon name="i-heroicons-check" class="w-8 h-8 text-white dark:text-black" />
</div>
<h2
class="text-2xl font-bold text-black dark:text-white mb-2 uppercase tracking-wide"
>
You're all set!
</h2>
<p class="text-neutral-600 dark:text-neutral-400 mb-6">
Your co-op is configured and ready to go.
</p>
<div class="flex justify-center gap-4">
<button class="export-btn" @click="restartWizard" :disabled="isResetting">
Start Over
</button>
<button class="export-btn primary" @click="navigateTo('/budget')">
Go to Dashboard
</button>
</div>
</div>
</div>
<!-- Vertical Steps Layout -->
<div v-else class="space-y-4">
<!-- Step 1: Members -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 1"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 1 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(1)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
membersStore.isValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
"
>
<UIcon
v-if="membersStore.isValid"
name="i-heroicons-check"
class="w-4 h-4"
/>
<span v-else>1</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Add your team
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 1 }"
/>
</div>
</div>
<div
v-if="focusedStep === 1"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardMembersStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 2: Wage -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 2"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 2 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(2)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
policiesStore.isValid
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
"
>
<UIcon
v-if="policiesStore.isValid"
name="i-heroicons-check"
class="w-4 h-4"
/>
<span v-else>2</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Set your wage
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 2 }"
/>
</div>
</div>
<div
v-if="focusedStep === 2"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardPoliciesStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 3: Costs -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 3"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 3 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(3)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2 bg-black dark:bg-white text-white dark:text-black border-black dark:border-white"
>
<UIcon name="i-heroicons-check" class="w-4 h-4" />
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Monthly costs
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 3 }"
/>
</div>
</div>
<div
v-if="focusedStep === 3"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardCostsStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 4: Revenue -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 4"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 4 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(4)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
streamsStore.hasValidStreams
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
"
>
<UIcon
v-if="streamsStore.hasValidStreams"
name="i-heroicons-check"
class="w-4 h-4"
/>
<span v-else>4</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Revenue streams
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 4 }"
/>
</div>
</div>
<div
v-if="focusedStep === 4"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardRevenueStep @save-status="handleSaveStatus" />
</div>
</div>
</div>
<!-- Step 5: Review -->
<div class="relative">
<!-- Dithered shadow for selected state -->
<div
v-if="focusedStep === 5"
class="absolute top-2 left-2 w-full h-full dither-shadow"
></div>
<div
:class="[
'relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white overflow-hidden',
focusedStep === 5 ? 'item-selected' : '',
]"
>
<div
class="p-8 cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
@click="setFocusedStep(5)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 flex items-center justify-center text-sm font-bold border-2"
:class="
canComplete
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-white'
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-white'
"
>
<UIcon v-if="canComplete" name="i-heroicons-check" class="w-4 h-4" />
<span v-else>5</span>
</div>
<div>
<h3
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide"
>
Review & finish
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black dark:text-white transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 5 }"
/>
</div>
</div>
<div
v-if="focusedStep === 5"
class="p-8 bg-neutral-50 dark:bg-neutral-900 border-t-2 border-black dark:border-white"
>
<WizardReviewStep @complete="completeWizard" @reset="resetWizard" />
</div>
</div>
</div>
<!-- Progress Actions -->
<div class="flex justify-between items-center pt-8">
<button class="export-btn" @click="resetWizard" :disabled="isResetting">
Start Over
</button>
<div class="flex items-center gap-4">
<!-- Save status -->
<div
class="flex items-center gap-2 text-sm font-mono uppercase tracking-wide"
>
<UIcon
v-if="saveStatus === 'saving'"
name="i-heroicons-arrow-path"
class="w-4 h-4 animate-spin text-neutral-500 dark:text-neutral-400"
/>
<UIcon
v-if="saveStatus === 'saved'"
name="i-heroicons-check-circle"
class="w-4 h-4 text-black dark:text-white"
/>
<span
v-if="saveStatus === 'saving'"
class="text-neutral-500 dark:text-neutral-400"
>Saving...</span
>
<span v-if="saveStatus === 'saved'" class="text-black dark:text-white"
>Saved</span
>
</div>
<button v-if="canComplete" class="export-btn primary" @click="completeWizard">
Complete Setup
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
// Stores
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const coopBuilderStore = useCoopBuilderStore();
// UI state
const focusedStep = ref(1);
const saveStatus = ref("");
const isResetting = ref(false);
const isCompleted = ref(false);
// Computed validation
const canComplete = computed(
() => membersStore.isValid && policiesStore.isValid && streamsStore.hasValidStreams
);
// Save status handler
function handleSaveStatus(status: "saving" | "saved" | "error") {
saveStatus.value = status;
if (status === "saved") {
// Clear status after delay
setTimeout(() => {
if (saveStatus.value === "saved") {
saveStatus.value = "";
}
}, 2000);
}
}
// Step management
function setFocusedStep(step: number) {
// Toggle if clicking on already focused step
if (focusedStep.value === step) {
focusedStep.value = 0; // Close the section
} else {
focusedStep.value = step; // Open the section
}
}
function completeWizard() {
// Mark setup as complete and show restart button for testing
isCompleted.value = true;
}
async function resetWizard() {
isResetting.value = true;
// Reset all stores
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
// Reset coop builder state
coopBuilderStore.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
async function restartWizard() {
isResetting.value = true;
// Reset completion state
isCompleted.value = false;
focusedStep.value = 1;
// Reset all stores and coop builder state
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
coopBuilderStore.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
// SEO
useSeoMeta({
title: "Co-op Builder - Build Your Financial Foundation",
description:
"Build your co-op's financial foundation: set up members, policies, costs, and revenue streams.",
});
</script>

View file

@ -1,8 +1,8 @@
<template> <template>
<WizardPage /> <CoopBuilderPage />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// Reuse the existing wizard content by importing it as a component // Reuse the existing coop builder content by importing it as a component
import WizardPage from "~/pages/wizard.vue"; import CoopBuilderPage from "~/pages/coop-builder.vue";
</script> </script>

View file

@ -344,7 +344,7 @@ const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore(); const budgetStore = useBudgetStore();
const cashStore = useCashStore(); const cashStore = useCashStore();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const wizardStore = useWizardStore(); const coopBuilderStore = useCoopBuilderStore();
const isResetting = ref(false); const isResetting = ref(false);
@ -556,7 +556,7 @@ async function restartWizard() {
sessionStore.resetSession(); sessionStore.resetSession();
// Reset wizard state // Reset wizard state
wizardStore.reset(); coopBuilderStore.reset();
// Small delay for UX // Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -222,29 +222,7 @@ useHead({
</script> </script>
<style scoped> <style scoped>
/* Ubuntu font import */ /* Template index specific styles - no longer duplicated in main.css */
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
/* Removed full-screen dither pattern to avoid gray haze in dark mode */
/* Exact shadow style from value-flow inspiration */
.dither-shadow {
background: black;
background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px;
}
@media (prefers-color-scheme: dark) {
.dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
}
:global(.dark) .dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
.dither-shadow-disabled { .dither-shadow-disabled {
background: black; background: black;
@ -265,74 +243,6 @@ useHead({
background-image: radial-gradient(black 1px, transparent 1px); background-image: radial-gradient(black 1px, transparent 1px);
} }
/* Rely on Tailwind bg utilities on container */
.template-card {
@apply relative;
font-family: "Ubuntu", monospace;
}
.help-section {
@apply relative;
}
.coming-soon {
opacity: 0.7;
}
.dither-tag {
position: relative;
background: white;
}
.dither-tag::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
black 1px,
black 2px
);
opacity: 0.1;
pointer-events: none;
}
/* Button styling - pure bitmap, no colors */
.bitmap-button {
font-family: "Ubuntu Mono", monospace !important;
text-transform: uppercase;
font-weight: bold;
letter-spacing: 0.5px;
position: relative;
}
.bitmap-button:hover {
transform: translateY(-1px) translateX(-1px);
transition: transform 0.1s ease;
}
.bitmap-button:hover::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: -1px;
bottom: -1px;
border: 1px solid black;
background: white;
z-index: -1;
}
.disabled-button {
opacity: 0.6;
cursor: not-allowed;
}
/* Remove any inherited rounded corners */ /* Remove any inherited rounded corners */
.template-card > *, .template-card > *,
.help-section > *, .help-section > *,

View file

@ -1,18 +1,13 @@
<template> <template>
<div> <div>
<!-- Wizard Subnav -->
<WizardSubnav />
<!-- Export Options - Top --> <!-- Export Options - Top -->
<ExportOptions <ExportOptions
:export-data="exportData" :export-data="exportData"
filename="membership-agreement" filename="membership-agreement"
title="Membership Agreement" title="Membership Agreement" />
/>
<div <div
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100" class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
>
<!-- Document Container --> <!-- Document Container -->
<div class="document-page"> <div class="document-page">
<div class="template-content"> <div class="template-content">
@ -20,8 +15,9 @@
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 <h1
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100" class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100"
:data-coop-name="formData.cooperativeName || 'Worker Cooperative'" :data-coop-name="
> formData.cooperativeName || 'Worker Cooperative'
">
MEMBERSHIP AGREEMENT MEMBERSHIP AGREEMENT
</h1> </h1>
</div> </div>
@ -29,8 +25,7 @@
<!-- Section 1: Who We Are --> <!-- Section 1: Who We Are -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
1. Who We Are 1. Who We Are
</h2> </h2>
@ -42,8 +37,7 @@
size="xl" size="xl"
class="w-full" class="w-full"
@input="debouncedAutoSave" @input="debouncedAutoSave"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
<UFormField label="Date Established" class="form-group-large"> <UFormField label="Date Established" class="form-group-large">
@ -52,8 +46,7 @@
type="date" type="date"
size="xl" size="xl"
class="large-field" class="large-field"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
<UFormField label="Our Purpose" class="form-group-large"> <UFormField label="Our Purpose" class="form-group-large">
@ -64,8 +57,7 @@
size="xl" size="xl"
class="large-field" class="large-field"
@input="debouncedAutoSave" @input="debouncedAutoSave"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
<UFormField label="Our Core Values" class="form-group-large"> <UFormField label="Our Core Values" class="form-group-large">
@ -76,8 +68,7 @@
size="xl" size="xl"
class="large-field" class="large-field"
@input="debouncedAutoSave" @input="debouncedAutoSave"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
<div class="form-group-large"> <div class="form-group-large">
@ -91,8 +82,7 @@
size="sm" size="sm"
color="primary" color="primary"
variant="outline" variant="outline"
icon="i-heroicons-plus" icon="i-heroicons-plus">
>
Add Member Add Member
</UButton> </UButton>
</div> </div>
@ -101,10 +91,10 @@
<div <div
v-for="(member, index) in formData.members" v-for="(member, index) in formData.members"
:key="index" :key="index"
class="border border-neutral-600 rounded-lg p-4" class="border border-neutral-600 rounded-lg p-4">
>
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<h4 class="font-medium text-neutral-900 dark:text-neutral-100"> <h4
class="font-medium text-neutral-900 dark:text-neutral-100">
Member {{ index + 1 }} Member {{ index + 1 }}
</h4> </h4>
<UButton <UButton
@ -113,8 +103,7 @@
size="sm" size="sm"
color="red" color="red"
variant="ghost" variant="ghost"
icon="i-heroicons-trash" icon="i-heroicons-trash">
>
</UButton> </UButton>
</div> </div>
@ -126,8 +115,7 @@
size="xl" size="xl"
class="large-field" class="large-field"
@input="debouncedAutoSave" @input="debouncedAutoSave"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
<UFormField label="Email" class="form-group-large"> <UFormField label="Email" class="form-group-large">
@ -138,8 +126,7 @@
size="xl" size="xl"
class="large-field" class="large-field"
@input="debouncedAutoSave" @input="debouncedAutoSave"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
<UFormField label="Join Date" class="form-group-large"> <UFormField label="Join Date" class="form-group-large">
@ -148,22 +135,19 @@
type="date" type="date"
size="xl" size="xl"
class="large-field" class="large-field"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
<UFormField <UFormField
label="Current Role (Optional)" label="Current Role (Optional)"
class="form-group-large" class="form-group-large">
>
<UInput <UInput
v-model="member.role" v-model="member.role"
placeholder="e.g., Coordinator, Developer, etc." placeholder="e.g., Coordinator, Developer, etc."
size="xl" size="xl"
class="large-field" class="large-field"
@input="debouncedAutoSave" @input="debouncedAutoSave"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
</div> </div>
</div> </div>
@ -175,16 +159,14 @@
<!-- Section 2: Membership --> <!-- Section 2: Membership -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
2. Membership 2. Membership
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Who Can Be a Member Who Can Be a Member
</h3> </h3>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
@ -193,8 +175,8 @@
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li>Shares our values and purpose</li> <li>Shares our values and purpose</li>
<li> <li>
Contributes labour to the cooperative (by doing actual work, not just Contributes labour to the cooperative (by doing actual work,
investing money) not just investing money)
</li> </li>
<li>Commits to collective decision-making</li> <li>Commits to collective decision-making</li>
<li>Participates in governance responsibilities</li> <li>Participates in governance responsibilities</li>
@ -203,14 +185,14 @@
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Becoming a Member Becoming a Member
</h3> </h3>
<p class="content-paragraph"> <p class="content-paragraph">
New members join through a consent process, which means existing members New members join through a consent process, which means
must agree that adding this person won't harm the cooperative. existing members must agree that adding this person won't harm
the cooperative.
</p> </p>
<ol class="content-list numbered my-2 pl-6 list-decimal"> <ol class="content-list numbered my-2 pl-6 list-decimal">
@ -221,8 +203,7 @@
type="number" type="number"
placeholder="3" placeholder="3"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
months working together months working together
</li> </li>
<li>Values alignment conversation</li> <li>Values alignment conversation</li>
@ -233,8 +214,7 @@
type="number" type="number"
placeholder="1000" placeholder="1000"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
(can be paid over time or waived based on need) (can be paid over time or waived based on need)
</li> </li>
</ol> </ol>
@ -242,20 +222,19 @@
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Leaving the Cooperative Leaving the Cooperative
</h3> </h3>
<p class="content-paragraph flex items-baseline gap-2 flex-wrap"> <p
class="content-paragraph flex items-baseline gap-2 flex-wrap">
Members can leave anytime with Members can leave anytime with
<UInput <UInput
v-model="formData.noticeDays" v-model="formData.noticeDays"
type="number" type="number"
placeholder="30" placeholder="30"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
days notice. The cooperative will: days notice. The cooperative will:
</p> </p>
@ -267,8 +246,7 @@
type="number" type="number"
placeholder="30" placeholder="30"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
days days
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
@ -278,11 +256,12 @@
type="number" type="number"
placeholder="90" placeholder="90"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
days days
</li> </li>
<li>Maintain respectful ongoing relationships when possible</li> <li>
Maintain respectful ongoing relationships when possible
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -291,80 +270,71 @@
<!-- Section 3: How We Make Decisions --> <!-- Section 3: How We Make Decisions -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
3. How We Make Decisions 3. How We Make Decisions
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Consent-Based Decisions Consent-Based Decisions
</h3> </h3>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
We use consent, not consensus. This means we move forward when no one We use consent, not consensus. This means we move forward when
has a principled objection that would harm the cooperative. An objection no one has a principled objection that would harm the
must explain how the proposal would contradict our values or threaten cooperative. An objection must explain how the proposal would
our sustainability. contradict our values or threaten our sustainability.
</p> </p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Day-to-Day Decisions Day-to-Day Decisions
</h3> </h3>
<p <p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap" class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
>
Decisions under $<UInput Decisions under $<UInput
v-model="formData.dayToDayLimit" v-model="formData.dayToDayLimit"
type="number" type="number"
placeholder="100" placeholder="100"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/> can be made by any member. Just tell others what you did at
can be made by any member. Just tell others what you did at the next the next meeting.
meeting.
</p> </p>
</div> </div>
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Regular Decisions Regular Decisions
</h3> </h3>
<p <p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap" class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
>
Decisions between $<UInput Decisions between $<UInput
v-model="formData.regularDecisionMin" v-model="formData.regularDecisionMin"
type="number" type="number"
placeholder="100" placeholder="100"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
and $<UInput and $<UInput
v-model="formData.regularDecisionMax" v-model="formData.regularDecisionMax"
type="number" type="number"
placeholder="1000" placeholder="1000"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/> need consent from members present at a meeting (minimum 2
need consent from members present at a meeting (minimum 2 members). members).
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Major Decisions Major Decisions
</h3> </h3>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
@ -379,8 +349,7 @@
type="number" type="number"
placeholder="5000" placeholder="5000"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
</li> </li>
<li>Fundamental changes to our purpose or structure</li> <li>Fundamental changes to our purpose or structure</li>
<li>Dissolution of the cooperative</li> <li>Dissolution of the cooperative</li>
@ -389,8 +358,7 @@
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Meeting Structure Meeting Structure
</h3> </h3>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
@ -400,8 +368,7 @@
v-model="formData.meetingFrequency" v-model="formData.meetingFrequency"
placeholder="weekly" placeholder="weekly"
class="inline-field" class="inline-field"
@change="autoSave" @change="autoSave" />
/>
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Emergency meetings need Emergency meetings need
@ -410,12 +377,13 @@
type="number" type="number"
placeholder="24" placeholder="24"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
hours notice hours notice
</li> </li>
<li>We rotate who facilitates meetings</li> <li>We rotate who facilitates meetings</li>
<li>Decisions and reasoning get documented in shared notes</li> <li>
Decisions and reasoning get documented in shared notes
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -424,28 +392,25 @@
<!-- Section 4: Money and Labour --> <!-- Section 4: Money and Labour -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
4. Money and Labour 4. Money and Labour
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Equal Ownership Equal Ownership
</h3> </h3>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
Each member owns an equal share of the cooperative, regardless of hours Each member owns an equal share of the cooperative, regardless
worked or tenure. of hours worked or tenure.
</p> </p>
</div> </div>
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Paying Ourselves Paying Ourselves
</h3> </h3>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
@ -455,8 +420,7 @@
type="number" type="number"
placeholder="25" placeholder="25"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />/hour for all members
/>/hour for all members
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Or: Equal monthly draw of $<UInput Or: Equal monthly draw of $<UInput
@ -464,8 +428,7 @@
type="number" type="number"
placeholder="2000" placeholder="2000"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
per member per member
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
@ -475,8 +438,7 @@
:items="dayOptions" :items="dayOptions"
placeholder="15" placeholder="15"
class="inline-field" class="inline-field"
@change="autoSave" @change="autoSave" />
/>
of each month of each month
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
@ -485,8 +447,7 @@
v-model="formData.surplusFrequency" v-model="formData.surplusFrequency"
placeholder="quarter" placeholder="quarter"
class="inline-field" class="inline-field"
@change="autoSave" @change="autoSave" />
/>
</li> </li>
</ul> </ul>
</div> </div>
@ -494,8 +455,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Work Expectations Work Expectations
</h3> </h3>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
@ -506,29 +466,31 @@
type="number" type="number"
placeholder="40" placeholder="40"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
(flexible based on capacity) (flexible based on capacity)
</li> </li>
<li>We explicitly reject crunch culture</li> <li>We explicitly reject crunch culture</li>
<li>Members communicate their capacity openly</li> <li>Members communicate their capacity openly</li>
<li> <li>
We adjust workload collectively when someone needs reduced hours We adjust workload collectively when someone needs reduced
hours
</li> </li>
</ul> </ul>
</div> </div>
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Financial Transparency Financial Transparency
</h3> </h3>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li>All members can access all financial records anytime</li> <li>
All members can access all financial records anytime
</li>
<li>Monthly financial check-ins at meetings</li> <li>Monthly financial check-ins at meetings</li>
<li> <li>
Quarterly reviews of our runway (how many months we can operate) Quarterly reviews of our runway (how many months we can
operate)
</li> </li>
</ul> </ul>
</div> </div>
@ -539,34 +501,31 @@
<!-- Section 5: Roles and Responsibilities --> <!-- Section 5: Roles and Responsibilities -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
5. Roles and Responsibilities 5. Roles and Responsibilities
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Rotating Roles Rotating Roles
</h3> </h3>
<p <p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap" class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
>
We rotate operational roles every We rotate operational roles every
<UInput <UInput
v-model="formData.roleRotationMonths" v-model="formData.roleRotationMonths"
type="number" type="number"
placeholder="6" placeholder="6"
class="inline-field number-field" class="inline-field number-field"
@change="autoSave" @change="autoSave" />
/>
months. Current roles include: months. Current roles include:
</p> </p>
<ul class="content-list"> <ul class="content-list">
<li> <li>
Financial coordinator (handles bookkeeping, not financial decisions) Financial coordinator (handles bookkeeping, not financial
decisions)
</li> </li>
<li>Meeting facilitator</li> <li>Meeting facilitator</li>
<li>External communications</li> <li>External communications</li>
@ -576,8 +535,7 @@
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Shared Responsibilities Shared Responsibilities
</h3> </h3>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
@ -595,16 +553,14 @@
<!-- Section 6: Conflict and Care --> <!-- Section 6: Conflict and Care -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
6. Conflict and Care 6. Conflict and Care
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
When Conflict Happens When Conflict Happens
</h3> </h3>
<ol class="content-list numbered my-2 pl-6 list-decimal"> <ol class="content-list numbered my-2 pl-6 list-decimal">
@ -617,15 +573,16 @@
<div> <div>
<h3 <h3
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3" class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
>
Care Commitments Care Commitments
</h3> </h3>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li>We check in about capacity and wellbeing regularly</li> <li>We check in about capacity and wellbeing regularly</li>
<li>We honour diverse access needs</li> <li>We honour diverse access needs</li>
<li>We maintain flexibility for life circumstances</li> <li>We maintain flexibility for life circumstances</li>
<li>We contribute to mutual aid when members face hardship</li> <li>
We contribute to mutual aid when members face hardship
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -634,31 +591,27 @@
<!-- Section 7: Changing This Agreement --> <!-- Section 7: Changing This Agreement -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
7. Changing This Agreement 7. Changing This Agreement
</h2> </h2>
<p <p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap" class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
>
This is a living document. We review it every This is a living document. We review it every
<UInput <UInput
v-model="formData.reviewFrequency" v-model="formData.reviewFrequency"
placeholder="year" placeholder="year"
class="inline-field" class="inline-field"
@change="autoSave" @change="autoSave" />
/> and update it through our consent process. Small clarifications
and update it through our consent process. Small clarifications can happen can happen anytime; structural changes need full member consent.
anytime; structural changes need full member consent.
</p> </p>
</div> </div>
<!-- Section 8: If We Need to Close --> <!-- Section 8: If We Need to Close -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
8. If We Need to Close 8. If We Need to Close
</h2> </h2>
@ -675,8 +628,7 @@
<UInput <UInput
v-model="formData.assetDonationTarget" v-model="formData.assetDonationTarget"
placeholder="Enter organization name" placeholder="Enter organization name"
class="inline-field wide-field" class="inline-field wide-field" />
/>
</li> </li>
</ol> </ol>
</div> </div>
@ -685,8 +637,7 @@
<!-- Section 9: Legal Bits --> <!-- Section 9: Legal Bits -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
9. Legal Bits 9. Legal Bits
</h2> </h2>
@ -697,8 +648,7 @@
v-model="formData.legalStructure" v-model="formData.legalStructure"
size="xl" size="xl"
class="w-full" class="w-full"
placeholder="Cooperative corporation, LLC, partnership, etc." placeholder="Cooperative corporation, LLC, partnership, etc." />
/>
</UFormField> </UFormField>
<UFormField label="Registered in" class="form-group-inline"> <UFormField label="Registered in" class="form-group-inline">
@ -707,8 +657,7 @@
placeholder="State/Province" placeholder="State/Province"
size="xl" size="xl"
class="inline-field w-full" class="inline-field w-full"
@change="autoSave" @change="autoSave" />
/>
</UFormField> </UFormField>
<div class="fiscal-year-group"> <div class="fiscal-year-group">
@ -720,25 +669,23 @@
placeholder="Month" placeholder="Month"
size="xl" size="xl"
class="w-60" class="w-60"
@change="autoSave" @change="autoSave" />
/>
<USelect <USelect
v-model="formData.fiscalYearEndDay" v-model="formData.fiscalYearEndDay"
:items="dayOptions" :items="dayOptions"
placeholder="Day" placeholder="Day"
size="xl" size="xl"
class="w-40" class="w-40"
@change="autoSave" @change="autoSave" />
/>
</div> </div>
</UFormField> </UFormField>
</div> </div>
</div> </div>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
This agreement works alongside but doesn't replace our legal incorporation This agreement works alongside but doesn't replace our legal
documents. Where they conflict, we follow the law but work to align our incorporation documents. Where they conflict, we follow the law
legal structure with our values. but work to align our legal structure with our values.
</p> </p>
</div> </div>
</div> </div>
@ -746,46 +693,39 @@
<!-- Section 10: Agreement Review --> <!-- Section 10: Agreement Review -->
<div class="section-card"> <div class="section-card">
<h2 <h2
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4" class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
>
10. Agreement Review 10. Agreement Review
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
By using this agreement, we commit to these principles and to showing up By using this agreement, we commit to these principles and to
for each other. showing up for each other.
</p> </p>
<div <div
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300" class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300">
>
<p <p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap" class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
>
This agreement was last updated on This agreement was last updated on
<UInput <UInput
v-model="formData.lastUpdated" v-model="formData.lastUpdated"
type="date" type="date"
class="inline-field" class="inline-field"
@change="autoSave" @change="autoSave" />. We commit to reviewing it on
/>. We commit to reviewing it on
<UInput <UInput
v-model="formData.nextReview" v-model="formData.nextReview"
type="date" type="date"
class="inline-field" class="inline-field"
@change="autoSave" @change="autoSave" />
/>
or sooner if circumstances require. or sooner if circumstances require.
</p> </p>
</div> </div>
<div <div
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950" class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950">
>
<p <p
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic" class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic">
>
[Space for member signatures when printed] [Space for member signatures when printed]
</p> </p>
</div> </div>
@ -799,8 +739,7 @@
<ExportOptions <ExportOptions
:export-data="exportData" :export-data="exportData"
filename="membership-agreement" filename="membership-agreement"
title="Membership Agreement" title="Membership Agreement" />
/>
</div> </div>
</template> </template>
@ -898,7 +837,10 @@ onMounted(() => {
// Auto-save individual field changes immediately // Auto-save individual field changes immediately
const autoSave = () => { const autoSave = () => {
localStorage.setItem("membership-agreement-data", JSON.stringify(formData.value)); localStorage.setItem(
"membership-agreement-data",
JSON.stringify(formData.value)
);
console.log("Manual auto-save triggered:", formData.value); console.log("Manual auto-save triggered:", formData.value);
}; };
@ -950,8 +892,12 @@ const handlePrint = () => {
`; `;
// Add signature lines for each member // Add signature lines for each member
const membersWithNames = formData.value.members?.filter((m) => m.name) || []; const membersWithNames =
const numSignatures = Math.max(2, Math.min(8, membersWithNames.length || 4)); formData.value.members?.filter((m) => m.name) || [];
const numSignatures = Math.max(
2,
Math.min(8, membersWithNames.length || 4)
);
for (let i = 0; i < numSignatures; i++) { for (let i = 0; i < numSignatures; i++) {
const memberName = membersWithNames[i]?.name || ""; const memberName = membersWithNames[i]?.name || "";
@ -981,7 +927,8 @@ const handlePrint = () => {
value = formData.value.cooperativeName; value = formData.value.cooperativeName;
else if (input.closest('[label="Date Established"]')) else if (input.closest('[label="Date Established"]'))
value = formData.value.dateEstablished; value = formData.value.dateEstablished;
else if (input.closest('[label="Our Purpose"]')) value = formData.value.purpose; else if (input.closest('[label="Our Purpose"]'))
value = formData.value.purpose;
else if (input.closest('[label="Our Core Values"]')) else if (input.closest('[label="Our Core Values"]'))
value = formData.value.coreValues; value = formData.value.coreValues;
else if (input.closest('[label="Legal Structure"]')) else if (input.closest('[label="Legal Structure"]'))
@ -998,13 +945,16 @@ const handlePrint = () => {
// Handle member fields // Handle member fields
else if (input.closest(".border-neutral-200")) { else if (input.closest(".border-neutral-200")) {
const memberCard = input.closest(".border-neutral-200"); const memberCard = input.closest(".border-neutral-200");
const memberIndex = Array.from(memberCard.parentNode.children).indexOf(memberCard); const memberIndex = Array.from(memberCard.parentNode.children).indexOf(
memberCard
);
const member = formData.value.members?.[memberIndex]; const member = formData.value.members?.[memberIndex];
if (member) { if (member) {
if (input.closest('[label="Full Name"]')) value = member.name; if (input.closest('[label="Full Name"]')) value = member.name;
else if (input.closest('[label="Email"]')) value = member.email; else if (input.closest('[label="Email"]')) value = member.email;
else if (input.closest('[label="Join Date"]')) value = member.joinDate; else if (input.closest('[label="Join Date"]')) value = member.joinDate;
else if (input.closest('[label="Current Role (Optional)"]')) value = member.role; else if (input.closest('[label="Current Role (Optional)"]'))
value = member.role;
} }
} }
// Fallback to input.value // Fallback to input.value
@ -1143,11 +1093,13 @@ const exportData = computed(() => ({
.template-content.font-ubuntu, .template-content.font-ubuntu,
.template-content.font-ubuntu * { .template-content.font-ubuntu * {
font-family: "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; font-family: "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif !important;
} }
.template-content.font-inter, .template-content.font-inter,
.template-content.font-inter * { .template-content.font-inter * {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif !important;
} }
</style> </style>

View file

@ -1,26 +1,20 @@
<template> <template>
<div> <div>
<!-- Wizard Subnav -->
<WizardSubnav />
<!-- Export Options - Top --> <!-- Export Options - Top -->
<ExportOptions <ExportOptions
:export-data="exportData" :export-data="exportData"
filename="tech-charter" filename="tech-charter"
title="Technology Charter" title="Technology Charter" />
/>
<div <div
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100" class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
>
<!-- Document Container --> <!-- Document Container -->
<div class="document-page"> <div class="document-page">
<div class="template-content"> <div class="template-content">
<!-- Document Header --> <!-- Document Header -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 <h1
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100" class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100">
>
Tech Charter Tech Charter
</h1> </h1>
</div> </div>
@ -30,9 +24,12 @@
<!-- Purpose Section --> <!-- Purpose Section -->
<div class="section-card"> <div class="section-card">
<div> <div>
<h2 class="text-2xl font-bold text-neutral-800 mb-4">Charter Purpose</h2> <h2 class="text-2xl font-bold text-neutral-800 mb-4">
Charter Purpose
</h2>
<p class="text-neutral-600 mb-4"> <p class="text-neutral-600 mb-4">
Describe what this charter will guide and why it matters to your group. Describe what this charter will guide and why it matters to
your group.
</p> </p>
</div> </div>
@ -40,8 +37,7 @@
<textarea <textarea
v-model="charterPurpose" v-model="charterPurpose"
class="w-full min-h-32 p-4 border-2 border-neutral-300 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:border-black dark:focus:border-white transition-colors resize-y" class="w-full min-h-32 p-4 border-2 border-neutral-300 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:border-black dark:focus:border-white transition-colors resize-y"
rows="4" rows="4" />
/>
</div> </div>
</div> </div>
@ -52,37 +48,39 @@
Define Your Principles & Importance Define Your Principles & Importance
</h2> </h2>
<p class="text-neutral-600 mb-6"> <p class="text-neutral-600 mb-6">
Select principles and set their importance. Zero means excluded, 5 means Select principles and set their importance. Zero means
critical. excluded, 5 means critical.
</p> </p>
</div> </div>
<div class="grid md:grid-cols-1 gap-4"> <div class="grid md:grid-cols-1 gap-4">
<div v-for="principle in principles" :key="principle.id" class="relative"> <div
v-for="principle in principles"
:key="principle.id"
class="relative">
<!-- Dithered shadow for selected cards --> <!-- Dithered shadow for selected cards -->
<div <div
v-if="principleWeights[principle.id] > 0" v-if="principleWeights[principle.id] > 0"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<div <div
:class="[ :class="[
'relative transition-all', 'relative transition-all',
principleWeights[principle.id] > 0 principleWeights[principle.id] > 0
? 'principle-selected border-2 border-black dark:border-white bg-white dark:bg-neutral-950' ? 'item-selected border-2 border-black dark:border-white bg-white dark:bg-neutral-950'
: 'border border-black dark:border-white bg-transparent', : 'border border-black dark:border-white bg-transparent',
]" ]">
>
<div class="p-6"> <div class="p-6">
<div class="flex items-start gap-6"> <div class="flex items-start gap-6">
<!-- Principle info --> <!-- Principle info -->
<div class="flex-1"> <div class="flex-1">
<div <div
:class="[ :class="[
'principle-text-bg mb-3', 'item-text-bg mb-3',
principleWeights[principle.id] > 0 ? 'selected' : '', principleWeights[principle.id] > 0
]" ? 'selected'
> : '',
]">
<h3 class="font-bold text-lg mb-2"> <h3 class="font-bold text-lg mb-2">
{{ principle.name }} {{ principle.name }}
</h3> </h3>
@ -92,8 +90,7 @@
? 'text-neutral-700' ? 'text-neutral-700'
: 'text-neutral-600' : 'text-neutral-600'
" "
class="text-sm" class="text-sm">
>
{{ principle.description }} {{ principle.description }}
</p> </p>
</div> </div>
@ -102,8 +99,7 @@
<!-- Importance selector --> <!-- Importance selector -->
<div class="flex flex-col items-center gap-2"> <div class="flex flex-col items-center gap-2">
<label <label
class="text-xs font-bold text-neutral-500 uppercase tracking-wider" class="text-xs font-bold text-neutral-500 uppercase tracking-wider">
>
Importance Importance
</label> </label>
@ -119,8 +115,7 @@
? 'bg-black text-white border-black dark:bg-white dark:text-black dark:border-white' ? 'bg-black text-white border-black dark:bg-white dark:text-black dark:border-white'
: 'bg-white border-neutral-300 hover:border-neutral-500 dark:bg-neutral-950', : 'bg-white border-neutral-300 hover:border-neutral-500 dark:bg-neutral-950',
]" ]"
:title="`Set importance to ${level}`" :title="`Set importance to ${level}`">
>
{{ level }} {{ level }}
</button> </button>
</div> </div>
@ -131,7 +126,11 @@
{{ principleWeights[principle.id] || 0 }} {{ principleWeights[principle.id] || 0 }}
</div> </div>
<div class="text-xs text-neutral-500"> <div class="text-xs text-neutral-500">
{{ getWeightLabel(principleWeights[principle.id] || 0) }} {{
getWeightLabel(
principleWeights[principle.id] || 0
)
}}
</div> </div>
</div> </div>
</div> </div>
@ -140,20 +139,19 @@
<!-- Non-negotiable toggle (only shows for weights > 0) --> <!-- Non-negotiable toggle (only shows for weights > 0) -->
<div <div
v-if="principleWeights[principle.id] > 0" v-if="principleWeights[principle.id] > 0"
class="mt-4 pt-4 border-t border-neutral-200" class="mt-4 pt-4 border-t border-neutral-200">
>
<label <label
:class="[ :class="[
'flex items-center gap-3 cursor-pointer principle-label-bg px-2 py-1', 'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1',
nonNegotiables.includes(principle.id) ? 'selected' : '', nonNegotiables.includes(principle.id)
]" ? 'selected'
> : '',
]">
<input <input
type="checkbox" type="checkbox"
:checked="nonNegotiables.includes(principle.id)" :checked="nonNegotiables.includes(principle.id)"
@change="toggleNonNegotiable(principle.id)" @change="toggleNonNegotiable(principle.id)"
class="w-4 h-4" class="w-4 h-4" />
/>
<span class="text-sm font-medium text-red-600"> <span class="text-sm font-medium text-red-600">
Make this non-negotiable Make this non-negotiable
</span> </span>
@ -163,9 +161,9 @@
<!-- Show rubric description when selected --> <!-- Show rubric description when selected -->
<div <div
v-if="principleWeights[principle.id] > 0" v-if="principleWeights[principle.id] > 0"
class="mt-4 p-3 principle-label-bg selected border border-neutral-200" class="mt-4 p-3 item-label-bg selected border border-neutral-200">
> <div
<div class="text-xs font-bold uppercase text-neutral-500 mb-1"> class="text-xs font-bold uppercase text-neutral-500 mb-1">
Evaluation Criteria: Evaluation Criteria:
</div> </div>
<div class="text-sm"> <div class="text-sm">
@ -183,8 +181,7 @@
<div> <div>
<h2 <h2
class="text-2xl font-bold text-neutral-800 mb-2" class="text-2xl font-bold text-neutral-800 mb-2"
id="constraints-heading" id="constraints-heading">
>
Technical Constraints Technical Constraints
</h2> </h2>
</div> </div>
@ -195,18 +192,15 @@
<div <div
class="flex flex-wrap gap-3 constraint-buttons" class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup" role="radiogroup"
aria-labelledby="auth-heading" aria-labelledby="auth-heading">
>
<div <div
v-for="option in authOptions" v-for="option in authOptions"
:key="option.value" :key="option.value"
class="relative" class="relative">
>
<!-- Dithered shadow for selected buttons --> <!-- Dithered shadow for selected buttons -->
<div <div
v-if="constraints.sso === option.value" v-if="constraints.sso === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<button <button
@click="constraints.sso = option.value" @click="constraints.sso = option.value"
:aria-pressed="constraints.sso === option.value" :aria-pressed="constraints.sso === option.value"
@ -217,8 +211,7 @@
constraints.sso === option.value constraints.sso === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black' ? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer', : 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]" ]">
>
{{ option.label }} {{ option.label }}
</button> </button>
</div> </div>
@ -230,18 +223,15 @@
<div <div
class="flex flex-wrap gap-3 constraint-buttons" class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup" role="radiogroup"
aria-labelledby="hosting-heading" aria-labelledby="hosting-heading">
>
<div <div
v-for="option in hostingOptions" v-for="option in hostingOptions"
:key="option.value" :key="option.value"
class="relative" class="relative">
>
<!-- Dithered shadow for selected buttons --> <!-- Dithered shadow for selected buttons -->
<div <div
v-if="constraints.hosting === option.value" v-if="constraints.hosting === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<button <button
@click="constraints.hosting = option.value" @click="constraints.hosting = option.value"
:aria-pressed="constraints.hosting === option.value" :aria-pressed="constraints.hosting === option.value"
@ -252,8 +242,7 @@
constraints.hosting === option.value constraints.hosting === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black' ? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer', : 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]" ]">
>
{{ option.label }} {{ option.label }}
</button> </button>
</div> </div>
@ -261,29 +250,32 @@
</fieldset> </fieldset>
<fieldset class="bg-neutral-50 p-6 rounded-lg"> <fieldset class="bg-neutral-50 p-6 rounded-lg">
<legend class="font-semibold text-lg">Required Integrations</legend> <legend class="font-semibold text-lg">
<p class="text-sm text-neutral-600 mb-4">Select all that apply</p> Required Integrations
</legend>
<p class="text-sm text-neutral-600 mb-4">
Select all that apply
</p>
<div class="flex flex-wrap gap-3 constraint-buttons"> <div class="flex flex-wrap gap-3 constraint-buttons">
<div <div
v-for="integration in integrationOptions" v-for="integration in integrationOptions"
:key="integration" :key="integration"
class="relative" class="relative">
>
<!-- Dithered shadow for selected buttons --> <!-- Dithered shadow for selected buttons -->
<div <div
v-if="constraints.integrations.includes(integration)" v-if="constraints.integrations.includes(integration)"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<button <button
@click="toggleIntegration(integration)" @click="toggleIntegration(integration)"
:aria-pressed="constraints.integrations.includes(integration)" :aria-pressed="
constraints.integrations.includes(integration)
"
:class="[ :class="[
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer', 'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
constraints.integrations.includes(integration) constraints.integrations.includes(integration)
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black' ? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer', : 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]" ]">
>
{{ integration }} {{ integration }}
</button> </button>
</div> </div>
@ -291,22 +283,21 @@
</fieldset> </fieldset>
<fieldset class="bg-neutral-50 p-6 rounded-lg"> <fieldset class="bg-neutral-50 p-6 rounded-lg">
<legend class="font-semibold text-lg">Support Expectations</legend> <legend class="font-semibold text-lg">
Support Expectations
</legend>
<div <div
class="flex flex-wrap gap-3 constraint-buttons" class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup" role="radiogroup"
aria-labelledby="support-heading" aria-labelledby="support-heading">
>
<div <div
v-for="option in supportOptions" v-for="option in supportOptions"
:key="option.value" :key="option.value"
class="relative" class="relative">
>
<!-- Dithered shadow for selected buttons --> <!-- Dithered shadow for selected buttons -->
<div <div
v-if="constraints.support === option.value" v-if="constraints.support === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<button <button
@click="constraints.support = option.value" @click="constraints.support = option.value"
:aria-pressed="constraints.support === option.value" :aria-pressed="constraints.support === option.value"
@ -317,8 +308,7 @@
constraints.support === option.value constraints.support === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black' ? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer', : 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]" ]">
>
{{ option.label }} {{ option.label }}
</button> </button>
</div> </div>
@ -326,22 +316,21 @@
</fieldset> </fieldset>
<fieldset class="bg-neutral-50 p-6 rounded-lg"> <fieldset class="bg-neutral-50 p-6 rounded-lg">
<legend class="font-semibold text-lg">Migration Timeline</legend> <legend class="font-semibold text-lg">
Migration Timeline
</legend>
<div <div
class="flex flex-wrap gap-3 constraint-buttons" class="flex flex-wrap gap-3 constraint-buttons"
role="radiogroup" role="radiogroup"
aria-labelledby="timeline-heading" aria-labelledby="timeline-heading">
>
<div <div
v-for="option in timelineOptions" v-for="option in timelineOptions"
:key="option.value" :key="option.value"
class="relative" class="relative">
>
<!-- Dithered shadow for selected buttons --> <!-- Dithered shadow for selected buttons -->
<div <div
v-if="constraints.timeline === option.value" v-if="constraints.timeline === option.value"
class="absolute top-2 left-2 w-full h-full dither-shadow" class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
></div>
<button <button
@click="constraints.timeline = option.value" @click="constraints.timeline = option.value"
:aria-pressed="constraints.timeline === option.value" :aria-pressed="constraints.timeline === option.value"
@ -352,8 +341,7 @@
constraints.timeline === option.value constraints.timeline === option.value
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black' ? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer', : 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]" ]">
>
{{ option.label }} {{ option.label }}
</button> </button>
</div> </div>
@ -367,8 +355,7 @@
<button <button
@click="resetForm" @click="resetForm"
class="export-btn" class="export-btn"
title="Clear all form data and start over" title="Clear all form data and start over">
>
<UIcon name="i-heroicons-arrow-path" /> <UIcon name="i-heroicons-arrow-path" />
Reset Form Reset Form
</button> </button>
@ -381,17 +368,19 @@
v-if="charterGenerated" v-if="charterGenerated"
class="relative animate-fadeIn" class="relative animate-fadeIn"
role="main" role="main"
aria-label="Generated Technology Charter" aria-label="Generated Technology Charter">
>
<!-- Dithered shadow --> <!-- Dithered shadow -->
<div class="absolute top-4 left-4 right-0 bottom-0 dither-shadow"></div> <div
class="absolute top-4 left-4 right-0 bottom-0 dither-shadow"></div>
<!-- Charter container --> <!-- Charter container -->
<div <div
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8" class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
> <div
<div class="text-center mb-8 pb-6 border-b-2 border-black dark:border-white"> class="text-center mb-8 pb-6 border-b-2 border-black dark:border-white">
<h2 class="text-3xl font-bold text-neutral-800" id="charter-title"> <h2
class="text-3xl font-bold text-neutral-800"
id="charter-title">
Technology Charter Technology Charter
</h2> </h2>
<p class="text-neutral-600 mt-2"> <p class="text-neutral-600 mt-2">
@ -407,8 +396,7 @@
<div class="mt-4"> <div class="mt-4">
<button <button
@click="scrollToTop" @click="scrollToTop"
class="text-sm text-neutral-600 hover:text-neutral-800 underline focus:outline-none focus:ring-2 focus:ring-neutral-500 rounded" class="text-sm text-neutral-600 hover:text-neutral-800 underline focus:outline-none focus:ring-2 focus:ring-neutral-500 rounded">
>
Back to form Back to form
</button> </button>
</div> </div>
@ -418,10 +406,10 @@
<section class="mb-8"> <section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3> <h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
<p class="text-neutral-700 leading-relaxed"> <p class="text-neutral-700 leading-relaxed">
This charter guides our cooperative's technology decisions based on our This charter guides our cooperative's technology decisions
shared values and operational needs. It ensures we choose tools that based on our shared values and operational needs. It ensures
support our mission while respecting our principles of autonomy, we choose tools that support our mission while respecting our
sustainability, and mutual aid. principles of autonomy, sustainability, and mutual aid.
</p> </p>
</section> </section>
@ -429,21 +417,25 @@
class="mb-8" class="mb-8"
v-if=" v-if="
Object.keys(principleWeights).filter( Object.keys(principleWeights).filter(
(p) => principleWeights[p] > 0 && !nonNegotiables.includes(p) (p) =>
principleWeights[p] > 0 && !nonNegotiables.includes(p)
).length > 0 ).length > 0
" ">
> <h3 class="text-xl font-bold text-neutral-800 mb-3">
<h3 class="text-xl font-bold text-neutral-800 mb-3">Core Principles</h3> Core Principles
</h3>
<ul class="space-y-2"> <ul class="space-y-2">
<li <li
v-for="principleId in Object.keys(principleWeights).filter( v-for="principleId in Object.keys(principleWeights).filter(
(p) => principleWeights[p] > 0 && !nonNegotiables.includes(p) (p) =>
principleWeights[p] > 0 && !nonNegotiables.includes(p)
)" )"
:key="principleId" :key="principleId"
class="flex items-start" class="flex items-start">
>
<span class="text-neutral-600 mr-2"></span> <span class="text-neutral-600 mr-2"></span>
<span>{{ principles.find((p) => p.id === principleId)?.name }}</span> <span>{{
principles.find((p) => p.id === principleId)?.name
}}</span>
</li> </li>
</ul> </ul>
</section> </section>
@ -453,16 +445,18 @@
Non-Negotiable Requirements Non-Negotiable Requirements
</h3> </h3>
<p class="text-red-600 font-semibold mb-3"> <p class="text-red-600 font-semibold mb-3">
Any vendor failing these requirements is automatically disqualified. Any vendor failing these requirements is automatically
disqualified.
</p> </p>
<ul class="space-y-2"> <ul class="space-y-2">
<li <li
v-for="principleId in nonNegotiables" v-for="principleId in nonNegotiables"
:key="principleId" :key="principleId"
class="flex items-start text-red-600 font-semibold" class="flex items-start text-red-600 font-semibold">
>
<span class="mr-2"></span> <span class="mr-2"></span>
<span>{{ principles.find((p) => p.id === principleId)?.name }}</span> <span>{{
principles.find((p) => p.id === principleId)?.name
}}</span>
</li> </li>
</ul> </ul>
</section> </section>
@ -477,7 +471,8 @@
<span <span
>Authentication: >Authentication:
{{ {{
authOptions.find((o) => o.value === constraints.sso)?.label authOptions.find((o) => o.value === constraints.sso)
?.label
}}</span }}</span
> >
</li> </li>
@ -486,11 +481,15 @@
<span <span
>Hosting: >Hosting:
{{ {{
hostingOptions.find((o) => o.value === constraints.hosting)?.label hostingOptions.find(
(o) => o.value === constraints.hosting
)?.label
}}</span }}</span
> >
</li> </li>
<li v-if="constraints.integrations.length > 0" class="flex items-start"> <li
v-if="constraints.integrations.length > 0"
class="flex items-start">
<span class="text-purple-600 mr-2"></span> <span class="text-purple-600 mr-2"></span>
<span <span
>Required Integrations: >Required Integrations:
@ -502,7 +501,9 @@
<span <span
>Support Level: >Support Level:
{{ {{
supportOptions.find((o) => o.value === constraints.support)?.label supportOptions.find(
(o) => o.value === constraints.support
)?.label
}}</span }}</span
> >
</li> </li>
@ -511,8 +512,9 @@
<span <span
>Migration Timeline: >Migration Timeline:
{{ {{
timelineOptions.find((o) => o.value === constraints.timeline) timelineOptions.find(
?.label (o) => o.value === constraints.timeline
)?.label
}}</span }}</span
> >
</li> </li>
@ -520,27 +522,27 @@
</section> </section>
<section class="mb-8"> <section class="mb-8">
<h3 class="text-xl font-bold text-neutral-800 mb-3">Evaluation Rubric</h3> <h3 class="text-xl font-bold text-neutral-800 mb-3">
Evaluation Rubric
</h3>
<p class="text-neutral-700 mb-4"> <p class="text-neutral-700 mb-4">
Score each vendor option using these weighted criteria (0-5 scale): Score each vendor option using these weighted criteria (0-5
scale):
</p> </p>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full border-collapse"> <table class="w-full border-collapse">
<thead> <thead>
<tr class="bg-neutral-100"> <tr class="bg-neutral-100">
<th <th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left" class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
>
Criterion Criterion
</th> </th>
<th <th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left" class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
>
Description Description
</th> </th>
<th <th
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center" class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center">
>
Weight Weight
</th> </th>
</tr> </tr>
@ -549,21 +551,17 @@
<tr <tr
v-for="weight in sortedWeights" v-for="weight in sortedWeights"
:key="weight.id" :key="weight.id"
class="hover:bg-neutral-50" class="hover:bg-neutral-50">
>
<td <td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 font-semibold" class="border-2 border-neutral-900 dark:border-neutral-100 p-3 font-semibold">
>
{{ weight.name }} {{ weight.name }}
</td> </td>
<td <td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-sm text-neutral-600" class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-sm text-neutral-600">
>
{{ weight.rubricDescription }} {{ weight.rubricDescription }}
</td> </td>
<td <td
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center font-bold text-neutral-600" class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center font-bold text-neutral-600">
>
{{ principleWeights[weight.id] }} {{ principleWeights[weight.id] }}
</td> </td>
</tr> </tr>
@ -580,8 +578,8 @@
<li class="flex items-start"> <li class="flex items-start">
<span class="text-neutral-600 mr-2"></span> <span class="text-neutral-600 mr-2"></span>
<span <span
>Any vendor failing a non-negotiable requirement is automatically >Any vendor failing a non-negotiable requirement is
eliminated</span automatically eliminated</span
> >
</li> </li>
<li class="flex items-start"> <li class="flex items-start">
@ -594,8 +592,8 @@
<li class="flex items-start"> <li class="flex items-start">
<span class="text-neutral-600 mr-2"></span> <span class="text-neutral-600 mr-2"></span>
<span <span
>When scores are within 10%, choose based on alignment with >When scores are within 10%, choose based on alignment
cooperative values</span with cooperative values</span
> >
</li> </li>
<li class="flex items-start"> <li class="flex items-start">
@ -638,11 +636,16 @@
</li> </li>
<li class="flex items-start"> <li class="flex items-start">
<span class="text-neutral-600 mr-2"></span> <span class="text-neutral-600 mr-2"></span>
<span>Document any exceptions with clear justification</span> <span
>Document any exceptions with clear justification</span
>
</li> </li>
<li class="flex items-start"> <li class="flex items-start">
<span class="text-neutral-600 mr-2"></span> <span class="text-neutral-600 mr-2"></span>
<span>Share learnings with other cooperatives in our network</span> <span
>Share learnings with other cooperatives in our
network</span
>
</li> </li>
</ul> </ul>
</section> </section>
@ -656,8 +659,7 @@
<ExportOptions <ExportOptions
:export-data="exportData" :export-data="exportData"
filename="tech-charter" filename="tech-charter"
title="Technology Charter" title="Technology Charter" />
/>
</div> </div>
</template> </template>
@ -705,13 +707,15 @@ const principles = [
id: "portability", id: "portability",
name: "Data Freedom", name: "Data Freedom",
description: "Easy export, no vendor lock-in, migration-friendly", description: "Easy export, no vendor lock-in, migration-friendly",
rubricDescription: "Export capabilities, proprietary formats, switching costs", rubricDescription:
"Export capabilities, proprietary formats, switching costs",
defaultWeight: 4, defaultWeight: 4,
}, },
{ {
id: "opensource", id: "opensource",
name: "Open Source & Community", name: "Open Source & Community",
description: "FOSS preference, transparent development, community governance", description:
"FOSS preference, transparent development, community governance",
rubricDescription: "License type, community involvement, code transparency", rubricDescription: "License type, community involvement, code transparency",
defaultWeight: 3, defaultWeight: 3,
}, },
@ -719,7 +723,8 @@ const principles = [
id: "sustainability", id: "sustainability",
name: "Sustainable Operations", name: "Sustainable Operations",
description: "Predictable costs, green hosting, efficient resource use", description: "Predictable costs, green hosting, efficient resource use",
rubricDescription: "Total cost of ownership, carbon footprint, resource efficiency", rubricDescription:
"Total cost of ownership, carbon footprint, resource efficiency",
defaultWeight: 3, defaultWeight: 3,
}, },
{ {
@ -732,8 +737,10 @@ const principles = [
{ {
id: "usability", id: "usability",
name: "User Experience", name: "User Experience",
description: "Intuitive interface, minimal learning curve, daily efficiency", description:
rubricDescription: "Onboarding time, user satisfaction, workflow integration", "Intuitive interface, minimal learning curve, daily efficiency",
rubricDescription:
"Onboarding time, user satisfaction, workflow integration",
defaultWeight: 3, defaultWeight: 3,
}, },
]; ];
@ -769,7 +776,9 @@ const timelineOptions = [
const sortedWeights = computed(() => { const sortedWeights = computed(() => {
return principles return principles
.filter((p) => principleWeights.value[p.id] > 0) .filter((p) => principleWeights.value[p.id] > 0)
.sort((a, b) => principleWeights.value[b.id] - principleWeights.value[a.id]); .sort(
(a, b) => principleWeights.value[b.id] - principleWeights.value[a.id]
);
}); });
const canGenerateCharter = computed(() => { const canGenerateCharter = computed(() => {
@ -862,7 +871,9 @@ const resetForm = () => {
}; };
const scrollToTop = () => { const scrollToTop = () => {
document.querySelector(".template-wrapper").scrollIntoView({ behavior: "smooth" }); document
.querySelector(".template-wrapper")
.scrollIntoView({ behavior: "smooth" });
}; };
// Load saved data // Load saved data
@ -905,9 +916,13 @@ onMounted(() => {
}); });
// Auto-save when data changes // Auto-save when data changes
watch([charterPurpose, principleWeights, nonNegotiables, constraints], autoSave, { watch(
deep: true, [charterPurpose, principleWeights, nonNegotiables, constraints],
}); autoSave,
{
deep: true,
}
);
</script> </script>
<style scoped> <style scoped>
@ -919,115 +934,6 @@ watch([charterPurpose, principleWeights, nonNegotiables, constraints], autoSave,
@apply mb-8 relative; @apply mb-8 relative;
} }
/* Principle card selected styling - using dithered shadow and background */
.principle-selected {
position: relative;
background: white;
}
.principle-selected::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
black 1px,
black 2px
);
opacity: 0.1;
pointer-events: none;
z-index: 0;
}
.principle-selected > * {
position: relative;
z-index: 1;
}
/* Dark mode */
html.dark .principle-selected {
background: #0a0a0a;
}
html.dark .principle-selected::after {
background-image: repeating-linear-gradient(
45deg,
transparent 0px,
transparent 1px,
white 1px,
white 2px
);
}
/* Text background for better readability */
.principle-label-bg {
background: rgba(255, 255, 255, 0.85);
border-radius: 4px;
}
.principle-label-bg.selected {
background: rgba(255, 255, 255, 0.95);
}
/* Dark mode text backgrounds */
html.dark .principle-text-bg {
background: rgba(10, 10, 10, 0.9);
}
html.dark .principle-text-bg.selected {
background: rgba(10, 10, 10, 0.95);
}
html.dark .principle-label-bg {
background: rgba(10, 10, 10, 0.85);
}
html.dark .principle-label-bg.selected {
background: rgba(10, 10, 10, 0.95);
}
/* Constraint button selected styling - black background */
button.constraint-selected {
background: black !important;
color: white !important;
}
button.constraint-selected:hover {
background: black !important;
color: white !important;
}
/* Dark mode */
html.dark button.constraint-selected {
background: white !important;
color: black !important;
}
html.dark button.constraint-selected:hover {
background: white !important;
color: black !important;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out;
}
.content-title { .content-title {
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;

View file

@ -1,372 +0,0 @@
<template>
<div>
<!-- Wizard Subnav -->
<WizardSubnav />
<section class="py-8 max-w-4xl mx-auto">
<!-- Header -->
<div class="mb-10">
<h1 class="text-5xl font-black text-black mb-4 leading-tight">
Set up your co-op
</h1>
<p class="text-xl text-neutral-700 font-medium">
Get your worker-owned co-op configured in a few simple steps. Jump to
any step or work through them in order.
</p>
</div>
<!-- Completed State -->
<div v-if="isCompleted" class="text-center py-12">
<div
class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<UIcon name="i-heroicons-check" class="w-8 h-8 text-green-600" />
</div>
<h2 class="text-2xl font-bold text-black mb-2">You're all set!</h2>
<p class="text-neutral-600 mb-6">
Your co-op is configured and ready to go.
</p>
<div class="flex justify-center gap-4">
<UButton
variant="outline"
color="gray"
@click="restartWizard"
:disabled="isResetting">
Start Over
</UButton>
<UButton
@click="navigateTo('/scenarios')"
size="lg"
variant="solid"
color="black">
Go to Dashboard
</UButton>
</div>
</div>
<!-- Vertical Steps Layout -->
<div v-else class="space-y-4">
<!-- Step 1: Members -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-yellow-50 transition-colors"
:class="{ 'bg-yellow-100': focusedStep === 1 }"
@click="setFocusedStep(1)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="
membersStore.isValid
? 'bg-green-100 text-green-700'
: 'bg-white text-black border-2 border-black'
">
<UIcon
v-if="membersStore.isValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>1</span>
</div>
<div>
<h3 class="text-2xl font-black text-black">Add your team</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 1 }" />
</div>
</div>
<div v-if="focusedStep === 1" class="p-8 bg-yellow-25">
<WizardMembersStep @save-status="handleSaveStatus" />
</div>
</div>
<!-- Step 2: Wage -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-green-50 transition-colors"
:class="{ 'bg-green-100': focusedStep === 2 }"
@click="setFocusedStep(2)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="
policiesStore.isValid
? 'bg-green-100 text-green-700'
: 'bg-white text-black border-2 border-black'
">
<UIcon
v-if="policiesStore.isValid"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>2</span>
</div>
<div>
<h3 class="text-2xl font-black text-black">Set your wage</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 2 }" />
</div>
</div>
<div v-if="focusedStep === 2" class="p-8 bg-green-25">
<WizardPoliciesStep @save-status="handleSaveStatus" />
</div>
</div>
<!-- Step 3: Costs -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-blue-50 transition-colors"
:class="{ 'bg-blue-100': focusedStep === 3 }"
@click="setFocusedStep(3)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-sm font-bold">
<UIcon name="i-heroicons-check" class="w-4 h-4" />
</div>
<div>
<h3 class="text-2xl font-black text-black">Monthly costs</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 3 }" />
</div>
</div>
<div v-if="focusedStep === 3" class="p-8 bg-blue-25">
<WizardCostsStep @save-status="handleSaveStatus" />
</div>
</div>
<!-- Step 4: Revenue -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-purple-50 transition-colors"
:class="{ 'bg-purple-100': focusedStep === 4 }"
@click="setFocusedStep(4)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="
streamsStore.hasValidStreams
? 'bg-green-100 text-green-700'
: 'bg-white text-black border-2 border-black'
">
<UIcon
v-if="streamsStore.hasValidStreams"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>4</span>
</div>
<div>
<h3 class="text-2xl font-black text-black">
Revenue streams
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 4 }" />
</div>
</div>
<div v-if="focusedStep === 4" class="p-8 bg-purple-25">
<WizardRevenueStep @save-status="handleSaveStatus" />
</div>
</div>
<!-- Step 5: Review -->
<div
class="bg-white border-4 border-black rounded-xl overflow-hidden shadow-lg">
<div
class="p-8 cursor-pointer hover:bg-orange-50 transition-colors"
:class="{ 'bg-orange-100': focusedStep === 5 }"
@click="setFocusedStep(5)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
:class="
canComplete
? 'bg-green-100 text-green-700'
: 'bg-white text-black border-2 border-black'
">
<UIcon
v-if="canComplete"
name="i-heroicons-check"
class="w-4 h-4" />
<span v-else>5</span>
</div>
<div>
<h3 class="text-2xl font-black text-black">
Review & finish
</h3>
</div>
</div>
<UIcon
name="i-heroicons-chevron-down"
class="w-6 h-6 text-black transition-transform font-bold"
:class="{ 'rotate-180': focusedStep === 5 }" />
</div>
</div>
<div v-if="focusedStep === 5" class="p-8 bg-orange-25">
<WizardReviewStep @complete="completeWizard" @reset="resetWizard" />
</div>
</div>
<!-- Progress Actions -->
<div class="flex justify-between items-center pt-8">
<UButton
variant="outline"
color="red"
@click="resetWizard"
:disabled="isResetting">
Start Over
</UButton>
<div class="flex items-center gap-4">
<!-- Save status -->
<div class="flex items-center gap-2 text-sm">
<UIcon
v-if="saveStatus === 'saving'"
name="i-heroicons-arrow-path"
class="w-4 h-4 animate-spin text-neutral-500" />
<UIcon
v-if="saveStatus === 'saved'"
name="i-heroicons-check-circle"
class="w-4 h-4 text-green-500" />
<span v-if="saveStatus === 'saving'" class="text-neutral-500"
>Saving...</span
>
<span v-if="saveStatus === 'saved'" class="text-green-600"
>Saved</span
>
</div>
<UButton
v-if="canComplete"
@click="completeWizard"
size="lg"
variant="solid"
color="black">
Complete Setup
</UButton>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
// Stores
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const wizardStore = useWizardStore();
// UI state
const focusedStep = ref(1);
const saveStatus = ref("");
const isResetting = ref(false);
const isCompleted = ref(false);
// Computed validation
const canComplete = computed(
() =>
membersStore.isValid &&
policiesStore.isValid &&
streamsStore.hasValidStreams
);
// Save status handler
function handleSaveStatus(status: "saving" | "saved" | "error") {
saveStatus.value = status;
if (status === "saved") {
// Clear status after delay
setTimeout(() => {
if (saveStatus.value === "saved") {
saveStatus.value = "";
}
}, 2000);
}
}
// Step management
function setFocusedStep(step: number) {
// Toggle if clicking on already focused step
if (focusedStep.value === step) {
focusedStep.value = 0; // Close the section
} else {
focusedStep.value = step; // Open the section
}
}
function completeWizard() {
// Mark setup as complete and show restart button for testing
isCompleted.value = true;
}
async function resetWizard() {
isResetting.value = true;
// Reset all stores
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
// Reset wizard state
wizardStore.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
async function restartWizard() {
isResetting.value = true;
// Reset completion state
isCompleted.value = false;
focusedStep.value = 1;
// Reset all stores and wizard state
membersStore.resetMembers();
policiesStore.resetPolicies();
streamsStore.resetStreams();
budgetStore.resetBudgetOverhead();
wizardStore.reset();
saveStatus.value = "";
// Small delay for UX
await new Promise((resolve) => setTimeout(resolve, 300));
isResetting.value = false;
}
// SEO
useSeoMeta({
title: "Setup Wizard - Configure Your Co-op",
description:
"Set up your co-op members, policies, costs, and revenue streams.",
});
</script>

View file

@ -1,11 +1,7 @@
<template> <template>
<div> <div>
<!-- Wizard Subnav -->
<WizardSubnav />
<div <div
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8" class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
style="font-family: 'Ubuntu', 'Ubuntu Mono', monospace"
> >
<div class="max-w-6xl mx-auto px-4 relative"> <div class="max-w-6xl mx-auto px-4 relative">
<div class="mb-8"> <div class="mb-8">
@ -72,69 +68,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-12 help-section">
<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"
>
<h2
class="text-xl font-semibold text-neutral-900 dark:text-white mb-3"
style="font-family: 'Ubuntu', monospace"
>
How Wizards Work
</h2>
<div class="grid md:grid-cols-2 gap-6 text-neutral-900 dark:text-neutral-100">
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace"
>
FILL OUT FORMS
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Wizards include form fields for all necessary information. Data
auto-saves as you type.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace"
>
LOCAL STORAGE
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
All data saves in your browser only. Nothing is sent to external
servers.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace"
>
EXPORT OPTIONS
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Download as PDF, plain text, Markdown.
</p>
</div>
<div>
<h3
class="font-medium mb-2 text-neutral-900 dark:text-white"
style="font-family: 'Ubuntu Mono', monospace"
>
RESUME ANYTIME
</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-200">
Come back later and your progress will be saved. Clear browser data to
start fresh.
</p>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -158,7 +91,7 @@ const templates = [
}, },
{ {
id: "conflict-resolution-framework", id: "conflict-resolution-framework",
name: "Conflict Resolution Framework", name: "Conflict Resolution",
description: description:
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.", "A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
icon: "i-heroicons-scale", icon: "i-heroicons-scale",
@ -310,35 +243,4 @@ useHead({
background: white; background: white;
z-index: -1; z-index: -1;
} }
.disabled-button {
opacity: 0.6;
cursor: not-allowed;
}
.template-card > *,
.help-section > *,
button,
.px-4,
div[class*="border"] {
border-radius: 0 !important;
}
* {
border-radius: 0 !important;
font-family: "Ubuntu", monospace;
}
html.dark :deep(.text-neutral-700),
html.dark :deep(.text-neutral-500),
html.dark :deep(.bg-neutral-50),
html.dark :deep(.bg-neutral-100) {
color: white !important;
background-color: #0a0a0a !important;
}
:deep(.border-neutral-200),
:deep(.border-neutral-300) {
border-color: black !important;
}
</style> </style>

View file

@ -0,0 +1,40 @@
import type { Member, SkillTag, ProblemTag } from "~/types/coaching";
import { skillsCatalog, problemsCatalog } from "~/data/skillsProblems";
export const membersSample: Member[] = [
{
id: "sample-1",
name: "Maya Chen",
role: "Design Lead",
hourly: 32,
availableHrs: 40
},
{
id: "sample-2",
name: "Alex Rivera",
role: "Developer",
hourly: 45,
availableHrs: 30
},
{
id: "sample-3",
name: "Jordan Blake",
role: "Content Writer",
hourly: 28,
availableHrs: 20
}
];
export const skillsCatalogSample: SkillTag[] = skillsCatalog;
export const problemsCatalogSample: ProblemTag[] = problemsCatalog;
// Pre-selected sample data for quick demos
export const sampleSelections = {
selectedSkillsByMember: {
"sample-1": ["design", "facilitation"], // Maya: Design + Facilitation
"sample-2": ["dev", "pm"], // Alex: Dev + PM
"sample-3": ["writing", "marketing"] // Jordan: Writing + Marketing
},
selectedProblems: ["unclear-pitch", "need-landing-store-page"]
};

View file

@ -4,24 +4,143 @@ export const useBudgetStore = defineStore(
"budget", "budget",
() => { () => {
// Schema version for persistence // Schema version for persistence
const schemaVersion = "1.0"; const schemaVersion = "2.0";
// Monthly budget lines by period (YYYY-MM) // Canonical categories from WizardRevenueStep - matches the wizard exactly
const revenueCategories = ref([
'Games & Products',
'Services & Contracts',
'Grants & Funding',
'Community Support',
'Partnerships',
'Investment Income',
'In-Kind Contributions'
]);
// Revenue subcategories by main category (for reference and grouping)
const revenueSubcategories = ref({
'Games & Products': ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'],
'Services & Contracts': ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'],
'Grants & Funding': ['Government funding', 'Arts council grants', 'Foundation support', 'Research grants'],
'Community Support': ['Patreon/subscriptions', 'Crowdfunding', 'Donations', 'Mutual aid received'],
'Partnerships': ['Corporate partnerships', 'Academic partnerships', 'Sponsorships'],
'Investment Income': ['Impact investment', 'Venture capital', 'Loans'],
'In-Kind Contributions': ['Office space', 'Equipment/hardware', 'Software licenses', 'Professional services', 'Marketing/PR services', 'Legal services']
});
const expenseCategories = ref([
'Salaries & Benefits',
'Development Costs',
'Equipment & Technology',
'Marketing & Outreach',
'Office & Operations',
'Legal & Professional',
'Other Expenses'
]);
// NEW: Budget worksheet structure (starts empty, populated from wizard data)
const budgetWorksheet = ref({
revenue: [],
expenses: []
});
// Track if worksheet has been initialized from wizard data
const isInitialized = ref(false);
// LEGACY: Keep for backward compatibility
const budgetLines = ref({}); const budgetLines = ref({});
// Overhead costs (recurring monthly)
const overheadCosts = ref([]); const overheadCosts = ref([]);
// Production costs (variable monthly)
const productionCosts = ref([]); const productionCosts = ref([]);
// Current selected period - use current month/year // Computed grouped data for category headers
const groupedRevenue = computed(() => {
const groups = {};
revenueCategories.value.forEach(category => {
groups[category] = budgetWorksheet.value.revenue.filter(item => item.mainCategory === category);
});
return groups;
});
const groupedExpenses = computed(() => {
const groups = {};
expenseCategories.value.forEach(category => {
groups[category] = budgetWorksheet.value.expenses.filter(item => item.mainCategory === category);
});
return groups;
});
// Computed totals for budget worksheet (legacy - keep for backward compatibility)
const budgetTotals = computed(() => {
const years = ['year1', 'year2', 'year3'];
const scenarios = ['best', 'worst', 'mostLikely'];
const totals = {};
years.forEach(year => {
totals[year] = {};
scenarios.forEach(scenario => {
// Calculate revenue total
const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => {
return sum + (item.values?.[year]?.[scenario] || 0);
}, 0);
// Calculate expenses total
const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => {
return sum + (item.values?.[year]?.[scenario] || 0);
}, 0);
// Calculate net income
const netIncome = revenueTotal - expensesTotal;
totals[year][scenario] = {
revenue: revenueTotal,
expenses: expensesTotal,
net: netIncome
};
});
});
return totals;
});
// Monthly totals computation
const monthlyTotals = computed(() => {
const totals = {};
// Generate month keys for next 12 months
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')}`;
// Calculate revenue total for this month
const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Calculate expenses total for this month
const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Calculate net income
const netIncome = revenueTotal - expensesTotal;
totals[monthKey] = {
revenue: revenueTotal,
expenses: expensesTotal,
net: netIncome
};
}
return totals;
});
// LEGACY: Keep for backward compatibility
const currentDate = new Date(); const currentDate = new Date();
const currentYear = currentDate.getFullYear(); const currentYear = currentDate.getFullYear();
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0'); const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
const currentPeriod = ref(`${currentYear}-${currentMonth}`); const currentPeriod = ref(`${currentYear}-${currentMonth}`);
// Computed current budget
const currentBudget = computed(() => { const currentBudget = computed(() => {
return ( return (
budgetLines.value[currentPeriod.value] || { budgetLines.value[currentPeriod.value] || {
@ -100,13 +219,418 @@ export const useBudgetStore = defineStore(
currentPeriod.value = period; currentPeriod.value = period;
} }
// Initialize worksheet from wizard data
async function initializeFromWizardData() {
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
console.log('Already initialized with data, skipping...');
return;
}
console.log('Initializing budget from wizard data...');
// Import stores dynamically to avoid circular deps
const { useStreamsStore } = await import('./streams');
const { useMembersStore } = await import('./members');
const { usePoliciesStore } = await import('./policies');
const streamsStore = useStreamsStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
console.log('Streams:', streamsStore.streams.length, 'streams');
console.log('Members capacity:', membersStore.capacityTotals);
console.log('Policies wage:', policiesStore.equalHourlyWage);
// Clear existing data
budgetWorksheet.value.revenue = [];
budgetWorksheet.value.expenses = [];
// Add revenue streams from wizard
if (streamsStore.streams.length === 0) {
console.log('No wizard streams found, adding sample data');
// Initialize with minimal demo if no wizard data exists
await streamsStore.initializeWithFixtures();
}
streamsStore.streams.forEach(stream => {
const monthlyAmount = stream.targetMonthlyAmount || 0;
console.log('Adding stream:', stream.name, 'category:', stream.category, 'subcategory:', stream.subcategory, 'amount:', monthlyAmount);
console.log('Full stream object:', stream);
// Simple category mapping - just map the key categories we know exist
let mappedCategory = 'Games & Products'; // Default
const categoryLower = (stream.category || '').toLowerCase();
if (categoryLower === 'games' || categoryLower === 'product') mappedCategory = 'Games & Products';
else if (categoryLower === 'services' || categoryLower === 'service') mappedCategory = 'Services & Contracts';
else if (categoryLower === 'grants' || categoryLower === 'grant') mappedCategory = 'Grants & Funding';
else if (categoryLower === 'community') mappedCategory = 'Community Support';
else if (categoryLower === 'partnerships' || categoryLower === 'partnership') mappedCategory = 'Partnerships';
else if (categoryLower === 'investment') mappedCategory = 'Investment Income';
console.log('Mapped category from', stream.category, 'to', mappedCategory);
// Create monthly values - split the annual target evenly across 12 months
const monthlyValues = {};
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')}`;
monthlyValues[monthKey] = monthlyAmount;
}
console.log('Created monthly values for', stream.name, ':', monthlyValues);
budgetWorksheet.value.revenue.push({
id: `revenue-${stream.id}`,
name: stream.name,
mainCategory: mappedCategory,
subcategory: stream.subcategory || 'Direct sales', // Use actual subcategory from stream
source: 'wizard',
monthlyValues,
values: {
year1: { best: monthlyAmount * 12, worst: monthlyAmount * 6, mostLikely: monthlyAmount * 10 },
year2: { best: monthlyAmount * 15, worst: monthlyAmount * 8, mostLikely: monthlyAmount * 12 },
year3: { best: monthlyAmount * 18, worst: monthlyAmount * 10, mostLikely: monthlyAmount * 15 }
}
});
});
// Add payroll from wizard data
const totalHours = membersStore.capacityTotals.targetHours || 0;
const hourlyWage = policiesStore.equalHourlyWage || 0;
const oncostPct = policiesStore.payrollOncostPct || 0;
if (totalHours > 0 && hourlyWage > 0) {
const monthlyPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
// Create monthly values for payroll
const monthlyValues = {};
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')}`;
monthlyValues[monthKey] = monthlyPayroll;
}
budgetWorksheet.value.expenses.push({
id: 'expense-payroll',
name: 'Payroll',
mainCategory: 'Salaries & Benefits',
subcategory: 'Base wages and benefits',
source: 'wizard',
monthlyValues,
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 }
}
});
}
// Add overhead costs from wizard
overheadCosts.value.forEach(cost => {
if (cost.amount > 0) {
const annualAmount = cost.amount * 12;
// Map overhead cost categories to expense categories
let expenseCategory = 'Other Expenses'; // Default
if (cost.category === 'Operations') expenseCategory = 'Office & Operations';
else if (cost.category === 'Technology') expenseCategory = 'Equipment & Technology';
else if (cost.category === 'Legal') expenseCategory = 'Legal & Professional';
else if (cost.category === 'Marketing') expenseCategory = 'Marketing & Outreach';
// Create monthly values for overhead costs
const monthlyValues = {};
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')}`;
monthlyValues[monthKey] = cost.amount;
}
budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`,
name: cost.name,
mainCategory: expenseCategory,
subcategory: cost.name, // Use the cost name as subcategory
source: 'wizard',
monthlyValues,
values: {
year1: { best: annualAmount, worst: annualAmount * 0.8, mostLikely: annualAmount },
year2: { best: annualAmount * 1.1, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.05 },
year3: { best: annualAmount * 1.2, worst: annualAmount, mostLikely: annualAmount * 1.1 }
}
});
}
});
// Add production costs from wizard
productionCosts.value.forEach(cost => {
if (cost.amount > 0) {
const annualAmount = cost.amount * 12;
// Create monthly values for production costs
const monthlyValues = {};
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')}`;
monthlyValues[monthKey] = cost.amount;
}
budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`,
name: cost.name,
mainCategory: 'Development Costs',
subcategory: cost.name, // Use the cost name as subcategory
source: 'wizard',
monthlyValues,
values: {
year1: { best: annualAmount, worst: annualAmount * 0.7, mostLikely: annualAmount * 0.9 },
year2: { best: annualAmount * 1.2, worst: annualAmount * 0.8, mostLikely: annualAmount },
year3: { best: annualAmount * 1.3, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.1 }
}
});
}
});
// If still no data after initialization, add a sample row
if (budgetWorksheet.value.revenue.length === 0) {
console.log('Adding sample revenue line');
// Create monthly values for sample revenue
const monthlyValues = {};
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')}`;
monthlyValues[monthKey] = 667; // ~8000/12
}
budgetWorksheet.value.revenue.push({
id: 'revenue-sample',
name: 'Sample Revenue',
mainCategory: 'Games & Products',
subcategory: 'Direct sales',
source: 'user',
monthlyValues,
values: {
year1: { best: 10000, worst: 5000, mostLikely: 8000 },
year2: { best: 12000, worst: 6000, mostLikely: 10000 },
year3: { best: 15000, worst: 8000, mostLikely: 12000 }
}
});
}
if (budgetWorksheet.value.expenses.length === 0) {
console.log('Adding sample expense line');
// Create monthly values for sample expense
const monthlyValues = {};
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')}`;
monthlyValues[monthKey] = 67; // ~800/12
}
budgetWorksheet.value.expenses.push({
id: 'expense-sample',
name: 'Sample Expense',
mainCategory: 'Other Expenses',
subcategory: 'Miscellaneous',
source: 'user',
monthlyValues,
values: {
year1: { best: 1000, worst: 500, mostLikely: 800 },
year2: { best: 1200, worst: 600, mostLikely: 1000 },
year3: { best: 1500, worst: 800, mostLikely: 1200 }
}
});
}
// Debug: Log all revenue items and their categories
console.log('Final revenue items:');
budgetWorksheet.value.revenue.forEach(item => {
console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`);
});
console.log('Final expense items:');
budgetWorksheet.value.expenses.forEach(item => {
console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`);
});
// Ensure all items have monthlyValues and new structure - migrate existing items
[...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => {
// Migrate to new structure if needed
if (item.category && !item.mainCategory) {
console.log('Migrating item structure for:', item.name);
item.mainCategory = item.category; // Old category becomes mainCategory
// Set appropriate subcategory based on the main category and item name
if (item.category === 'Games & Products') {
const gameSubcategories = ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'];
item.subcategory = gameSubcategories.includes(item.name) ? item.name : 'Direct sales';
} else if (item.category === 'Services & Contracts') {
const serviceSubcategories = ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'];
item.subcategory = serviceSubcategories.includes(item.name) ? item.name : 'Contract development';
} else if (item.category === 'Investment Income') {
const investmentSubcategories = ['Impact investment', 'Venture capital', 'Loans'];
item.subcategory = investmentSubcategories.includes(item.name) ? item.name : 'Impact investment';
} else if (item.category === 'Salaries & Benefits') {
item.subcategory = 'Base wages and benefits';
} else if (item.category === 'Office & Operations') {
// Map specific office tools to appropriate subcategories
if (item.name.toLowerCase().includes('rent') || item.name.toLowerCase().includes('office')) {
item.subcategory = 'Rent';
} else if (item.name.toLowerCase().includes('util')) {
item.subcategory = 'Utilities';
} else if (item.name.toLowerCase().includes('insurance')) {
item.subcategory = 'Insurance';
} else {
item.subcategory = 'Office supplies';
}
} else {
// For other categories, use appropriate default
item.subcategory = 'Miscellaneous';
}
delete item.category; // Remove old property
}
if (!item.monthlyValues) {
console.log('Migrating item to monthly values:', item.name);
item.monthlyValues = {};
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')}`;
// Try to use most likely value divided by 12, or default to 0
const yearlyValue = item.values?.year1?.mostLikely || 0;
item.monthlyValues[monthKey] = Math.round(yearlyValue / 12);
}
console.log('Added monthly values to', item.name, ':', item.monthlyValues);
}
});
console.log('Initialization complete. Revenue items:', budgetWorksheet.value.revenue.length, 'Expense items:', budgetWorksheet.value.expenses.length);
isInitialized.value = true;
}
// NEW: Budget worksheet functions
function updateBudgetValue(category, itemId, year, scenario, value) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.values[year][scenario] = Number(value) || 0;
}
}
function updateMonthlyValue(category, itemId, monthKey, value) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
if (!item.monthlyValues) {
item.monthlyValues = {};
}
item.monthlyValues[monthKey] = Number(value) || 0;
}
}
function addBudgetItem(category, name, selectedCategory = '') {
const id = `${category}-${Date.now()}`;
// Create empty monthly values for next 12 months
const monthlyValues = {};
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')}`;
monthlyValues[monthKey] = 0;
}
const newItem = {
id,
name,
mainCategory: selectedCategory || (category === 'revenue' ? 'Games & Products' : 'Other Expenses'),
subcategory: '', // Will be set by user via dropdown
source: 'user',
monthlyValues,
values: {
year1: { best: 0, worst: 0, mostLikely: 0 },
year2: { best: 0, worst: 0, mostLikely: 0 },
year3: { best: 0, worst: 0, mostLikely: 0 }
}
};
budgetWorksheet.value[category].push(newItem);
return id;
}
function removeBudgetItem(category, itemId) {
const items = budgetWorksheet.value[category];
const index = items.findIndex(i => i.id === itemId);
if (index > -1) {
items.splice(index, 1);
}
}
function renameBudgetItem(category, itemId, newName) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.name = newName;
}
}
function updateBudgetCategory(category, itemId, newCategory) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.category = newCategory;
}
}
function addCustomCategory(type, categoryName) {
if (type === 'revenue' && !revenueCategories.value.includes(categoryName)) {
revenueCategories.value.push(categoryName);
} else if (type === 'expenses' && !expenseCategories.value.includes(categoryName)) {
expenseCategories.value.push(categoryName);
}
}
// Reset function // Reset function
function resetBudgetOverhead() { function resetBudgetOverhead() {
overheadCosts.value = []; overheadCosts.value = [];
productionCosts.value = []; productionCosts.value = [];
} }
function resetBudgetWorksheet() {
// Reset all values to 0 but keep the structure
[...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => {
Object.keys(item.values).forEach(year => {
Object.keys(item.values[year]).forEach(scenario => {
item.values[year][scenario] = 0;
});
});
});
}
return { return {
// NEW: Budget worksheet
budgetWorksheet,
budgetTotals,
monthlyTotals,
revenueCategories,
expenseCategories,
revenueSubcategories,
groupedRevenue,
groupedExpenses,
isInitialized,
initializeFromWizardData,
updateBudgetValue,
updateMonthlyValue,
addBudgetItem,
removeBudgetItem,
renameBudgetItem,
updateBudgetCategory,
addCustomCategory,
resetBudgetWorksheet,
// LEGACY: Keep for backward compatibility
budgetLines, budgetLines,
overheadCosts, overheadCosts,
productionCosts, productionCosts,
@ -128,7 +652,7 @@ export const useBudgetStore = defineStore(
{ {
persist: { persist: {
key: "urgent-tools-budget", key: "urgent-tools-budget",
paths: ["overheadCosts", "productionCosts", "currentPeriod"], paths: ["budgetWorksheet", "revenueCategories", "expenseCategories", "isInitialized", "overheadCosts", "productionCosts", "currentPeriod"],
}, },
} }
); );

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export const useWizardStore = defineStore( export const useCoopBuilderStore = defineStore(
"wizard", "coop-builder",
() => { () => {
const currentStep = ref(1); const currentStep = ref(1);

22
stores/plan.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineStore } from 'pinia';
import type { Member, Offer } from '~/types/coaching';
import type { StreamRow } from '~/utils/offerToStream';
import { offersToStreams } from '~/utils/offerToStream';
export const usePlanStore = defineStore('plan', {
state: () => ({
members: [] as Member[],
streams: [] as StreamRow[]
}),
actions: {
setMembers(m: Member[]) {
this.members = m;
},
addStreamsFromOffers(o: Offer[]) {
const newStreams = offersToStreams(o, this.members);
this.streams.push(...newStreams);
}
}
});

View file

@ -87,6 +87,31 @@ export const useStreamsStore = defineStore(
} }
} }
// Initialize with fixture data if empty
async function initializeWithFixtures() {
if (streams.value.length === 0) {
const { useFixtures } = await import('~/composables/useFixtures');
const fixtures = useFixtures();
const { revenueStreams } = await fixtures.loadStreams();
revenueStreams.forEach(stream => {
upsertStream(stream);
});
}
}
// Load realistic demo data (for better user experience)
async function loadDemoData() {
resetStreams();
const { useFixtures } = await import('~/composables/useFixtures');
const fixtures = useFixtures();
const { revenueStreams } = await fixtures.loadStreams();
revenueStreams.forEach(stream => {
upsertStream(stream);
});
}
// Reset function // Reset function
function resetStreams() { function resetStreams() {
streams.value = []; streams.value = [];
@ -102,6 +127,8 @@ export const useStreamsStore = defineStore(
// Wizard actions // Wizard actions
upsertStream, upsertStream,
resetStreams, resetStreams,
initializeWithFixtures,
loadDemoData,
// Legacy actions // Legacy actions
addStream, addStream,
updateStream, updateStream,

View file

@ -1,14 +1,5 @@
import type { Config } from 'tailwindcss' import type { Config } from "tailwindcss";
export default { export default {
darkMode: 'class', darkMode: "class",
theme: { } satisfies Config;
extend: {
colors: {
neutral: {
950: '#0a0a0a'
}
}
}
}
} satisfies Config

View file

@ -0,0 +1,431 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { nextTick } from 'vue';
// Import components and utilities
import CoachSkillsToOffers from '~/pages/coach/skills-to-offers.vue';
import WizardRevenueStep from '~/components/WizardRevenueStep.vue';
import { useOfferSuggestor } from '~/composables/useOfferSuggestor';
import { usePlanStore } from '~/stores/plan';
import { offerToStream, offersToStreams } from '~/utils/offerToStream';
import {
membersSample,
skillsCatalogSample,
problemsCatalogSample,
sampleSelections
} from '~/sample/skillsToOffersSamples';
// Mock router
vi.mock('vue-router', () => ({
useRouter: () => ({
push: vi.fn()
})
}));
// Mock stores
const mockStreamsStore = {
streams: [],
upsertStream: vi.fn(),
removeStream: vi.fn()
};
vi.mock('~/stores/streams', () => ({
useStreamsStore: () => mockStreamsStore
}));
describe('Coach Integration Tests', () => {
let pinia: any;
let planStore: any;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
planStore = usePlanStore();
// Reset stores
planStore.members = [];
planStore.streams = [];
mockStreamsStore.streams = [];
// Clear mocks
vi.clearAllMocks();
});
describe('Offer Generation with Sample Data', () => {
it('generates "Pitch Polish (2 days)" offer for Design+Writing and "Unclear pitch"', () => {
const { suggestOffers } = useOfferSuggestor();
// Setup input with sample data
const input = {
members: membersSample,
selectedSkillsByMember: {
[membersSample[0].id]: ['design'], // Maya: Design
[membersSample[2].id]: ['writing'] // Jordan: Writing
},
selectedProblems: ['unclear-pitch']
};
const catalogs = {
skills: skillsCatalogSample,
problems: problemsCatalogSample
};
// Generate offers
const offers = suggestOffers(input, catalogs);
// Should generate at least one offer
expect(offers.length).toBeGreaterThan(0);
// Should include Pitch Polish offer
const pitchPolishOffer = offers.find(offer =>
offer.name.includes('Pitch Polish') && offer.name.includes('2 days')
);
expect(pitchPolishOffer).toBeDefined();
expect(pitchPolishOffer?.name).toBe('Pitch Polish (2 days)');
});
it('calculates baseline price using correct formula: sum(hours*hourly*1.25) * 1.10', () => {
const { suggestOffers } = useOfferSuggestor();
const input = {
members: membersSample,
selectedSkillsByMember: {
[membersSample[0].id]: ['design'], // Maya: 32€/h
[membersSample[2].id]: ['writing'] // Jordan: 28€/h
},
selectedProblems: ['unclear-pitch']
};
const catalogs = {
skills: skillsCatalogSample,
problems: problemsCatalogSample
};
const offers = suggestOffers(input, catalogs);
const pitchPolishOffer = offers.find(offer =>
offer.name.includes('Pitch Polish')
);
expect(pitchPolishOffer).toBeDefined();
// Calculate expected price manually
// Pitch Polish is 2 days = 16 hours total (8 hours per day)
// Assume hours are distributed between Maya and Jordan
let expectedCost = 0;
for (const allocation of pitchPolishOffer!.hoursByMember) {
const member = membersSample.find(m => m.id === allocation.memberId);
if (member) {
expectedCost += allocation.hours * member.hourly * 1.25;
}
}
const expectedBaseline = Math.round(expectedCost * 1.10);
// Allow for ±1 rounding difference
expect(pitchPolishOffer!.price.baseline).toBeCloseTo(expectedBaseline, 0);
expect(Math.abs(pitchPolishOffer!.price.baseline - expectedBaseline)).toBeLessThanOrEqual(1);
});
it('generates offers that include required whyThis and riskNotes', () => {
const { suggestOffers } = useOfferSuggestor();
const input = {
members: membersSample,
selectedSkillsByMember: sampleSelections.selectedSkillsByMember,
selectedProblems: sampleSelections.selectedProblems
};
const catalogs = {
skills: skillsCatalogSample,
problems: problemsCatalogSample
};
const offers = suggestOffers(input, catalogs);
expect(offers.length).toBeGreaterThan(0);
offers.forEach(offer => {
expect(offer.whyThis).toBeDefined();
expect(offer.whyThis.length).toBeGreaterThan(0);
expect(offer.riskNotes).toBeDefined();
expect(offer.riskNotes.length).toBeGreaterThan(0);
expect(offer.price.calcNote).toBeDefined();
expect(offer.price.calcNote.length).toBeGreaterThan(0);
});
});
});
describe('Coach Page Integration', () => {
it('loads sample data and generates offers automatically', async () => {
const wrapper = mount(CoachSkillsToOffers, {
global: {
plugins: [pinia]
}
});
// Trigger sample data loading
await wrapper.vm.loadSampleData();
await nextTick();
// Wait for debounced offer generation
await new Promise(resolve => setTimeout(resolve, 350));
// Should have loaded sample members
expect(wrapper.vm.members).toEqual(membersSample);
// Should have pre-selected skills and problems
expect(wrapper.vm.selectedSkills).toEqual(sampleSelections.selectedSkillsByMember);
expect(wrapper.vm.selectedProblems).toEqual(sampleSelections.selectedProblems);
// Should have generated offers
expect(wrapper.vm.offers).toBeDefined();
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
});
it('handles "Use these" action correctly', async () => {
const wrapper = mount(CoachSkillsToOffers, {
global: {
plugins: [pinia]
}
});
// Load sample data and generate offers
await wrapper.vm.loadSampleData();
await nextTick();
await new Promise(resolve => setTimeout(resolve, 350));
// Ensure we have offers
expect(wrapper.vm.offers?.length).toBeGreaterThan(0);
const initialOffers = wrapper.vm.offers!;
// Trigger "Use these" action
await wrapper.vm.useOffers();
// Should have added streams to plan store
expect(planStore.streams.length).toBe(initialOffers.length);
// Verify streams are properly converted
planStore.streams.forEach((stream: any, index: number) => {
const originalOffer = initialOffers[index];
expect(stream.id).toBe(`offer-${originalOffer.id}`);
expect(stream.name).toBe(originalOffer.name);
expect(stream.unitPrice).toBe(originalOffer.price.baseline);
expect(stream.payoutDelayDays).toBe(originalOffer.payoutDelayDays);
expect(stream.feePercent).toBe(3);
expect(stream.notes).toBe(originalOffer.whyThis.join('. '));
});
});
});
describe('Stream Conversion and Validation', () => {
it('converts offers to streams with correct structure', () => {
const mockOffer = {
id: 'test-offer',
name: 'Test Offer (5 days)',
scope: ['Test scope item'],
hoursByMember: [
{ memberId: membersSample[0].id, hours: 20 },
{ memberId: membersSample[1].id, hours: 15 }
],
price: { baseline: 2000, stretch: 2400, calcNote: 'Test calculation' },
payoutDelayDays: 30,
whyThis: ['Test reason 1', 'Test reason 2'],
riskNotes: ['Test risk']
};
const stream = offerToStream(mockOffer, membersSample);
expect(stream.id).toBe('offer-test-offer');
expect(stream.name).toBe('Test Offer (5 days)');
expect(stream.unitPrice).toBe(2000);
expect(stream.payoutDelayDays).toBe(30);
expect(stream.feePercent).toBe(3);
expect(stream.notes).toBe('Test reason 1. Test reason 2');
expect(stream.restrictions).toBe('General');
expect(stream.certainty).toBe('Probable');
});
it('handles batch conversion correctly', () => {
const offers = [
{
id: 'offer1',
name: 'Workshop Offer',
scope: [],
hoursByMember: [{ memberId: membersSample[0].id, hours: 8 }],
price: { baseline: 800, stretch: 960, calcNote: 'Workshop calc' },
payoutDelayDays: 14,
whyThis: ['Quick workshop'],
riskNotes: ['Time constraint']
},
{
id: 'offer2',
name: 'Sprint Offer',
scope: [],
hoursByMember: [{ memberId: membersSample[1].id, hours: 40 }],
price: { baseline: 2000, stretch: 2400, calcNote: 'Sprint calc' },
payoutDelayDays: 45,
whyThis: ['Full development'],
riskNotes: ['Scope creep']
}
];
const streams = offersToStreams(offers, membersSample);
expect(streams.length).toBe(2);
expect(streams[0].name).toBe('Workshop Offer');
expect(streams[1].name).toBe('Sprint Offer');
expect(streams[0].unitsPerMonth).toBe(1); // Workshop
expect(streams[1].unitsPerMonth).toBe(0); // Sprint
});
});
describe('Wizard Integration', () => {
it('displays coach streams with proper highlighting', () => {
// Add coach streams to plan store
planStore.streams = [
{
id: 'offer-test',
name: 'Test Coach Offer',
unitPrice: 1500,
unitsPerMonth: 0, // Needs setup
payoutDelayDays: 30,
feePercent: 3,
notes: 'AI-suggested offer',
targetMonthlyAmount: 0,
category: 'services'
}
];
const wrapper = mount(WizardRevenueStep, {
global: {
plugins: [pinia]
}
});
// Should show coach stream with special styling
const coachStreamElements = wrapper.findAll('[data-testid="coach-stream"]');
expect(coachStreamElements.length).toBeGreaterThan(0);
// Should show hint about setting units
expect(wrapper.text()).toContain('Setting just 1 unit per offer');
});
it('allows proceeding to Review when unitsPerMonth is set to 1', () => {
// Setup coach stream with units set
planStore.streams = [
{
id: 'offer-test',
name: 'Test Coach Offer',
unitPrice: 1500,
unitsPerMonth: 1,
payoutDelayDays: 30,
feePercent: 3,
targetMonthlyAmount: 1500,
category: 'services'
}
];
const wrapper = mount(WizardRevenueStep, {
global: {
plugins: [pinia]
}
});
// Should not show setup hint when units are configured
expect(wrapper.text()).not.toContain('Setting just 1 unit per offer');
// Should have proper monthly amount calculated
const stream = planStore.streams[0];
expect(stream.targetMonthlyAmount).toBe(1500); // 1500 * 1
});
it('calculates summary metrics correctly', () => {
planStore.streams = [
{
id: 'offer-1',
targetMonthlyAmount: 3000,
payoutDelayDays: 30
},
{
id: 'offer-2',
targetMonthlyAmount: 2000,
payoutDelayDays: 45
}
];
const wrapper = mount(WizardRevenueStep, {
global: {
plugins: [pinia]
}
});
// Top source should be 60% (3000 out of 5000 total)
expect(wrapper.vm.getTopSourcePercentage()).toBe(60);
// Weighted payout should be 36 days ((3000*30 + 2000*45) / 5000)
expect(wrapper.vm.getWeightedPayoutDelay()).toBe(36);
});
});
describe('Error Handling and Edge Cases', () => {
it('handles empty selections gracefully', () => {
const { suggestOffers } = useOfferSuggestor();
const input = {
members: membersSample,
selectedSkillsByMember: {},
selectedProblems: []
};
const catalogs = {
skills: skillsCatalogSample,
problems: problemsCatalogSample
};
const offers = suggestOffers(input, catalogs);
// Should return empty array for invalid input
expect(offers).toEqual([]);
});
it('handles missing members gracefully', () => {
const mockOffer = {
id: 'test',
name: 'Test',
scope: [],
hoursByMember: [{ memberId: 'nonexistent', hours: 10 }],
price: { baseline: 1000, stretch: 1200, calcNote: 'Test' },
payoutDelayDays: 30,
whyThis: ['Test'],
riskNotes: ['Test']
};
expect(() => {
offerToStream(mockOffer, membersSample);
}).toThrow('Member not found: nonexistent');
});
it('handles zero amounts in calculations', () => {
planStore.streams = [
{
id: 'test-1',
targetMonthlyAmount: 0,
payoutDelayDays: 30
}
];
const wrapper = mount(WizardRevenueStep, {
global: {
plugins: [pinia]
}
});
expect(wrapper.vm.getTopSourcePercentage()).toBe(0);
expect(wrapper.vm.getWeightedPayoutDelay()).toBe(0);
});
});
});

29
types/coaching.ts Normal file
View file

@ -0,0 +1,29 @@
export type Member = {
id: string;
name: string;
role?: string;
hourly: number;
availableHrs: number;
};
export type SkillTag = {
id: string;
label: string;
};
export type ProblemTag = {
id: string;
label: string;
examples: string[];
};
export type Offer = {
id: string;
name: string;
scope: string[];
hoursByMember: Array<{ memberId: string; hours: number }>;
price: { baseline: number; stretch: number; calcNote: string };
payoutDelayDays: number;
whyThis: string[];
riskNotes: string[];
};

183
utils/offerToStream.ts Normal file
View file

@ -0,0 +1,183 @@
import type { Member, Offer } from "~/types/coaching";
interface StreamRow {
id: string;
name: string;
category: string;
subcategory: string;
targetPct: number;
targetMonthlyAmount: number;
certainty: "Committed" | "Probable" | "Aspirational";
payoutDelayDays: number;
terms: string;
revenueSharePct: number;
platformFeePct: number;
restrictions: "Restricted" | "General";
seasonalityWeights: number[];
effortHoursPerMonth: number;
// Extended fields for offer-derived streams
unitPrice?: number;
unitsPerMonth?: number;
feePercent?: number;
notes?: string;
costBreakdown?: {
totalHours: number;
totalCost: number;
memberCosts: Array<{
memberId: string;
memberName: string;
hours: number;
hourlyRate: number;
baseCost: number;
onCostAmount: number;
totalCost: number;
}>;
};
}
interface OfferToStreamOptions {
onCostPercent?: number; // Default 25% on-cost
category?: string; // Default category for offer-derived streams
}
export function offerToStream(
offer: Offer,
members: Member[],
options: OfferToStreamOptions = {}
): StreamRow {
const { onCostPercent = 25, category = "services" } = options;
// Create member lookup map
const memberMap = new Map(members.map(m => [m.id, m]));
// Determine units per month based on offer type
const unitsPerMonth = getUnitsPerMonth(offer);
// Calculate cost breakdown
const costBreakdown = calculateCostBreakdown(offer, memberMap, onCostPercent);
// Generate terms string
const terms = offer.payoutDelayDays <= 15 ? "Net 15" :
offer.payoutDelayDays <= 30 ? "Net 30" :
offer.payoutDelayDays <= 45 ? "Net 45" :
`Net ${offer.payoutDelayDays}`;
// Calculate monthly amount
const targetMonthlyAmount = unitsPerMonth > 0 ? offer.price.baseline * unitsPerMonth : 0;
return {
id: `offer-${offer.id}`,
name: offer.name,
category,
subcategory: inferSubcategory(offer.name),
targetPct: 0, // Will be calculated later when all streams are known
targetMonthlyAmount,
certainty: "Probable" as const, // Offers are more than aspirational but not fully committed
payoutDelayDays: offer.payoutDelayDays,
terms,
revenueSharePct: 0,
platformFeePct: 0,
restrictions: "General" as const,
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: calculateEffortHours(offer),
// Extended fields
unitPrice: offer.price.baseline,
unitsPerMonth,
feePercent: 3, // Default 3% processing/platform fee
notes: offer.whyThis.join(". "),
costBreakdown
};
}
export function offersToStreams(
offers: Offer[],
members: Member[],
options: OfferToStreamOptions = {}
): StreamRow[] {
return offers.map(offer => offerToStream(offer, members, options));
}
// Helper functions
function getUnitsPerMonth(offer: Offer): number {
const offerName = offer.name.toLowerCase();
// Clinics and workshops are typically one-time deliverables per month
if (offerName.includes("clinic") || offerName.includes("workshop")) {
return 1;
}
// Check for specific patterns that indicate recurring work
if (offerName.includes("retainer") || offerName.includes("ongoing")) {
return 1;
}
// Sprints and other project-based work default to 0 (custom/project basis)
return 0;
}
function inferSubcategory(offerName: string): string {
const name = offerName.toLowerCase();
if (name.includes("training") || name.includes("workshop")) {
return "Workshops/teaching";
}
if (name.includes("consulting") || name.includes("review") || name.includes("clinic")) {
return "Consulting";
}
if (name.includes("development") || name.includes("sprint") || name.includes("prototype")) {
return "Contract development";
}
if (name.includes("narrative") || name.includes("writing")) {
return "Technical services";
}
if (name.includes("art") || name.includes("pipeline")) {
return "Technical services";
}
return "Contract development"; // Default fallback
}
function calculateEffortHours(offer: Offer): number {
// Sum up total hours across all members
return offer.hoursByMember.reduce((total, allocation) => total + allocation.hours, 0);
}
function calculateCostBreakdown(
offer: Offer,
memberMap: Map<string, Member>,
onCostPercent: number
): StreamRow["costBreakdown"] {
const memberCosts = offer.hoursByMember.map(allocation => {
const member = memberMap.get(allocation.memberId);
if (!member) {
throw new Error(`Member not found: ${allocation.memberId}`);
}
const baseCost = allocation.hours * member.hourly;
const onCostAmount = baseCost * (onCostPercent / 100);
const totalCost = baseCost + onCostAmount;
return {
memberId: member.id,
memberName: member.name,
hours: allocation.hours,
hourlyRate: member.hourly,
baseCost,
onCostAmount,
totalCost
};
});
const totalHours = memberCosts.reduce((sum, mc) => sum + mc.hours, 0);
const totalCost = memberCosts.reduce((sum, mc) => sum + mc.totalCost, 0);
return {
totalHours,
totalCost,
memberCosts
};
}
// Type export for use in other files
export type { StreamRow };