refactor: update app.vue and various components to improve routing paths, enhance UI consistency, and streamline layout for better user experience
This commit is contained in:
parent
b6e8d3b7ec
commit
78af43770c
29 changed files with 1699 additions and 1990 deletions
1910
pages/tools/budget.vue
Normal file
1910
pages/tools/budget.vue
Normal file
File diff suppressed because it is too large
Load diff
442
pages/tools/coop-builder.vue
Normal file
442
pages/tools/coop-builder.vue
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
<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">
|
||||
Budget 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('/tools/budget')">
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vertical Steps Layout -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Step 1: Pay Policy -->
|
||||
<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-neutral-500 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="
|
||||
policiesValid
|
||||
? '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="policiesValid"
|
||||
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">
|
||||
Choose pay approach
|
||||
</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-neutral-500">
|
||||
<WizardPoliciesStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Members -->
|
||||
<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-neutral-500 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="
|
||||
membersValid
|
||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
|
||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
|
||||
">
|
||||
<UIcon
|
||||
v-if="membersValid"
|
||||
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">
|
||||
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 === 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-neutral-500">
|
||||
<WizardMembersStep @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-neutral-500 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"
|
||||
:class="
|
||||
costsValid
|
||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
|
||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
|
||||
">
|
||||
<UIcon
|
||||
v-if="costsValid"
|
||||
name="i-heroicons-check"
|
||||
class="w-4 h-4" />
|
||||
<span v-else>3</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-black dark:text-white uppercase tracking-wide">
|
||||
Expenses
|
||||
</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-neutral-500">
|
||||
<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-neutral-500 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="
|
||||
streamsValid
|
||||
? 'bg-black dark:bg-white text-white dark:text-black border-black dark:border-neutral-500'
|
||||
: 'bg-white dark:bg-neutral-950 text-black dark:text-white border-black dark:border-neutral-500'
|
||||
">
|
||||
<UIcon
|
||||
v-if="streamsValid"
|
||||
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-neutral-500">
|
||||
<WizardRevenueStep @save-status="handleSaveStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Actions -->
|
||||
<div class="flex justify-between items-center pt-8">
|
||||
<button
|
||||
class="export-btn"
|
||||
@click="resetWizard"
|
||||
:disabled="isResetting">
|
||||
Clear Data
|
||||
</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>
|
||||
|
||||
<UTooltip :text="incompleteSectionsText" :prevent="canComplete">
|
||||
<button
|
||||
class="export-btn primary"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !canComplete }"
|
||||
:disabled="!canComplete"
|
||||
@click="canComplete ? completeWizard() : null">
|
||||
Complete Setup
|
||||
</button>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Store
|
||||
const coop = useCoopBuilder();
|
||||
|
||||
// UI state
|
||||
const focusedStep = ref(1);
|
||||
const saveStatus = ref("");
|
||||
const isResetting = ref(false);
|
||||
const isCompleted = ref(false);
|
||||
|
||||
// Local validity flags for step headers
|
||||
const membersValid = computed(() => {
|
||||
// Valid if at least one member with a name and positive hours
|
||||
return coop.members.value.some((m: any) => {
|
||||
const hasName = typeof m.name === "string" && m.name.trim().length > 0;
|
||||
const hours = Number((m as any).hoursPerMonth ?? 0);
|
||||
return hasName && Number.isFinite(hours) && hours > 0;
|
||||
});
|
||||
});
|
||||
|
||||
const policiesValid = computed(() => {
|
||||
// Placeholder policy validity; mark true when wage text or policy set exists
|
||||
// Since policy not persisted yet in this store, consider valid when any member exists
|
||||
return coop.members.value.length > 0;
|
||||
});
|
||||
|
||||
const costsValid = computed(() => {
|
||||
// Costs are optional, so always mark as valid for now
|
||||
return true;
|
||||
});
|
||||
|
||||
const streamsValid = computed(() => {
|
||||
// Valid if all streams have name, category, and non-negative monthly
|
||||
return (
|
||||
coop.streams.value.length > 0 &&
|
||||
coop.streams.value.every((s: any) => {
|
||||
const monthly = Number((s as any).monthly ?? 0);
|
||||
return (s.label || "").toString().trim().length > 0 && monthly >= 0;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Check if we have basic data for scenario exploration
|
||||
const hasBasicData = computed(() => {
|
||||
return membersValid.value && (costsValid.value || streamsValid.value);
|
||||
});
|
||||
|
||||
// Computed validation - all 4 steps must be valid
|
||||
const canComplete = computed(() => {
|
||||
return (
|
||||
policiesValid.value &&
|
||||
membersValid.value &&
|
||||
costsValid.value &&
|
||||
streamsValid.value
|
||||
);
|
||||
});
|
||||
|
||||
// Generate tooltip text for incomplete sections
|
||||
const incompleteSectionsText = computed(() => {
|
||||
if (canComplete.value) return "";
|
||||
|
||||
const incomplete = [];
|
||||
if (!policiesValid.value) incomplete.push("Choose pay approach");
|
||||
if (!membersValid.value) incomplete.push("Add team members");
|
||||
if (!costsValid.value) incomplete.push("Add monthly costs");
|
||||
if (!streamsValid.value) incomplete.push("Add revenue streams");
|
||||
|
||||
return `Complete these sections: ${incomplete.join(", ")}`;
|
||||
});
|
||||
|
||||
// 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) {
|
||||
console.log("Setting focused step to:", step, "Current:", focusedStep.value);
|
||||
// Toggle if clicking on already focused step
|
||||
if (focusedStep.value === step) {
|
||||
focusedStep.value = 0; // Close the section
|
||||
} else {
|
||||
focusedStep.value = step; // Open the section
|
||||
}
|
||||
console.log("Focused step is now:", focusedStep.value);
|
||||
}
|
||||
|
||||
function completeWizard() {
|
||||
// Mark setup as complete and show restart button for testing
|
||||
isCompleted.value = true;
|
||||
}
|
||||
|
||||
async function resetWizard() {
|
||||
isResetting.value = true;
|
||||
|
||||
// Reset centralized store
|
||||
coop.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 centralized store
|
||||
coop.reset();
|
||||
saveStatus.value = "";
|
||||
|
||||
// Small delay for UX
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
isResetting.value = false;
|
||||
}
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: "Budget Builder",
|
||||
description:
|
||||
"Build your co-op's financial foundation: set up members, policies, costs, and revenue streams.",
|
||||
});
|
||||
</script>
|
||||
8
pages/tools/coop-planner.vue
Normal file
8
pages/tools/coop-planner.vue
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<CoopBuilderPage />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Reuse the existing coop builder content by importing it as a component
|
||||
import CoopBuilderPage from "~/pages/coop-builder.vue";
|
||||
</script>
|
||||
87
pages/tools/dashboard-simple.vue
Normal file
87
pages/tools/dashboard-simple.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
||||
<div class="text-sm text-neutral-600">
|
||||
Mode: {{ currentMode }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple Core Metrics -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Core Metrics</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ runwayDisplay }}</div>
|
||||
<div class="text-sm text-neutral-600">Runway</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ coverageDisplay }}</div>
|
||||
<div class="text-sm text-neutral-600">Coverage</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-purple-600">{{ streamCount }}</div>
|
||||
<div class="text-sm text-neutral-600">Revenue Streams</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Simple Member List -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-medium">Members ({{ memberCount }})</h3>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(member, index) in membersList" :key="index" class="flex items-center justify-between p-2 border border-neutral-200 rounded">
|
||||
<span class="font-medium">{{ member.name }}</span>
|
||||
<span class="text-sm text-neutral-600">{{ member.relationship }}</span>
|
||||
</div>
|
||||
<div v-if="memberCount === 0" class="text-sm text-neutral-500 italic p-4">
|
||||
No members configured yet.
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Simple reactive data without complex computations
|
||||
const currentMode = ref('minimum')
|
||||
const runwayDisplay = ref('∞')
|
||||
const coverageDisplay = ref('100%')
|
||||
const streamCount = ref(0)
|
||||
const memberCount = ref(0)
|
||||
const membersList = ref([])
|
||||
|
||||
// Try to initialize with store data
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Simple store access without composable
|
||||
const membersStore = useMembersStore()
|
||||
const streamsStore = useStreamsStore()
|
||||
const policiesStore = usePoliciesStore()
|
||||
|
||||
// Update reactive values
|
||||
currentMode.value = 'target' // Simplified - always use target mode
|
||||
memberCount.value = membersStore.members?.length || 0
|
||||
streamCount.value = streamsStore.streams?.length || 0
|
||||
|
||||
// Simple member list
|
||||
membersList.value = membersStore.members?.map(m => ({
|
||||
name: m.displayName || 'Unknown',
|
||||
relationship: m.payRelationship || 'Unknown'
|
||||
})) || []
|
||||
|
||||
console.log('Dashboard initialized:', {
|
||||
mode: currentMode.value,
|
||||
members: memberCount.value,
|
||||
streams: streamCount.value
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing simple dashboard:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
381
pages/tools/index.vue
Normal file
381
pages/tools/index.vue
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
<template>
|
||||
<section class="py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">Compensation</h2>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs font-mono">
|
||||
Runway: {{ Math.round(metrics.runway) }}mo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="onExport"
|
||||
class="px-4 py-2 border-2 border-black bg-white font-bold uppercase text-sm hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
@click="onImport"
|
||||
class="px-4 py-2 border-2 border-black bg-white font-bold uppercase text-sm hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||
Import JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CoverageMeter
|
||||
:funded-paid-hours="Math.round(metrics.totalTargetHours * 0.65)"
|
||||
:target-hours="metrics.totalTargetHours"
|
||||
description="Funded hours vs target capacity across all members." />
|
||||
|
||||
<ReserveMeter
|
||||
:current-savings="savingsProgress.current"
|
||||
:savings-target-months="savingsProgress.targetMonths"
|
||||
:monthly-burn="getMonthlyBurn()"
|
||||
:description="`${savingsProgress.progressPct.toFixed(0)}% of savings target reached. ${savingsProgress.gap > 0 ? 'Gap: ' + $format.currency(savingsProgress.gap) : 'Target achieved!'}`" />
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Components with Wizard Styling -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Needs Coverage Bars -->
|
||||
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div class="border-b-2 border-black p-4">
|
||||
<h3 class="text-lg font-bold uppercase">Member Coverage</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<NeedsCoverageBars />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Milestone-Runway Overlay -->
|
||||
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div class="border-b-2 border-black p-4">
|
||||
<h3 class="text-lg font-bold uppercase">Runway vs Milestones</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<MilestoneRunwayOverlay />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts Section with Wizard Styling -->
|
||||
<div class="border-2 border-black bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
||||
<div class="border-b-2 border-black p-4">
|
||||
<h3 class="text-lg font-bold uppercase">Alerts</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Concentration Risk Alert -->
|
||||
<div
|
||||
v-if="topSourcePct > 50"
|
||||
class="border-2 border-red-600 bg-red-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-600 font-bold text-xl">!</span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold uppercase mb-1">Revenue Concentration Risk</h4>
|
||||
<p class="text-sm mb-2">{{ topStreamName }} = {{ topSourcePct }}% of total → consider balancing</p>
|
||||
<button
|
||||
@click="handleAlertNavigation('/tools/', 'concentration')"
|
||||
class="text-sm underline font-bold">
|
||||
VIEW DETAILS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cushion Breach Alert -->
|
||||
<div
|
||||
v-if="alerts.cushionBreach"
|
||||
class="border-2 border-orange-600 bg-orange-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-orange-600 font-bold text-xl">!</span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold uppercase mb-1">Cash Cushion Breach Forecast</h4>
|
||||
<p class="text-sm mb-2">Projected to breach minimum cushion in week {{ cushionForecast.firstBreachWeek || 'unknown' }}</p>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
@click="handleAlertNavigation('/tools/cash', 'breach-forecast')"
|
||||
class="text-sm underline font-bold">
|
||||
VIEW CALENDAR
|
||||
</button>
|
||||
<button
|
||||
@click="handleAlertNavigation('/tools/budget', 'expenses')"
|
||||
class="text-sm underline font-bold">
|
||||
ADJUST BUDGET
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Savings Below Target Alert -->
|
||||
<div
|
||||
v-if="alerts.savingsBelowTarget"
|
||||
class="border-2 border-yellow-600 bg-yellow-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-yellow-600 font-bold text-xl">!</span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold uppercase mb-1">Savings Below Target</h4>
|
||||
<p class="text-sm mb-2">{{ savingsProgress.progressPct.toFixed(0) }}% of target reached. Build savings before increasing paid hours.</p>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
@click="handleAlertNavigation('/tools/budget', 'savings')"
|
||||
class="text-sm underline font-bold">
|
||||
VIEW PROGRESS
|
||||
</button>
|
||||
<button
|
||||
@click="handleAlertNavigation('/tools/coop-builder', 'policies')"
|
||||
class="text-sm underline font-bold">
|
||||
ADJUST POLICIES
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Over-Deferred Member Alert -->
|
||||
<div
|
||||
v-if="deferredAlert.show"
|
||||
class="border-2 border-purple-600 bg-purple-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-purple-600 font-bold text-xl">!</span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold uppercase mb-1">Member Over-Deferred</h4>
|
||||
<p class="text-sm mb-2">{{ deferredAlert.description }}</p>
|
||||
<button
|
||||
@click="handleAlertNavigation('/tools/coop-builder', 'members')"
|
||||
class="text-sm underline font-bold">
|
||||
REVIEW MEMBERS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success message when no alerts -->
|
||||
<div v-if="!alerts.cushionBreach && !alerts.savingsBelowTarget && topSourcePct <= 50 && !deferredAlert.show"
|
||||
class="text-center py-8">
|
||||
<span class="text-4xl font-bold">✓</span>
|
||||
<p class="font-bold uppercase mt-2">All systems looking good!</p>
|
||||
<p class="text-sm mt-1">No critical alerts at this time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Quick Actions with Wizard Styling -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
@click="navigateTo('/tools/cash-flow')"
|
||||
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||
<div class="font-bold uppercase mb-1">Cash Flow Analysis</div>
|
||||
<div class="text-sm">Detailed runway & one-time events</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="navigateTo('/tools/budget')"
|
||||
class="border-2 border-black bg-white p-4 text-left hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-shadow">
|
||||
<div class="font-bold uppercase mb-1">Budget Planning</div>
|
||||
<div class="text-sm">Manage expenses & savings</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Dashboard page
|
||||
const { $format } = useNuxtApp();
|
||||
|
||||
// Use real store data instead of fixtures
|
||||
const membersStore = useMembersStore();
|
||||
const policiesStore = usePoliciesStore();
|
||||
const streamsStore = useStreamsStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
const cashStore = useCashStore();
|
||||
|
||||
// Runway composable with operating mode integration
|
||||
const { getDualModeRunway, getMonthlyBurn } = useRunway();
|
||||
|
||||
// Cushion forecast and savings progress
|
||||
const { savingsProgress, cushionForecast, alerts } = useCushionForecast();
|
||||
|
||||
|
||||
// Calculate metrics from real store data
|
||||
const metrics = computed(() => {
|
||||
const totalTargetHours = membersStore.members.reduce(
|
||||
(sum, member) => sum + (member.capacity?.targetHours || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const totalTargetRevenue = streamsStore.streams.reduce(
|
||||
(sum, stream) => sum + (stream.targetMonthlyAmount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const totalOverheadCosts = budgetStore.overheadCosts.reduce(
|
||||
(sum, cost) => sum + (cost.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Use integrated runway calculations that respect operating mode
|
||||
const currentMode = 'target'; // Always target mode now
|
||||
const monthlyBurn = getMonthlyBurn(currentMode);
|
||||
|
||||
// Use actual cash store values with fallback
|
||||
const cash = cashStore.currentCash || 50000;
|
||||
const savings = cashStore.currentSavings || 15000;
|
||||
const totalLiquid = cash + savings;
|
||||
|
||||
// Get dual-mode runway data
|
||||
const runwayData = getDualModeRunway(cash, savings);
|
||||
const runway = currentMode === 'target' ? runwayData.target : runwayData.minimum;
|
||||
|
||||
return {
|
||||
totalTargetHours,
|
||||
totalTargetRevenue,
|
||||
monthlyPayroll: runwayData.minBurn, // Use actual calculated payroll
|
||||
monthlyBurn,
|
||||
runway,
|
||||
runwayData, // Include dual-mode data
|
||||
finances: {
|
||||
currentBalances: {
|
||||
cash: cashStore.currentCash,
|
||||
savings: cashStore.currentSavings,
|
||||
totalLiquid,
|
||||
},
|
||||
policies: {
|
||||
equalHourlyWage: policiesStore.equalHourlyWage,
|
||||
payrollOncostPct: policiesStore.payrollOncostPct,
|
||||
savingsTargetMonths: policiesStore.savingsTargetMonths,
|
||||
minCashCushionAmount: policiesStore.minCashCushionAmount,
|
||||
},
|
||||
deferredLiabilities: {
|
||||
totalDeferred: membersStore.members.reduce(
|
||||
(sum, m) =>
|
||||
sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
|
||||
0
|
||||
),
|
||||
},
|
||||
surplus: Math.max(0, totalTargetRevenue - monthlyBurn),
|
||||
savingsGap: Math.max(
|
||||
0,
|
||||
policiesStore.savingsTargetMonths * monthlyBurn -
|
||||
cashStore.currentSavings
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate concentration metrics
|
||||
const topSourcePct = computed(() => {
|
||||
if (streamsStore.streams.length === 0) return 0;
|
||||
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
|
||||
const total = amounts.reduce((sum, amt) => sum + amt, 0);
|
||||
return total > 0 ? Math.round((Math.max(...amounts) / total) * 100) : 0;
|
||||
});
|
||||
|
||||
const topStreamName = computed(() => {
|
||||
if (streamsStore.streams.length === 0) return 'No streams';
|
||||
const amounts = streamsStore.streams.map((s) => s.targetMonthlyAmount || 0);
|
||||
const maxAmount = Math.max(...amounts);
|
||||
const topStream = streamsStore.streams.find(s => (s.targetMonthlyAmount || 0) === maxAmount);
|
||||
return topStream?.name || 'Unknown stream';
|
||||
});
|
||||
|
||||
const concentrationStatus = computed(() => {
|
||||
if (topSourcePct.value > 50) return "red";
|
||||
if (topSourcePct.value > 35) return "yellow";
|
||||
return "green";
|
||||
});
|
||||
|
||||
const concentrationColor = computed(() => {
|
||||
if (topSourcePct.value > 50) return "text-red-600";
|
||||
if (topSourcePct.value > 35) return "text-yellow-600";
|
||||
return "text-green-600";
|
||||
});
|
||||
|
||||
function getRunwayColor(months: number): string {
|
||||
if (months >= 6) return 'text-green-600'
|
||||
if (months >= 3) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
|
||||
// Cash breach description
|
||||
const cashBreachDescription = computed(() => {
|
||||
// Check cash store for first breach week from projections
|
||||
const breachWeek = cashStore.weeklyProjections.find(
|
||||
(week) => week.breachesCushion
|
||||
);
|
||||
if (breachWeek) {
|
||||
return `Week ${breachWeek.number} would drop below your minimum cushion.`;
|
||||
}
|
||||
return "No cushion breach currently projected.";
|
||||
});
|
||||
|
||||
const onExport = () => {
|
||||
const data = exportAll();
|
||||
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 = "urgent-tools.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "application/json";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
importAll(JSON.parse(text));
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const { exportAll, importAll } = useFixtureIO();
|
||||
|
||||
|
||||
// Deferred alert logic
|
||||
const deferredAlert = computed(() => {
|
||||
const maxDeferredRatio = 1.5; // From CLAUDE.md - flag if >1.5× monthly payroll
|
||||
const monthlyPayrollCost = getMonthlyBurn() * 0.7; // Estimate payroll as 70% of burn
|
||||
const totalDeferred = membersStore.members.reduce(
|
||||
(sum, m) => sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
|
||||
0
|
||||
);
|
||||
|
||||
const deferredRatio = monthlyPayrollCost > 0 ? totalDeferred / monthlyPayrollCost : 0;
|
||||
const show = deferredRatio > maxDeferredRatio;
|
||||
|
||||
const overDeferredMembers = membersStore.members.filter(m => {
|
||||
const memberDeferred = (m.deferredHours || 0) * policiesStore.equalHourlyWage;
|
||||
const memberMonthlyPay = m.monthlyPayPlanned || 0;
|
||||
return memberDeferred > memberMonthlyPay * 2; // Member has >2 months of pay deferred
|
||||
});
|
||||
|
||||
return {
|
||||
show,
|
||||
description: show
|
||||
? `${overDeferredMembers.length} member(s) over deferred cap. Total: ${(deferredRatio * 100).toFixed(0)}% of monthly payroll.`
|
||||
: ''
|
||||
};
|
||||
});
|
||||
|
||||
// Alert navigation with context
|
||||
function handleAlertNavigation(path: string, section?: string) {
|
||||
// Store alert context for target page to highlight relevant section
|
||||
if (section) {
|
||||
localStorage.setItem('urgent-tools-alert-context', JSON.stringify({ section, timestamp: Date.now() }));
|
||||
}
|
||||
navigateTo(path);
|
||||
};
|
||||
</script>
|
||||
130
pages/tools/project-budget.vue
Normal file
130
pages/tools/project-budget.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="">
|
||||
<h1 class="font-bold text-2xl mb-4">Project Budget Estimate</h1>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mx-auto mb-4">
|
||||
This tool provides a rough estimate of what it would cost to build your
|
||||
project using the pay policy you've set in the setup.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<!-- Sustainable payroll toggle hidden - defaulting to theoretical maximum -->
|
||||
<div class="hidden">
|
||||
<span class="text-sm font-medium">Sustainable Payroll</span>
|
||||
<USwitch v-model="useTheoreticalPayroll" size="md" />
|
||||
<span class="text-sm font-medium">Theoretical Maximum</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="membersWithPay.length === 0" class="text-center py-8">
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
No team members set up yet.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/tools/coop-builder"
|
||||
class="px-4 py-2 border-2 border-black dark:border-white bg-white dark:bg-black text-black dark:text-white font-bold hover:bg-neutral-100 dark:hover:bg-neutral-900">
|
||||
Set up your team in Setup Wizard
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<ProjectBudgetEstimate
|
||||
v-else
|
||||
:members="membersWithPay"
|
||||
:oncost-rate="coopStore.payrollOncostPct / 100"
|
||||
:payroll-mode="useTheoreticalPayroll ? 'theoretical' : 'sustainable'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { allocatePayroll as allocatePayrollImpl } from "~/types/members";
|
||||
|
||||
const coopStore = useCoopBuilderStore();
|
||||
const budgetStore = useBudgetStore();
|
||||
|
||||
// Toggle between sustainable and theoretical payroll modes - defaulting to theoretical maximum
|
||||
const useTheoreticalPayroll = ref(true);
|
||||
|
||||
// Calculate member pay using different logic based on payroll mode
|
||||
const membersWithPay = computed(() => {
|
||||
// Use the member's desired hours (targetHours if available, otherwise hoursPerMonth)
|
||||
const getHoursForMember = (member: any) => {
|
||||
return member.capacity?.targetHours || member.hoursPerMonth || 0;
|
||||
};
|
||||
|
||||
let allocatedMembers;
|
||||
|
||||
if (useTheoreticalPayroll.value) {
|
||||
// Theoretical mode: Calculate true theoretical maximum without revenue constraints
|
||||
const allMembers = coopStore.members.map((m: any) => ({
|
||||
...m,
|
||||
displayName: m.name,
|
||||
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
||||
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
||||
hoursPerMonth: m.hoursPerMonth || 0,
|
||||
}));
|
||||
|
||||
const payPolicy = {
|
||||
relationship: coopStore.policy.relationship || ("equal-pay" as const),
|
||||
};
|
||||
|
||||
// Calculate theoretical maximum budget: total hours × hourly wage
|
||||
const totalHours = allMembers.reduce(
|
||||
(sum, m) => sum + (m.hoursPerMonth || 0),
|
||||
0
|
||||
);
|
||||
const hourlyWage = coopStore.equalHourlyWage || 0;
|
||||
const theoreticalMaxBudget = totalHours * hourlyWage;
|
||||
|
||||
allocatedMembers = allocatePayrollImpl(
|
||||
allMembers,
|
||||
payPolicy,
|
||||
theoreticalMaxBudget
|
||||
);
|
||||
} else {
|
||||
// Sustainable mode: Use revenue-constrained allocation (current behavior)
|
||||
const { allocatePayroll } = useCoopBuilder();
|
||||
const sustainableMembers = allocatePayroll();
|
||||
|
||||
const today = new Date();
|
||||
const currentMonthKey = `${today.getFullYear()}-${String(
|
||||
today.getMonth() + 1
|
||||
).padStart(2, "0")}`;
|
||||
|
||||
const payrollExpense = budgetStore.budgetWorksheet.expenses.find(
|
||||
(item) =>
|
||||
item.id === "expense-payroll-base" || item.id === "expense-payroll"
|
||||
);
|
||||
const actualPayrollBudget =
|
||||
payrollExpense?.monthlyValues?.[currentMonthKey] || 0;
|
||||
|
||||
const theoreticalTotal = sustainableMembers.reduce(
|
||||
(sum, m) => sum + (m.monthlyPayPlanned || 0),
|
||||
0
|
||||
);
|
||||
const scaleFactor =
|
||||
theoreticalTotal > 0 ? actualPayrollBudget / theoreticalTotal : 0;
|
||||
|
||||
allocatedMembers = sustainableMembers.map((member) => ({
|
||||
...member,
|
||||
monthlyPayPlanned: (member.monthlyPayPlanned || 0) * scaleFactor,
|
||||
}));
|
||||
}
|
||||
|
||||
return allocatedMembers
|
||||
.map((member: any) => {
|
||||
const hours = getHoursForMember(member);
|
||||
|
||||
return {
|
||||
name: member.displayName || "Unnamed",
|
||||
hoursPerMonth: hours,
|
||||
monthlyPay: member.monthlyPayPlanned || 0,
|
||||
};
|
||||
})
|
||||
.filter((m: any) => m.hoursPerMonth > 0); // Only include members with hours
|
||||
});
|
||||
|
||||
// Set page meta
|
||||
definePageMeta({
|
||||
title: "Project Budget Estimate",
|
||||
});
|
||||
</script>
|
||||
165
pages/tools/resources.vue
Normal file
165
pages/tools/resources.vue
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8">
|
||||
<div class="max-w-6xl mx-auto px-4 relative">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
More Resources & Templates
|
||||
</h1>
|
||||
<p class="text-neutral-700 dark:text-neutral-200">
|
||||
Additional tools, templates, and resources to support your
|
||||
cooperative journey.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- External Templates Section -->
|
||||
<section>
|
||||
<h2
|
||||
class="text-2xl font-semibold text-neutral-900 dark:text-white mb-4">
|
||||
External Templates
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Miro Template -->
|
||||
<div class="template-card">
|
||||
<div
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<div
|
||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
|
||||
<h3
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
|
||||
Goals & Values Exercise
|
||||
</h3>
|
||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
A Miro template to help your team align on shared goals and
|
||||
values through collaborative exercises. Make sure to do this
|
||||
WITH your full team!
|
||||
</p>
|
||||
<a
|
||||
href="https://miro.com/miroverse/goals-values-exercise/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity">
|
||||
Open in Miro
|
||||
<svg
|
||||
class="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CommunityRule Templates -->
|
||||
<div class="template-card">
|
||||
<div
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<div
|
||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6">
|
||||
<h3
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
|
||||
CommunityRule Governance Templates
|
||||
</h3>
|
||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
A collection of governance templates and patterns for
|
||||
democratic organizations and communities.
|
||||
</p>
|
||||
<a
|
||||
href="https://communityrule.info/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-4 py-2 bg-black dark:bg-white text-white dark:text-black font-medium hover:opacity-90 transition-opacity">
|
||||
Browse Templates
|
||||
<svg
|
||||
class="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002 2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// PDF downloads list with placeholder data
|
||||
const pdfDownloads = [
|
||||
{
|
||||
id: "bylaws",
|
||||
name: "Bylaws Wizard",
|
||||
description: "Create comprehensive bylaws for your cooperative",
|
||||
available: false,
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
id: "operating-agreement",
|
||||
name: "Operating Agreement Wizard",
|
||||
description: "Draft an operating agreement for your LLC cooperative",
|
||||
available: false,
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
id: "articles",
|
||||
name: "Articles of Incorporation",
|
||||
description: "Template for articles of incorporation",
|
||||
available: false,
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
id: "membership",
|
||||
name: "Membership Agreement",
|
||||
description: "Define membership terms and conditions",
|
||||
available: false,
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
id: "patronage",
|
||||
name: "Patronage Policy",
|
||||
description: "Structure your patronage distribution system",
|
||||
available: false,
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
id: "conflict",
|
||||
name: "Conflict Resolution Process",
|
||||
description: "Establish clear conflict resolution procedures",
|
||||
available: false,
|
||||
url: "#",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dither-tag {
|
||||
background-image: repeating-linear-gradient(
|
||||
135deg,
|
||||
transparent,
|
||||
transparent 1px,
|
||||
currentColor 1px,
|
||||
currentColor 2px
|
||||
);
|
||||
background-size: 4px 4px;
|
||||
}
|
||||
</style>
|
||||
2171
pages/tools/templates/conflict-resolution-framework.vue
Normal file
2171
pages/tools/templates/conflict-resolution-framework.vue
Normal file
File diff suppressed because it is too large
Load diff
1141
pages/tools/templates/decision-framework.vue
Normal file
1141
pages/tools/templates/decision-framework.vue
Normal file
File diff suppressed because it is too large
Load diff
279
pages/tools/templates/index.vue
Normal file
279
pages/tools/templates/index.vue
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<template>
|
||||
<div
|
||||
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
|
||||
style="font-family: 'Ubuntu', 'Ubuntu Mono', monospace">
|
||||
<div class="max-w-6xl mx-auto px-4 relative">
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
class="text-3xl font-bold text-neutral-900 dark:text-white mb-2"
|
||||
style="font-family: 'Ubuntu', monospace">
|
||||
Document Templates
|
||||
</h1>
|
||||
<p class="text-neutral-700 dark:text-neutral-200">
|
||||
Fillable forms for cooperative documents. Data saves locally in your
|
||||
browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="template-card h-full flex flex-col">
|
||||
<!-- Dithered shadow background -->
|
||||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div
|
||||
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6 h-full flex flex-col">
|
||||
<div class="mb-4">
|
||||
<h3
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
{{ template.name }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
{{ template.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<span
|
||||
v-for="tag in template.tags"
|
||||
:key="tag"
|
||||
class="px-2 py-1 text-xs font-medium bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-900 border border-black dark:border-white dither-tag">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>{{ template.estimatedTime }}</span>
|
||||
<span>{{ template.fields }} fields</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer to push buttons to bottom -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="flex gap-2 mt-auto">
|
||||
<NuxtLink
|
||||
:to="template.path"
|
||||
class="flex-1 px-4 py-2 bg-black dark:bg-white text-white dark:text-black border border-black dark:border-white hover:bg-black dark:hover:bg-white transition-colors text-center font-medium bitmap-button"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
START TEMPLATE
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="hasData(template.id)"
|
||||
:to="template.path"
|
||||
class="px-4 py-2 bg-white dark:bg-neutral-950 text-black dark:text-white border border-black dark:border-white hover:bg-white dark:hover:bg-neutral-950 transition-colors bitmap-button"
|
||||
title="Continue from saved data"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
RESUME
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="mt-12 help-section">
|
||||
<!-- Dithered shadow background -->
|
||||
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<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 Templates 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">
|
||||
Templates 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 (print), plain text, Markdown, or Word document.
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const templates = [
|
||||
{
|
||||
id: "membership-agreement",
|
||||
name: "Membership Agreement",
|
||||
description:
|
||||
"A comprehensive agreement outlining member rights, responsibilities, decision-making processes, and financial arrangements for worker cooperatives.",
|
||||
icon: "i-heroicons-user-group",
|
||||
path: "/templates/membership-agreement",
|
||||
tags: ["Legal", "Governance", "Membership"],
|
||||
estimatedTime: "15-30 min",
|
||||
fields: 25,
|
||||
storageKey: "membership-agreement-data",
|
||||
},
|
||||
{
|
||||
id: "conflict-resolution-framework",
|
||||
name: "Conflict Resolution Framework",
|
||||
description:
|
||||
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
|
||||
icon: "i-heroicons-scale",
|
||||
path: "/templates/conflict-resolution-framework",
|
||||
tags: ["Governance", "Process", "Care"],
|
||||
estimatedTime: "20-40 min",
|
||||
fields: 35,
|
||||
storageKey: "conflict-resolution-framework-data",
|
||||
},
|
||||
{
|
||||
id: "tech-charter",
|
||||
name: "Technology Charter",
|
||||
description:
|
||||
"Build technology decisions on cooperative values. Define principles, technical constraints, and evaluation criteria for vendor selection.",
|
||||
icon: "i-heroicons-cog-6-tooth",
|
||||
path: "/templates/tech-charter",
|
||||
tags: ["Technology", "Decision-Making", "Governance"],
|
||||
estimatedTime: "10-20 min",
|
||||
fields: 20,
|
||||
storageKey: "tech-charter-data",
|
||||
},
|
||||
{
|
||||
id: "decision-framework",
|
||||
name: "Decision Framework Helper",
|
||||
description:
|
||||
"Interactive tool to help determine the best decision-making approach based on urgency, expertise, stakes, and team dynamics.",
|
||||
icon: "i-heroicons-light-bulb",
|
||||
path: "/templates/decision-framework",
|
||||
tags: ["Decision-Making", "Process", "Governance"],
|
||||
estimatedTime: "5-10 min",
|
||||
fields: 7,
|
||||
storageKey: "decision-framework-data",
|
||||
},
|
||||
];
|
||||
|
||||
const hasData = (templateId) => {
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (!template?.storageKey) return false;
|
||||
|
||||
if (process.client) {
|
||||
const saved = localStorage.getItem(template.storageKey);
|
||||
return saved && saved !== "{}";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Remove the JavaScript background handler since we're using CSS classes
|
||||
|
||||
useHead({
|
||||
title: "Document Templates - Co-op Pay & Value Tool",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Fillable document templates for worker cooperatives including membership agreements and governance documents.",
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Template index specific styles - no longer duplicated in main.css */
|
||||
|
||||
.dither-shadow-disabled {
|
||||
background: black;
|
||||
background-image: radial-gradient(white 1px, transparent 1px);
|
||||
background-size: 2px 2px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dither-shadow-disabled {
|
||||
background: white;
|
||||
background-image: radial-gradient(black 1px, transparent 1px);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) .dither-shadow-disabled {
|
||||
background: white;
|
||||
background-image: radial-gradient(black 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Remove any inherited rounded corners */
|
||||
.template-card > *,
|
||||
.help-section > *,
|
||||
button,
|
||||
.px-4,
|
||||
div[class*="border"] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Button hover effects with bitmap feel */
|
||||
.template-card .relative:hover {
|
||||
transform: translateY(-1px);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* Ensure sharp edges on all elements */
|
||||
* {
|
||||
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>
|
||||
1415
pages/tools/templates/membership-agreement.vue
Normal file
1415
pages/tools/templates/membership-agreement.vue
Normal file
File diff suppressed because it is too large
Load diff
967
pages/tools/templates/tech-charter.vue
Normal file
967
pages/tools/templates/tech-charter.vue
Normal file
|
|
@ -0,0 +1,967 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Export Options - Top -->
|
||||
<ExportOptions
|
||||
:export-data="exportData"
|
||||
filename="tech-charter"
|
||||
title="Technology Charter" />
|
||||
|
||||
<div
|
||||
class="template-wrapper bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
|
||||
<!-- Document Container -->
|
||||
<div class="document-page">
|
||||
<div class="template-content">
|
||||
<!-- Document Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1
|
||||
class="text-3xl md:text-5xl font-bold uppercase text-neutral-900 dark:text-white m-0 py-4 border-t-2 border-b-2 border-neutral-900 dark:border-neutral-100">
|
||||
Tech Charter
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="">
|
||||
<!-- Purpose Section -->
|
||||
<div class="section-card">
|
||||
<div>
|
||||
<h2
|
||||
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-4">
|
||||
Charter Purpose
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
Describe what this charter will guide and why it matters to
|
||||
you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<textarea
|
||||
v-model="charterPurpose"
|
||||
class="w-full min-h-32 p-4 border-2 border-neutral-300 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:border-black dark:focus:border-white transition-colors resize-y"
|
||||
rows="4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unified Principles & Importance Section -->
|
||||
<div class="section-card">
|
||||
<div>
|
||||
<h2
|
||||
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-4">
|
||||
Define Your Principles & Importance
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-6">
|
||||
Select principles and set their importance. Zero means
|
||||
excluded, 5 means critical.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="principle in principles"
|
||||
:key="principle.id"
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected cards -->
|
||||
<div
|
||||
v-if="principleWeights[principle.id] > 0"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'relative transition-all',
|
||||
principleWeights[principle.id] > 0
|
||||
? 'item-selected border-2 border-black dark:border-neutral-400 bg-white dark:bg-neutral-950'
|
||||
: 'border border-black dark:border-white bg-transparent',
|
||||
]">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-6">
|
||||
<!-- Principle info -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
:class="[
|
||||
'item-text-bg mb-3',
|
||||
principleWeights[principle.id] > 0
|
||||
? 'selected'
|
||||
: '',
|
||||
]">
|
||||
<h3 class="font-bold text-lg mb-2">
|
||||
{{ principle.name }}
|
||||
</h3>
|
||||
<p
|
||||
:class="
|
||||
principleWeights[principle.id] > 0
|
||||
? 'text-neutral-700'
|
||||
: 'text-neutral-600'
|
||||
"
|
||||
class="text-sm dark:text-neutral-200">
|
||||
{{ principle.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Importance selector -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-bold text-neutral-500 uppercase tracking-wider">
|
||||
Importance
|
||||
</label>
|
||||
|
||||
<!-- Visual weight indicator -->
|
||||
<div class="flex gap-1 mb-2">
|
||||
<button
|
||||
v-for="level in [0, 1, 2, 3, 4, 5]"
|
||||
:key="level"
|
||||
@click="setPrincipleWeight(principle.id, level)"
|
||||
:class="[
|
||||
'w-8 h-8 border-2 font-mono text-sm transition-all',
|
||||
principleWeights[principle.id] >= level
|
||||
? 'bg-black text-white border-black dark:bg-white dark:text-black dark:border-white'
|
||||
: 'bg-white border-neutral-300 hover:border-neutral-500 dark:bg-neutral-950',
|
||||
]"
|
||||
:title="`Set importance to ${level}`">
|
||||
{{ level }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Weight value display -->
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold">
|
||||
{{ principleWeights[principle.id] || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500">
|
||||
{{
|
||||
getWeightLabel(
|
||||
principleWeights[principle.id] || 0
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Non-negotiable toggle (only shows for weights > 0) -->
|
||||
<div
|
||||
v-if="principleWeights[principle.id] > 0"
|
||||
class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<UCheckbox
|
||||
:model-value="nonNegotiables.includes(principle.id)"
|
||||
@update:model-value="
|
||||
(checked) =>
|
||||
toggleNonNegotiableCheckbox(principle.id, checked)
|
||||
"
|
||||
label="Make this non-negotiable"
|
||||
class="item-label-bg px-2 py-1" />
|
||||
</div>
|
||||
|
||||
<!-- Show rubric description when selected -->
|
||||
<div
|
||||
v-if="principleWeights[principle.id] > 0"
|
||||
class="mt-4 p-3 item-label-bg selected border border-neutral-200">
|
||||
<div
|
||||
class="text-xs font-bold uppercase text-neutral-800 dark:text-neutral-300 mb-1">
|
||||
Evaluation Criteria:
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ principle.rubricDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Constraints Section -->
|
||||
<div class="section-card">
|
||||
<div>
|
||||
<h2
|
||||
class="text-2xl font-bold text-neutral-800 dark:text-white font-display mb-2"
|
||||
id="constraints-heading">
|
||||
Technical Constraints
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<fieldset class="bg-neutral-50 dark:bg-neutral-800 p-6">
|
||||
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||
Authentication
|
||||
</legend>
|
||||
<div
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
aria-labelledby="auth-heading">
|
||||
<div
|
||||
v-for="option in authOptions"
|
||||
:key="option.value"
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.sso === option.value"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="constraints.sso = option.value"
|
||||
:aria-pressed="constraints.sso === option.value"
|
||||
role="radio"
|
||||
:aria-checked="constraints.sso === option.value"
|
||||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
constraints.sso === option.value
|
||||
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||
Hosting Model
|
||||
</legend>
|
||||
<div
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
aria-labelledby="hosting-heading">
|
||||
<div
|
||||
v-for="option in hostingOptions"
|
||||
:key="option.value"
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.hosting === option.value"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="constraints.hosting = option.value"
|
||||
:aria-pressed="constraints.hosting === option.value"
|
||||
role="radio"
|
||||
:aria-checked="constraints.hosting === option.value"
|
||||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
constraints.hosting === option.value
|
||||
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer '
|
||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||
Required Integrations
|
||||
</legend>
|
||||
<p
|
||||
class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
Select all that apply
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3 constraint-buttons">
|
||||
<div
|
||||
v-for="integration in integrationOptions"
|
||||
:key="integration"
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.integrations.includes(integration)"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="toggleIntegration(integration)"
|
||||
:aria-pressed="
|
||||
constraints.integrations.includes(integration)
|
||||
"
|
||||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
constraints.integrations.includes(integration)
|
||||
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||
]">
|
||||
{{ integration }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||
<legend class="font-semibold text-lg">
|
||||
Support Expectations
|
||||
</legend>
|
||||
<div
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
aria-labelledby="support-heading">
|
||||
<div
|
||||
v-for="option in supportOptions"
|
||||
:key="option.value"
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.support === option.value"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="constraints.support = option.value"
|
||||
:aria-pressed="constraints.support === option.value"
|
||||
role="radio"
|
||||
:aria-checked="constraints.support === option.value"
|
||||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
constraints.support === option.value
|
||||
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="bg-neutral-50 p-6 dark:bg-neutral-800">
|
||||
<legend class="font-semibold text-lg dark:text-neutral-200">
|
||||
Migration Timeline
|
||||
</legend>
|
||||
<div
|
||||
class="flex flex-wrap gap-3 constraint-buttons"
|
||||
role="radiogroup"
|
||||
aria-labelledby="timeline-heading">
|
||||
<div
|
||||
v-for="option in timelineOptions"
|
||||
:key="option.value"
|
||||
class="relative">
|
||||
<!-- Dithered shadow for selected buttons -->
|
||||
<div
|
||||
v-if="constraints.timeline === option.value"
|
||||
class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
|
||||
<button
|
||||
@click="constraints.timeline = option.value"
|
||||
:aria-pressed="constraints.timeline === option.value"
|
||||
role="radio"
|
||||
:aria-checked="constraints.timeline === option.value"
|
||||
:class="[
|
||||
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
|
||||
constraints.timeline === option.value
|
||||
? 'constraint-selected border-black dark:border-neutral-400 cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
|
||||
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
|
||||
]">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Form Section -->
|
||||
<div v-if="canGenerateCharter" class="text-center mt-8">
|
||||
<button
|
||||
@click="resetForm"
|
||||
class="export-btn"
|
||||
title="Clear all form data and start over">
|
||||
<UIcon name="i-heroicons-arrow-path" />
|
||||
Reset Form
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generated Charter Output -->
|
||||
<div
|
||||
v-if="charterGenerated"
|
||||
class="relative animate-fadeIn"
|
||||
role="main"
|
||||
aria-label="Generated Technology Charter">
|
||||
<!-- Dithered shadow -->
|
||||
<div
|
||||
class="absolute top-4 left-4 right-0 bottom-0 dither-shadow"></div>
|
||||
|
||||
<!-- Charter container -->
|
||||
<div
|
||||
class="relative bg-white dark:bg-neutral-950 border-2 border-black dark:border-white p-8">
|
||||
<div
|
||||
class="text-center mb-8 pb-6 border-b-2 border-black dark:border-white">
|
||||
<h2
|
||||
class="text-3xl font-bold text-neutral-800"
|
||||
id="charter-title">
|
||||
Technology Charter
|
||||
</h2>
|
||||
<p class="text-neutral-600 mt-2">
|
||||
Generated
|
||||
{{
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="scrollToTop"
|
||||
class="text-sm text-neutral-600 hover:text-neutral-800 underline focus:outline-none focus:ring-2 focus:ring-neutral-500 rounded">
|
||||
Back to form
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose max-w-none">
|
||||
<section class="mb-8">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">Purpose</h3>
|
||||
<p class="text-neutral-700 leading-relaxed">
|
||||
This charter guides our cooperative's technology decisions, so
|
||||
that we can choose tools that don't contradict our values.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mb-8"
|
||||
v-if="
|
||||
Object.keys(principleWeights).filter(
|
||||
(p) =>
|
||||
principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
||||
).length > 0
|
||||
">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||
Core Principles
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="principleId in Object.keys(principleWeights).filter(
|
||||
(p) =>
|
||||
principleWeights[p] > 0 && !nonNegotiables.includes(p)
|
||||
)"
|
||||
:key="principleId"
|
||||
class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span>{{
|
||||
principles.find((p) => p.id === principleId)?.name
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8" v-if="nonNegotiables.length > 0">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||
Non-Negotiable Requirements
|
||||
</h3>
|
||||
<p class="text-red-600 font-semibold mb-3">
|
||||
Any vendor failing these requirements is automatically
|
||||
disqualified.
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="principleId in nonNegotiables"
|
||||
:key="principleId"
|
||||
class="flex items-start text-red-600 font-semibold">
|
||||
<span class="mr-2">→</span>
|
||||
<span>{{
|
||||
principles.find((p) => p.id === principleId)?.name
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||
Technical Constraints
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Authentication:
|
||||
{{
|
||||
authOptions.find((o) => o.value === constraints.sso)
|
||||
?.label
|
||||
}}</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Hosting:
|
||||
{{
|
||||
hostingOptions.find(
|
||||
(o) => o.value === constraints.hosting
|
||||
)?.label
|
||||
}}</span
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
v-if="constraints.integrations.length > 0"
|
||||
class="flex items-start">
|
||||
<span class="text-purple-600 mr-2">→</span>
|
||||
<span
|
||||
>Required Integrations:
|
||||
{{ constraints.integrations.join(", ") }}</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Support Level:
|
||||
{{
|
||||
supportOptions.find(
|
||||
(o) => o.value === constraints.support
|
||||
)?.label
|
||||
}}</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Migration Timeline:
|
||||
{{
|
||||
timelineOptions.find(
|
||||
(o) => o.value === constraints.timeline
|
||||
)?.label
|
||||
}}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||
Evaluation Rubric
|
||||
</h3>
|
||||
<p class="text-neutral-700 mb-4">
|
||||
Score each vendor option using these weighted criteria (0-5
|
||||
scale):
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-neutral-100">
|
||||
<th
|
||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
|
||||
Criterion
|
||||
</th>
|
||||
<th
|
||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-left">
|
||||
Description
|
||||
</th>
|
||||
<th
|
||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center">
|
||||
Weight
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="weight in sortedWeights"
|
||||
:key="weight.id"
|
||||
class="hover:bg-neutral-50">
|
||||
<td
|
||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 font-semibold">
|
||||
{{ weight.name }}
|
||||
</td>
|
||||
<td
|
||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-sm text-neutral-600">
|
||||
{{ weight.rubricDescription }}
|
||||
</td>
|
||||
<td
|
||||
class="border-2 border-neutral-900 dark:border-neutral-100 p-3 text-center font-bold text-neutral-600">
|
||||
{{ principleWeights[weight.id] }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||
Decision Heuristics
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Any vendor failing a non-negotiable requirement is
|
||||
automatically eliminated</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Prefer open standards and clear data export over feature
|
||||
abundance</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>When scores are within 10%, choose based on alignment
|
||||
with cooperative values</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Document all decisions in the Vendor Decision Log for
|
||||
transparency</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||
Procurement Process
|
||||
</h3>
|
||||
<ol class="space-y-2 list-decimal list-inside">
|
||||
<li>Identify need through collective discussion</li>
|
||||
<li>Research 3-5 potential vendors/solutions</li>
|
||||
<li>Eliminate any failing non-negotiables</li>
|
||||
<li>Score remaining options using rubric</li>
|
||||
<li>Trial top 2 options if possible</li>
|
||||
<li>Make collective decision with documented rationale</li>
|
||||
<li>Create migration/exit plan before commitment</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h3 class="text-xl font-bold text-neutral-800 mb-3">
|
||||
Review & Accountability
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span>Review this charter annually at minimum</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span>Audit existing tools against charter quarterly</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Document any exceptions with clear justification</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-neutral-600 mr-2">→</span>
|
||||
<span
|
||||
>Share learnings with other cooperatives in our
|
||||
network</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options - Bottom -->
|
||||
<ExportOptions
|
||||
:export-data="exportData"
|
||||
filename="tech-charter"
|
||||
title="Technology Charter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
// State
|
||||
const charterPurpose = ref(
|
||||
"This charter guides our cooperative's technology decisions, so that we can choose tools that don't contradict our values."
|
||||
);
|
||||
const principleWeights = ref({});
|
||||
const nonNegotiables = ref([]);
|
||||
const charterGenerated = ref(false);
|
||||
|
||||
const constraints = ref({
|
||||
sso: "optional",
|
||||
hosting: "either",
|
||||
integrations: [],
|
||||
support: "business",
|
||||
timeline: "quarter",
|
||||
});
|
||||
|
||||
// Data - Unified principles with rubric descriptions
|
||||
const principles = [
|
||||
{
|
||||
id: "privacy",
|
||||
name: "Privacy and data control",
|
||||
description: "Data minimization, encryption, sovereignty, and user consent",
|
||||
rubricDescription:
|
||||
"Data collection practices, encryption standards, jurisdiction control",
|
||||
defaultWeight: 4,
|
||||
},
|
||||
{
|
||||
id: "accessibility",
|
||||
name: "Universal access",
|
||||
description: "WCAG compliance, screen readers, keyboard navigation",
|
||||
rubricDescription: "WCAG 2.2 AA, keyboard nav, screen reader support",
|
||||
defaultWeight: 5,
|
||||
},
|
||||
{
|
||||
id: "portability",
|
||||
name: "Data freedom",
|
||||
description: "Easy export, no vendor lock-in, migration-friendly",
|
||||
rubricDescription:
|
||||
"Export capabilities, proprietary formats, switching costs",
|
||||
defaultWeight: 4,
|
||||
},
|
||||
{
|
||||
id: "opensource",
|
||||
name: "Open source and community",
|
||||
description:
|
||||
"FOSS preference, transparent development, community governance",
|
||||
rubricDescription: "License type, community involvement, code transparency",
|
||||
defaultWeight: 3,
|
||||
},
|
||||
{
|
||||
id: "sustainability",
|
||||
name: "Sustainable operations",
|
||||
description: "Predictable costs, green hosting, efficient resource use",
|
||||
rubricDescription:
|
||||
"Total cost of ownership, carbon footprint, resource efficiency",
|
||||
defaultWeight: 3,
|
||||
},
|
||||
{
|
||||
id: "localization",
|
||||
name: "Local support",
|
||||
description: "Multi-language, timezone aware, cultural sensitivity",
|
||||
rubricDescription: "Language options, cultural awareness, regional support",
|
||||
defaultWeight: 2,
|
||||
},
|
||||
{
|
||||
id: "usability",
|
||||
name: "User experience",
|
||||
description:
|
||||
"Intuitive interface, minimal learning curve, daily efficiency",
|
||||
rubricDescription:
|
||||
"Onboarding time, user satisfaction, workflow integration",
|
||||
defaultWeight: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const authOptions = [
|
||||
{ value: "required", label: "SSO required" },
|
||||
{ value: "preferred", label: "SSO preferred" },
|
||||
{ value: "optional", label: "SSO optional" },
|
||||
];
|
||||
|
||||
const hostingOptions = [
|
||||
{ value: "self", label: "Self-hosted only" },
|
||||
{ value: "either", label: "Either" },
|
||||
{ value: "managed", label: "Managed only" },
|
||||
];
|
||||
|
||||
const integrationOptions = ["Slack", "OIDC/OAuth", "Webhooks", "REST API"];
|
||||
|
||||
const supportOptions = [
|
||||
{ value: "community", label: "Community only OK" },
|
||||
{ value: "business", label: "Business hours" },
|
||||
{ value: "24-7", label: "24/7 required" },
|
||||
];
|
||||
|
||||
const timelineOptions = [
|
||||
{ value: "immediate", label: "This month" },
|
||||
{ value: "quarter", label: "This quarter" },
|
||||
{ value: "year", label: "This year" },
|
||||
{ value: "exploring", label: "Just exploring" },
|
||||
];
|
||||
|
||||
// Computed
|
||||
const sortedWeights = computed(() => {
|
||||
return principles
|
||||
.filter((p) => principleWeights.value[p.id] > 0)
|
||||
.sort(
|
||||
(a, b) => principleWeights.value[b.id] - principleWeights.value[a.id]
|
||||
);
|
||||
});
|
||||
|
||||
const canGenerateCharter = computed(() => {
|
||||
// At least one principle must have weight > 0
|
||||
return Object.values(principleWeights.value).some((w) => w > 0);
|
||||
});
|
||||
|
||||
const selectedPrincipleCount = computed(() => {
|
||||
return Object.values(principleWeights.value).filter((w) => w > 0).length;
|
||||
});
|
||||
|
||||
// Export data for the ExportOptions component
|
||||
const exportData = computed(() => ({
|
||||
charterPurpose: charterPurpose.value,
|
||||
principleWeights: principleWeights.value,
|
||||
nonNegotiables: nonNegotiables.value,
|
||||
constraints: constraints.value,
|
||||
principles: principles.filter((p) => principleWeights.value[p.id] > 0),
|
||||
sortedWeights: sortedWeights.value,
|
||||
summary: {
|
||||
selectedPrincipleCount: selectedPrincipleCount.value,
|
||||
nonNegotiableCount: nonNegotiables.value.length,
|
||||
canGenerateCharter: canGenerateCharter.value,
|
||||
},
|
||||
exportedAt: new Date().toISOString(),
|
||||
section: "tech-charter",
|
||||
}));
|
||||
|
||||
// Methods
|
||||
const setPrincipleWeight = (principleId, weight) => {
|
||||
principleWeights.value[principleId] = weight;
|
||||
|
||||
// If setting to 0, remove from non-negotiables
|
||||
if (weight === 0) {
|
||||
const idx = nonNegotiables.value.indexOf(principleId);
|
||||
if (idx !== -1) {
|
||||
nonNegotiables.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getWeightLabel = (weight) => {
|
||||
const labels = {
|
||||
0: "Excluded",
|
||||
1: "Low",
|
||||
2: "Medium-Low",
|
||||
3: "Medium",
|
||||
4: "High",
|
||||
5: "Critical",
|
||||
};
|
||||
return labels[weight] || "";
|
||||
};
|
||||
|
||||
const toggleNonNegotiable = (principleId) => {
|
||||
const idx = nonNegotiables.value.indexOf(principleId);
|
||||
if (idx === -1) {
|
||||
nonNegotiables.value.push(principleId);
|
||||
} else {
|
||||
nonNegotiables.value.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIntegration = (integration) => {
|
||||
const idx = constraints.value.integrations.indexOf(integration);
|
||||
if (idx === -1) {
|
||||
constraints.value.integrations.push(integration);
|
||||
} else {
|
||||
constraints.value.integrations.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIntegrationCheckbox = (integration, checked) => {
|
||||
if (checked) {
|
||||
if (!constraints.value.integrations.includes(integration)) {
|
||||
constraints.value.integrations.push(integration);
|
||||
}
|
||||
} else {
|
||||
const idx = constraints.value.integrations.indexOf(integration);
|
||||
if (idx !== -1) {
|
||||
constraints.value.integrations.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNonNegotiableCheckbox = (principleId, checked) => {
|
||||
if (checked) {
|
||||
if (!nonNegotiables.value.includes(principleId)) {
|
||||
nonNegotiables.value.push(principleId);
|
||||
}
|
||||
} else {
|
||||
const idx = nonNegotiables.value.indexOf(principleId);
|
||||
if (idx !== -1) {
|
||||
nonNegotiables.value.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
if (confirm("Are you sure you want to clear all form data and start over?")) {
|
||||
charterPurpose.value =
|
||||
"This charter guides our cooperative's technology decisions, so that we can choose tools that don't contradict our values.";
|
||||
// Reset all principle weights to 0
|
||||
principles.forEach((p) => {
|
||||
principleWeights.value[p.id] = 0;
|
||||
});
|
||||
nonNegotiables.value = [];
|
||||
constraints.value = {
|
||||
sso: "optional",
|
||||
hosting: "either",
|
||||
integrations: [],
|
||||
support: "business",
|
||||
timeline: "quarter",
|
||||
};
|
||||
localStorage.removeItem("tech-charter-data");
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
document
|
||||
.querySelector(".template-wrapper")
|
||||
.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Load saved data
|
||||
const loadSavedData = () => {
|
||||
const saved = localStorage.getItem("tech-charter-data");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsedData = JSON.parse(saved);
|
||||
if (parsedData.charterPurpose !== undefined) {
|
||||
charterPurpose.value = parsedData.charterPurpose;
|
||||
}
|
||||
principleWeights.value = parsedData.principleWeights || {};
|
||||
nonNegotiables.value = parsedData.nonNegotiables || [];
|
||||
constraints.value = { ...constraints.value, ...parsedData.constraints };
|
||||
} catch (error) {
|
||||
console.error("Error loading saved data:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-save data
|
||||
const autoSave = () => {
|
||||
const data = {
|
||||
charterPurpose: charterPurpose.value,
|
||||
principleWeights: principleWeights.value,
|
||||
nonNegotiables: nonNegotiables.value,
|
||||
constraints: constraints.value,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem("tech-charter-data", JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Load data on mount
|
||||
onMounted(() => {
|
||||
// Initialize all principle weights to 0
|
||||
principles.forEach((p) => {
|
||||
principleWeights.value[p.id] = 0;
|
||||
});
|
||||
loadSavedData();
|
||||
});
|
||||
|
||||
// Auto-save when data changes
|
||||
watch(
|
||||
[charterPurpose, principleWeights, nonNegotiables, constraints],
|
||||
autoSave,
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* Template-specific styles not in main.css */
|
||||
|
||||
.section-card {
|
||||
@apply mb-8 relative;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
204
pages/tools/wizards.vue
Normal file
204
pages/tools/wizards.vue
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8">
|
||||
<div class="max-w-6xl mx-auto px-4 relative">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Wizards
|
||||
</h1>
|
||||
<p class="text-neutral-700 dark:text-neutral-200">
|
||||
Fillable forms for cooperative documents. Data saves locally in your
|
||||
browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="template-card h-full flex flex-col">
|
||||
<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 h-full flex flex-col">
|
||||
<div class="mb-4">
|
||||
<h3
|
||||
class="text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
{{ template.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
{{ template.description }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<span
|
||||
v-for="tag in template.tags"
|
||||
:key="tag"
|
||||
class="px-2 py-1 text-xs font-medium bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-900 border border-black dark:border-white dither-tag">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-neutral-700 dark:text-neutral-200 mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>{{ template.estimatedTime }}</span>
|
||||
<span>{{ template.fields }} fields</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex gap-2 mt-auto">
|
||||
<NuxtLink
|
||||
:to="template.path"
|
||||
class="flex-1 px-4 py-2 bg-black dark:bg-white text-white dark:text-black text-center font-medium tracking-wider hover:underline"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
START WIZARD
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="hasData(template.id)"
|
||||
:to="template.path"
|
||||
class="px-4 py-2 bg-white dark:bg-neutral-950 text-black dark:text-white border border-black dark:border-white hover:bg-white dark:hover:bg-neutral-950 transition-colors bitmap-button"
|
||||
title="Continue from saved data"
|
||||
style="font-family: 'Ubuntu Mono', monospace">
|
||||
RESUME
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const templates = [
|
||||
{
|
||||
id: "membership-agreement",
|
||||
name: "Membership Agreement",
|
||||
description:
|
||||
"An agreement outlining member rights, responsibilities, decision-making processes, and financial arrangements.",
|
||||
icon: "i-heroicons-user-group",
|
||||
path: "/templates/membership-agreement",
|
||||
tags: ["Legal", "Governance", "Membership"],
|
||||
estimatedTime: "15-30 min",
|
||||
fields: 25,
|
||||
storageKey: "membership-agreement-data",
|
||||
},
|
||||
{
|
||||
id: "conflict-resolution-framework",
|
||||
name: "Conflict Resolution",
|
||||
description:
|
||||
"A framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
|
||||
icon: "i-heroicons-scale",
|
||||
path: "/templates/conflict-resolution-framework",
|
||||
tags: ["Governance", "Process", "Care"],
|
||||
estimatedTime: "20-40 min",
|
||||
fields: 35,
|
||||
storageKey: "conflict-resolution-framework-data",
|
||||
},
|
||||
{
|
||||
id: "tech-charter",
|
||||
name: "Technology Charter",
|
||||
description:
|
||||
"How do you decide what technology and tools align with your values? This wizard helps you define principles, technical constraints, and evaluation criteria for tech selection.",
|
||||
icon: "i-heroicons-cog-6-tooth",
|
||||
path: "/templates/tech-charter",
|
||||
tags: ["Technology", "Decision-Making", "Governance"],
|
||||
estimatedTime: "10-20 min",
|
||||
fields: 20,
|
||||
storageKey: "tech-charter-data",
|
||||
},
|
||||
{
|
||||
id: "decision-framework",
|
||||
name: "Decision Framework Helper",
|
||||
description:
|
||||
"Need help deciding how to decide? This wizard guides you towards a decision-making approach based on urgency, expertise, stakes, and team dynamics.",
|
||||
icon: "i-heroicons-light-bulb",
|
||||
path: "/templates/decision-framework",
|
||||
tags: ["Decision-Making", "Process", "Governance"],
|
||||
estimatedTime: "5-10 min",
|
||||
fields: 7,
|
||||
storageKey: "decision-framework-data",
|
||||
},
|
||||
];
|
||||
|
||||
const hasData = (templateId) => {
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (!template?.storageKey) return false;
|
||||
if (process.client) {
|
||||
const saved = localStorage.getItem(template.storageKey);
|
||||
return saved && saved !== "{}";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: "Wizards - Co-op Pay & Value Tool",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Interactive wizards for worker cooperatives including membership agreements and governance documents.",
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
|
||||
.bitmap-button {
|
||||
font-family: "Ubuntu Mono", monospace !important;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bitmap-button:hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
border: 1px solid black;
|
||||
background: white;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue