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">
|
<div class="relative flex items-center justify-center">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/"
|
to="/"
|
||||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||||
<UIcon
|
>
|
||||||
name="i-heroicons-rocket-launch"
|
|
||||||
class="text-primary-500" />
|
|
||||||
<h1
|
<h1
|
||||||
class="font-semibold text-black dark:text-white text-center">
|
class="text-black dark:text-white text-center text-2xl font-mono uppercase font-bold"
|
||||||
|
>
|
||||||
Urgent Tools
|
Urgent Tools
|
||||||
</h1>
|
</h1>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -25,49 +24,41 @@
|
||||||
<nav
|
<nav
|
||||||
class="mt-4 flex items-center justify-center gap-1"
|
class="mt-4 flex items-center justify-center gap-1"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="Main navigation">
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/coop-planner"
|
to="/coop-planner"
|
||||||
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-neutral-100 dark:bg-neutral-800': isCoopSection,
|
'bg-neutral-100 dark:bg-neutral-800': isCoopBuilderSection,
|
||||||
}">
|
}"
|
||||||
Co-Op in 6 Months
|
>
|
||||||
|
Co-Op Builder
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<!-- Coach feature - hidden for now -->
|
||||||
|
<!-- <NuxtLink
|
||||||
|
to="/coach/skills-to-offers"
|
||||||
|
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
||||||
|
:class="{
|
||||||
|
'bg-neutral-100 dark:bg-neutral-800': $route.path.startsWith('/coach'),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
Coach
|
||||||
|
</NuxtLink> -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/wizards"
|
to="/wizards"
|
||||||
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-neutral-100 dark:bg-neutral-800':
|
'bg-neutral-100 dark:bg-neutral-800': $route.path === '/wizards',
|
||||||
$route.path === '/wizards',
|
}"
|
||||||
}">
|
>
|
||||||
Wizards
|
Wizards
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<UButton
|
|
||||||
color="gray"
|
|
||||||
variant="ghost"
|
|
||||||
disabled
|
|
||||||
class="px-3 py-2 text-sm text-black dark:text-white rounded-md opacity-60 cursor-not-allowed">
|
|
||||||
Downloads
|
|
||||||
</UButton>
|
|
||||||
</nav>
|
|
||||||
<nav
|
|
||||||
v-if="isCoopSection"
|
|
||||||
class="mt-2 flex items-center justify-center gap-1"
|
|
||||||
role="navigation"
|
|
||||||
aria-label="Co-Op in 6 Months sub navigation">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="item in coopMenu[0]"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
|
||||||
:class="{
|
|
||||||
'bg-neutral-100 dark:bg-neutral-800':
|
|
||||||
route.path === item.to,
|
|
||||||
}">
|
|
||||||
{{ item.label }}
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="py-8">
|
||||||
|
<CoopBuilderSubnav v-if="isCoopBuilderSection" />
|
||||||
|
<WizardSubnav v-if="isWizardSection" />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</UContainer>
|
</UContainer>
|
||||||
|
|
@ -82,17 +73,20 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const coopMenu = [
|
|
||||||
[
|
|
||||||
{ label: "Dashboard", to: "/" },
|
|
||||||
{ label: "Revenue Mix", to: "/mix" },
|
|
||||||
{ label: "Budget", to: "/budget" },
|
|
||||||
{ label: "Scenarios", to: "/scenarios" },
|
|
||||||
{ label: "Cash", to: "/cash" },
|
|
||||||
{ label: "Glossary", to: "/glossary" },
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const isCoopSection = computed(() => route.path === "/coop-planner");
|
const isCoopBuilderSection = computed(
|
||||||
|
() =>
|
||||||
|
route.path === "/coop-planner" ||
|
||||||
|
route.path === "/coop-builder" ||
|
||||||
|
route.path === "/" ||
|
||||||
|
route.path === "/mix" ||
|
||||||
|
route.path === "/budget" ||
|
||||||
|
route.path === "/scenarios" ||
|
||||||
|
route.path === "/cash" ||
|
||||||
|
route.path === "/glossary"
|
||||||
|
);
|
||||||
|
|
||||||
|
const isWizardSection = computed(
|
||||||
|
() => route.path === "/wizards" || route.path.startsWith("/templates/")
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,25 @@
|
||||||
|
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@nuxt/ui";
|
@import "@nuxt/ui";
|
||||||
|
|
||||||
/* Ubuntu font import */
|
@theme {
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
|
--font-body: "Ubuntu", "Inter", sans-serif;
|
||||||
|
--font-mono: "Ubuntu Mono", monospace;
|
||||||
[data-theme="dark"] {
|
|
||||||
html { @apply bg-white text-neutral-900; }
|
|
||||||
html.dark { @apply bg-neutral-950 text-neutral-100; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disable all animations, transitions, and smooth scrolling app-wide */
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
scroll-behavior: auto !important;
|
@apply font-body bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
/* All headers use Inter font */
|
||||||
*::before,
|
h1, h2, h3, h4, h5, h6 {
|
||||||
*::after {
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
animation: none !important;
|
|
||||||
transition: none !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
TEMPLATE DOCUMENT LAYOUT
|
TEMPLATE DOCUMENT LAYOUT
|
||||||
========================= */
|
========================= */
|
||||||
|
|
@ -47,7 +43,7 @@ body {
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
.section-card {
|
.section-card {
|
||||||
@apply border border-neutral-200 dark:border-neutral-800 rounded-lg p-5 mb-8;
|
@apply border border-neutral-200 dark:border-neutral-800 rounded-lg p-5 mb-8 relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-card::before {
|
.section-card::before {
|
||||||
|
|
@ -57,27 +53,24 @@ body {
|
||||||
left: 4px;
|
left: 4px;
|
||||||
right: -4px;
|
right: -4px;
|
||||||
bottom: -4px;
|
bottom: -4px;
|
||||||
background: black;
|
@apply bg-black dark:bg-white;
|
||||||
background-image: radial-gradient(white 1px, transparent 1px);
|
background-image: radial-gradient(white 1px, transparent 1px);
|
||||||
background-size: 2px 2px;
|
background-size: 2px 2px;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark .section-card::before {
|
||||||
|
background-image: radial-gradient(black 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.75rem;
|
@apply text-3xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4;
|
||||||
font-weight: 800;
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||||
color: inherit;
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.subsection-title {
|
.subsection-title {
|
||||||
font-size: 1.25rem;
|
@apply text-xl font-semibold text-neutral-700 dark:text-neutral-300 mb-3 no-underline border-b border-neutral-200 dark:border-neutral-700 pb-1;
|
||||||
font-weight: 600;
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||||
color: #374151;
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
padding-bottom: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
|
|
@ -121,55 +114,36 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group-large .large-field {
|
.form-group-large .large-field {
|
||||||
@apply block w-full mt-2 text-lg rounded-md border-none transition-colors duration-150 ease-in-out;
|
@apply block w-full mt-2 text-lg rounded-md border-none transition-colors duration-150 ease-in-out bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group-large .large-field:focus {
|
.form-group-large .large-field:focus {
|
||||||
background: #f3f4f6;
|
@apply bg-neutral-100 dark:bg-neutral-700 outline-2 outline-blue-500 -outline-offset-2;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
outline: 2px solid #3b82f6;
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-field {
|
.inline-field {
|
||||||
display: inline-block;
|
@apply inline-block mx-1 min-w-[120px] border-none bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-2 py-1 rounded;
|
||||||
margin: 0 0.25rem;
|
|
||||||
min-width: 120px;
|
|
||||||
border: none;
|
|
||||||
background: #f9fafb;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-field:focus {
|
.inline-field:focus {
|
||||||
background: #f3f4f6;
|
@apply bg-neutral-100 dark:bg-neutral-700 outline-1 outline-blue-500 -outline-offset-1;
|
||||||
outline: 1px solid #3b82f6;
|
|
||||||
outline-offset: -1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-field {
|
.number-field {
|
||||||
min-width: 80px !important;
|
@apply min-w-[80px] text-center;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wide-field {
|
.wide-field {
|
||||||
min-width: 250px !important;
|
@apply min-w-[250px];
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group-block .block-field {
|
.form-group-block .block-field {
|
||||||
display: block;
|
@apply block w-full mt-1 border-none bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 p-2 rounded;
|
||||||
width: 100%;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
border: none;
|
|
||||||
background: #f9fafb;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group-block .block-field:focus {
|
.form-group-block .block-field:focus {
|
||||||
background: #f3f4f6;
|
@apply bg-neutral-100 dark:bg-neutral-700 outline-1 outline-blue-500 -outline-offset-1;
|
||||||
outline: 1px solid #3b82f6;
|
|
||||||
outline-offset: -1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
|
|
@ -263,16 +237,75 @@ body {
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
.dither-shadow {
|
.dither-shadow {
|
||||||
background: black;
|
@apply bg-black dark:bg-white;
|
||||||
background-image: radial-gradient(white 1px, transparent 1px);
|
background-image: radial-gradient(white 1px, transparent 1px);
|
||||||
background-size: 2px 2px;
|
background-size: 2px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .dither-shadow {
|
html.dark .dither-shadow {
|
||||||
background: white;
|
|
||||||
background-image: radial-gradient(black 1px, transparent 1px);
|
background-image: radial-gradient(black 1px, transparent 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
SELECTED ITEM PATTERN
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
/* Pattern for selected items with dithered shadow and patterned background */
|
||||||
|
.item-selected {
|
||||||
|
@apply relative bg-white dark:bg-neutral-950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-selected::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 1px,
|
||||||
|
black 1px,
|
||||||
|
black 2px
|
||||||
|
);
|
||||||
|
opacity: 0.1;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .item-selected::after {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 1px,
|
||||||
|
white 1px,
|
||||||
|
white 2px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-selected > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text background for better readability on selected items */
|
||||||
|
.item-label-bg {
|
||||||
|
@apply bg-white/85 dark:bg-neutral-950/85 rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-label-bg.selected {
|
||||||
|
@apply bg-white/95 dark:bg-neutral-950/95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-text-bg {
|
||||||
|
@apply bg-white/90 dark:bg-neutral-950/90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-text-bg.selected {
|
||||||
|
@apply bg-white/95 dark:bg-neutral-950/95;
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
BUTTON STYLING
|
BUTTON STYLING
|
||||||
========================= */
|
========================= */
|
||||||
|
|
@ -330,123 +363,71 @@ html.dark .export-btn.primary:hover {
|
||||||
@apply bg-white text-black;
|
@apply bg-white text-black;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* General buttons with bitmap styling */
|
/* Bitmap button base styling - more targeted approach */
|
||||||
button:not(.export-btn) {
|
.bitmap-style {
|
||||||
background: white !important;
|
@apply bg-white dark:bg-neutral-950 border border-black dark:border-white text-black dark:text-white font-mono uppercase font-bold tracking-wide cursor-pointer;
|
||||||
border: 1px solid black !important;
|
|
||||||
color: black !important;
|
|
||||||
font-family: "Ubuntu Mono", monospace !important;
|
|
||||||
text-transform: uppercase !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
letter-spacing: 0.5px !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:not(.export-btn):hover {
|
.bitmap-style:hover {
|
||||||
background: black !important;
|
@apply bg-black dark:bg-white text-white dark:text-black;
|
||||||
color: white !important;
|
transform: translateY(-1px) translateX(-1px);
|
||||||
transform: translateY(-1px) translateX(-1px) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode buttons */
|
/* Bitmap button styling for template cards */
|
||||||
html.dark button:not(.export-btn) {
|
.bitmap-button {
|
||||||
background: #0a0a0a !important;
|
@apply font-mono uppercase font-bold tracking-wider relative;
|
||||||
border: 1px solid white !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark button:not(.export-btn):hover {
|
.bitmap-button:hover {
|
||||||
background: white !important;
|
transform: translateY(-1px) translateX(-1px);
|
||||||
color: black !important;
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitmap-button:hover::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
@apply border border-black dark:border-white bg-white dark:bg-neutral-950;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Constraint button selected styling */
|
||||||
|
.constraint-selected {
|
||||||
|
@apply bg-black dark:bg-white text-white dark:text-black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.constraint-selected:hover {
|
||||||
|
@apply bg-black dark:bg-white text-white dark:text-black;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
BITMAP AESTHETIC OVERRIDES
|
BITMAP AESTHETIC OVERRIDES
|
||||||
========================= */
|
========================= */
|
||||||
|
|
||||||
/* Remove all rounded corners */
|
|
||||||
* {
|
/* Bitmap form field styling - applied selectively */
|
||||||
border-radius: 0 !important;
|
.bitmap-input {
|
||||||
font-family: "Ubuntu", monospace !important;
|
@apply border border-black dark:border-white bg-white dark:bg-neutral-950 text-black dark:text-white font-mono;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form fields with bitmap styling */
|
.bitmap-input:focus {
|
||||||
input,
|
@apply outline-2 outline-black dark:outline-white -outline-offset-2 bg-white dark:bg-neutral-950;
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
border: 1px solid black !important;
|
|
||||||
background: white !important;
|
|
||||||
color: black !important;
|
|
||||||
font-family: "Ubuntu Mono", monospace !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
/* Checkbox and radio button styling for bitmap theme */
|
||||||
textarea:focus,
|
.bitmap-checkbox {
|
||||||
select:focus {
|
@apply border-2 border-black dark:border-white bg-white dark:bg-neutral-950;
|
||||||
outline: 2px solid black !important;
|
|
||||||
outline-offset: -2px !important;
|
|
||||||
background: white !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode form fields */
|
.bitmap-checkbox:checked {
|
||||||
html.dark input,
|
@apply bg-black dark:bg-white text-white dark:text-black;
|
||||||
html.dark textarea,
|
|
||||||
html.dark select {
|
|
||||||
border: 1px solid white !important;
|
|
||||||
background: #0a0a0a !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark input:focus,
|
|
||||||
html.dark textarea:focus,
|
|
||||||
html.dark select:focus {
|
|
||||||
outline: 2px solid white !important;
|
|
||||||
background: #0a0a0a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox and radio button styling */
|
|
||||||
input[type="checkbox"],
|
|
||||||
input[type="radio"] {
|
|
||||||
border: 2px solid black !important;
|
|
||||||
background: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"]:checked,
|
|
||||||
input[type="radio"]:checked {
|
|
||||||
background: black !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark input[type="checkbox"],
|
|
||||||
html.dark input[type="radio"] {
|
|
||||||
border: 2px solid white !important;
|
|
||||||
background: #0a0a0a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark input[type="checkbox"]:checked,
|
|
||||||
html.dark input[type="radio"]:checked {
|
|
||||||
background: white !important;
|
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Document titles */
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
font-family: "Ubuntu", monospace !important;
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* All text */
|
|
||||||
p,
|
|
||||||
span,
|
|
||||||
div {
|
|
||||||
color: inherit !important;
|
|
||||||
font-family: "Ubuntu", monospace !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
HIDE ELEMENTS FROM PRINT
|
HIDE ELEMENTS FROM PRINT
|
||||||
|
|
@ -523,6 +504,85 @@ div {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
VALIDATION STYLES
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
.validation-error {
|
||||||
|
@apply text-red-500 dark:text-red-400 text-sm font-medium mt-2 flex items-center gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
TEMPLATE CARD STYLES
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
.template-card {
|
||||||
|
@apply relative font-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon {
|
||||||
|
@apply opacity-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dither-tag {
|
||||||
|
@apply relative bg-white dark:bg-neutral-950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dither-tag::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 1px,
|
||||||
|
black 1px,
|
||||||
|
black 2px
|
||||||
|
);
|
||||||
|
opacity: 0.1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .dither-tag::before {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 1px,
|
||||||
|
white 1px,
|
||||||
|
white 2px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-button {
|
||||||
|
@apply opacity-60 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
ANIMATIONS
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fadeIn {
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
MOBILE RESPONSIVENESS
|
MOBILE RESPONSIVENESS
|
||||||
========================= */
|
========================= */
|
||||||
|
|
|
||||||
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-options" :class="containerClass">
|
||||||
<div class="export-content">
|
<div class="export-content">
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
<h3 class="export-title">Export Options:</h3>
|
|
||||||
<div class="export-buttons">
|
<div class="export-buttons">
|
||||||
<button
|
<UButton
|
||||||
@click="copyToClipboard"
|
@click="copyToClipboard"
|
||||||
class="export-btn"
|
class="export-btn"
|
||||||
:disabled="isProcessing"
|
:disabled="isProcessing"
|
||||||
|
|
@ -13,9 +12,9 @@
|
||||||
<UIcon name="i-heroicons-clipboard" />
|
<UIcon name="i-heroicons-clipboard" />
|
||||||
<span>Copy as Text</span>
|
<span>Copy as Text</span>
|
||||||
<UIcon v-if="showCopySuccess" name="i-heroicons-check" class="success-icon" />
|
<UIcon v-if="showCopySuccess" name="i-heroicons-check" class="success-icon" />
|
||||||
</button>
|
</UButton>
|
||||||
|
|
||||||
<button
|
<UButton
|
||||||
@click="downloadAsMarkdown"
|
@click="downloadAsMarkdown"
|
||||||
class="export-btn"
|
class="export-btn"
|
||||||
:disabled="isProcessing"
|
:disabled="isProcessing"
|
||||||
|
|
@ -28,7 +27,7 @@
|
||||||
name="i-heroicons-check"
|
name="i-heroicons-check"
|
||||||
class="success-icon"
|
class="success-icon"
|
||||||
/>
|
/>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -582,6 +581,7 @@ const downloadFile = (content: string, filename: string, type: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-buttons {
|
.export-buttons {
|
||||||
|
@apply font-mono;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,137 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
<!-- Section Header with Export Controls -->
|
<!-- Section Header -->
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="mb-8">
|
||||||
<div>
|
<h3 class="text-2xl font-black text-black mb-2">
|
||||||
<h3 class="text-2xl font-black text-black mb-2">
|
Where will your money come from?
|
||||||
Where will your money come from?
|
</h3>
|
||||||
</h3>
|
<p class="text-neutral-600">
|
||||||
<p class="text-neutral-600">
|
Add sources like client work, grants, product sales, or donations.
|
||||||
Add sources like client work, grants, product sales, or donations.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UButton
|
|
||||||
variant="outline"
|
|
||||||
color="gray"
|
|
||||||
size="sm"
|
|
||||||
@click="exportStreams">
|
|
||||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
|
||||||
Export
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
v-if="streams.length > 0"
|
|
||||||
@click="addRevenueStream"
|
|
||||||
size="sm"
|
|
||||||
variant="solid"
|
|
||||||
color="success"
|
|
||||||
:ui="{
|
|
||||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
|
||||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
|
||||||
}">
|
|
||||||
<UIcon name="i-heroicons-plus" class="mr-1" />
|
|
||||||
Add stream
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<!-- Removed Tab Navigation - showing streams directly -->
|
||||||
<div
|
<div class="space-y-6">
|
||||||
v-if="streams.length === 0"
|
<!-- Export Controls -->
|
||||||
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
<div class="flex justify-end">
|
||||||
<h4 class="font-medium text-neutral-900 mb-2">
|
<UButton
|
||||||
No revenue streams yet
|
variant="outline"
|
||||||
</h4>
|
color="gray"
|
||||||
<p class="text-sm text-neutral-500 mb-4">
|
size="sm"
|
||||||
Get started by adding your first revenue source.
|
@click="exportStreams">
|
||||||
</p>
|
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
||||||
<UButton
|
Export
|
||||||
@click="addRevenueStream"
|
</UButton>
|
||||||
size="lg"
|
</div>
|
||||||
variant="solid"
|
|
||||||
color="primary">
|
|
||||||
<UIcon name="i-heroicons-plus" class="mr-2" />
|
|
||||||
Add your first revenue stream
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div class="space-y-3">
|
||||||
v-for="stream in streams"
|
<div
|
||||||
:key="stream.id"
|
v-if="streams.length === 0"
|
||||||
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
class="text-center py-12 border-4 border-dashed border-black rounded-xl bg-white shadow-lg">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<h4 class="font-medium text-neutral-900 mb-2">
|
||||||
<UFormField label="Category" required>
|
No revenue streams yet
|
||||||
<USelect
|
</h4>
|
||||||
v-model="stream.category"
|
<p class="text-sm text-neutral-500 mb-4">
|
||||||
:items="categoryOptions"
|
Get started by adding your first revenue source.
|
||||||
size="xl"
|
</p>
|
||||||
class="text-xl font-bold w-full"
|
<UButton
|
||||||
@update:model-value="saveStream(stream)" />
|
@click="addRevenueStream"
|
||||||
</UFormField>
|
size="lg"
|
||||||
|
variant="solid"
|
||||||
|
color="primary">
|
||||||
|
<UIcon name="i-heroicons-plus" class="mr-2" />
|
||||||
|
Add your first revenue stream
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<UFormField label="Revenue source name" required>
|
<div
|
||||||
<USelectMenu
|
v-for="stream in streams"
|
||||||
v-model="stream.name"
|
:key="stream.id"
|
||||||
:items="nameOptionsByCategory[stream.category] || []"
|
class="p-6 border-3 border-black rounded-xl bg-white shadow-md">
|
||||||
placeholder="Select or type a source name"
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
creatable
|
<UFormField label="Category" required>
|
||||||
searchable
|
<USelect
|
||||||
size="xl"
|
v-model="stream.category"
|
||||||
class="text-xl font-bold w-full"
|
:items="categoryOptions"
|
||||||
@update:model-value="saveStream(stream)" />
|
size="xl"
|
||||||
</UFormField>
|
class="text-xl font-bold w-full"
|
||||||
|
@update:model-value="saveStream(stream)" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Monthly amount" required>
|
<UFormField label="Revenue source name" required>
|
||||||
<UInput
|
<USelectMenu
|
||||||
v-model="stream.targetMonthlyAmount"
|
v-model="stream.name"
|
||||||
type="text"
|
:items="nameOptionsByCategory[stream.category] || []"
|
||||||
placeholder="5000"
|
placeholder="Select or type a source name"
|
||||||
size="xl"
|
creatable
|
||||||
class="text-xl font-black w-full"
|
searchable
|
||||||
@update:model-value="validateAndSaveAmount($event, stream)"
|
size="xl"
|
||||||
@blur="saveStream(stream)">
|
class="text-xl font-bold w-full"
|
||||||
<template #leading>
|
@update:model-value="saveStream(stream)" />
|
||||||
<span class="text-neutral-500 text-xl">$</span>
|
</UFormField>
|
||||||
</template>
|
|
||||||
</UInput>
|
<UFormField label="Monthly amount" required>
|
||||||
</UFormField>
|
<UInput
|
||||||
|
v-model="stream.targetMonthlyAmount"
|
||||||
|
type="text"
|
||||||
|
placeholder="5000"
|
||||||
|
size="xl"
|
||||||
|
class="text-xl font-black w-full"
|
||||||
|
@update:model-value="validateAndSaveAmount($event, stream)"
|
||||||
|
@blur="saveStream(stream)">
|
||||||
|
<template #leading>
|
||||||
|
<span class="text-neutral-500 text-xl">$</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
variant="solid"
|
||||||
|
color="error"
|
||||||
|
@click="removeStream(stream.id)"
|
||||||
|
:ui="{
|
||||||
|
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
||||||
|
}">
|
||||||
|
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Stream Button (when items exist) -->
|
||||||
|
<div v-if="streams.length > 0" class="flex justify-center">
|
||||||
|
<UButton
|
||||||
|
@click="addRevenueStream"
|
||||||
|
size="lg"
|
||||||
|
variant="solid"
|
||||||
|
color="success"
|
||||||
|
:ui="{
|
||||||
|
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||||
|
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||||
|
}">
|
||||||
|
<UIcon name="i-heroicons-plus" class="mr-2" />
|
||||||
|
Add another stream
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="streams.length > 0" class="flex items-center gap-3 justify-end">
|
||||||
|
<UButton
|
||||||
|
@click="addRevenueStream"
|
||||||
|
size="sm"
|
||||||
|
variant="solid"
|
||||||
|
color="success"
|
||||||
|
:ui="{
|
||||||
|
base: 'cursor-pointer hover:scale-105 transition-transform',
|
||||||
|
leadingIcon: 'hover:rotate-90 transition-transform',
|
||||||
|
}">
|
||||||
|
<UIcon name="i-heroicons-plus" class="mr-1" />
|
||||||
|
Add stream
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end mt-6 pt-6 border-t-3 border-black">
|
|
||||||
<UButton
|
|
||||||
size="xs"
|
|
||||||
variant="solid"
|
|
||||||
color="error"
|
|
||||||
@click="removeStream(stream.id)"
|
|
||||||
:ui="{
|
|
||||||
base: 'cursor-pointer hover:opacity-90 transition-opacity',
|
|
||||||
}">
|
|
||||||
<UIcon name="i-heroicons-trash" class="w-4 h-4" />
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Stream Button (when items exist) -->
|
|
||||||
<div v-if="streams.length > 0" class="flex justify-center">
|
|
||||||
<UButton
|
|
||||||
@click="addRevenueStream"
|
|
||||||
size="lg"
|
|
||||||
variant="solid"
|
|
||||||
color="success"
|
|
||||||
:ui="{
|
|
||||||
base: 'cursor-pointer hover:scale-105 transition-transform',
|
|
||||||
leadingIcon: 'hover:rotate-90 transition-transform',
|
|
||||||
}">
|
|
||||||
<UIcon name="i-heroicons-plus" class="mr-2" />
|
|
||||||
Add another stream
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
<!-- Section Header with Export Controls -->
|
<!-- Section Header -->
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="mb-8">
|
||||||
<div>
|
<h3 class="text-2xl font-black text-black dark:text-white mb-2">Review & Complete</h3>
|
||||||
<h3 class="text-2xl font-black text-black mb-2">Review & Complete</h3>
|
<p class="text-neutral-600 dark:text-neutral-400">
|
||||||
<p class="text-neutral-600">
|
Review your setup and complete the wizard to start using your co-op
|
||||||
Review your setup and complete the wizard to start using your co-op
|
tool.
|
||||||
tool.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UButton variant="outline" color="gray" size="sm" @click="exportSetup">
|
|
||||||
<UIcon name="i-heroicons-arrow-down-tray" class="mr-1" />
|
|
||||||
Export All
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
@ -371,28 +363,4 @@ function completeSetup() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportSetup() {
|
|
||||||
// Create export data
|
|
||||||
const setupData = {
|
|
||||||
members: members.value,
|
|
||||||
policies: policies.value,
|
|
||||||
overheadCosts: overheadCosts.value,
|
|
||||||
streams: streams.value,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
version: "1.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Download as JSON
|
|
||||||
const blob = new Blob([JSON.stringify(setupData, null, 2)], {
|
|
||||||
type: "application/json",
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `coop-setup-${new Date().toISOString().split("T")[0]}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="mb-12">
|
||||||
class="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900">
|
<div class="w-full mx-auto">
|
||||||
<div class="max-w-4xl mx-auto px-4 py-3">
|
<nav
|
||||||
<nav class="flex items-center space-x-1 overflow-x-auto">
|
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
|
||||||
<!-- Main Setup Wizard -->
|
>
|
||||||
<NuxtLink
|
|
||||||
to="/wizard"
|
|
||||||
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap"
|
|
||||||
:class="
|
|
||||||
isActive('/wizard')
|
|
||||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
||||||
">
|
|
||||||
<UIcon name="i-heroicons-cog-6-tooth" class="w-4 h-4 mr-2" />
|
|
||||||
Setup Wizard
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="h-6 w-px bg-neutral-300 dark:bg-neutral-600 mx-2"></div>
|
|
||||||
|
|
||||||
<!-- Template Wizards -->
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="wizard in templateWizards"
|
v-for="wizard in templateWizards"
|
||||||
:key="wizard.id"
|
:key="wizard.id"
|
||||||
:to="wizard.path"
|
:to="wizard.path"
|
||||||
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap"
|
class="inline-flex items-center px-3 py-2 text-sm font-bold transition-colors whitespace-nowrap underline"
|
||||||
:class="
|
:class="
|
||||||
isActive(wizard.path)
|
isActive(wizard.path)
|
||||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
? 'bg-black text-white dark:bg-white dark:text-black no-underline'
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
: ''
|
||||||
">
|
"
|
||||||
<UIcon :name="wizard.icon" class="w-4 h-4 mr-2" />
|
>
|
||||||
{{ wizard.name }}
|
{{ wizard.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- All Wizards Link -->
|
|
||||||
<div class="h-6 w-px bg-neutral-300 dark:bg-neutral-600 mx-2"></div>
|
|
||||||
<NuxtLink
|
|
||||||
to="/wizards"
|
|
||||||
class="inline-flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap"
|
|
||||||
:class="
|
|
||||||
isActive('/wizards')
|
|
||||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
|
||||||
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
|
||||||
">
|
|
||||||
<UIcon name="i-heroicons-squares-plus" class="w-4 h-4 mr-2" />
|
|
||||||
All Wizards
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,30 +25,25 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
// Template wizards data - matches the wizards.vue page
|
|
||||||
const templateWizards = [
|
const templateWizards = [
|
||||||
{
|
{
|
||||||
id: "membership-agreement",
|
id: "membership-agreement",
|
||||||
name: "Membership Agreement",
|
name: "Membership Agreement",
|
||||||
icon: "i-heroicons-user-group",
|
|
||||||
path: "/templates/membership-agreement",
|
path: "/templates/membership-agreement",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "conflict-resolution-framework",
|
id: "conflict-resolution-framework",
|
||||||
name: "Conflict Resolution",
|
name: "Conflict Resolution",
|
||||||
icon: "i-heroicons-scale",
|
|
||||||
path: "/templates/conflict-resolution-framework",
|
path: "/templates/conflict-resolution-framework",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tech-charter",
|
id: "tech-charter",
|
||||||
name: "Tech Charter",
|
name: "Tech Charter",
|
||||||
icon: "i-heroicons-cog-6-tooth",
|
|
||||||
path: "/templates/tech-charter",
|
path: "/templates/tech-charter",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "decision-framework",
|
id: "decision-framework",
|
||||||
name: "Decision Helper",
|
name: "Decision Framework",
|
||||||
icon: "i-heroicons-light-bulb",
|
|
||||||
path: "/templates/decision-framework",
|
path: "/templates/decision-framework",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export const useFixtures = () => {
|
||||||
category: 'Services',
|
category: 'Services',
|
||||||
subcategory: 'Development',
|
subcategory: 'Development',
|
||||||
targetPct: 65,
|
targetPct: 65,
|
||||||
targetMonthlyAmount: 0,
|
targetMonthlyAmount: 13000,
|
||||||
certainty: 'Committed',
|
certainty: 'Committed',
|
||||||
payoutDelayDays: 30,
|
payoutDelayDays: 30,
|
||||||
terms: 'Net 30',
|
terms: 'Net 30',
|
||||||
|
|
@ -73,7 +73,7 @@ export const useFixtures = () => {
|
||||||
category: 'Product',
|
category: 'Product',
|
||||||
subcategory: 'Digital Tools',
|
subcategory: 'Digital Tools',
|
||||||
targetPct: 20,
|
targetPct: 20,
|
||||||
targetMonthlyAmount: 0,
|
targetMonthlyAmount: 4000,
|
||||||
certainty: 'Probable',
|
certainty: 'Probable',
|
||||||
payoutDelayDays: 14,
|
payoutDelayDays: 14,
|
||||||
terms: 'Platform payout',
|
terms: 'Platform payout',
|
||||||
|
|
@ -88,7 +88,7 @@ export const useFixtures = () => {
|
||||||
category: 'Grant',
|
category: 'Grant',
|
||||||
subcategory: 'Government',
|
subcategory: 'Government',
|
||||||
targetPct: 10,
|
targetPct: 10,
|
||||||
targetMonthlyAmount: 0,
|
targetMonthlyAmount: 2000,
|
||||||
certainty: 'Committed',
|
certainty: 'Committed',
|
||||||
payoutDelayDays: 45,
|
payoutDelayDays: 45,
|
||||||
terms: 'Quarterly disbursement',
|
terms: 'Quarterly disbursement',
|
||||||
|
|
@ -103,7 +103,7 @@ export const useFixtures = () => {
|
||||||
category: 'Donation',
|
category: 'Donation',
|
||||||
subcategory: 'Individual',
|
subcategory: 'Individual',
|
||||||
targetPct: 3,
|
targetPct: 3,
|
||||||
targetMonthlyAmount: 0,
|
targetMonthlyAmount: 600,
|
||||||
certainty: 'Aspirational',
|
certainty: 'Aspirational',
|
||||||
payoutDelayDays: 3,
|
payoutDelayDays: 3,
|
||||||
terms: 'Immediate',
|
terms: 'Immediate',
|
||||||
|
|
@ -118,7 +118,7 @@ export const useFixtures = () => {
|
||||||
category: 'Other',
|
category: 'Other',
|
||||||
subcategory: 'Professional Services',
|
subcategory: 'Professional Services',
|
||||||
targetPct: 2,
|
targetPct: 2,
|
||||||
targetMonthlyAmount: 0,
|
targetMonthlyAmount: 400,
|
||||||
certainty: 'Probable',
|
certainty: 'Probable',
|
||||||
payoutDelayDays: 21,
|
payoutDelayDays: 21,
|
||||||
terms: 'Net 21',
|
terms: 'Net 21',
|
||||||
|
|
|
||||||
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>
|
<template>
|
||||||
<section class="py-8 space-y-6">
|
<section class="py-8 space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-2xl font-semibold">Operating Plan</h2>
|
<h2 class="text-2xl font-semibold">Budget Worksheet</h2>
|
||||||
<USelect
|
<div class="flex items-center gap-4">
|
||||||
v-model="selectedMonth"
|
<UButton @click="forceReinitialize" variant="outline" size="sm" color="orange">Force Re-init</UButton>
|
||||||
:options="months"
|
<UButton @click="resetWorksheet" variant="outline" size="sm">Reset All</UButton>
|
||||||
placeholder="Select month" />
|
<UButton @click="exportBudget" variant="outline" size="sm">Export</UButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cash Waterfall Summary -->
|
<!-- Budget Worksheet Table -->
|
||||||
<UCard>
|
<UCard>
|
||||||
<template #header>
|
<div class="overflow-x-auto">
|
||||||
<h3 class="text-lg font-medium">
|
<table class="w-full border-collapse border border-gray-300 text-sm">
|
||||||
Cash Waterfall - {{ selectedMonth }}
|
<thead>
|
||||||
</h3>
|
<tr class="bg-gray-50">
|
||||||
</template>
|
<th class="border border-gray-300 px-3 py-2 text-left min-w-40 sticky left-0 bg-gray-50 z-10">Category</th>
|
||||||
<div
|
<!-- Monthly columns -->
|
||||||
class="flex items-center justify-between py-4 border-b border-neutral-200">
|
<th v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-center min-w-20">{{ month.label }}</th>
|
||||||
<div class="flex items-center gap-8">
|
</tr>
|
||||||
<div class="text-center">
|
</thead>
|
||||||
<div class="text-2xl font-bold text-blue-600">
|
<tbody>
|
||||||
€{{ budgetMetrics.grossRevenue.toLocaleString() }}
|
<!-- Revenue Section -->
|
||||||
</div>
|
<tr class="bg-blue-50 font-medium">
|
||||||
<div class="text-xs text-neutral-600">Gross Revenue</div>
|
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-50 z-10">
|
||||||
</div>
|
<div class="flex items-center justify-between">
|
||||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
<span>Revenue</span>
|
||||||
<div class="text-center">
|
<UButton @click="addRevenueLine" size="xs" variant="soft">+</UButton>
|
||||||
<div class="text-2xl font-bold text-red-600">
|
</div>
|
||||||
-€{{ budgetMetrics.totalFees.toLocaleString() }}
|
</td>
|
||||||
</div>
|
<td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
|
||||||
<div class="text-xs text-neutral-600">Fees</div>
|
</tr>
|
||||||
</div>
|
|
||||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
<!-- Revenue by Category -->
|
||||||
<div class="text-center">
|
<template v-for="(category, categoryName) in budgetStore.groupedRevenue" :key="`revenue-${categoryName}`">
|
||||||
<div class="text-2xl font-bold text-green-600">
|
<tr v-if="category.length > 0" class="bg-blue-100 font-medium">
|
||||||
€{{ budgetMetrics.netRevenue.toLocaleString() }}
|
<td class="border border-gray-300 px-4 py-1 sticky left-0 bg-blue-100 z-10 text-sm text-blue-700">
|
||||||
</div>
|
{{ categoryName }} ({{ category.length }} items)
|
||||||
<div class="text-xs text-neutral-600">Net Revenue</div>
|
</td>
|
||||||
</div>
|
<td class="border border-gray-300 px-2 py-1" :colspan="monthlyHeaders.length"></td>
|
||||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
</tr>
|
||||||
<div class="text-center">
|
<tr v-for="item in category" :key="item.id">
|
||||||
<div class="text-2xl font-bold text-blue-600">
|
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-white z-10">
|
||||||
€{{ Math.round(budgetMetrics.savingsAmount).toLocaleString() }}
|
<div class="space-y-2">
|
||||||
</div>
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-xs text-neutral-600">To Savings</div>
|
<input
|
||||||
</div>
|
v-model="item.name"
|
||||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
@blur="saveWorksheet"
|
||||||
<div class="text-center">
|
class="bg-transparent border-none outline-none w-full font-medium"
|
||||||
<div class="text-2xl font-bold text-purple-600">
|
:class="{ 'italic text-gray-500': item.name === 'New Revenue Item' }"
|
||||||
€{{ Math.round(budgetMetrics.totalPayroll).toLocaleString() }}
|
/>
|
||||||
</div>
|
<UButton @click="removeItem('revenue', item.id)" size="xs" variant="ghost" color="error">×</UButton>
|
||||||
<div class="text-xs text-neutral-600">Payroll</div>
|
</div>
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
<UIcon name="i-heroicons-arrow-right" class="text-neutral-400" />
|
<BudgetCategorySelector
|
||||||
<div class="text-center">
|
v-model="item.subcategory"
|
||||||
<div class="text-2xl font-bold text-orange-600">
|
type="revenue"
|
||||||
€{{ budgetMetrics.totalOverhead.toLocaleString() }}
|
:main-category="item.mainCategory"
|
||||||
</div>
|
placeholder="Subcategory"
|
||||||
<div class="text-xs text-neutral-600">Overhead</div>
|
@update:model-value="saveWorksheet"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-4">
|
</td>
|
||||||
<div class="flex items-center justify-between">
|
<!-- Monthly columns -->
|
||||||
<span class="text-lg font-medium">Available for Operations</span>
|
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-1 py-1">
|
||||||
<span class="text-2xl font-bold text-green-600"
|
<input
|
||||||
>€{{
|
type="number"
|
||||||
Math.round(budgetMetrics.availableForOps).toLocaleString()
|
:value="item.monthlyValues?.[month.key] || 0"
|
||||||
}}</span
|
@input="updateMonthlyValue('revenue', item.id, month.key, $event.target.value)"
|
||||||
>
|
class="w-full text-right border-none outline-none bg-transparent"
|
||||||
</div>
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Total Revenue Row -->
|
||||||
|
<tr class="bg-blue-100 font-bold">
|
||||||
|
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-100 z-10">Total Revenue</td>
|
||||||
|
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right">
|
||||||
|
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.revenue || 0) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Expenses Section -->
|
||||||
|
<tr class="bg-red-50 font-medium">
|
||||||
|
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-red-50 z-10">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>Expenses</span>
|
||||||
|
<UButton @click="addExpenseLine" size="xs" variant="soft">+</UButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Expenses by Category -->
|
||||||
|
<template v-for="(category, categoryName) in budgetStore.groupedExpenses" :key="`expense-${categoryName}`">
|
||||||
|
<tr v-if="category.length > 0" class="bg-red-100 font-medium">
|
||||||
|
<td class="border border-gray-300 px-4 py-1 sticky left-0 bg-red-100 z-10 text-sm text-red-700">
|
||||||
|
{{ categoryName }}
|
||||||
|
</td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1" :colspan="monthlyHeaders.length"></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="item in category" :key="item.id">
|
||||||
|
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-white z-10">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<input
|
||||||
|
v-model="item.name"
|
||||||
|
@blur="saveWorksheet"
|
||||||
|
class="bg-transparent border-none outline-none w-full font-medium"
|
||||||
|
:class="{ 'italic text-gray-500': item.name === 'New Expense Item' }"
|
||||||
|
/>
|
||||||
|
<UButton @click="removeItem('expenses', item.id)" size="xs" variant="ghost" color="error">×</UButton>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<BudgetCategorySelector
|
||||||
|
v-model="item.subcategory"
|
||||||
|
type="expenses"
|
||||||
|
:main-category="item.mainCategory"
|
||||||
|
placeholder="Subcategory"
|
||||||
|
@update:model-value="saveWorksheet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- Monthly columns -->
|
||||||
|
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="item.monthlyValues?.[month.key] || 0"
|
||||||
|
@input="updateMonthlyValue('expenses', item.id, month.key, $event.target.value)"
|
||||||
|
class="w-full text-right border-none outline-none bg-transparent"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Total Expenses Row -->
|
||||||
|
<tr class="bg-red-100 font-bold">
|
||||||
|
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-red-100 z-10">Total Expenses</td>
|
||||||
|
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right">
|
||||||
|
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.expenses || 0) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Net Income Row -->
|
||||||
|
<tr class="bg-green-100 font-bold text-lg">
|
||||||
|
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-green-100 z-10">Net Income</td>
|
||||||
|
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right"
|
||||||
|
:class="getNetIncomeClass(budgetStore.monthlyTotals[month.key]?.net || 0)">
|
||||||
|
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.net || 0) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<!-- Monthly Revenue Table -->
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Revenue by Stream</h3>
|
|
||||||
</template>
|
|
||||||
<UTable :rows="revenueStreams" :columns="revenueColumns">
|
|
||||||
<template #name-data="{ row }">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">{{ row.name }}</span>
|
|
||||||
<RestrictionChip :restriction="row.restrictions" size="xs" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #target-data="{ row }">
|
|
||||||
<span class="font-medium">€{{ row.target.toLocaleString() }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #committed-data="{ row }">
|
|
||||||
<span class="font-medium text-green-600"
|
|
||||||
>€{{ row.committed.toLocaleString() }}</span
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actual-data="{ row }">
|
|
||||||
<span
|
|
||||||
class="font-medium"
|
|
||||||
:class="
|
|
||||||
row.actual >= row.committed ? 'text-green-600' : 'text-orange-600'
|
|
||||||
">
|
|
||||||
€{{ row.actual.toLocaleString() }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #variance-data="{ row }">
|
|
||||||
<span :class="row.variance >= 0 ? 'text-green-600' : 'text-red-600'">
|
|
||||||
{{ row.variance >= 0 ? "+" : "" }}€{{
|
|
||||||
row.variance.toLocaleString()
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</UTable>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Costs Breakdown -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Costs</h3>
|
|
||||||
</template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-sm mb-2">Payroll</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600"
|
|
||||||
>Wages ({{ budgetMetrics.totalHours }}h @ €{{
|
|
||||||
budgetMetrics.hourlyWage
|
|
||||||
}})</span
|
|
||||||
>
|
|
||||||
<span class="font-medium"
|
|
||||||
>€{{
|
|
||||||
Math.round(budgetMetrics.grossWages).toLocaleString()
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600"
|
|
||||||
>On-costs ({{ budgetMetrics.oncostPct }}%)</span
|
|
||||||
>
|
|
||||||
<span class="font-medium"
|
|
||||||
>€{{
|
|
||||||
Math.round(budgetMetrics.oncosts).toLocaleString()
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex justify-between text-sm font-medium border-t pt-2">
|
|
||||||
<span>Total Payroll</span>
|
|
||||||
<span
|
|
||||||
>€{{
|
|
||||||
Math.round(budgetMetrics.totalPayroll).toLocaleString()
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-sm mb-2">Overhead</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-if="budgetStore.overheadCosts.length === 0"
|
|
||||||
class="text-sm text-neutral-500 italic">
|
|
||||||
No overhead costs added yet
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="cost in budgetStore.overheadCosts"
|
|
||||||
:key="cost.id"
|
|
||||||
class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">{{ cost.name }}</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>€{{ (cost.amount || 0).toLocaleString() }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex justify-between text-sm font-medium border-t pt-2">
|
|
||||||
<span>Total Overhead</span>
|
|
||||||
<span>€{{ budgetMetrics.totalOverhead.toLocaleString() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-sm mb-2">Production</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Dev kits</span>
|
|
||||||
<span class="font-medium">€500</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex justify-between text-sm font-medium border-t pt-2">
|
|
||||||
<span>Total Production</span>
|
|
||||||
<span>€500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-medium">Net Impact on Savings</h3>
|
|
||||||
</template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Net Revenue</span>
|
|
||||||
<span class="font-medium text-green-600"
|
|
||||||
>€{{ budgetMetrics.netRevenue.toLocaleString() }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Total Costs</span>
|
|
||||||
<span class="font-medium text-red-600"
|
|
||||||
>-€{{
|
|
||||||
Math.round(budgetMetrics.totalCosts).toLocaleString()
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-lg font-bold border-t pt-3">
|
|
||||||
<span>Net</span>
|
|
||||||
<span
|
|
||||||
:class="
|
|
||||||
budgetMetrics.monthlyNet >= 0
|
|
||||||
? 'text-green-600'
|
|
||||||
: 'text-red-600'
|
|
||||||
"
|
|
||||||
>{{ budgetMetrics.monthlyNet >= 0 ? "+" : "" }}€{{
|
|
||||||
Math.round(budgetMetrics.monthlyNet).toLocaleString()
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-50 rounded-lg p-4">
|
|
||||||
<h4 class="font-medium text-sm mb-3">Allocation</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">To Savings</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>€{{
|
|
||||||
Math.round(budgetMetrics.savingsAmount).toLocaleString()
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-neutral-600">Available</span>
|
|
||||||
<span class="font-medium"
|
|
||||||
>€{{
|
|
||||||
Math.round(
|
|
||||||
budgetMetrics.availableAfterSavings
|
|
||||||
).toLocaleString()
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs text-neutral-600 space-y-1">
|
|
||||||
<p>
|
|
||||||
<RestrictionChip restriction="Restricted" size="xs" /> funds can
|
|
||||||
only be used for approved purposes.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<RestrictionChip restriction="General" size="xs" /> funds have no
|
|
||||||
restrictions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Use real store data
|
// Import components explicitly
|
||||||
const membersStore = useMembersStore();
|
import BudgetCategorySelector from '~/components/BudgetCategorySelector.vue';
|
||||||
const policiesStore = usePoliciesStore();
|
|
||||||
const streamsStore = useStreamsStore();
|
// Use budget worksheet store
|
||||||
const budgetStore = useBudgetStore();
|
const budgetStore = useBudgetStore();
|
||||||
const cashStore = useCashStore();
|
|
||||||
|
|
||||||
const selectedMonth = ref("2024-01");
|
// Generate monthly headers for the next 12 months
|
||||||
const months = ref([
|
const monthlyHeaders = computed(() => {
|
||||||
{ label: "January 2024", value: "2024-01" },
|
const headers = [];
|
||||||
{ label: "February 2024", value: "2024-02" },
|
const today = new Date();
|
||||||
{ label: "March 2024", value: "2024-03" },
|
|
||||||
]);
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
// Calculate budget values from real data
|
const monthName = date.toLocaleString('default', { month: 'short' });
|
||||||
const budgetMetrics = computed(() => {
|
const year = date.getFullYear();
|
||||||
const totalHours = membersStore.capacityTotals.targetHours || 0;
|
|
||||||
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
headers.push({
|
||||||
const oncostPct = policiesStore.payrollOncostPct || 0;
|
key: `${year}-${String(date.getMonth() + 1).padStart(2, '0')}`,
|
||||||
|
label: `${monthName} ${year}`
|
||||||
const grossWages = totalHours * hourlyWage;
|
});
|
||||||
const oncosts = grossWages * (oncostPct / 100);
|
}
|
||||||
const totalPayroll = grossWages + oncosts;
|
|
||||||
|
return headers;
|
||||||
const totalOverhead = budgetStore.overheadCosts.reduce(
|
|
||||||
(sum, cost) => sum + (cost.amount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const grossRevenue = streamsStore.totalMonthlyAmount || 0;
|
|
||||||
|
|
||||||
// Calculate fees from streams with platform fees
|
|
||||||
const totalFees = streamsStore.streams.reduce((sum, stream) => {
|
|
||||||
const revenue = stream.targetMonthlyAmount || 0;
|
|
||||||
const platformFee = (stream.platformFeePct || 0) / 100;
|
|
||||||
const revShareFee = (stream.revenueSharePct || 0) / 100;
|
|
||||||
return sum + revenue * platformFee + revenue * revShareFee;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const netRevenue = grossRevenue - totalFees;
|
|
||||||
const totalCosts = totalPayroll + totalOverhead;
|
|
||||||
const monthlyNet = netRevenue - totalCosts;
|
|
||||||
const savingsAmount = Math.max(0, monthlyNet * 0.3); // Save 30% of positive net if possible
|
|
||||||
const availableAfterSavings = Math.max(0, monthlyNet - savingsAmount);
|
|
||||||
const availableForOps = Math.max(
|
|
||||||
0,
|
|
||||||
netRevenue - totalPayroll - totalOverhead - savingsAmount
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
grossRevenue,
|
|
||||||
totalFees,
|
|
||||||
netRevenue,
|
|
||||||
totalCosts,
|
|
||||||
monthlyNet,
|
|
||||||
savingsAmount,
|
|
||||||
availableAfterSavings,
|
|
||||||
totalPayroll,
|
|
||||||
grossWages,
|
|
||||||
oncosts,
|
|
||||||
totalOverhead,
|
|
||||||
availableForOps,
|
|
||||||
totalHours,
|
|
||||||
hourlyWage,
|
|
||||||
oncostPct,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert streams to budget table format
|
// Initialize from wizard data on first load
|
||||||
const revenueStreams = computed(() =>
|
onMounted(async () => {
|
||||||
streamsStore.streams.map((stream) => ({
|
console.log('Budget page mounted, initializing...');
|
||||||
id: stream.id,
|
if (!budgetStore.isInitialized) {
|
||||||
name: stream.name,
|
await budgetStore.initializeFromWizardData();
|
||||||
target: stream.targetMonthlyAmount || 0,
|
}
|
||||||
committed: Math.round((stream.targetMonthlyAmount || 0) * 0.8), // 80% committed assumption
|
console.log('Budget worksheet:', budgetStore.budgetWorksheet);
|
||||||
actual: Math.round((stream.targetMonthlyAmount || 0) * 0.9), // 90% actual assumption
|
console.log('Grouped revenue:', budgetStore.groupedRevenue);
|
||||||
variance: Math.round((stream.targetMonthlyAmount || 0) * 0.1), // 10% positive variance
|
console.log('Grouped expenses:', budgetStore.groupedExpenses);
|
||||||
restrictions: stream.restrictions || "General",
|
});
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const revenueColumns = [
|
// Budget worksheet functions
|
||||||
{ id: "name", key: "name", label: "Stream" },
|
function updateValue(category: string, itemId: string, year: string, scenario: string, value: string) {
|
||||||
{ id: "target", key: "target", label: "Target" },
|
budgetStore.updateBudgetValue(category, itemId, year, scenario, value);
|
||||||
{ id: "committed", key: "committed", label: "Committed" },
|
}
|
||||||
{ id: "actual", key: "actual", label: "Actual" },
|
|
||||||
{ id: "variance", key: "variance", label: "Variance" },
|
function updateMonthlyValue(category: string, itemId: string, monthKey: string, value: string) {
|
||||||
];
|
budgetStore.updateMonthlyValue(category, itemId, monthKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRevenueLine() {
|
||||||
|
console.log('Adding revenue line...');
|
||||||
|
budgetStore.addBudgetItem('revenue', 'New Revenue Item');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExpenseLine() {
|
||||||
|
console.log('Adding expense line...');
|
||||||
|
budgetStore.addBudgetItem('expenses', 'New Expense Item');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(category: string, itemId: string) {
|
||||||
|
budgetStore.removeBudgetItem(category, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWorksheet() {
|
||||||
|
// Auto-save is handled by the store persistence
|
||||||
|
console.log('Worksheet saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWorksheet() {
|
||||||
|
if (confirm('Are you sure you want to reset all budget data? This cannot be undone.')) {
|
||||||
|
budgetStore.resetBudgetWorksheet();
|
||||||
|
// Force re-initialization
|
||||||
|
budgetStore.isInitialized = false;
|
||||||
|
budgetStore.initializeFromWizardData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceReinitialize() {
|
||||||
|
console.log('Force re-initializing budget...');
|
||||||
|
// Clear all persistent data
|
||||||
|
localStorage.removeItem('urgent-tools-budget');
|
||||||
|
localStorage.removeItem('urgent-tools-streams');
|
||||||
|
localStorage.removeItem('urgent-tools-members');
|
||||||
|
localStorage.removeItem('urgent-tools-policies');
|
||||||
|
|
||||||
|
// Reset the store state completely
|
||||||
|
budgetStore.isInitialized = false;
|
||||||
|
budgetStore.budgetWorksheet.revenue = [];
|
||||||
|
budgetStore.budgetWorksheet.expenses = [];
|
||||||
|
|
||||||
|
// Reset categories to defaults
|
||||||
|
budgetStore.revenueCategories = [
|
||||||
|
'Games & Products',
|
||||||
|
'Services & Contracts',
|
||||||
|
'Grants & Funding',
|
||||||
|
'Community Support',
|
||||||
|
'Partnerships',
|
||||||
|
'Investment Income',
|
||||||
|
'In-Kind Contributions'
|
||||||
|
];
|
||||||
|
|
||||||
|
budgetStore.expenseCategories = [
|
||||||
|
'Salaries & Benefits',
|
||||||
|
'Development Costs',
|
||||||
|
'Equipment & Technology',
|
||||||
|
'Marketing & Outreach',
|
||||||
|
'Office & Operations',
|
||||||
|
'Legal & Professional',
|
||||||
|
'Other Expenses'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Force re-initialization
|
||||||
|
await budgetStore.initializeFromWizardData();
|
||||||
|
|
||||||
|
console.log('Re-initialization complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportBudget() {
|
||||||
|
const data = {
|
||||||
|
worksheet: budgetStore.budgetWorksheet,
|
||||||
|
totals: budgetStore.budgetTotals,
|
||||||
|
exportedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `budget-worksheet-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNetIncomeClass(amount: number): string {
|
||||||
|
if (amount > 0) return 'text-green-600';
|
||||||
|
if (amount < 0) return 'text-red-600';
|
||||||
|
return 'text-gray-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
useSeoMeta({
|
||||||
|
title: "Budget Worksheet - Plan Your Co-op's Financial Future",
|
||||||
|
description: "Interactive budget planning tool with multiple scenarios and multi-year projections for worker cooperatives.",
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
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>
|
<template>
|
||||||
<WizardPage />
|
<CoopBuilderPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Reuse the existing wizard content by importing it as a component
|
// Reuse the existing coop builder content by importing it as a component
|
||||||
import WizardPage from "~/pages/wizard.vue";
|
import CoopBuilderPage from "~/pages/coop-builder.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@ const streamsStore = useStreamsStore();
|
||||||
const budgetStore = useBudgetStore();
|
const budgetStore = useBudgetStore();
|
||||||
const cashStore = useCashStore();
|
const cashStore = useCashStore();
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const wizardStore = useWizardStore();
|
const coopBuilderStore = useCoopBuilderStore();
|
||||||
|
|
||||||
const isResetting = ref(false);
|
const isResetting = ref(false);
|
||||||
|
|
||||||
|
|
@ -556,7 +556,7 @@ async function restartWizard() {
|
||||||
sessionStore.resetSession();
|
sessionStore.resetSession();
|
||||||
|
|
||||||
// Reset wizard state
|
// Reset wizard state
|
||||||
wizardStore.reset();
|
coopBuilderStore.reset();
|
||||||
|
|
||||||
// Small delay for UX
|
// Small delay for UX
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -222,29 +222,7 @@ useHead({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Ubuntu font import */
|
/* Template index specific styles - no longer duplicated in main.css */
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
|
|
||||||
|
|
||||||
/* Removed full-screen dither pattern to avoid gray haze in dark mode */
|
|
||||||
|
|
||||||
/* Exact shadow style from value-flow inspiration */
|
|
||||||
.dither-shadow {
|
|
||||||
background: black;
|
|
||||||
background-image: radial-gradient(white 1px, transparent 1px);
|
|
||||||
background-size: 2px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.dither-shadow {
|
|
||||||
background: white;
|
|
||||||
background-image: radial-gradient(black 1px, transparent 1px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .dither-shadow {
|
|
||||||
background: white;
|
|
||||||
background-image: radial-gradient(black 1px, transparent 1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dither-shadow-disabled {
|
.dither-shadow-disabled {
|
||||||
background: black;
|
background: black;
|
||||||
|
|
@ -265,74 +243,6 @@ useHead({
|
||||||
background-image: radial-gradient(black 1px, transparent 1px);
|
background-image: radial-gradient(black 1px, transparent 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rely on Tailwind bg utilities on container */
|
|
||||||
|
|
||||||
.template-card {
|
|
||||||
@apply relative;
|
|
||||||
font-family: "Ubuntu", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-section {
|
|
||||||
@apply relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coming-soon {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dither-tag {
|
|
||||||
position: relative;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
.dither-tag::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent 0px,
|
|
||||||
transparent 1px,
|
|
||||||
black 1px,
|
|
||||||
black 2px
|
|
||||||
);
|
|
||||||
opacity: 0.1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button styling - pure bitmap, no colors */
|
|
||||||
.bitmap-button {
|
|
||||||
font-family: "Ubuntu Mono", monospace !important;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitmap-button:hover {
|
|
||||||
transform: translateY(-1px) translateX(-1px);
|
|
||||||
transition: transform 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bitmap-button:hover::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 1px;
|
|
||||||
left: 1px;
|
|
||||||
right: -1px;
|
|
||||||
bottom: -1px;
|
|
||||||
border: 1px solid black;
|
|
||||||
background: white;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled-button {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove any inherited rounded corners */
|
/* Remove any inherited rounded corners */
|
||||||
.template-card > *,
|
.template-card > *,
|
||||||
.help-section > *,
|
.help-section > *,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Wizard Subnav -->
|
|
||||||
<WizardSubnav />
|
|
||||||
|
|
||||||
<!-- Export Options - Top -->
|
<!-- Export Options - Top -->
|
||||||
<ExportOptions
|
<ExportOptions
|
||||||
:export-data="exportData"
|
:export-data="exportData"
|
||||||
filename="membership-agreement"
|
filename="membership-agreement"
|
||||||
title="Membership Agreement"
|
title="Membership Agreement" />
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100"
|
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
|
||||||
>
|
|
||||||
<!-- Document Container -->
|
<!-- Document Container -->
|
||||||
<div class="document-page">
|
<div class="document-page">
|
||||||
<div class="template-content">
|
<div class="template-content">
|
||||||
|
|
@ -20,8 +15,9 @@
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h1
|
<h1
|
||||||
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100"
|
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100"
|
||||||
:data-coop-name="formData.cooperativeName || 'Worker Cooperative'"
|
:data-coop-name="
|
||||||
>
|
formData.cooperativeName || 'Worker Cooperative'
|
||||||
|
">
|
||||||
MEMBERSHIP AGREEMENT
|
MEMBERSHIP AGREEMENT
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -29,8 +25,7 @@
|
||||||
<!-- Section 1: Who We Are -->
|
<!-- Section 1: Who We Are -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
1. Who We Are
|
1. Who We Are
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
@ -42,8 +37,7 @@
|
||||||
size="xl"
|
size="xl"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@input="debouncedAutoSave"
|
@input="debouncedAutoSave"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Date Established" class="form-group-large">
|
<UFormField label="Date Established" class="form-group-large">
|
||||||
|
|
@ -52,8 +46,7 @@
|
||||||
type="date"
|
type="date"
|
||||||
size="xl"
|
size="xl"
|
||||||
class="large-field"
|
class="large-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Our Purpose" class="form-group-large">
|
<UFormField label="Our Purpose" class="form-group-large">
|
||||||
|
|
@ -64,8 +57,7 @@
|
||||||
size="xl"
|
size="xl"
|
||||||
class="large-field"
|
class="large-field"
|
||||||
@input="debouncedAutoSave"
|
@input="debouncedAutoSave"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Our Core Values" class="form-group-large">
|
<UFormField label="Our Core Values" class="form-group-large">
|
||||||
|
|
@ -76,8 +68,7 @@
|
||||||
size="xl"
|
size="xl"
|
||||||
class="large-field"
|
class="large-field"
|
||||||
@input="debouncedAutoSave"
|
@input="debouncedAutoSave"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<div class="form-group-large">
|
<div class="form-group-large">
|
||||||
|
|
@ -91,8 +82,7 @@
|
||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon="i-heroicons-plus"
|
icon="i-heroicons-plus">
|
||||||
>
|
|
||||||
Add Member
|
Add Member
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,10 +91,10 @@
|
||||||
<div
|
<div
|
||||||
v-for="(member, index) in formData.members"
|
v-for="(member, index) in formData.members"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="border border-neutral-600 rounded-lg p-4"
|
class="border border-neutral-600 rounded-lg p-4">
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<h4 class="font-medium text-neutral-900 dark:text-neutral-100">
|
<h4
|
||||||
|
class="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
Member {{ index + 1 }}
|
Member {{ index + 1 }}
|
||||||
</h4>
|
</h4>
|
||||||
<UButton
|
<UButton
|
||||||
|
|
@ -113,8 +103,7 @@
|
||||||
size="sm"
|
size="sm"
|
||||||
color="red"
|
color="red"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon="i-heroicons-trash"
|
icon="i-heroicons-trash">
|
||||||
>
|
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -126,8 +115,7 @@
|
||||||
size="xl"
|
size="xl"
|
||||||
class="large-field"
|
class="large-field"
|
||||||
@input="debouncedAutoSave"
|
@input="debouncedAutoSave"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Email" class="form-group-large">
|
<UFormField label="Email" class="form-group-large">
|
||||||
|
|
@ -138,8 +126,7 @@
|
||||||
size="xl"
|
size="xl"
|
||||||
class="large-field"
|
class="large-field"
|
||||||
@input="debouncedAutoSave"
|
@input="debouncedAutoSave"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Join Date" class="form-group-large">
|
<UFormField label="Join Date" class="form-group-large">
|
||||||
|
|
@ -148,22 +135,19 @@
|
||||||
type="date"
|
type="date"
|
||||||
size="xl"
|
size="xl"
|
||||||
class="large-field"
|
class="large-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField
|
<UFormField
|
||||||
label="Current Role (Optional)"
|
label="Current Role (Optional)"
|
||||||
class="form-group-large"
|
class="form-group-large">
|
||||||
>
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="member.role"
|
v-model="member.role"
|
||||||
placeholder="e.g., Coordinator, Developer, etc."
|
placeholder="e.g., Coordinator, Developer, etc."
|
||||||
size="xl"
|
size="xl"
|
||||||
class="large-field"
|
class="large-field"
|
||||||
@input="debouncedAutoSave"
|
@input="debouncedAutoSave"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -175,16 +159,14 @@
|
||||||
<!-- Section 2: Membership -->
|
<!-- Section 2: Membership -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
2. Membership
|
2. Membership
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Who Can Be a Member
|
Who Can Be a Member
|
||||||
</h3>
|
</h3>
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
|
|
@ -193,8 +175,8 @@
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
<ul class="content-list my-2 pl-6 list-disc">
|
||||||
<li>Shares our values and purpose</li>
|
<li>Shares our values and purpose</li>
|
||||||
<li>
|
<li>
|
||||||
Contributes labour to the cooperative (by doing actual work, not just
|
Contributes labour to the cooperative (by doing actual work,
|
||||||
investing money)
|
not just investing money)
|
||||||
</li>
|
</li>
|
||||||
<li>Commits to collective decision-making</li>
|
<li>Commits to collective decision-making</li>
|
||||||
<li>Participates in governance responsibilities</li>
|
<li>Participates in governance responsibilities</li>
|
||||||
|
|
@ -203,14 +185,14 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Becoming a Member
|
Becoming a Member
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p class="content-paragraph">
|
<p class="content-paragraph">
|
||||||
New members join through a consent process, which means existing members
|
New members join through a consent process, which means
|
||||||
must agree that adding this person won't harm the cooperative.
|
existing members must agree that adding this person won't harm
|
||||||
|
the cooperative.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ol class="content-list numbered my-2 pl-6 list-decimal">
|
<ol class="content-list numbered my-2 pl-6 list-decimal">
|
||||||
|
|
@ -221,8 +203,7 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="3"
|
placeholder="3"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
months working together
|
months working together
|
||||||
</li>
|
</li>
|
||||||
<li>Values alignment conversation</li>
|
<li>Values alignment conversation</li>
|
||||||
|
|
@ -233,8 +214,7 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="1000"
|
placeholder="1000"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
(can be paid over time or waived based on need)
|
(can be paid over time or waived based on need)
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
@ -242,20 +222,19 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Leaving the Cooperative
|
Leaving the Cooperative
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p class="content-paragraph flex items-baseline gap-2 flex-wrap">
|
<p
|
||||||
|
class="content-paragraph flex items-baseline gap-2 flex-wrap">
|
||||||
Members can leave anytime with
|
Members can leave anytime with
|
||||||
<UInput
|
<UInput
|
||||||
v-model="formData.noticeDays"
|
v-model="formData.noticeDays"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="30"
|
placeholder="30"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
days notice. The cooperative will:
|
days notice. The cooperative will:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -267,8 +246,7 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="30"
|
placeholder="30"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
days
|
days
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
<li class="flex items-baseline gap-2 flex-wrap">
|
||||||
|
|
@ -278,11 +256,12 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="90"
|
placeholder="90"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
days
|
days
|
||||||
</li>
|
</li>
|
||||||
<li>Maintain respectful ongoing relationships when possible</li>
|
<li>
|
||||||
|
Maintain respectful ongoing relationships when possible
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -291,80 +270,71 @@
|
||||||
<!-- Section 3: How We Make Decisions -->
|
<!-- Section 3: How We Make Decisions -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
3. How We Make Decisions
|
3. How We Make Decisions
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Consent-Based Decisions
|
Consent-Based Decisions
|
||||||
</h3>
|
</h3>
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
We use consent, not consensus. This means we move forward when no one
|
We use consent, not consensus. This means we move forward when
|
||||||
has a principled objection that would harm the cooperative. An objection
|
no one has a principled objection that would harm the
|
||||||
must explain how the proposal would contradict our values or threaten
|
cooperative. An objection must explain how the proposal would
|
||||||
our sustainability.
|
contradict our values or threaten our sustainability.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Day-to-Day Decisions
|
Day-to-Day Decisions
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
|
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
|
||||||
>
|
|
||||||
Decisions under $<UInput
|
Decisions under $<UInput
|
||||||
v-model="formData.dayToDayLimit"
|
v-model="formData.dayToDayLimit"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="100"
|
placeholder="100"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
can be made by any member. Just tell others what you did at
|
||||||
can be made by any member. Just tell others what you did at the next
|
the next meeting.
|
||||||
meeting.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Regular Decisions
|
Regular Decisions
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
|
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
|
||||||
>
|
|
||||||
Decisions between $<UInput
|
Decisions between $<UInput
|
||||||
v-model="formData.regularDecisionMin"
|
v-model="formData.regularDecisionMin"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="100"
|
placeholder="100"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
and $<UInput
|
and $<UInput
|
||||||
v-model="formData.regularDecisionMax"
|
v-model="formData.regularDecisionMax"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="1000"
|
placeholder="1000"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
need consent from members present at a meeting (minimum 2
|
||||||
need consent from members present at a meeting (minimum 2 members).
|
members).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Major Decisions
|
Major Decisions
|
||||||
</h3>
|
</h3>
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
|
|
@ -379,8 +349,7 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="5000"
|
placeholder="5000"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
<li>Fundamental changes to our purpose or structure</li>
|
<li>Fundamental changes to our purpose or structure</li>
|
||||||
<li>Dissolution of the cooperative</li>
|
<li>Dissolution of the cooperative</li>
|
||||||
|
|
@ -389,8 +358,7 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Meeting Structure
|
Meeting Structure
|
||||||
</h3>
|
</h3>
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
<ul class="content-list my-2 pl-6 list-disc">
|
||||||
|
|
@ -400,8 +368,7 @@
|
||||||
v-model="formData.meetingFrequency"
|
v-model="formData.meetingFrequency"
|
||||||
placeholder="weekly"
|
placeholder="weekly"
|
||||||
class="inline-field"
|
class="inline-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
<li class="flex items-baseline gap-2 flex-wrap">
|
||||||
Emergency meetings need
|
Emergency meetings need
|
||||||
|
|
@ -410,12 +377,13 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="24"
|
placeholder="24"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
hours notice
|
hours notice
|
||||||
</li>
|
</li>
|
||||||
<li>We rotate who facilitates meetings</li>
|
<li>We rotate who facilitates meetings</li>
|
||||||
<li>Decisions and reasoning get documented in shared notes</li>
|
<li>
|
||||||
|
Decisions and reasoning get documented in shared notes
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -424,28 +392,25 @@
|
||||||
<!-- Section 4: Money and Labour -->
|
<!-- Section 4: Money and Labour -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
4. Money and Labour
|
4. Money and Labour
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Equal Ownership
|
Equal Ownership
|
||||||
</h3>
|
</h3>
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
Each member owns an equal share of the cooperative, regardless of hours
|
Each member owns an equal share of the cooperative, regardless
|
||||||
worked or tenure.
|
of hours worked or tenure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Paying Ourselves
|
Paying Ourselves
|
||||||
</h3>
|
</h3>
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
<ul class="content-list my-2 pl-6 list-disc">
|
||||||
|
|
@ -455,8 +420,7 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="25"
|
placeholder="25"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />/hour for all members
|
||||||
/>/hour for all members
|
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
<li class="flex items-baseline gap-2 flex-wrap">
|
||||||
Or: Equal monthly draw of $<UInput
|
Or: Equal monthly draw of $<UInput
|
||||||
|
|
@ -464,8 +428,7 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="2000"
|
placeholder="2000"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
per member
|
per member
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
<li class="flex items-baseline gap-2 flex-wrap">
|
||||||
|
|
@ -475,8 +438,7 @@
|
||||||
:items="dayOptions"
|
:items="dayOptions"
|
||||||
placeholder="15"
|
placeholder="15"
|
||||||
class="inline-field"
|
class="inline-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
of each month
|
of each month
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-baseline gap-2 flex-wrap">
|
<li class="flex items-baseline gap-2 flex-wrap">
|
||||||
|
|
@ -485,8 +447,7 @@
|
||||||
v-model="formData.surplusFrequency"
|
v-model="formData.surplusFrequency"
|
||||||
placeholder="quarter"
|
placeholder="quarter"
|
||||||
class="inline-field"
|
class="inline-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -494,8 +455,7 @@
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Work Expectations
|
Work Expectations
|
||||||
</h3>
|
</h3>
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
<ul class="content-list my-2 pl-6 list-disc">
|
||||||
|
|
@ -506,29 +466,31 @@
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="40"
|
placeholder="40"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
(flexible based on capacity)
|
(flexible based on capacity)
|
||||||
</li>
|
</li>
|
||||||
<li>We explicitly reject crunch culture</li>
|
<li>We explicitly reject crunch culture</li>
|
||||||
<li>Members communicate their capacity openly</li>
|
<li>Members communicate their capacity openly</li>
|
||||||
<li>
|
<li>
|
||||||
We adjust workload collectively when someone needs reduced hours
|
We adjust workload collectively when someone needs reduced
|
||||||
|
hours
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Financial Transparency
|
Financial Transparency
|
||||||
</h3>
|
</h3>
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
<ul class="content-list my-2 pl-6 list-disc">
|
||||||
<li>All members can access all financial records anytime</li>
|
<li>
|
||||||
|
All members can access all financial records anytime
|
||||||
|
</li>
|
||||||
<li>Monthly financial check-ins at meetings</li>
|
<li>Monthly financial check-ins at meetings</li>
|
||||||
<li>
|
<li>
|
||||||
Quarterly reviews of our runway (how many months we can operate)
|
Quarterly reviews of our runway (how many months we can
|
||||||
|
operate)
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -539,34 +501,31 @@
|
||||||
<!-- Section 5: Roles and Responsibilities -->
|
<!-- Section 5: Roles and Responsibilities -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
5. Roles and Responsibilities
|
5. Roles and Responsibilities
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Rotating Roles
|
Rotating Roles
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
|
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
|
||||||
>
|
|
||||||
We rotate operational roles every
|
We rotate operational roles every
|
||||||
<UInput
|
<UInput
|
||||||
v-model="formData.roleRotationMonths"
|
v-model="formData.roleRotationMonths"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="6"
|
placeholder="6"
|
||||||
class="inline-field number-field"
|
class="inline-field number-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
months. Current roles include:
|
months. Current roles include:
|
||||||
</p>
|
</p>
|
||||||
<ul class="content-list">
|
<ul class="content-list">
|
||||||
<li>
|
<li>
|
||||||
Financial coordinator (handles bookkeeping, not financial decisions)
|
Financial coordinator (handles bookkeeping, not financial
|
||||||
|
decisions)
|
||||||
</li>
|
</li>
|
||||||
<li>Meeting facilitator</li>
|
<li>Meeting facilitator</li>
|
||||||
<li>External communications</li>
|
<li>External communications</li>
|
||||||
|
|
@ -576,8 +535,7 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Shared Responsibilities
|
Shared Responsibilities
|
||||||
</h3>
|
</h3>
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
|
|
@ -595,16 +553,14 @@
|
||||||
<!-- Section 6: Conflict and Care -->
|
<!-- Section 6: Conflict and Care -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
6. Conflict and Care
|
6. Conflict and Care
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
When Conflict Happens
|
When Conflict Happens
|
||||||
</h3>
|
</h3>
|
||||||
<ol class="content-list numbered my-2 pl-6 list-decimal">
|
<ol class="content-list numbered my-2 pl-6 list-decimal">
|
||||||
|
|
@ -617,15 +573,16 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3"
|
class="subsection-title text-xl font-semibold text-neutral-700 dark:text-neutral-200 border-b border-neutral-200 dark:border-neutral-600 pb-1 mb-3">
|
||||||
>
|
|
||||||
Care Commitments
|
Care Commitments
|
||||||
</h3>
|
</h3>
|
||||||
<ul class="content-list my-2 pl-6 list-disc">
|
<ul class="content-list my-2 pl-6 list-disc">
|
||||||
<li>We check in about capacity and wellbeing regularly</li>
|
<li>We check in about capacity and wellbeing regularly</li>
|
||||||
<li>We honour diverse access needs</li>
|
<li>We honour diverse access needs</li>
|
||||||
<li>We maintain flexibility for life circumstances</li>
|
<li>We maintain flexibility for life circumstances</li>
|
||||||
<li>We contribute to mutual aid when members face hardship</li>
|
<li>
|
||||||
|
We contribute to mutual aid when members face hardship
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -634,31 +591,27 @@
|
||||||
<!-- Section 7: Changing This Agreement -->
|
<!-- Section 7: Changing This Agreement -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
7. Changing This Agreement
|
7. Changing This Agreement
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
|
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
|
||||||
>
|
|
||||||
This is a living document. We review it every
|
This is a living document. We review it every
|
||||||
<UInput
|
<UInput
|
||||||
v-model="formData.reviewFrequency"
|
v-model="formData.reviewFrequency"
|
||||||
placeholder="year"
|
placeholder="year"
|
||||||
class="inline-field"
|
class="inline-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
and update it through our consent process. Small clarifications
|
||||||
and update it through our consent process. Small clarifications can happen
|
can happen anytime; structural changes need full member consent.
|
||||||
anytime; structural changes need full member consent.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section 8: If We Need to Close -->
|
<!-- Section 8: If We Need to Close -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
8. If We Need to Close
|
8. If We Need to Close
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
@ -675,8 +628,7 @@
|
||||||
<UInput
|
<UInput
|
||||||
v-model="formData.assetDonationTarget"
|
v-model="formData.assetDonationTarget"
|
||||||
placeholder="Enter organization name"
|
placeholder="Enter organization name"
|
||||||
class="inline-field wide-field"
|
class="inline-field wide-field" />
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -685,8 +637,7 @@
|
||||||
<!-- Section 9: Legal Bits -->
|
<!-- Section 9: Legal Bits -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
9. Legal Bits
|
9. Legal Bits
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
@ -697,8 +648,7 @@
|
||||||
v-model="formData.legalStructure"
|
v-model="formData.legalStructure"
|
||||||
size="xl"
|
size="xl"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="Cooperative corporation, LLC, partnership, etc."
|
placeholder="Cooperative corporation, LLC, partnership, etc." />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Registered in" class="form-group-inline">
|
<UFormField label="Registered in" class="form-group-inline">
|
||||||
|
|
@ -707,8 +657,7 @@
|
||||||
placeholder="State/Province"
|
placeholder="State/Province"
|
||||||
size="xl"
|
size="xl"
|
||||||
class="inline-field w-full"
|
class="inline-field w-full"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<div class="fiscal-year-group">
|
<div class="fiscal-year-group">
|
||||||
|
|
@ -720,25 +669,23 @@
|
||||||
placeholder="Month"
|
placeholder="Month"
|
||||||
size="xl"
|
size="xl"
|
||||||
class="w-60"
|
class="w-60"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
<USelect
|
<USelect
|
||||||
v-model="formData.fiscalYearEndDay"
|
v-model="formData.fiscalYearEndDay"
|
||||||
:items="dayOptions"
|
:items="dayOptions"
|
||||||
placeholder="Day"
|
placeholder="Day"
|
||||||
size="xl"
|
size="xl"
|
||||||
class="w-40"
|
class="w-40"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
This agreement works alongside but doesn't replace our legal incorporation
|
This agreement works alongside but doesn't replace our legal
|
||||||
documents. Where they conflict, we follow the law but work to align our
|
incorporation documents. Where they conflict, we follow the law
|
||||||
legal structure with our values.
|
but work to align our legal structure with our values.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -746,46 +693,39 @@
|
||||||
<!-- Section 10: Agreement Review -->
|
<!-- Section 10: Agreement Review -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h2
|
<h2
|
||||||
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4"
|
class="section-title text-2xl font-extrabold text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
>
|
|
||||||
10. Agreement Review
|
10. Agreement Review
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
<p class="content-paragraph mb-3 leading-relaxed text-left">
|
||||||
By using this agreement, we commit to these principles and to showing up
|
By using this agreement, we commit to these principles and to
|
||||||
for each other.
|
showing up for each other.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300"
|
class="bg-neutral-50 dark:bg-neutral-900 p-4 rounded-md border-l-4 border-emerald-300">
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"
|
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
|
||||||
>
|
|
||||||
This agreement was last updated on
|
This agreement was last updated on
|
||||||
<UInput
|
<UInput
|
||||||
v-model="formData.lastUpdated"
|
v-model="formData.lastUpdated"
|
||||||
type="date"
|
type="date"
|
||||||
class="inline-field"
|
class="inline-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />. We commit to reviewing it on
|
||||||
/>. We commit to reviewing it on
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="formData.nextReview"
|
v-model="formData.nextReview"
|
||||||
type="date"
|
type="date"
|
||||||
class="inline-field"
|
class="inline-field"
|
||||||
@change="autoSave"
|
@change="autoSave" />
|
||||||
/>
|
|
||||||
or sooner if circumstances require.
|
or sooner if circumstances require.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950"
|
class="signature-space mt-8 p-8 border border-dashed border-neutral-300 rounded-md bg-neutral-50 dark:bg-neutral-950">
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic"
|
class="content-paragraph mb-3 leading-relaxed text-center text-neutral-600 italic">
|
||||||
>
|
|
||||||
[Space for member signatures when printed]
|
[Space for member signatures when printed]
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -799,8 +739,7 @@
|
||||||
<ExportOptions
|
<ExportOptions
|
||||||
:export-data="exportData"
|
:export-data="exportData"
|
||||||
filename="membership-agreement"
|
filename="membership-agreement"
|
||||||
title="Membership Agreement"
|
title="Membership Agreement" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -898,7 +837,10 @@ onMounted(() => {
|
||||||
|
|
||||||
// Auto-save individual field changes immediately
|
// Auto-save individual field changes immediately
|
||||||
const autoSave = () => {
|
const autoSave = () => {
|
||||||
localStorage.setItem("membership-agreement-data", JSON.stringify(formData.value));
|
localStorage.setItem(
|
||||||
|
"membership-agreement-data",
|
||||||
|
JSON.stringify(formData.value)
|
||||||
|
);
|
||||||
console.log("Manual auto-save triggered:", formData.value);
|
console.log("Manual auto-save triggered:", formData.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -950,8 +892,12 @@ const handlePrint = () => {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add signature lines for each member
|
// Add signature lines for each member
|
||||||
const membersWithNames = formData.value.members?.filter((m) => m.name) || [];
|
const membersWithNames =
|
||||||
const numSignatures = Math.max(2, Math.min(8, membersWithNames.length || 4));
|
formData.value.members?.filter((m) => m.name) || [];
|
||||||
|
const numSignatures = Math.max(
|
||||||
|
2,
|
||||||
|
Math.min(8, membersWithNames.length || 4)
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < numSignatures; i++) {
|
for (let i = 0; i < numSignatures; i++) {
|
||||||
const memberName = membersWithNames[i]?.name || "";
|
const memberName = membersWithNames[i]?.name || "";
|
||||||
|
|
@ -981,7 +927,8 @@ const handlePrint = () => {
|
||||||
value = formData.value.cooperativeName;
|
value = formData.value.cooperativeName;
|
||||||
else if (input.closest('[label="Date Established"]'))
|
else if (input.closest('[label="Date Established"]'))
|
||||||
value = formData.value.dateEstablished;
|
value = formData.value.dateEstablished;
|
||||||
else if (input.closest('[label="Our Purpose"]')) value = formData.value.purpose;
|
else if (input.closest('[label="Our Purpose"]'))
|
||||||
|
value = formData.value.purpose;
|
||||||
else if (input.closest('[label="Our Core Values"]'))
|
else if (input.closest('[label="Our Core Values"]'))
|
||||||
value = formData.value.coreValues;
|
value = formData.value.coreValues;
|
||||||
else if (input.closest('[label="Legal Structure"]'))
|
else if (input.closest('[label="Legal Structure"]'))
|
||||||
|
|
@ -998,13 +945,16 @@ const handlePrint = () => {
|
||||||
// Handle member fields
|
// Handle member fields
|
||||||
else if (input.closest(".border-neutral-200")) {
|
else if (input.closest(".border-neutral-200")) {
|
||||||
const memberCard = input.closest(".border-neutral-200");
|
const memberCard = input.closest(".border-neutral-200");
|
||||||
const memberIndex = Array.from(memberCard.parentNode.children).indexOf(memberCard);
|
const memberIndex = Array.from(memberCard.parentNode.children).indexOf(
|
||||||
|
memberCard
|
||||||
|
);
|
||||||
const member = formData.value.members?.[memberIndex];
|
const member = formData.value.members?.[memberIndex];
|
||||||
if (member) {
|
if (member) {
|
||||||
if (input.closest('[label="Full Name"]')) value = member.name;
|
if (input.closest('[label="Full Name"]')) value = member.name;
|
||||||
else if (input.closest('[label="Email"]')) value = member.email;
|
else if (input.closest('[label="Email"]')) value = member.email;
|
||||||
else if (input.closest('[label="Join Date"]')) value = member.joinDate;
|
else if (input.closest('[label="Join Date"]')) value = member.joinDate;
|
||||||
else if (input.closest('[label="Current Role (Optional)"]')) value = member.role;
|
else if (input.closest('[label="Current Role (Optional)"]'))
|
||||||
|
value = member.role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback to input.value
|
// Fallback to input.value
|
||||||
|
|
@ -1143,11 +1093,13 @@ const exportData = computed(() => ({
|
||||||
|
|
||||||
.template-content.font-ubuntu,
|
.template-content.font-ubuntu,
|
||||||
.template-content.font-ubuntu * {
|
.template-content.font-ubuntu * {
|
||||||
font-family: "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
font-family: "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-content.font-inter,
|
.template-content.font-inter,
|
||||||
.template-content.font-inter * {
|
.template-content.font-inter * {
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
sans-serif !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Wizard Subnav -->
|
|
||||||
<WizardSubnav />
|
|
||||||
|
|
||||||
<!-- Export Options - Top -->
|
<!-- Export Options - Top -->
|
||||||
<ExportOptions
|
<ExportOptions
|
||||||
:export-data="exportData"
|
:export-data="exportData"
|
||||||
filename="tech-charter"
|
filename="tech-charter"
|
||||||
title="Technology Charter"
|
title="Technology Charter" />
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100"
|
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
|
||||||
>
|
|
||||||
<!-- Document Container -->
|
<!-- Document Container -->
|
||||||
<div class="document-page">
|
<div class="document-page">
|
||||||
<div class="template-content">
|
<div class="template-content">
|
||||||
<!-- Document Header -->
|
<!-- Document Header -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h1
|
<h1
|
||||||
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100"
|
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100">
|
||||||
>
|
|
||||||
Tech Charter
|
Tech Charter
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,9 +24,12 @@
|
||||||
<!-- Purpose Section -->
|
<!-- Purpose Section -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-neutral-800 mb-4">Charter Purpose</h2>
|
<h2 class="text-2xl font-bold text-neutral-800 mb-4">
|
||||||
|
Charter Purpose
|
||||||
|
</h2>
|
||||||
<p class="text-neutral-600 mb-4">
|
<p class="text-neutral-600 mb-4">
|
||||||
Describe what this charter will guide and why it matters to your group.
|
Describe what this charter will guide and why it matters to
|
||||||
|
your group.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -40,8 +37,7 @@
|
||||||
<textarea
|
<textarea
|
||||||
v-model="charterPurpose"
|
v-model="charterPurpose"
|
||||||
class="w-full min-h-32 p-4 border-2 border-neutral-300 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:border-black dark:focus:border-white transition-colors resize-y"
|
class="w-full min-h-32 p-4 border-2 border-neutral-300 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:border-black dark:focus:border-white transition-colors resize-y"
|
||||||
rows="4"
|
rows="4" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -52,37 +48,39 @@
|
||||||
Define Your Principles & Importance
|
Define Your Principles & Importance
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-neutral-600 mb-6">
|
<p class="text-neutral-600 mb-6">
|
||||||
Select principles and set their importance. Zero means excluded, 5 means
|
Select principles and set their importance. Zero means
|
||||||
critical.
|
excluded, 5 means critical.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-1 gap-4">
|
<div class="grid md:grid-cols-1 gap-4">
|
||||||
<div v-for="principle in principles" :key="principle.id" class="relative">
|
<div
|
||||||
|
v-for="principle in principles"
|
||||||
|
:key="principle.id"
|
||||||
|
class="relative">
|
||||||
<!-- Dithered shadow for selected cards -->
|
<!-- Dithered shadow for selected cards -->
|
||||||
<div
|
<div
|
||||||
v-if="principleWeights[principle.id] > 0"
|
v-if="principleWeights[principle.id] > 0"
|
||||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||||
></div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'relative transition-all',
|
'relative transition-all',
|
||||||
principleWeights[principle.id] > 0
|
principleWeights[principle.id] > 0
|
||||||
? 'principle-selected border-2 border-black dark:border-white bg-white dark:bg-neutral-950'
|
? 'item-selected border-2 border-black dark:border-white bg-white dark:bg-neutral-950'
|
||||||
: 'border border-black dark:border-white bg-transparent',
|
: 'border border-black dark:border-white bg-transparent',
|
||||||
]"
|
]">
|
||||||
>
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-start gap-6">
|
<div class="flex items-start gap-6">
|
||||||
<!-- Principle info -->
|
<!-- Principle info -->
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'principle-text-bg mb-3',
|
'item-text-bg mb-3',
|
||||||
principleWeights[principle.id] > 0 ? 'selected' : '',
|
principleWeights[principle.id] > 0
|
||||||
]"
|
? 'selected'
|
||||||
>
|
: '',
|
||||||
|
]">
|
||||||
<h3 class="font-bold text-lg mb-2">
|
<h3 class="font-bold text-lg mb-2">
|
||||||
{{ principle.name }}
|
{{ principle.name }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -92,8 +90,7 @@
|
||||||
? 'text-neutral-700'
|
? 'text-neutral-700'
|
||||||
: 'text-neutral-600'
|
: 'text-neutral-600'
|
||||||
"
|
"
|
||||||
class="text-sm"
|
class="text-sm">
|
||||||
>
|
|
||||||
{{ principle.description }}
|
{{ principle.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,8 +99,7 @@
|
||||||
<!-- Importance selector -->
|
<!-- Importance selector -->
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<label
|
<label
|
||||||
class="text-xs font-bold text-neutral-500 uppercase tracking-wider"
|
class="text-xs font-bold text-neutral-500 uppercase tracking-wider">
|
||||||
>
|
|
||||||
Importance
|
Importance
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
@ -119,8 +115,7 @@
|
||||||
? 'bg-black text-white border-black dark:bg-white dark:text-black dark:border-white'
|
? 'bg-black text-white border-black dark:bg-white dark:text-black dark:border-white'
|
||||||
: 'bg-white border-neutral-300 hover:border-neutral-500 dark:bg-neutral-950',
|
: 'bg-white border-neutral-300 hover:border-neutral-500 dark:bg-neutral-950',
|
||||||
]"
|
]"
|
||||||
:title="`Set importance to ${level}`"
|
:title="`Set importance to ${level}`">
|
||||||
>
|
|
||||||
{{ level }}
|
{{ level }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -131,7 +126,11 @@
|
||||||
{{ principleWeights[principle.id] || 0 }}
|
{{ principleWeights[principle.id] || 0 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-neutral-500">
|
<div class="text-xs text-neutral-500">
|
||||||
{{ getWeightLabel(principleWeights[principle.id] || 0) }}
|
{{
|
||||||
|
getWeightLabel(
|
||||||
|
principleWeights[principle.id] || 0
|
||||||
|
)
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -140,20 +139,19 @@
|
||||||
<!-- Non-negotiable toggle (only shows for weights > 0) -->
|
<!-- Non-negotiable toggle (only shows for weights > 0) -->
|
||||||
<div
|
<div
|
||||||
v-if="principleWeights[principle.id] > 0"
|
v-if="principleWeights[principle.id] > 0"
|
||||||
class="mt-4 pt-4 border-t border-neutral-200"
|
class="mt-4 pt-4 border-t border-neutral-200">
|
||||||
>
|
|
||||||
<label
|
<label
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-3 cursor-pointer principle-label-bg px-2 py-1',
|
'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1',
|
||||||
nonNegotiables.includes(principle.id) ? 'selected' : '',
|
nonNegotiables.includes(principle.id)
|
||||||
]"
|
? 'selected'
|
||||||
>
|
: '',
|
||||||
|
]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="nonNegotiables.includes(principle.id)"
|
:checked="nonNegotiables.includes(principle.id)"
|
||||||
@change="toggleNonNegotiable(principle.id)"
|
@change="toggleNonNegotiable(principle.id)"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4" />
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium text-red-600">
|
<span class="text-sm font-medium text-red-600">
|
||||||
Make this non-negotiable
|
Make this non-negotiable
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -163,9 +161,9 @@
|
||||||
<!-- Show rubric description when selected -->
|
<!-- Show rubric description when selected -->
|
||||||
<div
|
<div
|
||||||
v-if="principleWeights[principle.id] > 0"
|
v-if="principleWeights[principle.id] > 0"
|
||||||
class="mt-4 p-3 principle-label-bg selected border border-neutral-200"
|
class="mt-4 p-3 item-label-bg selected border border-neutral-200">
|
||||||
>
|
<div
|
||||||
<div class="text-xs font-bold uppercase text-neutral-500 mb-1">
|
class="text-xs font-bold uppercase text-neutral-500 mb-1">
|
||||||
Evaluation Criteria:
|
Evaluation Criteria:
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
|
|
@ -183,8 +181,7 @@
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
class="text-2xl font-bold text-neutral-800 mb-2"
|
class="text-2xl font-bold text-neutral-800 mb-2"
|
||||||
id="constraints-heading"
|
id="constraints-heading">
|
||||||
>
|
|
||||||
Technical Constraints
|
Technical Constraints
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,18 +192,15 @@
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-3 constraint-buttons"
|
class="flex flex-wrap gap-3 constraint-buttons"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-labelledby="auth-heading"
|
aria-labelledby="auth-heading">
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="option in authOptions"
|
v-for="option in authOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="relative"
|
class="relative">
|
||||||
>
|
|
||||||
<!-- Dithered shadow for selected buttons -->
|
<!-- Dithered shadow for selected buttons -->
|
||||||
<div
|
<div
|
||||||
v-if="constraints.sso === option.value"
|
v-if="constraints.sso === option.value"
|
||||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||||
></div>
|
|
||||||
<button
|
<button
|
||||||
@click="constraints.sso = option.value"
|
@click="constraints.sso = option.value"
|
||||||
:aria-pressed="constraints.sso === option.value"
|
:aria-pressed="constraints.sso === option.value"
|
||||||
|
|
@ -217,8 +211,7 @@
|
||||||
constraints.sso === option.value
|
constraints.sso === option.value
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]"
|
]">
|
||||||
>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -230,18 +223,15 @@
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-3 constraint-buttons"
|
class="flex flex-wrap gap-3 constraint-buttons"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-labelledby="hosting-heading"
|
aria-labelledby="hosting-heading">
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="option in hostingOptions"
|
v-for="option in hostingOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="relative"
|
class="relative">
|
||||||
>
|
|
||||||
<!-- Dithered shadow for selected buttons -->
|
<!-- Dithered shadow for selected buttons -->
|
||||||
<div
|
<div
|
||||||
v-if="constraints.hosting === option.value"
|
v-if="constraints.hosting === option.value"
|
||||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||||
></div>
|
|
||||||
<button
|
<button
|
||||||
@click="constraints.hosting = option.value"
|
@click="constraints.hosting = option.value"
|
||||||
:aria-pressed="constraints.hosting === option.value"
|
:aria-pressed="constraints.hosting === option.value"
|
||||||
|
|
@ -252,8 +242,7 @@
|
||||||
constraints.hosting === option.value
|
constraints.hosting === option.value
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]"
|
]">
|
||||||
>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -261,29 +250,32 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
||||||
<legend class="font-semibold text-lg">Required Integrations</legend>
|
<legend class="font-semibold text-lg">
|
||||||
<p class="text-sm text-neutral-600 mb-4">Select all that apply</p>
|
Required Integrations
|
||||||
|
</legend>
|
||||||
|
<p class="text-sm text-neutral-600 mb-4">
|
||||||
|
Select all that apply
|
||||||
|
</p>
|
||||||
<div class="flex flex-wrap gap-3 constraint-buttons">
|
<div class="flex flex-wrap gap-3 constraint-buttons">
|
||||||
<div
|
<div
|
||||||
v-for="integration in integrationOptions"
|
v-for="integration in integrationOptions"
|
||||||
:key="integration"
|
:key="integration"
|
||||||
class="relative"
|
class="relative">
|
||||||
>
|
|
||||||
<!-- Dithered shadow for selected buttons -->
|
<!-- Dithered shadow for selected buttons -->
|
||||||
<div
|
<div
|
||||||
v-if="constraints.integrations.includes(integration)"
|
v-if="constraints.integrations.includes(integration)"
|
||||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||||
></div>
|
|
||||||
<button
|
<button
|
||||||
@click="toggleIntegration(integration)"
|
@click="toggleIntegration(integration)"
|
||||||
:aria-pressed="constraints.integrations.includes(integration)"
|
:aria-pressed="
|
||||||
|
constraints.integrations.includes(integration)
|
||||||
|
"
|
||||||
:class="[
|
:class="[
|
||||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||||
constraints.integrations.includes(integration)
|
constraints.integrations.includes(integration)
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]"
|
]">
|
||||||
>
|
|
||||||
{{ integration }}
|
{{ integration }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -291,22 +283,21 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
||||||
<legend class="font-semibold text-lg">Support Expectations</legend>
|
<legend class="font-semibold text-lg">
|
||||||
|
Support Expectations
|
||||||
|
</legend>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-3 constraint-buttons"
|
class="flex flex-wrap gap-3 constraint-buttons"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-labelledby="support-heading"
|
aria-labelledby="support-heading">
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="option in supportOptions"
|
v-for="option in supportOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="relative"
|
class="relative">
|
||||||
>
|
|
||||||
<!-- Dithered shadow for selected buttons -->
|
<!-- Dithered shadow for selected buttons -->
|
||||||
<div
|
<div
|
||||||
v-if="constraints.support === option.value"
|
v-if="constraints.support === option.value"
|
||||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||||
></div>
|
|
||||||
<button
|
<button
|
||||||
@click="constraints.support = option.value"
|
@click="constraints.support = option.value"
|
||||||
:aria-pressed="constraints.support === option.value"
|
:aria-pressed="constraints.support === option.value"
|
||||||
|
|
@ -317,8 +308,7 @@
|
||||||
constraints.support === option.value
|
constraints.support === option.value
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]"
|
]">
|
||||||
>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -326,22 +316,21 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
<fieldset class="bg-neutral-50 p-6 rounded-lg">
|
||||||
<legend class="font-semibold text-lg">Migration Timeline</legend>
|
<legend class="font-semibold text-lg">
|
||||||
|
Migration Timeline
|
||||||
|
</legend>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-3 constraint-buttons"
|
class="flex flex-wrap gap-3 constraint-buttons"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-labelledby="timeline-heading"
|
aria-labelledby="timeline-heading">
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="option in timelineOptions"
|
v-for="option in timelineOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="relative"
|
class="relative">
|
||||||
>
|
|
||||||
<!-- Dithered shadow for selected buttons -->
|
<!-- Dithered shadow for selected buttons -->
|
||||||
<div
|
<div
|
||||||
v-if="constraints.timeline === option.value"
|
v-if="constraints.timeline === option.value"
|
||||||
class="absolute top-2 left-2 w-full h-full dither-shadow"
|
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||||
></div>
|
|
||||||
<button
|
<button
|
||||||
@click="constraints.timeline = option.value"
|
@click="constraints.timeline = option.value"
|
||||||
:aria-pressed="constraints.timeline === option.value"
|
:aria-pressed="constraints.timeline === option.value"
|
||||||
|
|
@ -352,8 +341,7 @@
|
||||||
constraints.timeline === option.value
|
constraints.timeline === option.value
|
||||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||||
]"
|
]">
|
||||||
>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -367,8 +355,7 @@
|
||||||
<button
|
<button
|
||||||
@click="resetForm"
|
@click="resetForm"
|
||||||
class="export-btn"
|
class="export-btn"
|
||||||
title="Clear all form data and start over"
|
title="Clear all form data and start over">
|
||||||
>
|
|
||||||
<UIcon name="i-heroicons-arrow-path" />
|
<UIcon name="i-heroicons-arrow-path" />
|
||||||
Reset Form
|
Reset Form
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -381,17 +368,19 @@
|
||||||
v-if="charterGenerated"
|
v-if="charterGenerated"
|
||||||
class="relative animate-fadeIn"
|
class="relative animate-fadeIn"
|
||||||
role="main"
|
role="main"
|
||||||
aria-label="Generated Technology Charter"
|
aria-label="Generated Technology Charter">
|
||||||
>
|
|
||||||
<!-- Dithered shadow -->
|
<!-- Dithered shadow -->
|
||||||
<div class="absolute top-4 left-4 right-0 bottom-0 dither-shadow"></div>
|
<div
|
||||||
|
class="absolute top-4 left-4 right-0 bottom-0 dither-shadow"></div>
|
||||||
|
|
||||||
<!-- Charter container -->
|
<!-- Charter container -->
|
||||||
<div
|
<div
|
||||||
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8"
|
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
|
||||||
>
|
<div
|
||||||
<div class="text-center mb-8 pb-6 border-b-2 border-black dark:border-white">
|
class="text-center mb-8 pb-6 border-b-2 border-black dark:border-white">
|
||||||
<h2 class="text-3xl font-bold text-neutral-800" id="charter-title">
|
<h2
|
||||||
|
class="text-3xl font-bold text-neutral-800"
|
||||||
|
id="charter-title">
|
||||||
Technology Charter
|
Technology Charter
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-neutral-600 mt-2">
|
<p class="text-neutral-600 mt-2">
|
||||||
|
|
@ -407,8 +396,7 @@
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button
|
<button
|
||||||
@click="scrollToTop"
|
@click="scrollToTop"
|
||||||
class="text-sm text-neutral-600 hover:text-neutral-800 underline focus:outline-none focus:ring-2 focus:ring-neutral-500 rounded"
|
class="text-sm text-neutral-600 hover:text-neutral-800 underline focus:outline-none focus:ring-2 focus:ring-neutral-500 rounded">
|
||||||
>
|
|
||||||
Back to form
|
Back to form
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -418,10 +406,10 @@
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
|
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
|
||||||
<p class="text-neutral-700 leading-relaxed">
|
<p class="text-neutral-700 leading-relaxed">
|
||||||
This charter guides our cooperative's technology decisions based on our
|
This charter guides our cooperative's technology decisions
|
||||||
shared values and operational needs. It ensures we choose tools that
|
based on our shared values and operational needs. It ensures
|
||||||
support our mission while respecting our principles of autonomy,
|
we choose tools that support our mission while respecting our
|
||||||
sustainability, and mutual aid.
|
principles of autonomy, sustainability, and mutual aid.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -429,21 +417,25 @@
|
||||||
class="mb-8"
|
class="mb-8"
|
||||||
v-if="
|
v-if="
|
||||||
Object.keys(principleWeights).filter(
|
Object.keys(principleWeights).filter(
|
||||||
(p) => principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
(p) =>
|
||||||
|
principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
||||||
).length > 0
|
).length > 0
|
||||||
"
|
">
|
||||||
>
|
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">Core Principles</h3>
|
Core Principles
|
||||||
|
</h3>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li
|
<li
|
||||||
v-for="principleId in Object.keys(principleWeights).filter(
|
v-for="principleId in Object.keys(principleWeights).filter(
|
||||||
(p) => principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
(p) =>
|
||||||
|
principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
||||||
)"
|
)"
|
||||||
:key="principleId"
|
:key="principleId"
|
||||||
class="flex items-start"
|
class="flex items-start">
|
||||||
>
|
|
||||||
<span class="text-neutral-600 mr-2">→</span>
|
<span class="text-neutral-600 mr-2">→</span>
|
||||||
<span>{{ principles.find((p) => p.id === principleId)?.name }}</span>
|
<span>{{
|
||||||
|
principles.find((p) => p.id === principleId)?.name
|
||||||
|
}}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -453,16 +445,18 @@
|
||||||
Non-Negotiable Requirements
|
Non-Negotiable Requirements
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-red-600 font-semibold mb-3">
|
<p class="text-red-600 font-semibold mb-3">
|
||||||
Any vendor failing these requirements is automatically disqualified.
|
Any vendor failing these requirements is automatically
|
||||||
|
disqualified.
|
||||||
</p>
|
</p>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li
|
<li
|
||||||
v-for="principleId in nonNegotiables"
|
v-for="principleId in nonNegotiables"
|
||||||
:key="principleId"
|
:key="principleId"
|
||||||
class="flex items-start text-red-600 font-semibold"
|
class="flex items-start text-red-600 font-semibold">
|
||||||
>
|
|
||||||
<span class="mr-2">→</span>
|
<span class="mr-2">→</span>
|
||||||
<span>{{ principles.find((p) => p.id === principleId)?.name }}</span>
|
<span>{{
|
||||||
|
principles.find((p) => p.id === principleId)?.name
|
||||||
|
}}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -477,7 +471,8 @@
|
||||||
<span
|
<span
|
||||||
>Authentication:
|
>Authentication:
|
||||||
{{
|
{{
|
||||||
authOptions.find((o) => o.value === constraints.sso)?.label
|
authOptions.find((o) => o.value === constraints.sso)
|
||||||
|
?.label
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -486,11 +481,15 @@
|
||||||
<span
|
<span
|
||||||
>Hosting:
|
>Hosting:
|
||||||
{{
|
{{
|
||||||
hostingOptions.find((o) => o.value === constraints.hosting)?.label
|
hostingOptions.find(
|
||||||
|
(o) => o.value === constraints.hosting
|
||||||
|
)?.label
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="constraints.integrations.length > 0" class="flex items-start">
|
<li
|
||||||
|
v-if="constraints.integrations.length > 0"
|
||||||
|
class="flex items-start">
|
||||||
<span class="text-purple-600 mr-2">→</span>
|
<span class="text-purple-600 mr-2">→</span>
|
||||||
<span
|
<span
|
||||||
>Required Integrations:
|
>Required Integrations:
|
||||||
|
|
@ -502,7 +501,9 @@
|
||||||
<span
|
<span
|
||||||
>Support Level:
|
>Support Level:
|
||||||
{{
|
{{
|
||||||
supportOptions.find((o) => o.value === constraints.support)?.label
|
supportOptions.find(
|
||||||
|
(o) => o.value === constraints.support
|
||||||
|
)?.label
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -511,8 +512,9 @@
|
||||||
<span
|
<span
|
||||||
>Migration Timeline:
|
>Migration Timeline:
|
||||||
{{
|
{{
|
||||||
timelineOptions.find((o) => o.value === constraints.timeline)
|
timelineOptions.find(
|
||||||
?.label
|
(o) => o.value === constraints.timeline
|
||||||
|
)?.label
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -520,27 +522,27 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">Evaluation Rubric</h3>
|
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||||
|
Evaluation Rubric
|
||||||
|
</h3>
|
||||||
<p class="text-neutral-700 mb-4">
|
<p class="text-neutral-700 mb-4">
|
||||||
Score each vendor option using these weighted criteria (0-5 scale):
|
Score each vendor option using these weighted criteria (0-5
|
||||||
|
scale):
|
||||||
</p>
|
</p>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full border-collapse">
|
<table class="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-neutral-100">
|
<tr class="bg-neutral-100">
|
||||||
<th
|
<th
|
||||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left"
|
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
|
||||||
>
|
|
||||||
Criterion
|
Criterion
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left"
|
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
|
||||||
>
|
|
||||||
Description
|
Description
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center"
|
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center">
|
||||||
>
|
|
||||||
Weight
|
Weight
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -549,21 +551,17 @@
|
||||||
<tr
|
<tr
|
||||||
v-for="weight in sortedWeights"
|
v-for="weight in sortedWeights"
|
||||||
:key="weight.id"
|
:key="weight.id"
|
||||||
class="hover:bg-neutral-50"
|
class="hover:bg-neutral-50">
|
||||||
>
|
|
||||||
<td
|
<td
|
||||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 font-semibold"
|
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 font-semibold">
|
||||||
>
|
|
||||||
{{ weight.name }}
|
{{ weight.name }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-sm text-neutral-600"
|
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-sm text-neutral-600">
|
||||||
>
|
|
||||||
{{ weight.rubricDescription }}
|
{{ weight.rubricDescription }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center font-bold text-neutral-600"
|
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center font-bold text-neutral-600">
|
||||||
>
|
|
||||||
{{ principleWeights[weight.id] }}
|
{{ principleWeights[weight.id] }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -580,8 +578,8 @@
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<span class="text-neutral-600 mr-2">→</span>
|
<span class="text-neutral-600 mr-2">→</span>
|
||||||
<span
|
<span
|
||||||
>Any vendor failing a non-negotiable requirement is automatically
|
>Any vendor failing a non-negotiable requirement is
|
||||||
eliminated</span
|
automatically eliminated</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
|
|
@ -594,8 +592,8 @@
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<span class="text-neutral-600 mr-2">→</span>
|
<span class="text-neutral-600 mr-2">→</span>
|
||||||
<span
|
<span
|
||||||
>When scores are within 10%, choose based on alignment with
|
>When scores are within 10%, choose based on alignment
|
||||||
cooperative values</span
|
with cooperative values</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
|
|
@ -638,11 +636,16 @@
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<span class="text-neutral-600 mr-2">→</span>
|
<span class="text-neutral-600 mr-2">→</span>
|
||||||
<span>Document any exceptions with clear justification</span>
|
<span
|
||||||
|
>Document any exceptions with clear justification</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<span class="text-neutral-600 mr-2">→</span>
|
<span class="text-neutral-600 mr-2">→</span>
|
||||||
<span>Share learnings with other cooperatives in our network</span>
|
<span
|
||||||
|
>Share learnings with other cooperatives in our
|
||||||
|
network</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -656,8 +659,7 @@
|
||||||
<ExportOptions
|
<ExportOptions
|
||||||
:export-data="exportData"
|
:export-data="exportData"
|
||||||
filename="tech-charter"
|
filename="tech-charter"
|
||||||
title="Technology Charter"
|
title="Technology Charter" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -705,13 +707,15 @@ const principles = [
|
||||||
id: "portability",
|
id: "portability",
|
||||||
name: "Data Freedom",
|
name: "Data Freedom",
|
||||||
description: "Easy export, no vendor lock-in, migration-friendly",
|
description: "Easy export, no vendor lock-in, migration-friendly",
|
||||||
rubricDescription: "Export capabilities, proprietary formats, switching costs",
|
rubricDescription:
|
||||||
|
"Export capabilities, proprietary formats, switching costs",
|
||||||
defaultWeight: 4,
|
defaultWeight: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "opensource",
|
id: "opensource",
|
||||||
name: "Open Source & Community",
|
name: "Open Source & Community",
|
||||||
description: "FOSS preference, transparent development, community governance",
|
description:
|
||||||
|
"FOSS preference, transparent development, community governance",
|
||||||
rubricDescription: "License type, community involvement, code transparency",
|
rubricDescription: "License type, community involvement, code transparency",
|
||||||
defaultWeight: 3,
|
defaultWeight: 3,
|
||||||
},
|
},
|
||||||
|
|
@ -719,7 +723,8 @@ const principles = [
|
||||||
id: "sustainability",
|
id: "sustainability",
|
||||||
name: "Sustainable Operations",
|
name: "Sustainable Operations",
|
||||||
description: "Predictable costs, green hosting, efficient resource use",
|
description: "Predictable costs, green hosting, efficient resource use",
|
||||||
rubricDescription: "Total cost of ownership, carbon footprint, resource efficiency",
|
rubricDescription:
|
||||||
|
"Total cost of ownership, carbon footprint, resource efficiency",
|
||||||
defaultWeight: 3,
|
defaultWeight: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -732,8 +737,10 @@ const principles = [
|
||||||
{
|
{
|
||||||
id: "usability",
|
id: "usability",
|
||||||
name: "User Experience",
|
name: "User Experience",
|
||||||
description: "Intuitive interface, minimal learning curve, daily efficiency",
|
description:
|
||||||
rubricDescription: "Onboarding time, user satisfaction, workflow integration",
|
"Intuitive interface, minimal learning curve, daily efficiency",
|
||||||
|
rubricDescription:
|
||||||
|
"Onboarding time, user satisfaction, workflow integration",
|
||||||
defaultWeight: 3,
|
defaultWeight: 3,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -769,7 +776,9 @@ const timelineOptions = [
|
||||||
const sortedWeights = computed(() => {
|
const sortedWeights = computed(() => {
|
||||||
return principles
|
return principles
|
||||||
.filter((p) => principleWeights.value[p.id] > 0)
|
.filter((p) => principleWeights.value[p.id] > 0)
|
||||||
.sort((a, b) => principleWeights.value[b.id] - principleWeights.value[a.id]);
|
.sort(
|
||||||
|
(a, b) => principleWeights.value[b.id] - principleWeights.value[a.id]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const canGenerateCharter = computed(() => {
|
const canGenerateCharter = computed(() => {
|
||||||
|
|
@ -862,7 +871,9 @@ const resetForm = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
document.querySelector(".template-wrapper").scrollIntoView({ behavior: "smooth" });
|
document
|
||||||
|
.querySelector(".template-wrapper")
|
||||||
|
.scrollIntoView({ behavior: "smooth" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load saved data
|
// Load saved data
|
||||||
|
|
@ -905,9 +916,13 @@ onMounted(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-save when data changes
|
// Auto-save when data changes
|
||||||
watch([charterPurpose, principleWeights, nonNegotiables, constraints], autoSave, {
|
watch(
|
||||||
deep: true,
|
[charterPurpose, principleWeights, nonNegotiables, constraints],
|
||||||
});
|
autoSave,
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -919,115 +934,6 @@ watch([charterPurpose, principleWeights, nonNegotiables, constraints], autoSave,
|
||||||
@apply mb-8 relative;
|
@apply mb-8 relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Principle card selected styling - using dithered shadow and background */
|
|
||||||
.principle-selected {
|
|
||||||
position: relative;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.principle-selected::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent 0px,
|
|
||||||
transparent 1px,
|
|
||||||
black 1px,
|
|
||||||
black 2px
|
|
||||||
);
|
|
||||||
opacity: 0.1;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.principle-selected > * {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode */
|
|
||||||
html.dark .principle-selected {
|
|
||||||
background: #0a0a0a;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .principle-selected::after {
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent 0px,
|
|
||||||
transparent 1px,
|
|
||||||
white 1px,
|
|
||||||
white 2px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text background for better readability */
|
|
||||||
.principle-label-bg {
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.principle-label-bg.selected {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode text backgrounds */
|
|
||||||
html.dark .principle-text-bg {
|
|
||||||
background: rgba(10, 10, 10, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .principle-text-bg.selected {
|
|
||||||
background: rgba(10, 10, 10, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .principle-label-bg {
|
|
||||||
background: rgba(10, 10, 10, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .principle-label-bg.selected {
|
|
||||||
background: rgba(10, 10, 10, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Constraint button selected styling - black background */
|
|
||||||
button.constraint-selected {
|
|
||||||
background: black !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.constraint-selected:hover {
|
|
||||||
background: black !important;
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode */
|
|
||||||
html.dark button.constraint-selected {
|
|
||||||
background: white !important;
|
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark button.constraint-selected:hover {
|
|
||||||
background: white !important;
|
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fadeIn {
|
|
||||||
animation: fadeIn 0.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-title {
|
.content-title {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
|
||||||
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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Wizard Subnav -->
|
|
||||||
<WizardSubnav />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
|
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
|
||||||
style="font-family: 'Ubuntu', 'Ubuntu Mono', monospace"
|
|
||||||
>
|
>
|
||||||
<div class="max-w-6xl mx-auto px-4 relative">
|
<div class="max-w-6xl mx-auto px-4 relative">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
|
|
@ -72,69 +68,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-12 help-section">
|
|
||||||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
|
||||||
<div
|
|
||||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6"
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
class="text-xl font-semibold text-neutral-900 dark:text-white mb-3"
|
|
||||||
style="font-family: 'Ubuntu', monospace"
|
|
||||||
>
|
|
||||||
How Wizards Work
|
|
||||||
</h2>
|
|
||||||
<div class="grid md:grid-cols-2 gap-6 text-neutral-900 dark:text-neutral-100">
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="font-medium mb-2 text-neutral-900 dark:text-white"
|
|
||||||
style="font-family: 'Ubuntu Mono', monospace"
|
|
||||||
>
|
|
||||||
FILL OUT FORMS
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-neutral-700 dark:text-neutral-200">
|
|
||||||
Wizards include form fields for all necessary information. Data
|
|
||||||
auto-saves as you type.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="font-medium mb-2 text-neutral-900 dark:text-white"
|
|
||||||
style="font-family: 'Ubuntu Mono', monospace"
|
|
||||||
>
|
|
||||||
LOCAL STORAGE
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-neutral-700 dark:text-neutral-200">
|
|
||||||
All data saves in your browser only. Nothing is sent to external
|
|
||||||
servers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="font-medium mb-2 text-neutral-900 dark:text-white"
|
|
||||||
style="font-family: 'Ubuntu Mono', monospace"
|
|
||||||
>
|
|
||||||
EXPORT OPTIONS
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-neutral-700 dark:text-neutral-200">
|
|
||||||
Download as PDF, plain text, Markdown.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3
|
|
||||||
class="font-medium mb-2 text-neutral-900 dark:text-white"
|
|
||||||
style="font-family: 'Ubuntu Mono', monospace"
|
|
||||||
>
|
|
||||||
RESUME ANYTIME
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-neutral-700 dark:text-neutral-200">
|
|
||||||
Come back later and your progress will be saved. Clear browser data to
|
|
||||||
start fresh.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -158,7 +91,7 @@ const templates = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "conflict-resolution-framework",
|
id: "conflict-resolution-framework",
|
||||||
name: "Conflict Resolution Framework",
|
name: "Conflict Resolution",
|
||||||
description:
|
description:
|
||||||
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
|
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
|
||||||
icon: "i-heroicons-scale",
|
icon: "i-heroicons-scale",
|
||||||
|
|
@ -310,35 +243,4 @@ useHead({
|
||||||
background: white;
|
background: white;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.disabled-button {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-card > *,
|
|
||||||
.help-section > *,
|
|
||||||
button,
|
|
||||||
.px-4,
|
|
||||||
div[class*="border"] {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
font-family: "Ubuntu", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark :deep(.text-neutral-700),
|
|
||||||
html.dark :deep(.text-neutral-500),
|
|
||||||
html.dark :deep(.bg-neutral-50),
|
|
||||||
html.dark :deep(.bg-neutral-100) {
|
|
||||||
color: white !important;
|
|
||||||
background-color: #0a0a0a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.border-neutral-200),
|
|
||||||
:deep(.border-neutral-300) {
|
|
||||||
border-color: black !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
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",
|
"budget",
|
||||||
() => {
|
() => {
|
||||||
// Schema version for persistence
|
// Schema version for persistence
|
||||||
const schemaVersion = "1.0";
|
const schemaVersion = "2.0";
|
||||||
|
|
||||||
// Monthly budget lines by period (YYYY-MM)
|
// Canonical categories from WizardRevenueStep - matches the wizard exactly
|
||||||
|
const revenueCategories = ref([
|
||||||
|
'Games & Products',
|
||||||
|
'Services & Contracts',
|
||||||
|
'Grants & Funding',
|
||||||
|
'Community Support',
|
||||||
|
'Partnerships',
|
||||||
|
'Investment Income',
|
||||||
|
'In-Kind Contributions'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Revenue subcategories by main category (for reference and grouping)
|
||||||
|
const revenueSubcategories = ref({
|
||||||
|
'Games & Products': ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'],
|
||||||
|
'Services & Contracts': ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'],
|
||||||
|
'Grants & Funding': ['Government funding', 'Arts council grants', 'Foundation support', 'Research grants'],
|
||||||
|
'Community Support': ['Patreon/subscriptions', 'Crowdfunding', 'Donations', 'Mutual aid received'],
|
||||||
|
'Partnerships': ['Corporate partnerships', 'Academic partnerships', 'Sponsorships'],
|
||||||
|
'Investment Income': ['Impact investment', 'Venture capital', 'Loans'],
|
||||||
|
'In-Kind Contributions': ['Office space', 'Equipment/hardware', 'Software licenses', 'Professional services', 'Marketing/PR services', 'Legal services']
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenseCategories = ref([
|
||||||
|
'Salaries & Benefits',
|
||||||
|
'Development Costs',
|
||||||
|
'Equipment & Technology',
|
||||||
|
'Marketing & Outreach',
|
||||||
|
'Office & Operations',
|
||||||
|
'Legal & Professional',
|
||||||
|
'Other Expenses'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// NEW: Budget worksheet structure (starts empty, populated from wizard data)
|
||||||
|
const budgetWorksheet = ref({
|
||||||
|
revenue: [],
|
||||||
|
expenses: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track if worksheet has been initialized from wizard data
|
||||||
|
const isInitialized = ref(false);
|
||||||
|
|
||||||
|
// LEGACY: Keep for backward compatibility
|
||||||
const budgetLines = ref({});
|
const budgetLines = ref({});
|
||||||
|
|
||||||
// Overhead costs (recurring monthly)
|
|
||||||
const overheadCosts = ref([]);
|
const overheadCosts = ref([]);
|
||||||
|
|
||||||
// Production costs (variable monthly)
|
|
||||||
const productionCosts = ref([]);
|
const productionCosts = ref([]);
|
||||||
|
|
||||||
// Current selected period - use current month/year
|
// Computed grouped data for category headers
|
||||||
|
const groupedRevenue = computed(() => {
|
||||||
|
const groups = {};
|
||||||
|
revenueCategories.value.forEach(category => {
|
||||||
|
groups[category] = budgetWorksheet.value.revenue.filter(item => item.mainCategory === category);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedExpenses = computed(() => {
|
||||||
|
const groups = {};
|
||||||
|
expenseCategories.value.forEach(category => {
|
||||||
|
groups[category] = budgetWorksheet.value.expenses.filter(item => item.mainCategory === category);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed totals for budget worksheet (legacy - keep for backward compatibility)
|
||||||
|
const budgetTotals = computed(() => {
|
||||||
|
const years = ['year1', 'year2', 'year3'];
|
||||||
|
const scenarios = ['best', 'worst', 'mostLikely'];
|
||||||
|
const totals = {};
|
||||||
|
|
||||||
|
years.forEach(year => {
|
||||||
|
totals[year] = {};
|
||||||
|
scenarios.forEach(scenario => {
|
||||||
|
// Calculate revenue total
|
||||||
|
const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => {
|
||||||
|
return sum + (item.values?.[year]?.[scenario] || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calculate expenses total
|
||||||
|
const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => {
|
||||||
|
return sum + (item.values?.[year]?.[scenario] || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calculate net income
|
||||||
|
const netIncome = revenueTotal - expensesTotal;
|
||||||
|
|
||||||
|
totals[year][scenario] = {
|
||||||
|
revenue: revenueTotal,
|
||||||
|
expenses: expensesTotal,
|
||||||
|
net: netIncome
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return totals;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monthly totals computation
|
||||||
|
const monthlyTotals = computed(() => {
|
||||||
|
const totals = {};
|
||||||
|
|
||||||
|
// Generate month keys for next 12 months
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Calculate revenue total for this month
|
||||||
|
const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => {
|
||||||
|
return sum + (item.monthlyValues?.[monthKey] || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calculate expenses total for this month
|
||||||
|
const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => {
|
||||||
|
return sum + (item.monthlyValues?.[monthKey] || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calculate net income
|
||||||
|
const netIncome = revenueTotal - expensesTotal;
|
||||||
|
|
||||||
|
totals[monthKey] = {
|
||||||
|
revenue: revenueTotal,
|
||||||
|
expenses: expensesTotal,
|
||||||
|
net: netIncome
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals;
|
||||||
|
});
|
||||||
|
|
||||||
|
// LEGACY: Keep for backward compatibility
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const currentYear = currentDate.getFullYear();
|
const currentYear = currentDate.getFullYear();
|
||||||
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
|
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||||
const currentPeriod = ref(`${currentYear}-${currentMonth}`);
|
const currentPeriod = ref(`${currentYear}-${currentMonth}`);
|
||||||
|
|
||||||
// Computed current budget
|
|
||||||
const currentBudget = computed(() => {
|
const currentBudget = computed(() => {
|
||||||
return (
|
return (
|
||||||
budgetLines.value[currentPeriod.value] || {
|
budgetLines.value[currentPeriod.value] || {
|
||||||
|
|
@ -100,13 +219,418 @@ export const useBudgetStore = defineStore(
|
||||||
currentPeriod.value = period;
|
currentPeriod.value = period;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize worksheet from wizard data
|
||||||
|
async function initializeFromWizardData() {
|
||||||
|
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
|
||||||
|
console.log('Already initialized with data, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Initializing budget from wizard data...');
|
||||||
|
|
||||||
|
// Import stores dynamically to avoid circular deps
|
||||||
|
const { useStreamsStore } = await import('./streams');
|
||||||
|
const { useMembersStore } = await import('./members');
|
||||||
|
const { usePoliciesStore } = await import('./policies');
|
||||||
|
|
||||||
|
const streamsStore = useStreamsStore();
|
||||||
|
const membersStore = useMembersStore();
|
||||||
|
const policiesStore = usePoliciesStore();
|
||||||
|
|
||||||
|
console.log('Streams:', streamsStore.streams.length, 'streams');
|
||||||
|
console.log('Members capacity:', membersStore.capacityTotals);
|
||||||
|
console.log('Policies wage:', policiesStore.equalHourlyWage);
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
budgetWorksheet.value.revenue = [];
|
||||||
|
budgetWorksheet.value.expenses = [];
|
||||||
|
|
||||||
|
// Add revenue streams from wizard
|
||||||
|
if (streamsStore.streams.length === 0) {
|
||||||
|
console.log('No wizard streams found, adding sample data');
|
||||||
|
// Initialize with minimal demo if no wizard data exists
|
||||||
|
await streamsStore.initializeWithFixtures();
|
||||||
|
}
|
||||||
|
|
||||||
|
streamsStore.streams.forEach(stream => {
|
||||||
|
const monthlyAmount = stream.targetMonthlyAmount || 0;
|
||||||
|
console.log('Adding stream:', stream.name, 'category:', stream.category, 'subcategory:', stream.subcategory, 'amount:', monthlyAmount);
|
||||||
|
console.log('Full stream object:', stream);
|
||||||
|
|
||||||
|
// Simple category mapping - just map the key categories we know exist
|
||||||
|
let mappedCategory = 'Games & Products'; // Default
|
||||||
|
const categoryLower = (stream.category || '').toLowerCase();
|
||||||
|
if (categoryLower === 'games' || categoryLower === 'product') mappedCategory = 'Games & Products';
|
||||||
|
else if (categoryLower === 'services' || categoryLower === 'service') mappedCategory = 'Services & Contracts';
|
||||||
|
else if (categoryLower === 'grants' || categoryLower === 'grant') mappedCategory = 'Grants & Funding';
|
||||||
|
else if (categoryLower === 'community') mappedCategory = 'Community Support';
|
||||||
|
else if (categoryLower === 'partnerships' || categoryLower === 'partnership') mappedCategory = 'Partnerships';
|
||||||
|
else if (categoryLower === 'investment') mappedCategory = 'Investment Income';
|
||||||
|
|
||||||
|
console.log('Mapped category from', stream.category, 'to', mappedCategory);
|
||||||
|
|
||||||
|
// Create monthly values - split the annual target evenly across 12 months
|
||||||
|
const monthlyValues = {};
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyValues[monthKey] = monthlyAmount;
|
||||||
|
}
|
||||||
|
console.log('Created monthly values for', stream.name, ':', monthlyValues);
|
||||||
|
|
||||||
|
budgetWorksheet.value.revenue.push({
|
||||||
|
id: `revenue-${stream.id}`,
|
||||||
|
name: stream.name,
|
||||||
|
mainCategory: mappedCategory,
|
||||||
|
subcategory: stream.subcategory || 'Direct sales', // Use actual subcategory from stream
|
||||||
|
source: 'wizard',
|
||||||
|
monthlyValues,
|
||||||
|
values: {
|
||||||
|
year1: { best: monthlyAmount * 12, worst: monthlyAmount * 6, mostLikely: monthlyAmount * 10 },
|
||||||
|
year2: { best: monthlyAmount * 15, worst: monthlyAmount * 8, mostLikely: monthlyAmount * 12 },
|
||||||
|
year3: { best: monthlyAmount * 18, worst: monthlyAmount * 10, mostLikely: monthlyAmount * 15 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add payroll from wizard data
|
||||||
|
const totalHours = membersStore.capacityTotals.targetHours || 0;
|
||||||
|
const hourlyWage = policiesStore.equalHourlyWage || 0;
|
||||||
|
const oncostPct = policiesStore.payrollOncostPct || 0;
|
||||||
|
if (totalHours > 0 && hourlyWage > 0) {
|
||||||
|
const monthlyPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
|
||||||
|
|
||||||
|
// Create monthly values for payroll
|
||||||
|
const monthlyValues = {};
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyValues[monthKey] = monthlyPayroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetWorksheet.value.expenses.push({
|
||||||
|
id: 'expense-payroll',
|
||||||
|
name: 'Payroll',
|
||||||
|
mainCategory: 'Salaries & Benefits',
|
||||||
|
subcategory: 'Base wages and benefits',
|
||||||
|
source: 'wizard',
|
||||||
|
monthlyValues,
|
||||||
|
values: {
|
||||||
|
year1: { best: monthlyPayroll * 12, worst: monthlyPayroll * 8, mostLikely: monthlyPayroll * 12 },
|
||||||
|
year2: { best: monthlyPayroll * 14, worst: monthlyPayroll * 10, mostLikely: monthlyPayroll * 13 },
|
||||||
|
year3: { best: monthlyPayroll * 16, worst: monthlyPayroll * 12, mostLikely: monthlyPayroll * 15 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add overhead costs from wizard
|
||||||
|
overheadCosts.value.forEach(cost => {
|
||||||
|
if (cost.amount > 0) {
|
||||||
|
const annualAmount = cost.amount * 12;
|
||||||
|
// Map overhead cost categories to expense categories
|
||||||
|
let expenseCategory = 'Other Expenses'; // Default
|
||||||
|
if (cost.category === 'Operations') expenseCategory = 'Office & Operations';
|
||||||
|
else if (cost.category === 'Technology') expenseCategory = 'Equipment & Technology';
|
||||||
|
else if (cost.category === 'Legal') expenseCategory = 'Legal & Professional';
|
||||||
|
else if (cost.category === 'Marketing') expenseCategory = 'Marketing & Outreach';
|
||||||
|
|
||||||
|
// Create monthly values for overhead costs
|
||||||
|
const monthlyValues = {};
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyValues[monthKey] = cost.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetWorksheet.value.expenses.push({
|
||||||
|
id: `expense-${cost.id}`,
|
||||||
|
name: cost.name,
|
||||||
|
mainCategory: expenseCategory,
|
||||||
|
subcategory: cost.name, // Use the cost name as subcategory
|
||||||
|
source: 'wizard',
|
||||||
|
monthlyValues,
|
||||||
|
values: {
|
||||||
|
year1: { best: annualAmount, worst: annualAmount * 0.8, mostLikely: annualAmount },
|
||||||
|
year2: { best: annualAmount * 1.1, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.05 },
|
||||||
|
year3: { best: annualAmount * 1.2, worst: annualAmount, mostLikely: annualAmount * 1.1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add production costs from wizard
|
||||||
|
productionCosts.value.forEach(cost => {
|
||||||
|
if (cost.amount > 0) {
|
||||||
|
const annualAmount = cost.amount * 12;
|
||||||
|
// Create monthly values for production costs
|
||||||
|
const monthlyValues = {};
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyValues[monthKey] = cost.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetWorksheet.value.expenses.push({
|
||||||
|
id: `expense-${cost.id}`,
|
||||||
|
name: cost.name,
|
||||||
|
mainCategory: 'Development Costs',
|
||||||
|
subcategory: cost.name, // Use the cost name as subcategory
|
||||||
|
source: 'wizard',
|
||||||
|
monthlyValues,
|
||||||
|
values: {
|
||||||
|
year1: { best: annualAmount, worst: annualAmount * 0.7, mostLikely: annualAmount * 0.9 },
|
||||||
|
year2: { best: annualAmount * 1.2, worst: annualAmount * 0.8, mostLikely: annualAmount },
|
||||||
|
year3: { best: annualAmount * 1.3, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If still no data after initialization, add a sample row
|
||||||
|
if (budgetWorksheet.value.revenue.length === 0) {
|
||||||
|
console.log('Adding sample revenue line');
|
||||||
|
// Create monthly values for sample revenue
|
||||||
|
const monthlyValues = {};
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyValues[monthKey] = 667; // ~8000/12
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetWorksheet.value.revenue.push({
|
||||||
|
id: 'revenue-sample',
|
||||||
|
name: 'Sample Revenue',
|
||||||
|
mainCategory: 'Games & Products',
|
||||||
|
subcategory: 'Direct sales',
|
||||||
|
source: 'user',
|
||||||
|
monthlyValues,
|
||||||
|
values: {
|
||||||
|
year1: { best: 10000, worst: 5000, mostLikely: 8000 },
|
||||||
|
year2: { best: 12000, worst: 6000, mostLikely: 10000 },
|
||||||
|
year3: { best: 15000, worst: 8000, mostLikely: 12000 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (budgetWorksheet.value.expenses.length === 0) {
|
||||||
|
console.log('Adding sample expense line');
|
||||||
|
// Create monthly values for sample expense
|
||||||
|
const monthlyValues = {};
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyValues[monthKey] = 67; // ~800/12
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetWorksheet.value.expenses.push({
|
||||||
|
id: 'expense-sample',
|
||||||
|
name: 'Sample Expense',
|
||||||
|
mainCategory: 'Other Expenses',
|
||||||
|
subcategory: 'Miscellaneous',
|
||||||
|
source: 'user',
|
||||||
|
monthlyValues,
|
||||||
|
values: {
|
||||||
|
year1: { best: 1000, worst: 500, mostLikely: 800 },
|
||||||
|
year2: { best: 1200, worst: 600, mostLikely: 1000 },
|
||||||
|
year3: { best: 1500, worst: 800, mostLikely: 1200 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: Log all revenue items and their categories
|
||||||
|
console.log('Final revenue items:');
|
||||||
|
budgetWorksheet.value.revenue.forEach(item => {
|
||||||
|
console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Final expense items:');
|
||||||
|
budgetWorksheet.value.expenses.forEach(item => {
|
||||||
|
console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure all items have monthlyValues and new structure - migrate existing items
|
||||||
|
[...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => {
|
||||||
|
// Migrate to new structure if needed
|
||||||
|
if (item.category && !item.mainCategory) {
|
||||||
|
console.log('Migrating item structure for:', item.name);
|
||||||
|
item.mainCategory = item.category; // Old category becomes mainCategory
|
||||||
|
|
||||||
|
// Set appropriate subcategory based on the main category and item name
|
||||||
|
if (item.category === 'Games & Products') {
|
||||||
|
const gameSubcategories = ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'];
|
||||||
|
item.subcategory = gameSubcategories.includes(item.name) ? item.name : 'Direct sales';
|
||||||
|
} else if (item.category === 'Services & Contracts') {
|
||||||
|
const serviceSubcategories = ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'];
|
||||||
|
item.subcategory = serviceSubcategories.includes(item.name) ? item.name : 'Contract development';
|
||||||
|
} else if (item.category === 'Investment Income') {
|
||||||
|
const investmentSubcategories = ['Impact investment', 'Venture capital', 'Loans'];
|
||||||
|
item.subcategory = investmentSubcategories.includes(item.name) ? item.name : 'Impact investment';
|
||||||
|
} else if (item.category === 'Salaries & Benefits') {
|
||||||
|
item.subcategory = 'Base wages and benefits';
|
||||||
|
} else if (item.category === 'Office & Operations') {
|
||||||
|
// Map specific office tools to appropriate subcategories
|
||||||
|
if (item.name.toLowerCase().includes('rent') || item.name.toLowerCase().includes('office')) {
|
||||||
|
item.subcategory = 'Rent';
|
||||||
|
} else if (item.name.toLowerCase().includes('util')) {
|
||||||
|
item.subcategory = 'Utilities';
|
||||||
|
} else if (item.name.toLowerCase().includes('insurance')) {
|
||||||
|
item.subcategory = 'Insurance';
|
||||||
|
} else {
|
||||||
|
item.subcategory = 'Office supplies';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other categories, use appropriate default
|
||||||
|
item.subcategory = 'Miscellaneous';
|
||||||
|
}
|
||||||
|
|
||||||
|
delete item.category; // Remove old property
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.monthlyValues) {
|
||||||
|
console.log('Migrating item to monthly values:', item.name);
|
||||||
|
item.monthlyValues = {};
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
// Try to use most likely value divided by 12, or default to 0
|
||||||
|
const yearlyValue = item.values?.year1?.mostLikely || 0;
|
||||||
|
item.monthlyValues[monthKey] = Math.round(yearlyValue / 12);
|
||||||
|
}
|
||||||
|
console.log('Added monthly values to', item.name, ':', item.monthlyValues);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Initialization complete. Revenue items:', budgetWorksheet.value.revenue.length, 'Expense items:', budgetWorksheet.value.expenses.length);
|
||||||
|
|
||||||
|
isInitialized.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Budget worksheet functions
|
||||||
|
function updateBudgetValue(category, itemId, year, scenario, value) {
|
||||||
|
const items = budgetWorksheet.value[category];
|
||||||
|
const item = items.find(i => i.id === itemId);
|
||||||
|
if (item) {
|
||||||
|
item.values[year][scenario] = Number(value) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMonthlyValue(category, itemId, monthKey, value) {
|
||||||
|
const items = budgetWorksheet.value[category];
|
||||||
|
const item = items.find(i => i.id === itemId);
|
||||||
|
if (item) {
|
||||||
|
if (!item.monthlyValues) {
|
||||||
|
item.monthlyValues = {};
|
||||||
|
}
|
||||||
|
item.monthlyValues[monthKey] = Number(value) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBudgetItem(category, name, selectedCategory = '') {
|
||||||
|
const id = `${category}-${Date.now()}`;
|
||||||
|
|
||||||
|
// Create empty monthly values for next 12 months
|
||||||
|
const monthlyValues = {};
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyValues[monthKey] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
mainCategory: selectedCategory || (category === 'revenue' ? 'Games & Products' : 'Other Expenses'),
|
||||||
|
subcategory: '', // Will be set by user via dropdown
|
||||||
|
source: 'user',
|
||||||
|
monthlyValues,
|
||||||
|
values: {
|
||||||
|
year1: { best: 0, worst: 0, mostLikely: 0 },
|
||||||
|
year2: { best: 0, worst: 0, mostLikely: 0 },
|
||||||
|
year3: { best: 0, worst: 0, mostLikely: 0 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
budgetWorksheet.value[category].push(newItem);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBudgetItem(category, itemId) {
|
||||||
|
const items = budgetWorksheet.value[category];
|
||||||
|
const index = items.findIndex(i => i.id === itemId);
|
||||||
|
if (index > -1) {
|
||||||
|
items.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameBudgetItem(category, itemId, newName) {
|
||||||
|
const items = budgetWorksheet.value[category];
|
||||||
|
const item = items.find(i => i.id === itemId);
|
||||||
|
if (item) {
|
||||||
|
item.name = newName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBudgetCategory(category, itemId, newCategory) {
|
||||||
|
const items = budgetWorksheet.value[category];
|
||||||
|
const item = items.find(i => i.id === itemId);
|
||||||
|
if (item) {
|
||||||
|
item.category = newCategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomCategory(type, categoryName) {
|
||||||
|
if (type === 'revenue' && !revenueCategories.value.includes(categoryName)) {
|
||||||
|
revenueCategories.value.push(categoryName);
|
||||||
|
} else if (type === 'expenses' && !expenseCategories.value.includes(categoryName)) {
|
||||||
|
expenseCategories.value.push(categoryName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset function
|
// Reset function
|
||||||
function resetBudgetOverhead() {
|
function resetBudgetOverhead() {
|
||||||
overheadCosts.value = [];
|
overheadCosts.value = [];
|
||||||
productionCosts.value = [];
|
productionCosts.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetBudgetWorksheet() {
|
||||||
|
// Reset all values to 0 but keep the structure
|
||||||
|
[...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => {
|
||||||
|
Object.keys(item.values).forEach(year => {
|
||||||
|
Object.keys(item.values[year]).forEach(scenario => {
|
||||||
|
item.values[year][scenario] = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// NEW: Budget worksheet
|
||||||
|
budgetWorksheet,
|
||||||
|
budgetTotals,
|
||||||
|
monthlyTotals,
|
||||||
|
revenueCategories,
|
||||||
|
expenseCategories,
|
||||||
|
revenueSubcategories,
|
||||||
|
groupedRevenue,
|
||||||
|
groupedExpenses,
|
||||||
|
isInitialized,
|
||||||
|
initializeFromWizardData,
|
||||||
|
updateBudgetValue,
|
||||||
|
updateMonthlyValue,
|
||||||
|
addBudgetItem,
|
||||||
|
removeBudgetItem,
|
||||||
|
renameBudgetItem,
|
||||||
|
updateBudgetCategory,
|
||||||
|
addCustomCategory,
|
||||||
|
resetBudgetWorksheet,
|
||||||
|
// LEGACY: Keep for backward compatibility
|
||||||
budgetLines,
|
budgetLines,
|
||||||
overheadCosts,
|
overheadCosts,
|
||||||
productionCosts,
|
productionCosts,
|
||||||
|
|
@ -128,7 +652,7 @@ export const useBudgetStore = defineStore(
|
||||||
{
|
{
|
||||||
persist: {
|
persist: {
|
||||||
key: "urgent-tools-budget",
|
key: "urgent-tools-budget",
|
||||||
paths: ["overheadCosts", "productionCosts", "currentPeriod"],
|
paths: ["budgetWorksheet", "revenueCategories", "expenseCategories", "isInitialized", "overheadCosts", "productionCosts", "currentPeriod"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
export const useWizardStore = defineStore(
|
export const useCoopBuilderStore = defineStore(
|
||||||
"wizard",
|
"coop-builder",
|
||||||
() => {
|
() => {
|
||||||
const currentStep = ref(1);
|
const currentStep = ref(1);
|
||||||
|
|
||||||
22
stores/plan.ts
Normal file
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
|
// Reset function
|
||||||
function resetStreams() {
|
function resetStreams() {
|
||||||
streams.value = [];
|
streams.value = [];
|
||||||
|
|
@ -102,6 +127,8 @@ export const useStreamsStore = defineStore(
|
||||||
// Wizard actions
|
// Wizard actions
|
||||||
upsertStream,
|
upsertStream,
|
||||||
resetStreams,
|
resetStreams,
|
||||||
|
initializeWithFixtures,
|
||||||
|
loadDemoData,
|
||||||
// Legacy actions
|
// Legacy actions
|
||||||
addStream,
|
addStream,
|
||||||
updateStream,
|
updateStream,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
import type { Config } from 'tailwindcss'
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
darkMode: 'class',
|
darkMode: "class",
|
||||||
theme: {
|
} satisfies Config;
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
neutral: {
|
|
||||||
950: '#0a0a0a'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} satisfies Config
|
|
||||||
|
|
|
||||||
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