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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue