refactor: replace Wizard with CoopBuilder in navigation, enhance budget store structure, and streamline template components for improved user experience
This commit is contained in:
parent
eede87a273
commit
f67b138d95
33 changed files with 4970 additions and 2451 deletions
90
app.vue
90
app.vue
|
|
@ -9,12 +9,11 @@
|
|||
<div class="relative flex items-center justify-center">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<UIcon
|
||||
name="i-heroicons-rocket-launch"
|
||||
class="text-primary-500" />
|
||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<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
|
||||
</h1>
|
||||
</NuxtLink>
|
||||
|
|
@ -25,49 +24,41 @@
|
|||
<nav
|
||||
class="mt-4 flex items-center justify-center gap-1"
|
||||
role="navigation"
|
||||
aria-label="Main navigation">
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<NuxtLink
|
||||
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="{
|
||||
'bg-neutral-100 dark:bg-neutral-800': isCoopSection,
|
||||
}">
|
||||
Co-Op in 6 Months
|
||||
'bg-neutral-100 dark:bg-neutral-800': isCoopBuilderSection,
|
||||
}"
|
||||
>
|
||||
Co-Op Builder
|
||||
</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
|
||||
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="{
|
||||
'bg-neutral-100 dark:bg-neutral-800':
|
||||
$route.path === '/wizards',
|
||||
}">
|
||||
'bg-neutral-100 dark:bg-neutral-800': $route.path === '/wizards',
|
||||
}"
|
||||
>
|
||||
Wizards
|
||||
</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>
|
||||
<div class="py-8">
|
||||
<CoopBuilderSubnav v-if="isCoopBuilderSection" />
|
||||
<WizardSubnav v-if="isWizardSection" />
|
||||
</div>
|
||||
</header>
|
||||
<NuxtPage />
|
||||
</UContainer>
|
||||
|
|
@ -82,17 +73,20 @@
|
|||
</template>
|
||||
|
||||
<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 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>
|
||||
|
|
|
|||
|
|
@ -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 "@nuxt/ui";
|
||||
|
||||
/* Ubuntu font import */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
|
||||
|
||||
[data-theme="dark"] {
|
||||
html { @apply bg-white text-neutral-900; }
|
||||
html.dark { @apply bg-neutral-950 text-neutral-100; }
|
||||
@theme {
|
||||
--font-body: "Ubuntu", "Inter", sans-serif;
|
||||
--font-mono: "Ubuntu Mono", monospace;
|
||||
}
|
||||
|
||||
/* Disable all animations, transitions, and smooth scrolling app-wide */
|
||||
html,
|
||||
body {
|
||||
scroll-behavior: auto !important;
|
||||
@apply font-body bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
/* All headers use Inter font */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* =========================
|
||||
TEMPLATE DOCUMENT LAYOUT
|
||||
========================= */
|
||||
|
|
@ -47,7 +43,7 @@ body {
|
|||
========================= */
|
||||
|
||||
.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 {
|
||||
|
|
@ -57,27 +53,24 @@ body {
|
|||
left: 4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
background: black;
|
||||
@apply bg-black dark:bg-white;
|
||||
background-image: radial-gradient(white 1px, transparent 1px);
|
||||
background-size: 2px 2px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
html.dark .section-card::before {
|
||||
background-image: radial-gradient(black 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: inherit;
|
||||
margin: 0 0 1rem 0;
|
||||
@apply text-3xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4;
|
||||
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 0.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-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
|
|
@ -121,55 +114,36 @@ body {
|
|||
}
|
||||
|
||||
.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 {
|
||||
background: #f3f4f6;
|
||||
@apply bg-neutral-100 dark:bg-neutral-700 outline-2 outline-blue-500 -outline-offset-2;
|
||||
box-shadow: none;
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.inline-field {
|
||||
display: inline-block;
|
||||
margin: 0 0.25rem;
|
||||
min-width: 120px;
|
||||
border: none;
|
||||
background: #f9fafb;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
@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;
|
||||
}
|
||||
|
||||
.inline-field:focus {
|
||||
background: #f3f4f6;
|
||||
outline: 1px solid #3b82f6;
|
||||
outline-offset: -1px;
|
||||
@apply bg-neutral-100 dark:bg-neutral-700 outline-1 outline-blue-500 -outline-offset-1;
|
||||
}
|
||||
|
||||
.number-field {
|
||||
min-width: 80px !important;
|
||||
text-align: center;
|
||||
@apply min-w-[80px] text-center;
|
||||
}
|
||||
|
||||
.wide-field {
|
||||
min-width: 250px !important;
|
||||
@apply min-w-[250px];
|
||||
}
|
||||
|
||||
.form-group-block .block-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
border: none;
|
||||
background: #f9fafb;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
@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;
|
||||
}
|
||||
|
||||
.form-group-block .block-field:focus {
|
||||
background: #f3f4f6;
|
||||
outline: 1px solid #3b82f6;
|
||||
outline-offset: -1px;
|
||||
@apply bg-neutral-100 dark:bg-neutral-700 outline-1 outline-blue-500 -outline-offset-1;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
|
|
@ -263,16 +237,75 @@ body {
|
|||
========================= */
|
||||
|
||||
.dither-shadow {
|
||||
background: black;
|
||||
@apply bg-black dark:bg-white;
|
||||
background-image: radial-gradient(white 1px, transparent 1px);
|
||||
background-size: 2px 2px;
|
||||
}
|
||||
|
||||
html.dark .dither-shadow {
|
||||
background: white;
|
||||
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
|
||||
========================= */
|
||||
|
|
@ -330,123 +363,71 @@ html.dark .export-btn.primary:hover {
|
|||
@apply bg-white text-black;
|
||||
}
|
||||
|
||||
/* General buttons with bitmap styling */
|
||||
button:not(.export-btn) {
|
||||
background: white !important;
|
||||
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;
|
||||
/* Bitmap button base styling - more targeted approach */
|
||||
.bitmap-style {
|
||||
@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;
|
||||
}
|
||||
|
||||
button:not(.export-btn):hover {
|
||||
background: black !important;
|
||||
color: white !important;
|
||||
transform: translateY(-1px) translateX(-1px) !important;
|
||||
.bitmap-style:hover {
|
||||
@apply bg-black dark:bg-white text-white dark:text-black;
|
||||
transform: translateY(-1px) translateX(-1px);
|
||||
}
|
||||
|
||||
/* Dark mode buttons */
|
||||
html.dark button:not(.export-btn) {
|
||||
background: #0a0a0a !important;
|
||||
border: 1px solid white !important;
|
||||
color: white !important;
|
||||
/* Bitmap button styling for template cards */
|
||||
.bitmap-button {
|
||||
@apply font-mono uppercase font-bold tracking-wider relative;
|
||||
}
|
||||
|
||||
html.dark button:not(.export-btn):hover {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
.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;
|
||||
@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
|
||||
========================= */
|
||||
|
||||
/* Remove all rounded corners */
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
font-family: "Ubuntu", monospace !important;
|
||||
|
||||
/* Bitmap form field styling - applied selectively */
|
||||
.bitmap-input {
|
||||
@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 */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
border: 1px solid black !important;
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
font-family: "Ubuntu Mono", monospace !important;
|
||||
.bitmap-input:focus {
|
||||
@apply outline-2 outline-black dark:outline-white -outline-offset-2 bg-white dark:bg-neutral-950;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: 2px solid black !important;
|
||||
outline-offset: -2px !important;
|
||||
background: white !important;
|
||||
/* Checkbox and radio button styling for bitmap theme */
|
||||
.bitmap-checkbox {
|
||||
@apply border-2 border-black dark:border-white bg-white dark:bg-neutral-950;
|
||||
}
|
||||
|
||||
/* Dark mode form fields */
|
||||
html.dark input,
|
||||
html.dark textarea,
|
||||
html.dark select {
|
||||
border: 1px solid white !important;
|
||||
background: #0a0a0a !important;
|
||||
color: white !important;
|
||||
.bitmap-checkbox:checked {
|
||||
@apply bg-black dark:bg-white text-white dark:text-black;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -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
|
||||
========================= */
|
||||
|
|
|
|||
67
components/BudgetCategorySelector.vue
Normal file
67
components/BudgetCategorySelector.vue
Normal 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>
|
||||
56
components/CoopBuilderSubnav.vue
Normal file
56
components/CoopBuilderSubnav.vue
Normal 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>
|
||||
|
|
@ -2,9 +2,8 @@
|
|||
<div class="export-options" :class="containerClass">
|
||||
<div class="export-content">
|
||||
<div class="export-section">
|
||||
<h3 class="export-title">Export Options:</h3>
|
||||
<div class="export-buttons">
|
||||
<button
|
||||
<UButton
|
||||
@click="copyToClipboard"
|
||||
class="export-btn"
|
||||
:disabled="isProcessing"
|
||||
|
|
@ -13,9 +12,9 @@
|
|||
<UIcon name="i-heroicons-clipboard" />
|
||||
<span>Copy as Text</span>
|
||||
<UIcon v-if="showCopySuccess" name="i-heroicons-check" class="success-icon" />
|
||||
</button>
|
||||
</UButton>
|
||||
|
||||
<button
|
||||
<UButton
|
||||
@click="downloadAsMarkdown"
|
||||
class="export-btn"
|
||||
:disabled="isProcessing"
|
||||
|
|
@ -28,7 +27,7 @@
|
|||
name="i-heroicons-check"
|
||||
class="success-icon"
|
||||
/>
|
||||
</button>
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -582,6 +581,7 @@ const downloadFile = (content: string, filename: string, type: string) => {
|
|||
}
|
||||
|
||||
.export-buttons {
|
||||
@apply font-mono;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
|
|
|
|||
|
|
@ -1,132 +1,137 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header with Export Controls -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black mb-2">
|
||||
Where will your money come from?
|
||||
</h3>
|
||||
<p class="text-neutral-600">
|
||||
Add sources like client work, grants, product sales, or donations.
|
||||
</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>
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black mb-2">
|
||||
Where will your money come from?
|
||||
</h3>
|
||||
<p class="text-neutral-600">
|
||||
Add sources like client work, grants, product sales, or donations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="streams.length === 0"
|
||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
||||
<h4 class="font-medium text-neutral-900 mb-2">
|
||||
No revenue streams yet
|
||||
</h4>
|
||||
<p class="text-sm text-neutral-500 mb-4">
|
||||
Get started by adding your first revenue source.
|
||||
</p>
|
||||
<UButton
|
||||
@click="addRevenueStream"
|
||||
size="lg"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<UIcon name="i-heroicons-plus" class="mr-2" />
|
||||
Add your first revenue stream
|
||||
</UButton>
|
||||
</div>
|
||||
<!-- Removed Tab Navigation - showing streams directly -->
|
||||
<div class="space-y-6">
|
||||
<!-- Export Controls -->
|
||||
<div class="flex justify-end">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="gray"
|
||||
size="sm"
|
||||
@click="exportStreams">
|
||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
||||
Export
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="stream in streams"
|
||||
:key="stream.id"
|
||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<UFormField label="Category" required>
|
||||
<USelect
|
||||
v-model="stream.category"
|
||||
:items="categoryOptions"
|
||||
size="xl"
|
||||
class="text-xl font-bold w-full"
|
||||
@update:model-value="saveStream(stream)" />
|
||||
</UFormField>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="streams.length === 0"
|
||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
||||
<h4 class="font-medium text-neutral-900 mb-2">
|
||||
No revenue streams yet
|
||||
</h4>
|
||||
<p class="text-sm text-neutral-500 mb-4">
|
||||
Get started by adding your first revenue source.
|
||||
</p>
|
||||
<UButton
|
||||
@click="addRevenueStream"
|
||||
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>
|
||||
<USelectMenu
|
||||
v-model="stream.name"
|
||||
:items="nameOptionsByCategory[stream.category] || []"
|
||||
placeholder="Select or type a source name"
|
||||
creatable
|
||||
searchable
|
||||
size="xl"
|
||||
class="text-xl font-bold w-full"
|
||||
@update:model-value="saveStream(stream)" />
|
||||
</UFormField>
|
||||
<div
|
||||
v-for="stream in streams"
|
||||
:key="stream.id"
|
||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<UFormField label="Category" required>
|
||||
<USelect
|
||||
v-model="stream.category"
|
||||
:items="categoryOptions"
|
||||
size="xl"
|
||||
class="text-xl font-bold w-full"
|
||||
@update:model-value="saveStream(stream)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Monthly amount" required>
|
||||
<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>
|
||||
<UFormField label="Revenue source name" required>
|
||||
<USelectMenu
|
||||
v-model="stream.name"
|
||||
:items="nameOptionsByCategory[stream.category] || []"
|
||||
placeholder="Select or type a source name"
|
||||
creatable
|
||||
searchable
|
||||
size="xl"
|
||||
class="text-xl font-bold w-full"
|
||||
@update:model-value="saveStream(stream)" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Monthly amount" required>
|
||||
<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 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>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Section Header with Export Controls -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-black mb-2">Review & Complete</h3>
|
||||
<p class="text-neutral-600">
|
||||
Review your setup and complete the wizard to start using your co-op
|
||||
tool.
|
||||
</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>
|
||||
<!-- Section Header -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-black text-black dark:text-white mb-2">Review & Complete</h3>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
Review your setup and complete the wizard to start using your co-op
|
||||
tool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,22 @@
|
|||
<template>
|
||||
<div
|
||||
class="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900">
|
||||
<div class="max-w-4xl mx-auto px-4 py-3">
|
||||
<nav class="flex items-center space-x-1 overflow-x-auto">
|
||||
<!-- 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 -->
|
||||
<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="wizard in templateWizards"
|
||||
:key="wizard.id"
|
||||
: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="
|
||||
isActive(wizard.path)
|
||||
? '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="wizard.icon" class="w-4 h-4 mr-2" />
|
||||
? 'bg-black text-white dark:bg-white dark:text-black no-underline'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ wizard.name }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -55,30 +25,25 @@
|
|||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
|
||||
// Template wizards data - matches the wizards.vue page
|
||||
const templateWizards = [
|
||||
{
|
||||
id: "membership-agreement",
|
||||
name: "Membership Agreement",
|
||||
icon: "i-heroicons-user-group",
|
||||
path: "/templates/membership-agreement",
|
||||
},
|
||||
{
|
||||
id: "conflict-resolution-framework",
|
||||
name: "Conflict Resolution",
|
||||
icon: "i-heroicons-scale",
|
||||
path: "/templates/conflict-resolution-framework",
|
||||
},
|
||||
{
|
||||
id: "tech-charter",
|
||||
name: "Tech Charter",
|
||||
icon: "i-heroicons-cog-6-tooth",
|
||||
path: "/templates/tech-charter",
|
||||
},
|
||||
{
|
||||
id: "decision-framework",
|
||||
name: "Decision Helper",
|
||||
icon: "i-heroicons-light-bulb",
|
||||
name: "Decision Framework",
|
||||
path: "/templates/decision-framework",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export const useFixtures = () => {
|
|||
category: 'Services',
|
||||
subcategory: 'Development',
|
||||
targetPct: 65,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 13000,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 30,
|
||||
terms: 'Net 30',
|
||||
|
|
@ -73,7 +73,7 @@ export const useFixtures = () => {
|
|||
category: 'Product',
|
||||
subcategory: 'Digital Tools',
|
||||
targetPct: 20,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 4000,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 14,
|
||||
terms: 'Platform payout',
|
||||
|
|
@ -88,7 +88,7 @@ export const useFixtures = () => {
|
|||
category: 'Grant',
|
||||
subcategory: 'Government',
|
||||
targetPct: 10,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 2000,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 45,
|
||||
terms: 'Quarterly disbursement',
|
||||
|
|
@ -103,7 +103,7 @@ export const useFixtures = () => {
|
|||
category: 'Donation',
|
||||
subcategory: 'Individual',
|
||||
targetPct: 3,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 600,
|
||||
certainty: 'Aspirational',
|
||||
payoutDelayDays: 3,
|
||||
terms: 'Immediate',
|
||||
|
|
@ -118,7 +118,7 @@ export const useFixtures = () => {
|
|||
category: 'Other',
|
||||
subcategory: 'Professional Services',
|
||||
targetPct: 2,
|
||||
targetMonthlyAmount: 0,
|
||||
targetMonthlyAmount: 400,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 21,
|
||||
terms: 'Net 21',
|
||||
|
|
|
|||
256
composables/useOfferSuggestor.ts
Normal file
256
composables/useOfferSuggestor.ts
Normal 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
178
data/offerTemplates.ts
Normal 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
157
data/skillsProblems.ts
Normal 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);
|
||||
}
|
||||
652
pages/budget.vue
652
pages/budget.vue
|
|
@ -1,368 +1,320 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold">Operating Plan</h2>
|
||||
<USelect
|
||||
v-model="selectedMonth"
|
||||
:options="months"
|
||||
placeholder="Select month" />
|
||||
<h2 class="text-2xl font-semibold">Budget Worksheet</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<UButton @click="forceReinitialize" variant="outline" size="sm" color="orange">Force Re-init</UButton>
|
||||
<UButton @click="resetWorksheet" variant="outline" size="sm">Reset All</UButton>
|
||||
<UButton @click="exportBudget" variant="outline" size="sm">Export</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cash Waterfall Summary -->
|
||||
<!-- Budget Worksheet Table -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">
|
||||
Cash Waterfall - {{ selectedMonth }}
|
||||
</h3>
|
||||
</template>
|
||||
<div
|
||||
class="flex items-center justify-between py-4 border-b border-neutral-200">
|
||||
<div class="flex items-center gap-8">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
€{{ budgetMetrics.grossRevenue.toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Gross Revenue</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
-€{{ budgetMetrics.totalFees.toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Fees</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
€{{ budgetMetrics.netRevenue.toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Net Revenue</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
€{{ Math.round(budgetMetrics.savingsAmount).toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">To Savings</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">
|
||||
€{{ Math.round(budgetMetrics.totalPayroll).toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Payroll</div>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-orange-600">
|
||||
€{{ budgetMetrics.totalOverhead.toLocaleString() }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-600">Overhead</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-medium">Available for Operations</span>
|
||||
<span class="text-2xl font-bold text-green-600"
|
||||
>€{{
|
||||
Math.round(budgetMetrics.availableForOps).toLocaleString()
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse border border-gray-300 text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<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>
|
||||
<!-- Monthly columns -->
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Revenue Section -->
|
||||
<tr class="bg-blue-50 font-medium">
|
||||
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-50 z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Revenue</span>
|
||||
<UButton @click="addRevenueLine" size="xs" variant="soft">+</UButton>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
|
||||
</tr>
|
||||
|
||||
<!-- Revenue by Category -->
|
||||
<template v-for="(category, categoryName) in budgetStore.groupedRevenue" :key="`revenue-${categoryName}`">
|
||||
<tr v-if="category.length > 0" class="bg-blue-100 font-medium">
|
||||
<td class="border border-gray-300 px-4 py-1 sticky left-0 bg-blue-100 z-10 text-sm text-blue-700">
|
||||
{{ categoryName }} ({{ category.length }} items)
|
||||
</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 Revenue Item' }"
|
||||
/>
|
||||
<UButton @click="removeItem('revenue', item.id)" size="xs" variant="ghost" color="error">×</UButton>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BudgetCategorySelector
|
||||
v-model="item.subcategory"
|
||||
type="revenue"
|
||||
: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('revenue', item.id, month.key, $event.target.value)"
|
||||
class="w-full text-right border-none outline-none bg-transparent"
|
||||
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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Use real store data
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
// Import components explicitly
|
||||
import BudgetCategorySelector from '~/components/BudgetCategorySelector.vue';
|
||||
|
||||
// Use budget worksheet store
|
||||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
|
||||
const selectedMonth = ref("2024-01");
|
||||
const months = ref([
|
||||
{ label: "January 2024", value: "2024-01" },
|
||||
{ label: "February 2024", value: "2024-02" },
|
||||
{ label: "March 2024", value: "2024-03" },
|
||||
]);
|
||||
|
||||
// Calculate budget values from real data
|
||||
const budgetMetrics = computed(() => {
|
||||
const totalHours = membersStore.capacityTotals.targetHours || 0;
|
||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
||||
|
||||
const grossWages = totalHours * hourlyWage;
|
||||
const oncosts = grossWages * (oncostPct / 100);
|
||||
const totalPayroll = grossWages + oncosts;
|
||||
|
||||
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,
|
||||
};
|
||||
// Generate monthly headers for the next 12 months
|
||||
const monthlyHeaders = computed(() => {
|
||||
const headers = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||
const monthName = date.toLocaleString('default', { month: 'short' });
|
||||
const year = date.getFullYear();
|
||||
|
||||
headers.push({
|
||||
key: `${year}-${String(date.getMonth() + 1).padStart(2, '0')}`,
|
||||
label: `${monthName} ${year}`
|
||||
});
|
||||
}
|
||||
|
||||
return headers;
|
||||
});
|
||||
|
||||
// Convert streams to budget table format
|
||||
const revenueStreams = computed(() =>
|
||||
streamsStore.streams.map((stream) => ({
|
||||
id: stream.id,
|
||||
name: stream.name,
|
||||
target: stream.targetMonthlyAmount || 0,
|
||||
committed: Math.round((stream.targetMonthlyAmount || 0) * 0.8), // 80% committed assumption
|
||||
actual: Math.round((stream.targetMonthlyAmount || 0) * 0.9), // 90% actual assumption
|
||||
variance: Math.round((stream.targetMonthlyAmount || 0) * 0.1), // 10% positive variance
|
||||
restrictions: stream.restrictions || "General",
|
||||
}))
|
||||
);
|
||||
// Initialize from wizard data on first load
|
||||
onMounted(async () => {
|
||||
console.log('Budget page mounted, initializing...');
|
||||
if (!budgetStore.isInitialized) {
|
||||
await budgetStore.initializeFromWizardData();
|
||||
}
|
||||
console.log('Budget worksheet:', budgetStore.budgetWorksheet);
|
||||
console.log('Grouped revenue:', budgetStore.groupedRevenue);
|
||||
console.log('Grouped expenses:', budgetStore.groupedExpenses);
|
||||
});
|
||||
|
||||
const revenueColumns = [
|
||||
{ id: "name", key: "name", label: "Stream" },
|
||||
{ id: "target", key: "target", label: "Target" },
|
||||
{ id: "committed", key: "committed", label: "Committed" },
|
||||
{ id: "actual", key: "actual", label: "Actual" },
|
||||
{ id: "variance", key: "variance", label: "Variance" },
|
||||
];
|
||||
// Budget worksheet functions
|
||||
function updateValue(category: string, itemId: string, year: string, scenario: string, value: string) {
|
||||
budgetStore.updateBudgetValue(category, itemId, year, scenario, value);
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
684
pages/coach/skills-to-offers.vue
Normal file
684
pages/coach/skills-to-offers.vue
Normal 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 problem—we'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 "0–15 days";
|
||||
if (days <= 30) return "15–30 days";
|
||||
if (days <= 45) return "30–45 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
468
pages/coop-builder.vue
Normal 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>
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<WizardPage />
|
||||
<CoopBuilderPage />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Reuse the existing wizard content by importing it as a component
|
||||
import WizardPage from "~/pages/wizard.vue";
|
||||
// Reuse the existing coop builder content by importing it as a component
|
||||
import CoopBuilderPage from "~/pages/coop-builder.vue";
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ const streamsStore = useStreamsStore();
|
|||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const wizardStore = useWizardStore();
|
||||
const coopBuilderStore = useCoopBuilderStore();
|
||||
|
||||
const isResetting = ref(false);
|
||||
|
||||
|
|
@ -556,7 +556,7 @@ async function restartWizard() {
|
|||
sessionStore.resetSession();
|
||||
|
||||
// Reset wizard state
|
||||
wizardStore.reset();
|
||||
coopBuilderStore.reset();
|
||||
|
||||
// Small delay for UX
|
||||
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
|
|
@ -222,29 +222,7 @@ useHead({
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Ubuntu font import */
|
||||
@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);
|
||||
}
|
||||
/* Template index specific styles - no longer duplicated in main.css */
|
||||
|
||||
.dither-shadow-disabled {
|
||||
background: black;
|
||||
|
|
@ -265,74 +243,6 @@ useHead({
|
|||
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 */
|
||||
.template-card > *,
|
||||
.help-section > *,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Wizard Subnav -->
|
||||
<WizardSubnav />
|
||||
|
||||
<!-- Export Options - Top -->
|
||||
<ExportOptions
|
||||
:export-data="exportData"
|
||||
filename="membership-agreement"
|
||||
title="Membership Agreement"
|
||||
/>
|
||||
title="Membership Agreement" />
|
||||
|
||||
<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 -->
|
||||
<div class="document-page">
|
||||
<div class="template-content">
|
||||
|
|
@ -20,8 +15,9 @@
|
|||
<div class="text-center mb-8">
|
||||
<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"
|
||||
:data-coop-name="formData.cooperativeName || 'Worker Cooperative'"
|
||||
>
|
||||
:data-coop-name="
|
||||
formData.cooperativeName || 'Worker Cooperative'
|
||||
">
|
||||
MEMBERSHIP AGREEMENT
|
||||
</h1>
|
||||
</div>
|
||||
|
|
@ -29,8 +25,7 @@
|
|||
<!-- Section 1: Who We Are -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
|
|
@ -42,8 +37,7 @@
|
|||
size="xl"
|
||||
class="w-full"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Date Established" class="form-group-large">
|
||||
|
|
@ -52,8 +46,7 @@
|
|||
type="date"
|
||||
size="xl"
|
||||
class="large-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Our Purpose" class="form-group-large">
|
||||
|
|
@ -64,8 +57,7 @@
|
|||
size="xl"
|
||||
class="large-field"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Our Core Values" class="form-group-large">
|
||||
|
|
@ -76,8 +68,7 @@
|
|||
size="xl"
|
||||
class="large-field"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<div class="form-group-large">
|
||||
|
|
@ -91,8 +82,7 @@
|
|||
size="sm"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
icon="i-heroicons-plus"
|
||||
>
|
||||
icon="i-heroicons-plus">
|
||||
Add Member
|
||||
</UButton>
|
||||
</div>
|
||||
|
|
@ -101,10 +91,10 @@
|
|||
<div
|
||||
v-for="(member, index) in formData.members"
|
||||
: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">
|
||||
<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 }}
|
||||
</h4>
|
||||
<UButton
|
||||
|
|
@ -113,8 +103,7 @@
|
|||
size="sm"
|
||||
color="red"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-trash"
|
||||
>
|
||||
icon="i-heroicons-trash">
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
|
|
@ -126,8 +115,7 @@
|
|||
size="xl"
|
||||
class="large-field"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Email" class="form-group-large">
|
||||
|
|
@ -138,8 +126,7 @@
|
|||
size="xl"
|
||||
class="large-field"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Join Date" class="form-group-large">
|
||||
|
|
@ -148,22 +135,19 @@
|
|||
type="date"
|
||||
size="xl"
|
||||
class="large-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Current Role (Optional)"
|
||||
class="form-group-large"
|
||||
>
|
||||
class="form-group-large">
|
||||
<UInput
|
||||
v-model="member.role"
|
||||
placeholder="e.g., Coordinator, Developer, etc."
|
||||
size="xl"
|
||||
class="large-field"
|
||||
@input="debouncedAutoSave"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -175,16 +159,14 @@
|
|||
<!-- Section 2: Membership -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
|
|
@ -193,8 +175,8 @@
|
|||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
<li>Shares our values and purpose</li>
|
||||
<li>
|
||||
Contributes labour to the cooperative (by doing actual work, not just
|
||||
investing money)
|
||||
Contributes labour to the cooperative (by doing actual work,
|
||||
not just investing money)
|
||||
</li>
|
||||
<li>Commits to collective decision-making</li>
|
||||
<li>Participates in governance responsibilities</li>
|
||||
|
|
@ -203,14 +185,14 @@
|
|||
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
|
||||
<p class="content-paragraph">
|
||||
New members join through a consent process, which means existing members
|
||||
must agree that adding this person won't harm the cooperative.
|
||||
New members join through a consent process, which means
|
||||
existing members must agree that adding this person won't harm
|
||||
the cooperative.
|
||||
</p>
|
||||
|
||||
<ol class="content-list numbered my-2 pl-6 list-decimal">
|
||||
|
|
@ -221,8 +203,7 @@
|
|||
type="number"
|
||||
placeholder="3"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
months working together
|
||||
</li>
|
||||
<li>Values alignment conversation</li>
|
||||
|
|
@ -233,8 +214,7 @@
|
|||
type="number"
|
||||
placeholder="1000"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
(can be paid over time or waived based on need)
|
||||
</li>
|
||||
</ol>
|
||||
|
|
@ -242,20 +222,19 @@
|
|||
|
||||
<div>
|
||||
<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
|
||||
</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
|
||||
<UInput
|
||||
v-model="formData.noticeDays"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
days notice. The cooperative will:
|
||||
</p>
|
||||
|
||||
|
|
@ -267,8 +246,7 @@
|
|||
type="number"
|
||||
placeholder="30"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
days
|
||||
</li>
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
|
|
@ -278,11 +256,12 @@
|
|||
type="number"
|
||||
placeholder="90"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
days
|
||||
</li>
|
||||
<li>Maintain respectful ongoing relationships when possible</li>
|
||||
<li>
|
||||
Maintain respectful ongoing relationships when possible
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -291,80 +270,71 @@
|
|||
<!-- Section 3: How We Make Decisions -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
We use consent, not consensus. This means we move forward when no one
|
||||
has a principled objection that would harm the cooperative. An objection
|
||||
must explain how the proposal would contradict our values or threaten
|
||||
our sustainability.
|
||||
We use consent, not consensus. This means we move forward when
|
||||
no one has a principled objection that would harm the
|
||||
cooperative. An objection must explain how the proposal would
|
||||
contradict our values or threaten our sustainability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<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
|
||||
v-model="formData.dayToDayLimit"
|
||||
type="number"
|
||||
placeholder="100"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
can be made by any member. Just tell others what you did at the next
|
||||
meeting.
|
||||
@change="autoSave" />
|
||||
can be made by any member. Just tell others what you did at
|
||||
the next meeting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<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
|
||||
v-model="formData.regularDecisionMin"
|
||||
type="number"
|
||||
placeholder="100"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
and $<UInput
|
||||
v-model="formData.regularDecisionMax"
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
need consent from members present at a meeting (minimum 2 members).
|
||||
@change="autoSave" />
|
||||
need consent from members present at a meeting (minimum 2
|
||||
members).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
|
|
@ -379,8 +349,7 @@
|
|||
type="number"
|
||||
placeholder="5000"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</li>
|
||||
<li>Fundamental changes to our purpose or structure</li>
|
||||
<li>Dissolution of the cooperative</li>
|
||||
|
|
@ -389,8 +358,7 @@
|
|||
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
|
|
@ -400,8 +368,7 @@
|
|||
v-model="formData.meetingFrequency"
|
||||
placeholder="weekly"
|
||||
class="inline-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</li>
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
Emergency meetings need
|
||||
|
|
@ -410,12 +377,13 @@
|
|||
type="number"
|
||||
placeholder="24"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
hours notice
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -424,28 +392,25 @@
|
|||
<!-- Section 4: Money and Labour -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
Each member owns an equal share of the cooperative, regardless of hours
|
||||
worked or tenure.
|
||||
Each member owns an equal share of the cooperative, regardless
|
||||
of hours worked or tenure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
|
|
@ -455,8 +420,7 @@
|
|||
type="number"
|
||||
placeholder="25"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>/hour for all members
|
||||
@change="autoSave" />/hour for all members
|
||||
</li>
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
Or: Equal monthly draw of $<UInput
|
||||
|
|
@ -464,8 +428,7 @@
|
|||
type="number"
|
||||
placeholder="2000"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
per member
|
||||
</li>
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
|
|
@ -475,8 +438,7 @@
|
|||
:items="dayOptions"
|
||||
placeholder="15"
|
||||
class="inline-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
of each month
|
||||
</li>
|
||||
<li class="flex items-baseline gap-2 flex-wrap">
|
||||
|
|
@ -485,8 +447,7 @@
|
|||
v-model="formData.surplusFrequency"
|
||||
placeholder="quarter"
|
||||
class="inline-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -494,8 +455,7 @@
|
|||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
|
|
@ -506,29 +466,31 @@
|
|||
type="number"
|
||||
placeholder="40"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
(flexible based on capacity)
|
||||
</li>
|
||||
<li>We explicitly reject crunch culture</li>
|
||||
<li>Members communicate their capacity openly</li>
|
||||
<li>
|
||||
We adjust workload collectively when someone needs reduced hours
|
||||
We adjust workload collectively when someone needs reduced
|
||||
hours
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<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>
|
||||
Quarterly reviews of our runway (how many months we can operate)
|
||||
Quarterly reviews of our runway (how many months we can
|
||||
operate)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -539,34 +501,31 @@
|
|||
<!-- Section 5: Roles and Responsibilities -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<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
|
||||
<UInput
|
||||
v-model="formData.roleRotationMonths"
|
||||
type="number"
|
||||
placeholder="6"
|
||||
class="inline-field number-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
months. Current roles include:
|
||||
</p>
|
||||
<ul class="content-list">
|
||||
<li>
|
||||
Financial coordinator (handles bookkeeping, not financial decisions)
|
||||
Financial coordinator (handles bookkeeping, not financial
|
||||
decisions)
|
||||
</li>
|
||||
<li>Meeting facilitator</li>
|
||||
<li>External communications</li>
|
||||
|
|
@ -576,8 +535,7 @@
|
|||
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
|
|
@ -595,16 +553,14 @@
|
|||
<!-- Section 6: Conflict and Care -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<ol class="content-list numbered my-2 pl-6 list-decimal">
|
||||
|
|
@ -617,15 +573,16 @@
|
|||
|
||||
<div>
|
||||
<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
|
||||
</h3>
|
||||
<ul class="content-list my-2 pl-6 list-disc">
|
||||
<li>We check in about capacity and wellbeing regularly</li>
|
||||
<li>We honour diverse access needs</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -634,31 +591,27 @@
|
|||
<!-- Section 7: Changing This Agreement -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<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
|
||||
<UInput
|
||||
v-model="formData.reviewFrequency"
|
||||
placeholder="year"
|
||||
class="inline-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
and update it through our consent process. Small clarifications can happen
|
||||
anytime; structural changes need full member consent.
|
||||
@change="autoSave" />
|
||||
and update it through our consent process. Small clarifications
|
||||
can happen anytime; structural changes need full member consent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 8: If We Need to Close -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
|
|
@ -675,8 +628,7 @@
|
|||
<UInput
|
||||
v-model="formData.assetDonationTarget"
|
||||
placeholder="Enter organization name"
|
||||
class="inline-field wide-field"
|
||||
/>
|
||||
class="inline-field wide-field" />
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
|
@ -685,8 +637,7 @@
|
|||
<!-- Section 9: Legal Bits -->
|
||||
<div class="section-card">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
|
|
@ -697,8 +648,7 @@
|
|||
v-model="formData.legalStructure"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
placeholder="Cooperative corporation, LLC, partnership, etc."
|
||||
/>
|
||||
placeholder="Cooperative corporation, LLC, partnership, etc." />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Registered in" class="form-group-inline">
|
||||
|
|
@ -707,8 +657,7 @@
|
|||
placeholder="State/Province"
|
||||
size="xl"
|
||||
class="inline-field w-full"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</UFormField>
|
||||
|
||||
<div class="fiscal-year-group">
|
||||
|
|
@ -720,25 +669,23 @@
|
|||
placeholder="Month"
|
||||
size="xl"
|
||||
class="w-60"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
<USelect
|
||||
v-model="formData.fiscalYearEndDay"
|
||||
:items="dayOptions"
|
||||
placeholder="Day"
|
||||
size="xl"
|
||||
class="w-40"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
</div>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
This agreement works alongside but doesn't replace our legal incorporation
|
||||
documents. Where they conflict, we follow the law but work to align our
|
||||
legal structure with our values.
|
||||
This agreement works alongside but doesn't replace our legal
|
||||
incorporation documents. Where they conflict, we follow the law
|
||||
but work to align our legal structure with our values.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -746,46 +693,39 @@
|
|||
<!-- Section 10: Agreement Review -->
|
||||
<div class="section-card">
|
||||
<h2
|
||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
||||
>
|
||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||
10. Agreement Review
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||
By using this agreement, we commit to these principles and to showing up
|
||||
for each other.
|
||||
By using this agreement, we commit to these principles and to
|
||||
showing up for each other.
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300"
|
||||
>
|
||||
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300">
|
||||
<p
|
||||
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
|
||||
>
|
||||
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
|
||||
This agreement was last updated on
|
||||
<UInput
|
||||
v-model="formData.lastUpdated"
|
||||
type="date"
|
||||
class="inline-field"
|
||||
@change="autoSave"
|
||||
/>. We commit to reviewing it on
|
||||
@change="autoSave" />. We commit to reviewing it on
|
||||
<UInput
|
||||
v-model="formData.nextReview"
|
||||
type="date"
|
||||
class="inline-field"
|
||||
@change="autoSave"
|
||||
/>
|
||||
@change="autoSave" />
|
||||
or sooner if circumstances require.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950"
|
||||
>
|
||||
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950">
|
||||
<p
|
||||
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic"
|
||||
>
|
||||
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic">
|
||||
[Space for member signatures when printed]
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -799,8 +739,7 @@
|
|||
<ExportOptions
|
||||
:export-data="exportData"
|
||||
filename="membership-agreement"
|
||||
title="Membership Agreement"
|
||||
/>
|
||||
title="Membership Agreement" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -898,7 +837,10 @@ onMounted(() => {
|
|||
|
||||
// Auto-save individual field changes immediately
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
@ -950,8 +892,12 @@ const handlePrint = () => {
|
|||
`;
|
||||
|
||||
// Add signature lines for each member
|
||||
const membersWithNames = formData.value.members?.filter((m) => m.name) || [];
|
||||
const numSignatures = Math.max(2, Math.min(8, membersWithNames.length || 4));
|
||||
const membersWithNames =
|
||||
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++) {
|
||||
const memberName = membersWithNames[i]?.name || "";
|
||||
|
|
@ -981,7 +927,8 @@ const handlePrint = () => {
|
|||
value = formData.value.cooperativeName;
|
||||
else if (input.closest('[label="Date Established"]'))
|
||||
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"]'))
|
||||
value = formData.value.coreValues;
|
||||
else if (input.closest('[label="Legal Structure"]'))
|
||||
|
|
@ -998,13 +945,16 @@ const handlePrint = () => {
|
|||
// Handle member fields
|
||||
else if (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];
|
||||
if (member) {
|
||||
if (input.closest('[label="Full Name"]')) value = member.name;
|
||||
else if (input.closest('[label="Email"]')) value = member.email;
|
||||
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
|
||||
|
|
@ -1143,11 +1093,13 @@ const exportData = computed(() => ({
|
|||
|
||||
.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 * {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,20 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Wizard Subnav -->
|
||||
<WizardSubnav />
|
||||
|
||||
<!-- Export Options - Top -->
|
||||
<ExportOptions
|
||||
:export-data="exportData"
|
||||
filename="tech-charter"
|
||||
title="Technology Charter"
|
||||
/>
|
||||
title="Technology Charter" />
|
||||
|
||||
<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 -->
|
||||
<div class="document-page">
|
||||
<div class="template-content">
|
||||
<!-- Document Header -->
|
||||
<div class="text-center mb-8">
|
||||
<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
|
||||
</h1>
|
||||
</div>
|
||||
|
|
@ -30,9 +24,12 @@
|
|||
<!-- Purpose Section -->
|
||||
<div class="section-card">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
@ -40,8 +37,7 @@
|
|||
<textarea
|
||||
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"
|
||||
rows="4"
|
||||
/>
|
||||
rows="4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -52,37 +48,39 @@
|
|||
Define Your Principles & Importance
|
||||
</h2>
|
||||
<p class="text-neutral-600 mb-6">
|
||||
Select principles and set their importance. Zero means excluded, 5 means
|
||||
critical.
|
||||
Select principles and set their importance. Zero means
|
||||
excluded, 5 means critical.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<div
|
||||
v-if="principleWeights[principle.id] > 0"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'relative transition-all',
|
||||
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',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-6">
|
||||
<!-- Principle info -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
:class="[
|
||||
'principle-text-bg mb-3',
|
||||
principleWeights[principle.id] > 0 ? 'selected' : '',
|
||||
]"
|
||||
>
|
||||
'item-text-bg mb-3',
|
||||
principleWeights[principle.id] > 0
|
||||
? 'selected'
|
||||
: '',
|
||||
]">
|
||||
<h3 class="font-bold text-lg mb-2">
|
||||
{{ principle.name }}
|
||||
</h3>
|
||||
|
|
@ -92,8 +90,7 @@
|
|||
? 'text-neutral-700'
|
||||
: 'text-neutral-600'
|
||||
"
|
||||
class="text-sm"
|
||||
>
|
||||
class="text-sm">
|
||||
{{ principle.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -102,8 +99,7 @@
|
|||
<!-- Importance selector -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-bold text-neutral-500 uppercase tracking-wider"
|
||||
>
|
||||
class="text-xs font-bold text-neutral-500 uppercase tracking-wider">
|
||||
Importance
|
||||
</label>
|
||||
|
||||
|
|
@ -119,8 +115,7 @@
|
|||
? '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',
|
||||
]"
|
||||
:title="`Set importance to ${level}`"
|
||||
>
|
||||
:title="`Set importance to ${level}`">
|
||||
{{ level }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -131,7 +126,11 @@
|
|||
{{ principleWeights[principle.id] || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500">
|
||||
{{ getWeightLabel(principleWeights[principle.id] || 0) }}
|
||||
{{
|
||||
getWeightLabel(
|
||||
principleWeights[principle.id] || 0
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -140,20 +139,19 @@
|
|||
<!-- Non-negotiable toggle (only shows for weights > 0) -->
|
||||
<div
|
||||
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
|
||||
:class="[
|
||||
'flex items-center gap-3 cursor-pointer principle-label-bg px-2 py-1',
|
||||
nonNegotiables.includes(principle.id) ? 'selected' : '',
|
||||
]"
|
||||
>
|
||||
'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1',
|
||||
nonNegotiables.includes(principle.id)
|
||||
? 'selected'
|
||||
: '',
|
||||
]">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="nonNegotiables.includes(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">
|
||||
Make this non-negotiable
|
||||
</span>
|
||||
|
|
@ -163,9 +161,9 @@
|
|||
<!-- Show rubric description when selected -->
|
||||
<div
|
||||
v-if="principleWeights[principle.id] > 0"
|
||||
class="mt-4 p-3 principle-label-bg selected border border-neutral-200"
|
||||
>
|
||||
<div class="text-xs font-bold uppercase text-neutral-500 mb-1">
|
||||
class="mt-4 p-3 item-label-bg selected border border-neutral-200">
|
||||
<div
|
||||
class="text-xs font-bold uppercase text-neutral-500 mb-1">
|
||||
Evaluation Criteria:
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
|
|
@ -183,8 +181,7 @@
|
|||
<div>
|
||||
<h2
|
||||
class="text-2xl font-bold text-neutral-800 mb-2"
|
||||
id="constraints-heading"
|
||||
>
|
||||
id="constraints-heading">
|
||||
Technical Constraints
|
||||
</h2>
|
||||
</div>
|
||||
|
|
@ -195,18 +192,15 @@
|
|||
<div
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
aria-labelledby="auth-heading"
|
||||
>
|
||||
aria-labelledby="auth-heading">
|
||||
<div
|
||||
v-for="option in authOptions"
|
||||
:key="option.value"
|
||||
class="relative"
|
||||
>
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.sso === option.value"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="constraints.sso = option.value"
|
||||
:aria-pressed="constraints.sso === option.value"
|
||||
|
|
@ -217,8 +211,7 @@
|
|||
constraints.sso === option.value
|
||||
? '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',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -230,18 +223,15 @@
|
|||
<div
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
aria-labelledby="hosting-heading"
|
||||
>
|
||||
aria-labelledby="hosting-heading">
|
||||
<div
|
||||
v-for="option in hostingOptions"
|
||||
:key="option.value"
|
||||
class="relative"
|
||||
>
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.hosting === option.value"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="constraints.hosting = option.value"
|
||||
:aria-pressed="constraints.hosting === option.value"
|
||||
|
|
@ -252,8 +242,7 @@
|
|||
constraints.hosting === option.value
|
||||
? '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',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -261,29 +250,32 @@
|
|||
</fieldset>
|
||||
|
||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
||||
<legend class="font-semibold text-lg">Required Integrations</legend>
|
||||
<p class="text-sm text-neutral-600 mb-4">Select all that apply</p>
|
||||
<legend class="font-semibold text-lg">
|
||||
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
|
||||
v-for="integration in integrationOptions"
|
||||
:key="integration"
|
||||
class="relative"
|
||||
>
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.integrations.includes(integration)"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="toggleIntegration(integration)"
|
||||
:aria-pressed="constraints.integrations.includes(integration)"
|
||||
:aria-pressed="
|
||||
constraints.integrations.includes(integration)
|
||||
"
|
||||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
constraints.integrations.includes(integration)
|
||||
? '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',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
{{ integration }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -291,22 +283,21 @@
|
|||
</fieldset>
|
||||
|
||||
<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
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
aria-labelledby="support-heading"
|
||||
>
|
||||
aria-labelledby="support-heading">
|
||||
<div
|
||||
v-for="option in supportOptions"
|
||||
:key="option.value"
|
||||
class="relative"
|
||||
>
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.support === option.value"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="constraints.support = option.value"
|
||||
:aria-pressed="constraints.support === option.value"
|
||||
|
|
@ -317,8 +308,7 @@
|
|||
constraints.support === option.value
|
||||
? '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',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -326,22 +316,21 @@
|
|||
</fieldset>
|
||||
|
||||
<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
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
aria-labelledby="timeline-heading"
|
||||
>
|
||||
aria-labelledby="timeline-heading">
|
||||
<div
|
||||
v-for="option in timelineOptions"
|
||||
:key="option.value"
|
||||
class="relative"
|
||||
>
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.timeline === option.value"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
||||
></div>
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="constraints.timeline = option.value"
|
||||
:aria-pressed="constraints.timeline === option.value"
|
||||
|
|
@ -352,8 +341,7 @@
|
|||
constraints.timeline === option.value
|
||||
? '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',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -367,8 +355,7 @@
|
|||
<button
|
||||
@click="resetForm"
|
||||
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" />
|
||||
Reset Form
|
||||
</button>
|
||||
|
|
@ -381,17 +368,19 @@
|
|||
v-if="charterGenerated"
|
||||
class="relative animate-fadeIn"
|
||||
role="main"
|
||||
aria-label="Generated Technology Charter"
|
||||
>
|
||||
aria-label="Generated Technology Charter">
|
||||
<!-- 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 -->
|
||||
<div
|
||||
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8"
|
||||
>
|
||||
<div 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">
|
||||
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
|
||||
<div
|
||||
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">
|
||||
Technology Charter
|
||||
</h2>
|
||||
<p class="text-neutral-600 mt-2">
|
||||
|
|
@ -407,8 +396,7 @@
|
|||
<div class="mt-4">
|
||||
<button
|
||||
@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
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -418,10 +406,10 @@
|
|||
<section class="mb-8">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
|
||||
<p class="text-neutral-700 leading-relaxed">
|
||||
This charter guides our cooperative's technology decisions based on our
|
||||
shared values and operational needs. It ensures we choose tools that
|
||||
support our mission while respecting our principles of autonomy,
|
||||
sustainability, and mutual aid.
|
||||
This charter guides our cooperative's technology decisions
|
||||
based on our shared values and operational needs. It ensures
|
||||
we choose tools that support our mission while respecting our
|
||||
principles of autonomy, sustainability, and mutual aid.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -429,21 +417,25 @@
|
|||
class="mb-8"
|
||||
v-if="
|
||||
Object.keys(principleWeights).filter(
|
||||
(p) => principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
||||
(p) =>
|
||||
principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
||||
).length > 0
|
||||
"
|
||||
>
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">Core Principles</h3>
|
||||
">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||
Core Principles
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="principleId in Object.keys(principleWeights).filter(
|
||||
(p) => principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
||||
(p) =>
|
||||
principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
||||
)"
|
||||
:key="principleId"
|
||||
class="flex items-start"
|
||||
>
|
||||
class="flex items-start">
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
@ -453,16 +445,18 @@
|
|||
Non-Negotiable Requirements
|
||||
</h3>
|
||||
<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>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="principleId in nonNegotiables"
|
||||
: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>{{ principles.find((p) => p.id === principleId)?.name }}</span>
|
||||
<span>{{
|
||||
principles.find((p) => p.id === principleId)?.name
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
@ -477,7 +471,8 @@
|
|||
<span
|
||||
>Authentication:
|
||||
{{
|
||||
authOptions.find((o) => o.value === constraints.sso)?.label
|
||||
authOptions.find((o) => o.value === constraints.sso)
|
||||
?.label
|
||||
}}</span
|
||||
>
|
||||
</li>
|
||||
|
|
@ -486,11 +481,15 @@
|
|||
<span
|
||||
>Hosting:
|
||||
{{
|
||||
hostingOptions.find((o) => o.value === constraints.hosting)?.label
|
||||
hostingOptions.find(
|
||||
(o) => o.value === constraints.hosting
|
||||
)?.label
|
||||
}}</span
|
||||
>
|
||||
</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
|
||||
>Required Integrations:
|
||||
|
|
@ -502,7 +501,9 @@
|
|||
<span
|
||||
>Support Level:
|
||||
{{
|
||||
supportOptions.find((o) => o.value === constraints.support)?.label
|
||||
supportOptions.find(
|
||||
(o) => o.value === constraints.support
|
||||
)?.label
|
||||
}}</span
|
||||
>
|
||||
</li>
|
||||
|
|
@ -511,8 +512,9 @@
|
|||
<span
|
||||
>Migration Timeline:
|
||||
{{
|
||||
timelineOptions.find((o) => o.value === constraints.timeline)
|
||||
?.label
|
||||
timelineOptions.find(
|
||||
(o) => o.value === constraints.timeline
|
||||
)?.label
|
||||
}}</span
|
||||
>
|
||||
</li>
|
||||
|
|
@ -520,27 +522,27 @@
|
|||
</section>
|
||||
|
||||
<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">
|
||||
Score each vendor option using these weighted criteria (0-5 scale):
|
||||
Score each vendor option using these weighted criteria (0-5
|
||||
scale):
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-neutral-100">
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</th>
|
||||
</tr>
|
||||
|
|
@ -549,21 +551,17 @@
|
|||
<tr
|
||||
v-for="weight in sortedWeights"
|
||||
:key="weight.id"
|
||||
class="hover:bg-neutral-50"
|
||||
>
|
||||
class="hover:bg-neutral-50">
|
||||
<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 }}
|
||||
</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 }}
|
||||
</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] }}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -580,8 +578,8 @@
|
|||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Any vendor failing a non-negotiable requirement is automatically
|
||||
eliminated</span
|
||||
>Any vendor failing a non-negotiable requirement is
|
||||
automatically eliminated</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
|
|
@ -594,8 +592,8 @@
|
|||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>When scores are within 10%, choose based on alignment with
|
||||
cooperative values</span
|
||||
>When scores are within 10%, choose based on alignment
|
||||
with cooperative values</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
|
|
@ -638,11 +636,16 @@
|
|||
</li>
|
||||
<li class="flex items-start">
|
||||
<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 class="flex items-start">
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
@ -656,8 +659,7 @@
|
|||
<ExportOptions
|
||||
:export-data="exportData"
|
||||
filename="tech-charter"
|
||||
title="Technology Charter"
|
||||
/>
|
||||
title="Technology Charter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -705,13 +707,15 @@ const principles = [
|
|||
id: "portability",
|
||||
name: "Data Freedom",
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: "opensource",
|
||||
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",
|
||||
defaultWeight: 3,
|
||||
},
|
||||
|
|
@ -719,7 +723,8 @@ const principles = [
|
|||
id: "sustainability",
|
||||
name: "Sustainable Operations",
|
||||
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,
|
||||
},
|
||||
{
|
||||
|
|
@ -732,8 +737,10 @@ const principles = [
|
|||
{
|
||||
id: "usability",
|
||||
name: "User Experience",
|
||||
description: "Intuitive interface, minimal learning curve, daily efficiency",
|
||||
rubricDescription: "Onboarding time, user satisfaction, workflow integration",
|
||||
description:
|
||||
"Intuitive interface, minimal learning curve, daily efficiency",
|
||||
rubricDescription:
|
||||
"Onboarding time, user satisfaction, workflow integration",
|
||||
defaultWeight: 3,
|
||||
},
|
||||
];
|
||||
|
|
@ -769,7 +776,9 @@ const timelineOptions = [
|
|||
const sortedWeights = computed(() => {
|
||||
return principles
|
||||
.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(() => {
|
||||
|
|
@ -862,7 +871,9 @@ const resetForm = () => {
|
|||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
document.querySelector(".template-wrapper").scrollIntoView({ behavior: "smooth" });
|
||||
document
|
||||
.querySelector(".template-wrapper")
|
||||
.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Load saved data
|
||||
|
|
@ -905,9 +916,13 @@ onMounted(() => {
|
|||
});
|
||||
|
||||
// Auto-save when data changes
|
||||
watch([charterPurpose, principleWeights, nonNegotiables, constraints], autoSave, {
|
||||
deep: true,
|
||||
});
|
||||
watch(
|
||||
[charterPurpose, principleWeights, nonNegotiables, constraints],
|
||||
autoSave,
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -919,115 +934,6 @@ watch([charterPurpose, principleWeights, nonNegotiables, constraints], autoSave,
|
|||
@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 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
372
pages/wizard.vue
372
pages/wizard.vue
|
|
@ -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>
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Wizard Subnav -->
|
||||
<WizardSubnav />
|
||||
|
||||
<div
|
||||
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="mb-8">
|
||||
|
|
@ -72,69 +68,6 @@
|
|||
</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>
|
||||
|
|
@ -158,7 +91,7 @@ const templates = [
|
|||
},
|
||||
{
|
||||
id: "conflict-resolution-framework",
|
||||
name: "Conflict Resolution Framework",
|
||||
name: "Conflict Resolution",
|
||||
description:
|
||||
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
|
||||
icon: "i-heroicons-scale",
|
||||
|
|
@ -310,35 +243,4 @@ useHead({
|
|||
background: white;
|
||||
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>
|
||||
|
|
|
|||
40
sample/skillsToOffersSamples.ts
Normal file
40
sample/skillsToOffersSamples.ts
Normal 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"]
|
||||
};
|
||||
542
stores/budget.ts
542
stores/budget.ts
|
|
@ -4,24 +4,143 @@ export const useBudgetStore = defineStore(
|
|||
"budget",
|
||||
() => {
|
||||
// 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({});
|
||||
|
||||
// Overhead costs (recurring monthly)
|
||||
const overheadCosts = ref([]);
|
||||
|
||||
// Production costs (variable monthly)
|
||||
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 currentYear = currentDate.getFullYear();
|
||||
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||
const currentPeriod = ref(`${currentYear}-${currentMonth}`);
|
||||
|
||||
// Computed current budget
|
||||
const currentBudget = computed(() => {
|
||||
return (
|
||||
budgetLines.value[currentPeriod.value] || {
|
||||
|
|
@ -100,13 +219,418 @@ export const useBudgetStore = defineStore(
|
|||
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
|
||||
function resetBudgetOverhead() {
|
||||
overheadCosts.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 {
|
||||
// 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,
|
||||
overheadCosts,
|
||||
productionCosts,
|
||||
|
|
@ -128,7 +652,7 @@ export const useBudgetStore = defineStore(
|
|||
{
|
||||
persist: {
|
||||
key: "urgent-tools-budget",
|
||||
paths: ["overheadCosts", "productionCosts", "currentPeriod"],
|
||||
paths: ["budgetWorksheet", "revenueCategories", "expenseCategories", "isInitialized", "overheadCosts", "productionCosts", "currentPeriod"],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
export const useWizardStore = defineStore(
|
||||
"wizard",
|
||||
export const useCoopBuilderStore = defineStore(
|
||||
"coop-builder",
|
||||
() => {
|
||||
const currentStep = ref(1);
|
||||
|
||||
22
stores/plan.ts
Normal file
22
stores/plan.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
function resetStreams() {
|
||||
streams.value = [];
|
||||
|
|
@ -102,6 +127,8 @@ export const useStreamsStore = defineStore(
|
|||
// Wizard actions
|
||||
upsertStream,
|
||||
resetStreams,
|
||||
initializeWithFixtures,
|
||||
loadDemoData,
|
||||
// Legacy actions
|
||||
addStream,
|
||||
updateStream,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
import type { Config } from 'tailwindcss'
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
neutral: {
|
||||
950: '#0a0a0a'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Config
|
||||
darkMode: "class",
|
||||
} satisfies Config;
|
||||
|
|
|
|||
431
tests/coach-integration.spec.ts
Normal file
431
tests/coach-integration.spec.ts
Normal 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
29
types/coaching.ts
Normal 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
183
utils/offerToStream.ts
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue