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:
Jennie Robinson Faber 2025-09-11 11:51:48 +01:00
parent b6e8d3b7ec
commit 78af43770c
29 changed files with 1699 additions and 1990 deletions

30
app.vue
View file

@ -15,7 +15,8 @@
Urgent Tools
</h1>
</NuxtLink>
<div class="absolute right-0">
<div class="absolute right-0 flex items-center gap-2">
<CurrencySelector />
<ColorModeToggle />
</div>
</div>
@ -24,7 +25,7 @@
role="navigation"
aria-label="Main navigation">
<NuxtLink
to="/coop-planner"
to="/tools/coop-planner"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{
'bg-neutral-100 dark:bg-neutral-800': isCoopBuilderSection,
@ -42,20 +43,20 @@
Coach
</NuxtLink> -->
<NuxtLink
to="/wizards"
to="/tools/wizards"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{
'bg-neutral-100 dark:bg-neutral-800':
$route.path === '/wizards',
$route.path === '/tools/wizards',
}">
Wizards
</NuxtLink>
<NuxtLink
to="/resources"
to="/tools/resources"
class="px-3 py-2 text-sm text-black dark:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-md transition-colors"
:class="{
'bg-neutral-100 dark:bg-neutral-800':
$route.path === '/resources',
$route.path === '/tools/resources',
}">
More Resources & Templates
</NuxtLink>
@ -72,6 +73,7 @@
<NuxtPage />
</template>
</NuxtLayout>
<AppFooter />
<NuxtRouteAnnouncer />
</div>
</UApp>
@ -81,18 +83,16 @@
const route = useRoute();
const isCoopBuilderSection = computed(
() =>
route.path === "/coop-planner" ||
route.path === "/coop-builder" ||
route.path === "/" ||
route.path === "/mix" ||
route.path === "/budget" ||
route.path === "/project-budget" ||
route.path === "/settings" ||
route.path === "/glossary"
route.path === "/tools/coop-planner" ||
route.path === "/tools/coop-builder" ||
route.path === "/tools" ||
route.path === "/tools/mix" ||
route.path === "/tools/budget" ||
route.path === "/tools/project-budget"
);
const isWizardSection = computed(
() => route.path === "/wizards" || route.path.startsWith("/templates/")
() => route.path === "/tools/wizards" || route.path.startsWith("/tools/templates/")
);
// Run migrations on app startup

View file

@ -19,7 +19,9 @@ h1, h2, h3, h4, h5, h6 {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
a {
@apply underline;
}
/* =========================
TEMPLATE DOCUMENT LAYOUT
@ -124,7 +126,7 @@ html.dark .section-card::before {
}
.inline-field {
@apply inline-block mx-1 min-w-[120px] border-none bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-2 py-1 rounded;
@apply inline-block mx-1;
}
.inline-field:focus {

View file

@ -85,13 +85,6 @@
v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(", ") }}
</p>
<p class="text-xs text-black dark:text-white">
<NuxtLink
to="/help#revenue-diversification"
class="text-white dark:text-neutral-100 hover:text-white dark:hover:text-white underline">
Learn how to develop these revenue streams
</NuxtLink>
</p>
</div>
</td>
</tr>

57
components/AppFooter.vue Normal file
View file

@ -0,0 +1,57 @@
<template>
<footer class="bg-neutral-50 dark:bg-neutral-900 border-t border-neutral-200 dark:border-neutral-800 mt-20">
<UContainer>
<div class="py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div class="col-span-1 md:col-span-2">
<h3 class="text-lg font-mono uppercase font-bold text-black dark:text-white mb-4">
Urgent Tools
</h3>
<p class="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Tools for cooperative planning, budgeting, and resource management.
</p>
</div>
<div>
<h4 class="text-sm font-semibold text-black dark:text-white mb-3">Tools</h4>
<ul class="space-y-2">
<li>
<NuxtLink to="/tools/coop-planner" class="text-sm text-neutral-600 dark:text-neutral-400 hover:text-black dark:hover:text-white transition-colors">
Budget Builder
</NuxtLink>
</li>
<li>
<NuxtLink to="/tools/wizards" class="text-sm text-neutral-600 dark:text-neutral-400 hover:text-black dark:hover:text-white transition-colors">
Wizards
</NuxtLink>
</li>
<li>
<NuxtLink to="/tools/resources" class="text-sm text-neutral-600 dark:text-neutral-400 hover:text-black dark:hover:text-white transition-colors">
Resources & Templates
</NuxtLink>
</li>
</ul>
</div>
</div>
<USeparator class="my-8" />
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-xs text-neutral-500 dark:text-neutral-500">
© {{ currentYear }} Urgent Tools. All rights reserved.
</p>
<div class="flex items-center gap-4">
<a href="https://github.com" target="_blank" rel="noopener noreferrer" class="text-neutral-500 dark:text-neutral-500 hover:text-black dark:hover:text-white transition-colors">
<UIcon name="i-simple-icons-github" class="w-5 h-5" />
</a>
</div>
</div>
</div>
</UContainer>
</footer>
</template>
<script setup lang="ts">
const currentYear = computed(() => new Date().getFullYear())
</script>

View file

@ -30,17 +30,17 @@ const coopBuilderItems = [
{
id: "coop-builder",
name: "Settings",
path: "/coop-builder",
path: "/tools/coop-builder",
},
{
id: "budget",
name: "Studio Budget",
path: "/budget",
path: "/tools/budget",
},
{
id: "project-budget",
name: "Project Budget",
path: "/project-budget",
path: "/tools/project-budget",
},
];

View file

@ -0,0 +1,40 @@
<template>
<USelectMenu
v-model="selectedCurrency"
:items="selectOptions"
value-key="value"
:ui="{
trigger: 'flex items-center gap-1.5 text-sm px-2.5 py-1.5 bg-white dark:bg-neutral-950 border border-neutral-200 dark:border-neutral-800 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors',
width: 'w-auto min-w-[120px]'
}"
size="xs"
variant="ghost"
color="neutral"
>
<template #label>
<span class="text-xs font-mono">{{ getCurrencySymbol(selectedCurrency) }} {{ selectedCurrency }}</span>
</template>
</USelectMenu>
</template>
<script setup lang="ts">
import { currencyOptions, getCurrencySymbol } from '~/utils/currency'
const { currency } = useCoopBuilder()
// Create options for USelectMenu
const selectOptions = computed(() =>
currencyOptions.map((curr) => ({
label: `${curr.symbol} ${curr.code}`,
value: curr.code,
}))
)
// Two-way binding with the store
const selectedCurrency = computed({
get: () => currency.value,
set: (value: string) => {
currency.value = value
}
})
</script>

View file

@ -958,475 +958,13 @@ const formatMembershipAgreementAsMarkdown = (data: any): string => {
// Conflict Resolution Framework formatting - Complete document with all static and dynamic content
const formatConflictResolutionAsText = (data: any): string => {
const formData = data.formData || {};
const cooperativeName = formData.orgName || "[Cooperative Name]";
let content = `${cooperativeName.toUpperCase()} CONFLICT RESOLUTION POLICY\n${"=".repeat(
cooperativeName.length + 30
)}\n\n`;
content += `Framework Created: ${
formData.createdDate || new Date().toISOString().split("T")[0]
}\n`;
if (formData.reviewDate) {
content += `Next Review: ${formData.reviewDate}\n`;
}
content += `\n`;
// Core Values section (if enabled)
if (data.sectionsEnabled?.values !== false) {
content += `OUR VALUES\n----------\n\n`;
content += `This conflict resolution framework is guided by our core values:\n\n`;
if (formData.coreValuesList && formData.coreValuesList.length > 0) {
formData.coreValuesList.forEach((value: string) => {
content += `${value}\n`;
});
content += `\n`;
}
if (formData.customValues) {
content += `${formData.customValues}\n\n`;
}
}
// Resolution Philosophy
const approachDescriptions: { [key: string]: string } = {
restorative:
"We use a restorative/loving justice approach that focuses on healing, understanding root causes, and repairing relationships rather than punishment.",
mediation:
"We use a mediation-first approach where neutral third-party facilitators help parties dialogue and find solutions.",
progressive:
"We use progressive discipline with clear escalation steps and defined consequences for violations.",
hybrid:
"We use a hybrid approach that combines multiple methods based on the type and severity of conflict.",
};
if (formData.approach && approachDescriptions[formData.approach]) {
content += `OUR APPROACH\n------------\n\n`;
content += `${approachDescriptions[formData.approach]}\n\n`;
content += `We do our best to resolve conflicts at the lowest possible escalation step (direct resolution), but agree to escalate conflicts (to assisted resolution) if they are not resolved.\n\n`;
}
// Reflection Process (if enabled)
if (data.sectionsEnabled?.reflection !== false) {
content += `REFLECTION\n----------\n\n`;
content += `Before engaging in direct resolution, we encourage taking time for reflection:\n\n`;
content += `1. Set aside time to think through what happened. What was the other person's behaviour? How did it affect you? Distinguish other people's actions from your feelings about them.\n`;
content += `2. Consider uncertainties or misunderstandings that may have occurred.\n`;
content += `3. Distinguish disagreement from personal hostility. Disagreement and dissent are part of healthy discussion. Hostility is not.\n`;
content += `4. Use your personal support system (friends, family, therapist, etc.) to work through and clarify your perspective.\n`;
content += `5. Ask yourself what part you played, how you could have behaved differently, and what your needs are.\n\n`;
if (formData.customReflectionPrompts) {
content += `Additional Reflection Prompts:\n${formData.customReflectionPrompts}\n\n`;
}
const reflectionTiming =
formData.reflectionPeriod || "Before any escalation";
content += `Reflection Timing: ${reflectionTiming}\n\n`;
}
// Direct Resolution (if enabled)
if (data.sectionsEnabled?.directResolution !== false) {
content += `DIRECT RESOLUTION\n-----------------\n\n`;
content += `A direct resolution process occurs when individuals communicate their concerns and work together to resolve disputes without filing an informal or formal complaint.\n\n`;
content += `Have a Conversation\n-------------------\n\n`;
content += `When there is a disagreement, the involved people should first communicate with each other about their concerns.\n\n`;
content += `1. Choose a time and place to meet that is private and agreeable to both.\n`;
content += `2. Allow reasonable time for the conversation.\n`;
content += `3. The point is mutual understanding, not determining who is right or wrong. This requires patience and willingness to listen without immediately dismissing the other person's perspective.\n`;
content += `4. Express thoughts and feelings directly without belittling or dismissing. Use "I" statements and active listening techniques.\n`;
content += `5. Communicate your wants and needs and make offers and requests.\n`;
content += `6. Learn for the future. Ask questions like, "If what I/you said or did came across that way, what can we do to prevent this from happening in the future?"\n`;
if (formData.documentDirectResolution) {
content += `7. Keep a written record of the resolution agreed to by both parties.\n\n`;
} else {
content += `\n`;
}
// Communication Channels
if (formData.channelsList && formData.channelsList.length > 0) {
content += `Escalating Communication Bandwidth\n-----------------------------------\n\n`;
content += `Whenever a misunderstanding or conflict arises, escalate the bandwidth of the channel:\n\n`;
formData.channelsList.forEach((channel: string, index: number) => {
content += `${index + 1}. ${channel}\n`;
});
content += `\n`;
}
if (formData.requireDirectAttempt) {
content += `NOTE: Direct resolution must be attempted before escalating to assisted resolution, unless safety concerns prevent this.\n\n`;
}
}
// Assisted Resolution
content += `ASSISTED RESOLUTION\n-------------------\n\n`;
content += `If talking things out doesn't work, you can ask a responsible contact person for help in writing.\n\n`;
// Responsible Contact People
if (formData.receiversList && formData.receiversList.length > 0) {
content += `Initial Contact Options:\n`;
formData.receiversList.forEach((receiver: string) => {
content += `${receiver}\n`;
});
content += `\n`;
}
// Mediator Structure
if (formData.mediatorType) {
content += `Mediation/Facilitation Structure: ${formData.mediatorType}\n\n`;
if (formData.supportPeople) {
content += `Support People: Parties may bring a trusted person for emotional support during mediation sessions.\n\n`;
}
}
// Timeline
content += `Response Times:\n`;
if (formData.initialResponse) {
content += `• Initial Response: ${formData.initialResponse}\n`;
}
if (formData.resolutionTarget) {
content += `• Target Resolution: ${formData.resolutionTarget}\n\n`;
}
// Formal Complaints
content += `FORMAL COMPLAINTS\n-----------------\n\n`;
content += `If assisted resolution efforts do not result in an acceptable outcome within a reasonable timeframe, a formal complaint may be filed in writing.\n\n`;
// Required Elements
if (
formData.complaintElementsList &&
formData.complaintElementsList.length > 0
) {
content += `Written Complaint Requirements:\n`;
formData.complaintElementsList.forEach((element: string, index: number) => {
content += `${index + 1}. ${element}\n`;
});
content += `\n`;
}
// Formal Process Timeline
content += `Formal Process Timeline:\n`;
if (formData.formalAcknowledgmentTime) {
content += `• Acknowledgment: ${formData.formalAcknowledgmentTime}\n`;
}
if (formData.formalReviewTime) {
content += `• Review Completion: ${formData.formalReviewTime}\n\n`;
}
if (formData.requireExternalAdvice) {
content += `External Expertise: For complex complaints involving multiple parties or organizational leaders, external legal advice will be sought.\n\n`;
}
// Settlement Documentation
if (formData.requireMinutesOfSettlement) {
content += `Reaching Agreement\n------------------\n\n`;
content += `Any resolution agreed upon must be documented in "Minutes of Settlement" signed by both parties. These agreements will be kept confidential according to our privacy standards.\n\n`;
}
// Consequences and Actions
if (formData.actionsList && formData.actionsList.length > 0) {
content += `POSSIBLE OUTCOMES\n-----------------\n\n`;
content += `Depending on the situation, resolution may include:\n\n`;
formData.actionsList.forEach((action: string) => {
content += `${action}\n`;
});
content += `\n`;
}
if (formData.appealProcess) {
content += `Appeals Process: Parties may request review of decisions through our appeals process.\n\n`;
}
// Documentation and Privacy
if (data.sectionsEnabled?.documentation !== false) {
content += `DOCUMENTATION & PRIVACY\n-----------------------\n\n`;
if (formData.docLevel) {
content += `Documentation Level: ${formData.docLevel}\n\n`;
}
if (formData.confidentiality) {
content += `Confidentiality: ${formData.confidentiality}\n\n`;
}
if (formData.retention) {
content += `Record Retention: ${formData.retention}\n\n`;
}
}
// External Resources (if enabled)
if (data.sectionsEnabled?.externalResources !== false) {
content += `EXTERNAL RESOURCES\n------------------\n\n`;
if (formData.includeHumanRights) {
content += `Individuals who are not satisfied with the outcome of a harassment or discrimination complaint may file a complaint with the Canadian Human Rights Commission or their provincial human rights tribunal.\n\n`;
}
if (formData.additionalResources) {
content += `Additional Resources:\n${formData.additionalResources}\n\n`;
}
}
// Implementation
content += `POLICY MANAGEMENT\n-----------------\n\n`;
if (formData.training) {
content += `Training Requirements:\n${formData.training}\n\n`;
}
content += `Review and Updates:\n`;
if (formData.reviewSchedule) {
content += `This policy will be reviewed ${formData.reviewSchedule.toLowerCase()}.\n\n`;
}
if (formData.amendments) {
content += `Amendment Process: ${formData.amendments}\n\n`;
}
// Acknowledgments
if (formData.acknowledgments) {
content += `Acknowledgments:\n${formData.acknowledgments}\n\n`;
}
return content;
// Use the pre-generated content from the Vue component
return data.content || "No content available";
};
const formatConflictResolutionAsMarkdown = (data: any): string => {
const formData = data.formData || {};
const cooperativeName = formData.orgName || "[Cooperative Name]";
let content = `# ${cooperativeName} Conflict Resolution Policy\n\n`;
content += `*Framework Created: ${
formData.createdDate || new Date().toISOString().split("T")[0]
}*\n`;
if (formData.reviewDate) {
content += `*Next Review: ${formData.reviewDate}*\n`;
}
content += `\n---\n\n`;
// Core Values section (if enabled)
if (data.sectionsEnabled?.values !== false) {
content += `## Our Values\n\n`;
content += `This conflict resolution framework is guided by our core values:\n\n`;
if (formData.coreValuesList && formData.coreValuesList.length > 0) {
formData.coreValuesList.forEach((value: string) => {
content += `- **${value}**\n`;
});
content += `\n`;
}
if (formData.customValues) {
content += `${formData.customValues}\n\n`;
}
}
// Resolution Philosophy
const approachDescriptions: { [key: string]: string } = {
restorative:
"We use a **restorative/loving justice** approach that focuses on healing, understanding root causes, and repairing relationships rather than punishment.",
mediation:
"We use a **mediation-first** approach where neutral third-party facilitators help parties dialogue and find solutions.",
progressive:
"We use **progressive discipline** with clear escalation steps and defined consequences for violations.",
hybrid:
"We use a **hybrid approach** that combines multiple methods based on the type and severity of conflict.",
};
if (formData.approach && approachDescriptions[formData.approach]) {
content += `## Our Approach\n\n`;
content += `${approachDescriptions[formData.approach]}\n\n`;
content += `We do our best to resolve conflicts at the lowest possible escalation step (direct resolution), but agree to escalate conflicts (to assisted resolution) if they are not resolved.\n\n`;
}
// Reflection Process (if enabled)
if (data.sectionsEnabled?.reflection !== false) {
content += `## Reflection\n\n`;
content += `Before engaging in direct resolution, we encourage taking time for reflection:\n\n`;
content += `1. **Set aside time to think** through what happened. What was the other person's behaviour? How did it affect you? *Distinguish other people's **actions** from your **feelings** about them.*\n`;
content += `2. **Consider uncertainties** or misunderstandings that may have occurred.\n`;
content += `3. **Distinguish disagreement from personal hostility.** Disagreement and dissent are part of healthy discussion. Hostility is not.\n`;
content += `4. **Use your personal support system** (friends, family, therapist, etc.) to work through and clarify your perspective.\n`;
content += `5. **Ask yourself** what part you played, how you could have behaved differently, and what your needs are.\n\n`;
if (formData.customReflectionPrompts) {
content += `### Additional Reflection Prompts\n\n`;
content += `${formData.customReflectionPrompts}\n\n`;
}
const reflectionTiming =
formData.reflectionPeriod || "Before any escalation";
content += `**Reflection Timing:** ${reflectionTiming}\n\n`;
}
// Direct Resolution (if enabled)
if (data.sectionsEnabled?.directResolution !== false) {
content += `## Direct Resolution\n\n`;
content += `A *direct resolution* process occurs when individuals communicate their concerns and work together to resolve disputes without filing an informal or formal complaint.\n\n`;
content += `### Have a Conversation\n\n`;
content += `When there is a disagreement, the involved people should first **communicate with each other** about their concerns.\n\n`;
content += `1. **Choose a time and place** to meet that is private and agreeable to both.\n`;
content += `2. **Allow reasonable time** for the conversation.\n`;
content += `3. **The point is mutual understanding**, not determining who is right or wrong. This requires patience and willingness to listen without immediately dismissing the other person's perspective.\n`;
content += `4. **Express thoughts and feelings directly** without belittling or dismissing. Use "I" statements and active listening techniques.\n`;
content += `5. **Communicate your wants and needs** and make offers and requests.\n`;
content += `6. **Learn for the future.** Ask questions like, "If what I/you said or did came across that way, what can we do to prevent this from happening in the future?"\n`;
if (formData.documentDirectResolution) {
content += `7. **Keep a written record** of the resolution agreed to by both parties.\n\n`;
} else {
content += `\n`;
}
// Communication Channels
if (formData.channelsList && formData.channelsList.length > 0) {
content += `### Escalating Communication Bandwidth\n\n`;
content += `Whenever a misunderstanding or conflict arises, **escalate the bandwidth of the channel**:\n\n`;
formData.channelsList.forEach((channel: string, index: number) => {
content += `${index + 1}. ${channel}\n`;
});
content += `\n`;
}
if (formData.requireDirectAttempt) {
content += `> **Note:** Direct resolution must be attempted before escalating to assisted resolution, unless safety concerns prevent this.\n\n`;
}
}
// Assisted Resolution
content += `## Assisted Resolution\n\n`;
content += `If talking things out doesn't work, you can ask a responsible contact person for help in writing.\n\n`;
// Responsible Contact People
if (formData.receiversList && formData.receiversList.length > 0) {
content += `### Initial Contact Options\n\n`;
content += `You can report conflicts to any of the following:\n\n`;
formData.receiversList.forEach((receiver: string) => {
content += `- ${receiver}\n`;
});
content += `\n`;
}
// Mediator Structure
if (formData.mediatorType) {
content += `### Mediation/Facilitation\n\n`;
content += `**Structure:** ${formData.mediatorType}\n\n`;
if (formData.supportPeople) {
content += `**Support People:** Parties may bring a trusted person for emotional support during mediation sessions.\n\n`;
}
}
// Timeline
content += `### Response Times\n\n`;
if (formData.initialResponse) {
content += `- **Initial Response:** ${formData.initialResponse}\n`;
}
if (formData.resolutionTarget) {
content += `- **Target Resolution:** ${formData.resolutionTarget}\n\n`;
}
// Formal Complaints
content += `## Formal Complaints\n\n`;
content += `If assisted resolution efforts do not result in an acceptable outcome within a reasonable timeframe, a *formal complaint* may be filed in writing.\n\n`;
// Required Elements
if (
formData.complaintElementsList &&
formData.complaintElementsList.length > 0
) {
content += `### Written Complaint Requirements\n\n`;
content += `The formal complaint must include:\n\n`;
formData.complaintElementsList.forEach((element: string, index: number) => {
content += `${index + 1}. ${element}\n`;
});
content += `\n`;
}
// Formal Process Timeline
content += `### Formal Process Timeline\n\n`;
if (formData.formalAcknowledgmentTime) {
content += `- **Acknowledgment:** ${formData.formalAcknowledgmentTime}\n`;
}
if (formData.formalReviewTime) {
content += `- **Review Completion:** ${formData.formalReviewTime}\n\n`;
}
if (formData.requireExternalAdvice) {
content += `> **External Expertise:** For complex complaints involving multiple parties or organizational leaders, external legal advice will be sought.\n\n`;
}
// Settlement Documentation
if (formData.requireMinutesOfSettlement) {
content += `### Reaching Agreement\n\n`;
content += `Any resolution agreed upon must be documented in "Minutes of Settlement" signed by both parties. These agreements will be kept confidential according to our privacy standards.\n\n`;
}
// Consequences and Actions
if (formData.actionsList && formData.actionsList.length > 0) {
content += `## Possible Outcomes\n\n`;
content += `Depending on the situation, resolution may include:\n\n`;
formData.actionsList.forEach((action: string) => {
content += `- ${action}\n`;
});
content += `\n`;
}
if (formData.appealProcess) {
content += `### Appeals Process\n\n`;
content += `Parties may request review of decisions through our appeals process.\n\n`;
}
// Documentation and Privacy
if (data.sectionsEnabled?.documentation !== false) {
content += `## Documentation & Privacy\n\n`;
if (formData.docLevel) {
content += `**Documentation Level:** ${formData.docLevel}\n\n`;
}
if (formData.confidentiality) {
content += `**Confidentiality:** ${formData.confidentiality}\n\n`;
}
if (formData.retention) {
content += `**Record Retention:** ${formData.retention}\n\n`;
}
}
// External Resources (if enabled)
if (data.sectionsEnabled?.externalResources !== false) {
content += `## External Resources\n\n`;
if (formData.includeHumanRights) {
content += `Individuals who are not satisfied with the outcome of a harassment or discrimination complaint may file a complaint with the [Canadian Human Rights Commission](https://www.chrc-ccdp.gc.ca/eng) or their provincial human rights tribunal.\n\n`;
}
if (formData.additionalResources) {
content += `### Additional Resources\n\n`;
content += `${formData.additionalResources}\n\n`;
}
}
// Implementation
content += `## Policy Management\n\n`;
if (formData.training) {
content += `### Training Requirements\n\n`;
content += `${formData.training}\n\n`;
}
content += `### Review and Updates\n\n`;
if (formData.reviewSchedule) {
content += `This policy will be reviewed ${formData.reviewSchedule.toLowerCase()}.\n\n`;
}
if (formData.amendments) {
content += `**Amendment Process:** ${formData.amendments}\n\n`;
}
// Acknowledgments
if (formData.acknowledgments) {
content += `### Acknowledgments\n\n`;
content += `${formData.acknowledgments}\n\n`;
}
return content;
// Use the pre-generated content from the Vue component
return data.content || "No content available";
};
// Decision Framework formatting

View file

@ -1,51 +0,0 @@
<template>
<UTooltip
:text="definition"
:ui="{
background: 'bg-white dark:bg-neutral-900',
ring: 'ring-1 ring-neutral-200 dark:ring-neutral-800',
rounded: 'rounded-lg',
shadow: 'shadow-lg',
base: 'px-3 py-2 text-sm max-w-xs',
}"
:popper="{ arrow: true }">
<template #text>
<div class="space-y-2">
<div class="font-medium">{{ term }}</div>
<div class="text-neutral-600">{{ definition }}</div>
<NuxtLink
:to="`/glossary#${termId}`"
class="text-primary-600 hover:text-primary-700 text-xs inline-flex items-center gap-1"
@click="$emit('glossary-click')">
<UIcon name="i-heroicons-book-open" class="w-3 h-3" />
See full definition
</NuxtLink>
</div>
</template>
<span
class="underline decoration-dotted decoration-neutral-400 hover:decoration-primary-500 cursor-help"
:class="{ 'font-medium': emphasis }"
:tabindex="0"
:aria-describedby="`tooltip-${termId}`"
@keydown.enter="$emit('glossary-click')"
@keydown.space.prevent="$emit('glossary-click')">
<slot>{{ term }}</slot>
</span>
</UTooltip>
</template>
<script setup lang="ts">
interface Props {
term: string;
termId: string;
definition: string;
emphasis?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
emphasis: false,
});
defineEmits(["glossary-click"]);
</script>

View file

@ -2,7 +2,8 @@
<div class="mx-auto">
<div class="relative">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div class="relative bg-white dark:bg-neutral-950 border-1 border-black dark:border-neutral-400">
<div
class="relative bg-white dark:bg-neutral-950 border-1 border-black dark:border-neutral-400">
<!-- Controls -->
<div
class="p-6 border-b-1 border-black dark:border-neutral-400 bg-neutral-100 dark:bg-neutral-950">
@ -112,7 +113,9 @@
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Tools and software:</span>
<span class="font-mono">{{ currency(toolsSoftware) }}</span>
<span class="font-mono">{{
currency(toolsSoftware)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Licenses, subscriptions, cloud services, development
@ -164,7 +167,9 @@
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold">
<span>Subtotal:</span>
<span class="font-mono">{{ currency(budgetSubtotal) }}</span>
<span class="font-mono">{{
currency(budgetSubtotal)
}}</span>
</div>
<!-- Contingency -->
@ -194,6 +199,20 @@
class="text-black dark:text-white border-t border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-950">
<div class="p-6 text-black dark:text-white">
<h3 class="font-bold mb-4">Break-Even Sketch</h3>
<p class="mb-4">
There are so many ways to guess at the sales figures of published
games. One is to use a review-to-sales ratio, formalized in the
Boxleiter method and later discussed and refined by many others.
<a
href="https://app.sensortower.com/vgi/insights/article/how-to-estimate-steam-video-game-sales"
>VGInsights</a
>
and <a href="https://gamediscover.co/">GameDiscoverCo</a> are good
sources for more information on this method. Your chosen ratio
will vary, typically between 30 and 70 reviews per sale, and is
dependent on factors like genre, pricing, and market conditions at
the time of release.
</p>
<!-- Inputs -->
<div class="flex flex-wrap space-x-4 mb-6">
<div>
@ -253,21 +272,23 @@
<li>
At {{ currency(price) }} per copy after store fees, you'd need
about
<strong>{{ unitsToBreakEven.toLocaleString() }} sales</strong> to
cover this budget.
<strong class="bg-yellow-200 dark:text-black"
>{{ unitsToBreakEven.toLocaleString() }} sales</strong
>
to cover this budget.
</li>
<li>
That's roughly
<strong
>{{ reviewsToBreakEven.toLocaleString() }} Steam reviews</strong
<strong class="bg-yellow-200 dark:text-black"
>{{ reviewsToBreakEven.toLocaleString() }} Steam
reviews</strong
>
( {{ reviewToSales }} sales per review).
</li>
</ul>
<p class="text-base text-neutral-600 dark:text-neutral-400">
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not
included.
Taxes not included.
</p>
</div>
</div>

View file

@ -35,24 +35,7 @@
This hourly rate applies to all paid work in your co-op
</p>
<div class="flex gap-4 items-start">
<!-- Currency Selection -->
<UFormField label="Currency" class="w-1/2">
<USelect
v-model="selectedCurrency"
:items="currencySelectOptions"
placeholder="Select currency"
size="xl"
class="w-full"
@update:model-value="updateCurrency">
<template #leading>
<span class="text-lg">{{
getCurrencySymbol(selectedCurrency)
}}</span>
</template>
</USelect>
</UFormField>
<UFormField label="Hourly Rate" class="w-1/2">
<UFormField label="Hourly Rate" class="w-full">
<UInput
v-model="wageText"
type="text"
@ -62,7 +45,7 @@
@update:model-value="validateAndSaveWage">
<template #leading>
<span class="text-neutral-500 text-xl">{{
getCurrencySymbol(selectedCurrency)
getCurrencySymbol(selectedCurrency.value)
}}</span>
</template>
</UInput>
@ -73,7 +56,7 @@
</template>
<script setup lang="ts">
import { currencyOptions, getCurrencySymbol } from "~/utils/currency";
import { getCurrencySymbol } from "~/utils/currency";
const emit = defineEmits<{
"save-status": [status: "saving" | "saved" | "error"];
@ -87,7 +70,7 @@ const store = useCoopBuilderStore();
const selectedPolicy = ref(coop.policy.value?.relationship || "equal-pay");
const roleBands = ref(coop.policy.value?.roleBands || {});
const wageText = ref(String(store.equalHourlyWage || ""));
const selectedCurrency = ref(coop.currency.value || "EUR");
const selectedCurrency = computed(() => coop.currency.value || "EUR");
function parseNumberInput(val: unknown): number {
if (typeof val === "number") return val;
@ -115,23 +98,11 @@ const policyOptions = [
},
];
// Currency options for USelect (simplified format)
const currencySelectOptions = computed(() =>
currencyOptions.map((currency) => ({
label: `${currency.name} (${currency.code})`,
value: currency.code,
}))
);
// Already initialized above with store values
// Removed uniqueRoles computed - no longer needed with simplified policies
function updateCurrency(value: string) {
selectedCurrency.value = value;
coop.setCurrency(value);
emit("save-status", "saved");
}
function updatePolicy(value: string) {
coop.setPolicy(value as "equal-pay" | "needs-weighted" | "hours-weighted");

View file

@ -1,9 +1,8 @@
<template>
<div class="mb-12">
<div class="">
<div class="w-full mx-auto">
<nav
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center"
>
class="flex flex-wrap items-center space-x-1 font-mono uppercase justify-self-center">
<NuxtLink
v-for="wizard in templateWizards"
:key="wizard.id"
@ -13,8 +12,7 @@
isActive(wizard.path)
? 'bg-black text-white dark:bg-white dark:text-black no-underline'
: ''
"
>
">
{{ wizard.name }}
</NuxtLink>
</nav>
@ -29,22 +27,22 @@ const templateWizards = [
{
id: "membership-agreement",
name: "Membership Agreement",
path: "/templates/membership-agreement",
path: "/tools/templates/membership-agreement",
},
{
id: "conflict-resolution-framework",
name: "Conflict Resolution",
path: "/templates/conflict-resolution-framework",
path: "/tools/templates/conflict-resolution-framework",
},
{
id: "tech-charter",
name: "Tech Charter",
path: "/templates/tech-charter",
path: "/tools/templates/tech-charter",
},
{
id: "decision-framework",
name: "Decision Framework",
path: "/templates/decision-framework",
path: "/tools/templates/decision-framework",
},
];

View file

@ -63,7 +63,7 @@ export const useSetupState = () => {
}
const goToMemberManagement = () => {
navigateTo('/settings#members')
navigateTo('/coop-planner')
}

View file

@ -1,178 +0,0 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Glossary</h2>
<UInput
v-model="searchQuery"
placeholder="Search definitions..."
icon="i-heroicons-magnifying-glass"
class="w-64"
:ui="{ icon: { trailing: { pointer: '' } } }" />
</div>
<UCard>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div
v-for="letter in alphabeticalGroups"
:key="letter.letter"
class="space-y-4">
<h3
class="text-lg font-semibold text-primary-600 border-b border-neutral-200 pb-2">
{{ letter.letter }}
</h3>
<div class="space-y-4">
<div
v-for="term in letter.terms"
:key="term.id"
:id="term.id"
class="scroll-mt-20">
<dt class="font-medium text-neutral-900 mb-1">
{{ term.term }}
</dt>
<dd class="text-neutral-600 text-sm leading-relaxed">
{{ term.definition }}
<span
v-if="term.example"
class="block mt-1 text-neutral-500 italic">
Example: {{ term.example }}
</span>
</dd>
</div>
</div>
</div>
</div>
</UCard>
</section>
</template>
<script setup lang="ts">
const searchQuery = ref("");
// Glossary terms based on CLAUDE.md definitions
const glossaryTerms = ref([
{
id: "budget",
term: "Budget",
definition:
"Month-by-month plan of money in and money out. Not exact dates.",
example: "January budget shows €12,000 revenue and €9,900 costs",
},
{
id: "concentration",
term: "Concentration",
definition:
"Dependence on few revenue sources. UI shows top source percentage.",
example: "If 65% comes from one client, concentration is high risk",
},
{
id: "coverage",
term: "Coverage",
definition: "Funded paid hours divided by target hours across all members.",
example: "208 funded hours ÷ 320 target hours = 65% coverage",
},
{
id: "deferred-pay",
term: "Deferred Pay",
definition: "Unpaid hours the co-op owes later at the same wage.",
example: "Alex worked 40 hours unpaid in January, owed €800 later",
},
{
id: "equal-wage",
term: "Equal Wage",
definition: "Same hourly rate for all paid hours.",
example: "Everyone gets €20/hour for paid work, regardless of role",
},
{
id: "minimum-cash-cushion",
term: "Minimum Cash Cushion",
definition: "Lowest operating balance we agree not to breach.",
example: "€3,000 minimum means never go below this amount",
},
{
id: "on-costs",
term: "On-costs",
definition: "Employer taxes, benefits, and payroll fees on top of wages.",
example: "€6,400 wages + 25% on-costs = €8,000 total payroll",
},
{
id: "patronage",
term: "Patronage",
definition: "A way to share surplus based on recorded contributions.",
example: "Extra profits shared based on hours worked or value added",
},
{
id: "payout-delay",
term: "Payout Delay",
definition: "Time between earning money and receiving it.",
example: "Platform sales have 14-day delay, grants have 45-day delay",
},
{
id: "restricted-funds",
term: "Restricted Funds",
definition: "Money that can only be used for approved purposes.",
example: "Grant money restricted to development costs only",
},
{
id: "revenue-share",
term: "Revenue Share",
definition: "Percentage of earnings paid to platform or partner.",
example: "App store takes 30% revenue share on sales",
},
{
id: "runway",
term: "Runway",
definition:
"Months until cash plus savings run out under the current plan.",
example: "€13,000 available ÷ €4,600 monthly burn = 2.8 months runway",
},
{
id: "savings-target",
term: "Savings Target",
definition: "Money held for stability. Aim to reach before ramping hours.",
example: "3 months target = €13,800 for 3 months of expenses",
},
{
id: "surplus",
term: "Surplus",
definition: "Money left over after all costs are paid.",
example: "€12,000 revenue - €9,900 costs = €2,100 surplus",
},
]);
// Filter terms based on search
const filteredTerms = computed(() => {
if (!searchQuery.value) return glossaryTerms.value;
const query = searchQuery.value.toLowerCase();
return glossaryTerms.value.filter(
(term) =>
term.term.toLowerCase().includes(query) ||
term.definition.toLowerCase().includes(query)
);
});
// Group terms alphabetically
const alphabeticalGroups = computed(() => {
const groups = new Map();
filteredTerms.value
.sort((a, b) => a.term.localeCompare(b.term))
.forEach((term) => {
const letter = term.term[0].toUpperCase();
if (!groups.has(letter)) {
groups.set(letter, { letter, terms: [] });
}
groups.get(letter).terms.push(term);
});
return Array.from(groups.values()).sort((a, b) =>
a.letter.localeCompare(b.letter)
);
});
// SEO and accessibility
useSeoMeta({
title: "Glossary - Plain English Definitions",
description: "Plain English definitions of co-op financial terms. No jargon.",
});
</script>

View file

@ -1,191 +0,0 @@
<template>
<div class="min-h-screen bg-neutral-50 py-8">
<div class="container mx-auto max-w-4xl px-4">
<!-- Header -->
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-4">Budget Planning Help</h1>
<p class="text-xl text-neutral-600">Learn how to build a sustainable financial plan for your co-op or studio</p>
</div>
<!-- Navigation -->
<div class="mb-8">
<div class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="p-4">
<h2 class="text-xl font-bold mb-4">Quick Navigation</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<a href="#revenue-diversification" class="block p-3 bg-blue-50 border-2 border-blue-200 rounded hover:bg-blue-100 transition-colors">
<span class="font-semibold">Revenue Diversification</span>
<p class="text-sm text-neutral-600">How to develop multiple income streams</p>
</a>
<a href="#budget-categories" class="block p-3 bg-green-50 border-2 border-green-200 rounded hover:bg-green-100 transition-colors">
<span class="font-semibold">Budget Categories</span>
<p class="text-sm text-neutral-600">Understanding revenue and expense types</p>
</a>
<a href="#planning-tips" class="block p-3 bg-yellow-50 border-2 border-yellow-200 rounded hover:bg-yellow-100 transition-colors">
<span class="font-semibold">Planning Tips</span>
<p class="text-sm text-neutral-600">Best practices for financial planning</p>
</a>
<a href="#getting-started" class="block p-3 bg-purple-50 border-2 border-purple-200 rounded hover:bg-purple-100 transition-colors">
<span class="font-semibold">Getting Started</span>
<p class="text-sm text-neutral-600">Step-by-step setup guide</p>
</a>
</div>
</div>
</div>
</div>
<!-- Content Sections -->
<div class="space-y-8">
<!-- Revenue Diversification -->
<section id="revenue-diversification" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="bg-blue-500 text-white px-6 py-4">
<h2 class="text-2xl font-bold">Revenue Diversification</h2>
</div>
<div class="p-6">
<div class="prose max-w-none">
<p class="text-lg mb-4">
Building multiple revenue streams reduces risk and creates more stable income for your co-op.
</p>
<h3 class="text-xl font-semibold mb-3">How to Develop Games & Products</h3>
<p class="mb-4">
[Placeholder text - Add your content here about developing games and product revenue streams]
</p>
<h3 class="text-xl font-semibold mb-3">How to Develop Services & Contracts</h3>
<p class="mb-4">
[Placeholder text - Add your content here about developing service-based revenue streams]
</p>
<h3 class="text-xl font-semibold mb-3">How to Secure Grants & Funding</h3>
<p class="mb-4">
[Placeholder text - Add your content here about finding and securing grants]
</p>
<h3 class="text-xl font-semibold mb-3">Building Community Support</h3>
<p class="mb-4">
[Placeholder text - Add your content here about building community-supported revenue]
</p>
</div>
</div>
</section>
<!-- Budget Categories -->
<section id="budget-categories" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="bg-green-500 text-white px-6 py-4">
<h2 class="text-2xl font-bold">Budget Categories</h2>
</div>
<div class="p-6">
<div class="prose max-w-none">
<h3 class="text-xl font-semibold mb-3">Revenue Categories</h3>
<ul class="mb-6 space-y-2">
<li><strong>Games & Products:</strong> [Add description]</li>
<li><strong>Services & Contracts:</strong> [Add description]</li>
<li><strong>Grants & Funding:</strong> [Add description]</li>
<li><strong>Community Support:</strong> [Add description]</li>
<li><strong>Partnerships:</strong> [Add description]</li>
<li><strong>Investment Income:</strong> [Add description]</li>
<li><strong>In-Kind Contributions:</strong> [Add description]</li>
</ul>
<h3 class="text-xl font-semibold mb-3">Expense Categories</h3>
<ul class="space-y-2">
<li><strong>Salaries & Benefits:</strong> [Add description]</li>
<li><strong>Development Costs:</strong> [Add description]</li>
<li><strong>Equipment & Technology:</strong> [Add description]</li>
<li><strong>Marketing & Outreach:</strong> [Add description]</li>
<li><strong>Office & Operations:</strong> [Add description]</li>
<li><strong>Legal & Professional:</strong> [Add description]</li>
<li><strong>Other Expenses:</strong> [Add description]</li>
</ul>
</div>
</div>
</section>
<!-- Planning Tips -->
<section id="planning-tips" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="bg-yellow-500 text-white px-6 py-4">
<h2 class="text-2xl font-bold">Planning Tips</h2>
</div>
<div class="p-6">
<div class="prose max-w-none">
<h3 class="text-xl font-semibold mb-3">Concentration Risk</h3>
<p class="mb-4">
[Add content about managing concentration risk and why diversification matters]
</p>
<h3 class="text-xl font-semibold mb-3">Monthly vs Annual Planning</h3>
<p class="mb-4">
[Add content about balancing monthly budgets with annual strategic planning]
</p>
<h3 class="text-xl font-semibold mb-3">Setting Realistic Goals</h3>
<p class="mb-4">
[Add content about setting achievable revenue and expense targets]
</p>
</div>
</div>
</section>
<!-- Getting Started -->
<section id="getting-started" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="bg-purple-500 text-white px-6 py-4">
<h2 class="text-2xl font-bold">Getting Started</h2>
</div>
<div class="p-6">
<div class="prose max-w-none">
<h3 class="text-xl font-semibold mb-3">Step 1: Set Up Your Basic Budget</h3>
<p class="mb-4">
[Add step-by-step instructions for initial budget setup]
</p>
<h3 class="text-xl font-semibold mb-3">Step 2: Plan Your Revenue Streams</h3>
<p class="mb-4">
[Add guidance on planning initial revenue streams]
</p>
<h3 class="text-xl font-semibold mb-3">Step 3: Track and Adjust</h3>
<p class="mb-4">
[Add content about ongoing budget management]
</p>
</div>
</div>
</section>
</div>
<!-- Back to Budget -->
<div class="mt-8 text-center">
<NuxtLink
to="/budget"
class="inline-block px-6 py-3 bg-black text-white font-bold border-4 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
>
Back to Budget
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// Page meta
useHead({
title: 'Budget Planning Help - Urgent Tools',
meta: [
{ name: 'description', content: 'Learn how to build sustainable financial plans and develop diverse revenue streams for your co-op or studio.' }
]
});
</script>
<style scoped>
/* Smooth scrolling for anchor links */
html {
scroll-behavior: smooth;
}
/* Ensure anchor links account for any fixed headers */
section[id] {
scroll-margin-top: 2rem;
}
</style>

View file

@ -1,382 +1,432 @@
<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 class="min-h-screen">
<!-- Noise overlay and effects -->
<div class="noise" />
<div class="container">
<section class="coming-soon">
<header>
<div class="glitch-container">
<div class="logo">coop.love</div>
</div>
</header>
<p>
A new space to celebrate values-driven cooperative game development
around the world.
</p>
<p class="mt-2">
Join our list to be first to know when we launch!
</p>
<form
action="https://buttondown.com/api/emails/embed-subscribe/coop.love"
method="post"
target="popupwindow"
onsubmit="window.open('https://buttondown.com/coop.love', 'popupwindow')"
class="embeddable-buttondown-form">
<input
type="email"
name="email"
id="bd-email"
placeholder="your@email.com" />
<input type="submit" value="Subscribe" />
</form>
<div class="powered-by">
<p>A collaborative initiative powered by:</p>
<p class="org-names">
<a href="https://babyghosts.fund">Baby Ghosts</a> +
<a href="https://gammaspace.ca">Gamma Space</a>
</p>
</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('/dashboard', '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('/cash', 'breach-forecast')"
class="text-sm underline font-bold">
VIEW CALENDAR
</button>
<button
@click="handleAlertNavigation('/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('/budget', 'savings')"
class="text-sm underline font-bold">
VIEW PROGRESS
</button>
<button
@click="handleAlertNavigation('/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('/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('/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('/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>
<!-- Tools access -->
<div class="tools-access">
<NuxtLink to="/tools" class="tools-link">
Access Urgent Tools
</NuxtLink>
</div>
</section>
</div>
</div>
</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 setup>
useHead({
title: 'coop.love ❤️ Cooperative Game Development',
meta: [
{ name: 'description', content: 'A new space to celebrate values-driven cooperative game development around the world.' }
]
})
</script>
<style scoped>
/* Import Google Fonts for Courier Prime */
@import url("https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap");
:root {
--accent-1: #333333;
--accent-2: #555555;
--accent-3: #777777;
--light: #f5f5f5;
--lighter: #ffffff;
--text: #0a0a0a;
--scanline: rgba(0, 0, 0, 0.05);
--font-stack: "Courier Prime", "Consolas", "Menlo", "Monaco",
"Courier New", monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.min-h-screen {
background-color: var(--lighter);
background-image: radial-gradient(
circle at 50% 50%,
rgba(245, 245, 245, 0.6) 0%,
rgba(250, 250, 250, 0.8) 70%,
var(--lighter) 100%
);
color: var(--text);
font-family: var(--font-stack);
line-height: 1.6;
position: relative;
overflow-x: hidden;
}
.min-h-screen::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
rgba(0, 0, 0, 0) 50%,
rgba(0, 0, 0, 0.08) 50%
),
linear-gradient(
90deg,
rgba(0, 0, 0, 0.03),
rgba(0, 0, 0, 0.05),
rgba(0, 0, 0, 0.03)
);
background-size: 100% 3px, 3px 100%;
pointer-events: none;
z-index: 9999;
mix-blend-mode: multiply;
}
.noise {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 1000 1000' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
opacity: 0.08;
pointer-events: none;
z-index: 9998;
mix-blend-mode: multiply;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
position: relative;
z-index: 1;
}
header {
padding: 2rem 1rem;
position: relative;
border-bottom: 1px solid var(--accent-3);
margin-bottom: 4rem;
}
.glitch-container {
position: relative;
text-align: center;
margin-bottom: 2rem;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.logo {
font-family: var(--font-stack);
font-size: clamp(3rem, 10vw, 7rem);
font-weight: 400;
color: var(--text);
text-shadow: 0 0 5px rgba(214, 138, 229, 0.7),
0 0 10px rgba(84, 194, 243, 0.7);
margin-bottom: 1rem;
position: relative;
animation: flicker 4s infinite alternate;
width: 100%;
text-align: center;
}
.logo::before,
.logo::after {
content: "coop.love";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
text-align: center;
}
.logo::before {
color: var(--accent-2);
opacity: 0.5;
animation: glitch-1 2s infinite alternate-reverse;
}
.logo::after {
color: var(--accent-3);
opacity: 0.5;
animation: glitch-2 3.5s infinite alternate-reverse;
}
@keyframes flicker {
0%,
19.999%,
22%,
62.999%,
64%,
64.999%,
70%,
100% {
opacity: 1;
}
20%,
21.999%,
63%,
63.999%,
65%,
69.999% {
opacity: 0.5;
}
}
@keyframes glitch-1 {
0%,
100% {
transform: translate(0);
}
20% {
transform: translate(-2px, 2px);
}
40% {
transform: translate(-2px, -2px);
}
60% {
transform: translate(2px, 2px);
}
80% {
transform: translate(2px, -2px);
}
}
@keyframes glitch-2 {
0%,
100% {
transform: translate(0);
}
25% {
transform: translate(2px, -2px);
}
50% {
transform: translate(-2px, 2px);
}
75% {
transform: translate(2px, 2px);
}
}
.coming-soon {
text-align: center;
margin: 4rem 0;
padding: 3rem;
position: relative;
}
.embeddable-buttondown-form {
margin-top: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.embeddable-buttondown-form label {
margin-bottom: 0.5rem;
font-size: 1.1rem;
color: var(--accent-1);
}
.embeddable-buttondown-form input[type="email"] {
background: var(--lighter);
border: 1px solid var(--accent-2);
padding: 0.8rem;
width: 100%;
font-family: var(--font-stack);
color: var(--text);
font-size: 1rem;
transition: all 0.3s ease;
}
.embeddable-buttondown-form input[type="email"]:focus {
border-color: var(--accent-1);
outline: none;
box-shadow: 0 0 5px rgba(51, 51, 51, 0.3);
}
.embeddable-buttondown-form input[type="submit"] {
background: var(--lighter);
color: var(--accent-1);
border: 1px solid var(--accent-1);
padding: 0.8rem 2rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
font-family: var(--font-stack);
width: 100%;
}
.embeddable-buttondown-form input[type="submit"]:hover {
background: var(--accent-1);
color: var(--lighter);
text-shadow: none;
box-shadow: 0 0 10px rgba(51, 51, 51, 0.3);
}
.embeddable-buttondown-form input[type="submit"]:before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
to right,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.7s ease;
}
.embeddable-buttondown-form input[type="submit"]:hover:before {
left: 100%;
}
.powered-by {
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
gap: 1rem;
flex-wrap: wrap;
}
.powered-by p {
font-size: 0.9rem;
opacity: 0.7;
}
.org-names {
color: var(--accent-1);
}
.org-names a {
color: #8a34d6;
text-decoration: none;
transition: all 0.3s ease;
}
.org-names a:hover {
color: #d68ae5;
text-decoration: underline;
}
.tools-access {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--accent-3);
}
.tools-link {
background: var(--lighter);
color: var(--accent-1);
border: 1px solid var(--accent-1);
padding: 0.8rem 2rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
font-family: var(--font-stack);
}
.tools-link:hover {
background: var(--accent-1);
color: var(--lighter);
text-shadow: none;
box-shadow: 0 0 10px rgba(51, 51, 51, 0.3);
}
.tools-link:before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
to right,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.7s ease;
}
.tools-link:hover:before {
left: 100%;
}
@media (max-width: 768px) {
.logo {
width: 100%;
}
header {
padding: 1rem 0.5rem;
margin-bottom: 2rem;
}
.coming-soon {
padding: 1.5rem 1rem;
}
}
</style>

View file

@ -1,219 +0,0 @@
<template>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Policies & Privacy</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<UCard>
<template #header>
<h3 class="text-lg font-medium">Wage & Costs</h3>
</template>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2"
>Equal Hourly Wage</label
>
<UInput
v-model="policies.hourlyWage"
type="number"
:ui="{ wrapper: 'relative' }">
<template #leading>
<span class="text-neutral-500"></span>
</template>
</UInput>
</div>
<div>
<label class="block text-sm font-medium mb-2"
>Payroll On-costs (%)</label
>
<UInput
v-model="policies.payrollOncost"
type="number"
:ui="{ wrapper: 'relative' }">
<template #trailing>
<span class="text-neutral-500">%</span>
</template>
</UInput>
</div>
</div>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Cash Management</h3>
</template>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2"
>Savings Target (months)</label
>
<UInput
v-model="policies.savingsTargetMonths"
type="number"
step="0.1" />
</div>
<div>
<label class="block text-sm font-medium mb-2"
>Minimum Cash Cushion</label
>
<UInput
v-model="policies.minCashCushion"
type="number"
:ui="{ wrapper: 'relative' }">
<template #leading>
<span class="text-neutral-500"></span>
</template>
</UInput>
</div>
</div>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Deferred Pay Limits</h3>
</template>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2"
>Cap (hours per quarter)</label
>
<UInput v-model="policies.deferredCapHours" type="number" />
</div>
<div>
<label class="block text-sm font-medium mb-2"
>Sunset (months)</label
>
<UInput v-model="policies.deferredSunsetMonths" type="number" />
</div>
</div>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-medium">Distribution Order</h3>
</template>
<div class="space-y-4">
<p class="text-sm text-neutral-600">
Order of surplus distribution priorities.
</p>
<div class="space-y-2">
<div
v-for="(item, index) in distributionOrder"
:key="item"
class="flex items-center justify-between p-2 bg-neutral-50 rounded">
<span class="text-sm font-medium"
>{{ index + 1 }}. {{ item }}</span
>
<div class="flex gap-1">
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-chevron-up" />
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-chevron-down" />
</div>
</div>
</div>
</div>
</UCard>
</div>
<!-- Member Management Section -->
<UCard id="members">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Team Members</h3>
<div class="flex items-center gap-2">
<UBadge v-if="isSetupComplete" color="green" variant="subtle" size="xs">
Synchronized with Setup
</UBadge>
<UButton variant="ghost" size="xs" @click="goToSetup">
Edit in Setup
</UButton>
</div>
</div>
</template>
<div class="space-y-4">
<div v-if="members.length === 0" class="text-center py-8 text-neutral-500">
<p class="mb-4">No team members found.</p>
<UButton @click="goToSetup" variant="outline">
Add Members in Setup
</UButton>
</div>
<div v-else class="space-y-3">
<div
v-for="member in members"
:key="member.id"
class="p-4 border border-neutral-200 rounded-lg bg-neutral-50">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium">{{ member.displayName || member.name }}</h4>
<div class="text-sm text-neutral-600 space-y-1">
<div v-if="member.role">Role: {{ member.role }}</div>
<div>Monthly Target: {{ $format.currency(member.monthlyPayPlanned || 0) }}</div>
<div v-if="member.minMonthlyNeeds">Min Needs: {{ $format.currency(member.minMonthlyNeeds) }}</div>
</div>
</div>
<div class="text-right text-sm text-neutral-600">
<div>{{ Math.round((member.hoursPerMonth || member.hoursPerWeek * 4.33)) }} hrs/month</div>
</div>
</div>
</div>
</div>
</div>
</UCard>
<div class="flex justify-end">
<UButton color="primary"> Save Policies </UButton>
</div>
</section>
</template>
<script setup lang="ts">
// Store sync and setup state
const { initSync, getMembers, unifiedMembers } = useStoreSync();
const { isSetupComplete, goToSetup } = useSetupState();
const coopStore = useCoopBuilderStore();
const { $format } = useNuxtApp();
// Initialize synchronization on mount
onMounted(async () => {
await initSync();
});
// Get reactive synchronized member data
const members = unifiedMembers;
// Get synchronized policy data from setup
const policies = computed({
get: () => ({
hourlyWage: coopStore.equalHourlyWage,
payrollOncost: coopStore.payrollOncostPct,
savingsTargetMonths: coopStore.savingsTargetMonths,
minCashCushion: coopStore.minCashCushion,
deferredCapHours: 240, // These fields might not be in coop store yet
deferredSunsetMonths: 12,
}),
set: (newPolicies) => {
// Update the CoopBuilder store when policies change
coopStore.setEqualWage(newPolicies.hourlyWage);
coopStore.setOncostPct(newPolicies.payrollOncost);
coopStore.savingsTargetMonths = newPolicies.savingsTargetMonths;
coopStore.minCashCushion = newPolicies.minCashCushion;
}
});
const distributionOrder = ref([
"Deferred",
"Savings",
"Hardship",
"Training",
"Patronage",
"Retained",
]);
</script>

View file

@ -40,7 +40,7 @@
</p>
<div class="flex justify-center">
<NuxtLink
to="/coop-builder"
to="/tools/coop-builder"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-black hover:bg-neutral-800 border-2 border-black transition-colors">
Complete Setup Wizard
</NuxtLink>

View file

@ -39,7 +39,7 @@
:disabled="isResetting">
Start Over
</button>
<button class="export-btn primary" @click="navigateTo('/budget')">
<button class="export-btn primary" @click="navigateTo('/tools/budget')">
Go to Dashboard
</button>
</div>

381
pages/tools/index.vue Normal file
View 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>

View file

@ -21,7 +21,7 @@
No team members set up yet.
</p>
<NuxtLink
to="/coop-builder"
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>

View file

@ -76,6 +76,7 @@
v-model="value.checked"
:id="`core-value-${index}`"
:label="value.label"
class="w-full"
@change="autoSave" />
</div>
</div>
@ -124,6 +125,7 @@
v-model="conflict.checked"
:id="`conflict-type-${index}`"
:label="conflict.label"
class="w-full"
@change="autoSave" />
</div>
</div>
@ -157,6 +159,7 @@
id="anonymous-reporting"
label="Allow anonymous reporting"
help="Members can report issues without revealing their identity"
class="w-full"
@change="autoSave" />
</UFormField>
</div>
@ -191,6 +194,7 @@
v-model="receiver.checked"
:id="`report-receiver-${index}`"
:label="receiver.label"
class="w-full"
@change="autoSave" />
</div>
</div>
@ -221,6 +225,7 @@
id="support-people"
label="Allow support people in mediation sessions"
help="Parties can bring a trusted person for emotional support"
class="w-full"
@change="autoSave" />
</UFormField>
</div>
@ -283,6 +288,7 @@
v-model="step.checked"
:id="`process-step-${index}`"
:label="`${index + 1}. ${step.label}`"
class="w-full"
@change="autoSave" />
</div>
</div>
@ -378,6 +384,7 @@
v-model="action.checked"
:id="`available-action-${index}`"
:label="action.label"
class="w-full"
@change="autoSave" />
</div>
</div>
@ -396,6 +403,7 @@
id="appeal-process"
label="Include appeals process"
help="Parties can request review of decisions"
class="w-full"
@change="autoSave" />
</UFormField>
</div>
@ -439,6 +447,7 @@
v-model="circumstance.checked"
:id="`special-circumstance-${index}`"
:label="circumstance.label"
class="w-full"
@change="autoSave" />
</div>
</div>
@ -598,6 +607,7 @@
v-model="channel.checked"
:id="`comm-channel-${index}`"
:label="channel.label"
class="w-full"
@change="autoSave" />
</div>
</div>
@ -611,6 +621,7 @@
id="require-direct-attempt"
label="Require direct resolution attempt before escalation"
help="Parties must try to resolve directly before filing complaints"
class="w-full"
@change="autoSave" />
</UFormField>
@ -621,6 +632,7 @@
id="document-direct-resolution"
label="Require written record of direct resolution attempts"
help="Parties should document outcomes of direct conversations"
class="w-full"
@change="autoSave" />
</UFormField>
</div>
@ -699,6 +711,7 @@
v-model="element.checked"
:id="`complaint-element-${index}`"
:label="element.label"
class="w-full"
@change="autoSave" />
</div>
</div>
@ -737,6 +750,7 @@
id="require-external-advice"
label="Require external legal advice for complex complaints"
help="Seek external expertise for multi-party or member-coordinator complaints"
class="w-full"
@change="autoSave" />
</UFormField>
</div>
@ -753,6 +767,7 @@
id="minutes-of-settlement"
label="Require 'Minutes of Settlement' for resolved complaints"
help="Agreements must be documented in writing and signed by both parties"
class="w-full"
@change="autoSave" />
</UFormField>
@ -806,6 +821,7 @@
id="include-human-rights"
label="Include Human Rights Commission information"
help="Reference external discrimination complaint options"
class="w-full"
@change="autoSave" />
</UFormField>
@ -1041,7 +1057,7 @@ const coreValues = ref([
{ label: "Mutual Care", checked: true },
{ label: "Transparency", checked: true },
{ label: "Accountability", checked: false },
{ label: "Consent-Based", checked: false },
{ label: "Consent", checked: false },
{ label: "Anti-Oppression", checked: false },
{ label: "Restorative Justice", checked: false },
{ label: "Collective Liberation", checked: false },
@ -1280,7 +1296,7 @@ watch(
{ deep: true }
);
// Generate the complete policy document for preview and export
// Comprehensive generatePolicyDocument function with procedural structure
const generatePolicyDocument = () => {
const cooperativeName = formData.value.orgName || "[Cooperative Name]";
let content = `# ${cooperativeName} Conflict Resolution Policy\n\n`;
@ -1293,25 +1309,93 @@ const generatePolicyDocument = () => {
}
content += `\n---\n\n`;
// Core Values section (if enabled)
if (sectionsEnabled.value.values) {
content += `## Our Values\n\n`;
content += `This conflict resolution framework is guided by our core values:\n\n`;
// PURPOSE SECTION
content += `## Purpose\n\n`;
content += `Disagreements in groups are par for the course. But ignoring conflicts, or managing them poorly, can deeply harm individuals and our whole community.\n\n`;
content += `Addressing conflict head-on is **a way of caring for each other**.\n\n`;
content += `This policy aims to offer a straightforward, consistently enforced, and transparent approach to resolving conflicts and disputes that may emerge in relation to ${cooperativeName}'s programs, governance, or the actions of its members.\n\n`;
// GUIDING PRINCIPLES
content += `## Guiding Principles\n\n`;
content += `- All parties to a complaint will **actively participate** and strive to achieve a **collaborative** outcome at the earliest possible stage of the process\n`;
content += `- Information about a complaint will only be given to parties directly involved and others on a need-to-know basis\n`;
content += `- Parties will be provided with clear and understandable reasons for complaint decisions\n`;
content += `- Complaints will be dealt with promptly and resolved as quickly as possible\n`;
content += `- Review of complaints will be fair, impartial, and respectful, allowing all parties to have their perspectives heard\n`;
content += `- The review will be thorough and as detailed as possible based on the information provided\n`;
content += `- The process will be accessible and clearly communicated to all members\n\n`;
// Add selected values if enabled
if (sectionsEnabled.value.values) {
const selectedValues = coreValues.value.filter((v) => v.checked);
if (selectedValues.length > 0) {
content += `Additionally, this framework is guided by our core values:\n\n`;
selectedValues.forEach((value) => {
content += `- **${value.label}**\n`;
});
content += `\n`;
}
if (formData.value.customValues) {
content += `${formData.value.customValues}\n\n`;
}
}
// Resolution Philosophy
// DEFINITIONS SECTION
content += `## Definitions\n\n`;
content += `- **Conflict/Dispute**: Ongoing experiences of tension and misunderstandings, often leading to interpersonal discord. These terms are used interchangeably.\n`;
content += `- **Complainant**: The individual lodging a complaint against another party, policy, or practice.\n`;
content += `- **Respondent**: An individual against whom a complaint has been made.\n`;
// Add definitions based on selections
const selectedReceivers = reportReceivers.value.filter((r) => r.checked);
if (selectedReceivers.length > 0) {
content += `- **Responsible Contact People**: Those accountable for assisting in conflict resolution (${selectedReceivers
.map((r) => r.label)
.join(
", "
)}). They act as neutral implementers of this policy, not advocates.\n`;
}
if (formData.value.internalAdvisorType) {
content += `- **Internal Advisor**: ${formData.value.internalAdvisorType} who facilitates the conflict resolution process as a neutral intermediary.\n`;
}
if (formData.value.supportPeople) {
content += `- **Support People**: Individuals not connected to the conflict whom parties may choose to have present for emotional support during mediation.\n`;
}
content += `\n`;
// POLICY ROUTING TABLE
content += `## Which Policy Applies?\n\n`;
content += `| **Who Can File** | **Type of Complaint** | **Policy to Use** | **Initial Contact** |\n`;
content += `|------------------|----------------------|-------------------|--------------------|\n`;
const selectedConflictTypes = conflictTypes.value.filter((c) => c.checked);
selectedConflictTypes.forEach((conflict) => {
let policy = "This policy";
let contact = selectedReceivers[0]?.label || "Designated contact";
if (
conflict.label.includes("Harassment") ||
conflict.label.includes("discrimination")
) {
policy = "Code of Conduct / Human Rights";
if (formData.value.includeHumanRights) {
contact += " or Human Rights Tribunal";
}
} else if (conflict.label.includes("Code of Conduct")) {
policy = "Code of Conduct";
}
content += `| Members | ${conflict.label} | ${policy} | ${contact} |\n`;
});
if (formData.value.anonymousReporting) {
content += `| Any party | Anonymous reports | This policy | Anonymous reporting system |\n`;
}
content += `\n`;
// RESOLUTION APPROACH
const approachDescriptions = {
restorative:
"We use a **restorative/loving justice** approach that focuses on healing, understanding root causes, and repairing relationships rather than punishment.",
@ -1332,9 +1416,9 @@ const generatePolicyDocument = () => {
content += `We do our best to resolve conflicts at the lowest possible escalation step (direct resolution), but agree to escalate conflicts (to assisted resolution) if they are not resolved.\n\n`;
}
// Reflection Process (if enabled)
// REFLECTION PROCESS (if enabled)
if (sectionsEnabled.value.reflection) {
content += `## Reflection\n\n`;
content += `## Reflection Process\n\n`;
content += `Before engaging in direct resolution, we encourage taking time for reflection:\n\n`;
content += `1. **Set aside time to think** through what happened. What was the other person's behaviour? How did it affect you? *Distinguish other people's **actions** from your **feelings** about them.*\n`;
content += `2. **Consider uncertainties** or misunderstandings that may have occurred.\n`;
@ -1352,20 +1436,20 @@ const generatePolicyDocument = () => {
content += `**Reflection Timing:** ${reflectionTiming}\n\n`;
}
// Direct Resolution (if enabled)
// DIRECT RESOLUTION (if enabled)
if (sectionsEnabled.value.directResolution) {
content += `## Direct Resolution\n\n`;
content += `A *direct resolution* process occurs when individuals communicate their concerns and work together to resolve disputes without filing an informal or formal complaint.\n\n`;
content += `A *direct resolution* process occurs when individuals communicate their concerns and work together to resolve disputes without filing a formal complaint.\n\n`;
content += `### Have a Conversation\n\n`;
content += `When there is a disagreement, the involved people should first **communicate with each other** about their concerns.\n\n`;
content += `1. **Choose a time and place** to meet that is private and agreeable to both.\n`;
content += `2. **Allow reasonable time** for the conversation.\n`;
content += `3. **The point is mutual understanding**, not determining who is right or wrong. This requires patience and willingness to listen without immediately dismissing the other person's perspective.\n`;
content += `4. **Express thoughts and feelings directly** without belittling or dismissing. Use "I" statements and active listening techniques.\n`;
content += `3. **The point is mutual understanding**, not determining who is right or wrong.\n`;
content += `4. **Express thoughts and feelings directly** using "I" statements and active listening.\n`;
content += `5. **Communicate your wants and needs** and make offers and requests.\n`;
content += `6. **Learn for the future.** Ask questions like, "If what I/you said or did came across that way, what can we do to prevent this from happening in the future?"\n`;
content += `6. **Learn for the future** ask what can be done to prevent this from recurring.\n`;
if (formData.value.documentDirectResolution) {
content += `7. **Keep a written record** of the resolution agreed to by both parties.\n\n`;
@ -1391,43 +1475,175 @@ const generatePolicyDocument = () => {
}
}
// Assisted Resolution
// RECEIVING REPORTS SECTION
content += `## Receiving Reports\n\n`;
content += `### Document the Initial Incident Report\n\n`;
content += `Collect the following information and enter it in the Incident Log:\n\n`;
content += `| Field | Information to Collect |\n`;
content += `|-------|------------------------|\n`;
content += `| **Participant Name** | Name of individual(s) involved |\n`;
content += `| **Issue/Violation** | Brief description of the behavior or conflict |\n`;
content += `| **Date & Time** | When the incident occurred |\n`;
content += `| **Circumstances** | Context or situation surrounding the incident |\n`;
content += `| **Others Involved** | Names of any witnesses or additional participants |\n`;
content += `| **Conversation Notes** | Summary of discussion with the complainant |\n\n`;
content += `*Gather this information from the complainant do not "interview" witnesses unless they approach staff.*\n\n`;
// SUPPORTING THE COMPLAINANT
content += `### Supporting the Complainant\n\n`;
content += `Follow these steps to help the complainant feel safe:\n\n`;
content += `1. **Provide private space** for discussion (in digital spaces, use DM/private channels)\n`;
content += `2. **Allow the complainant to decide** if further action should be taken\n`;
content += `3. **Explain the process** walk them through next steps per this policy\n`;
content += `4. **Assure confidentiality** their identity will not be disclosed without permission\n`;
content += `5. **Confirm follow-up** they will be informed about any actions taken\n\n`;
// ASSISTED RESOLUTION
content += `## Assisted Resolution\n\n`;
content += `If talking things out doesn't work, you can ask a responsible contact person for help in writing.\n\n`;
content += `If direct resolution doesn't work, parties can request assistance from a responsible contact person.\n\n`;
// Process Steps
const selectedSteps = processSteps.value.filter((s) => s.checked);
if (selectedSteps.length > 0) {
content += `### Resolution Process Steps\n\n`;
selectedSteps.forEach((step, index) => {
content += `${index + 1}. ${step.label}\n`;
});
content += `\n`;
}
// Responsible Contact People
const selectedReceivers = reportReceivers.value.filter((r) => r.checked);
content += `### Responsible Contact People\n\n`;
if (selectedReceivers.length > 0) {
content += `### Initial Contact Options\n\n`;
content += `You can report conflicts to any of the following:\n\n`;
content += `**Initial Contact Options:**\n`;
selectedReceivers.forEach((receiver) => {
content += `- ${receiver.label}\n`;
});
content += `\n`;
}
// Contact People Structure
if (formData.value.internalAdvisorType) {
content += `**Internal Advisor:** ${formData.value.internalAdvisorType}\n`;
}
if (formData.value.staffLiaison) {
content += `**Member Liaison:** ${formData.value.staffLiaison}\n`;
}
if (formData.value.boardChairRole) {
content += `**Board Chair Role:** ${formData.value.boardChairRole}\n`;
}
if (
formData.value.internalAdvisorType ||
formData.value.staffLiaison ||
formData.value.boardChairRole
) {
content += `\n`;
}
// Mediator Structure
if (formData.value.mediatorType) {
content += `### Mediation/Facilitation\n\n`;
content += `**Structure:** ${formData.value.mediatorType}\n\n`;
content += `**Mediation Structure:** ${formData.value.mediatorType}\n`;
if (formData.value.supportPeople) {
content += `**Support People:** Parties may bring a trusted person for emotional support during mediation sessions.\n\n`;
content += `**Support People:** Parties may bring a trusted person for emotional support during mediation sessions.\n`;
}
content += `\n`;
}
// Timeline
content += `### Response Times\n\n`;
content += `### Response Timeline\n\n`;
content += `| Stage | Timeframe |\n`;
content += `|-------|----------|\n`;
if (formData.value.initialResponse) {
content += `- **Initial Response:** ${formData.value.initialResponse}\n`;
content += `| Initial Response | ${formData.value.initialResponse} |\n`;
}
if (formData.value.resolutionTarget) {
content += `- **Target Resolution:** ${formData.value.resolutionTarget}\n\n`;
content += `| Target Resolution | ${formData.value.resolutionTarget} |\n`;
}
content += `\n`;
// COMMITTEE MEETING PROCEDURES (if committee-based)
if (
formData.value.mediatorType &&
formData.value.mediatorType.toLowerCase().includes("committee")
) {
content += `## Committee Meeting Procedures\n\n`;
content += `### Before the Meeting\n`;
content += `- Notify respondent of complaint\n`;
content += `- Allow respondent to provide their perspective\n`;
content += `- Schedule meeting within ${
formData.value.initialResponse || "specified timeframe"
}\n\n`;
content += `### During the Meeting\n`;
content += `Committee members should review the incident report and discuss:\n`;
content += `- What happened?\n`;
content += `- What are we doing about it?\n`;
content += `- Who is implementing the decision?\n`;
content += `- When will it be implemented?\n\n`;
content += `*Neither the complainant nor respondent should attend the deliberation.*\n\n`;
content += `### After the Meeting\n`;
content += `- Communicate decision to all parties\n`;
content += `- Document all communications\n`;
content += `- Follow up with complainant about outcomes\n`;
content += `- Prepare report for organizational records\n\n`;
}
// Formal Complaints
// RESPONSE PROCEDURES MATRIX
content += `## Response Procedures\n\n`;
const selectedActions = availableActions.value.filter((a) => a.checked);
if (selectedActions.length > 0) {
content += `### Response Matrix\n\n`;
content += `| Issue Severity | Possible Response | Documentation Required |\n`;
content += `|----------------|-------------------|------------------------|\n`;
// Create severity-based responses
const hasVerbal = selectedActions.some((a) => a.label.includes("Verbal"));
const hasWritten = selectedActions.some((a) => a.label.includes("Written"));
const hasSuspension = selectedActions.some((a) =>
a.label.includes("suspension")
);
const hasRemoval = selectedActions.some(
(a) => a.label.includes("Removal") || a.label.includes("removal")
);
if (hasVerbal) {
content += `| First occurrence, minor | Verbal warning | Update incident log |\n`;
}
if (hasWritten) {
content += `| Repeated behavior | Written warning | Formal documentation |\n`;
}
if (hasSuspension) {
content += `| Serious violation | Temporary suspension | Full investigation report |\n`;
}
if (hasRemoval) {
content += `| Severe/safety threat | Immediate removal | Complete documentation + notifications |\n`;
}
content += `\n`;
content += `### Available Remedial Actions\n\n`;
selectedActions.forEach((action) => {
content += `- ${action.label}\n`;
});
content += `\n`;
}
if (formData.value.appealProcess) {
content += `### Appeals Process\n\n`;
content += `Parties may request review of decisions through our appeals process. Appeals must be submitted in writing within 30 days of the original decision.\n\n`;
}
// FORMAL COMPLAINTS
content += `## Formal Complaints\n\n`;
content += `If assisted resolution efforts do not result in an acceptable outcome within a reasonable timeframe, a *formal complaint* may be filed in writing.\n\n`;
content += `If assisted resolution does not result in an acceptable outcome, a formal complaint may be filed in writing.\n\n`;
// Required Elements
const selectedElements = formalComplaintElements.value.filter(
@ -1444,58 +1660,137 @@ const generatePolicyDocument = () => {
// Formal Process Timeline
content += `### Formal Process Timeline\n\n`;
content += `| Stage | Timeframe |\n`;
content += `|-------|----------|\n`;
if (formData.value.formalAcknowledgmentTime) {
content += `- **Acknowledgment:** ${formData.value.formalAcknowledgmentTime}\n`;
content += `| Acknowledgment of complaint | ${formData.value.formalAcknowledgmentTime} |\n`;
}
if (formData.value.formalReviewTime) {
content += `- **Review Completion:** ${formData.value.formalReviewTime}\n\n`;
content += `| Review completion | ${formData.value.formalReviewTime} |\n`;
}
content += `\n`;
if (formData.value.requireExternalAdvice) {
content += `> **External Expertise:** For complex complaints involving multiple parties or organizational leaders, external legal advice will be sought.\n\n`;
}
// Settlement Documentation
if (formData.value.requireMinutesOfSettlement) {
content += `### Reaching Agreement\n\n`;
content += `Any resolution agreed upon must be documented in "Minutes of Settlement" signed by both parties. These agreements will be kept confidential according to our privacy standards.\n\n`;
// PREVENTING RETALIATION (if anti-retaliation is selected)
const hasAntiRetaliation = specialCircumstances.value.some(
(c) => c.checked && c.label.toLowerCase().includes("retaliation")
);
if (hasAntiRetaliation) {
content += `## Preventing Retaliation\n\n`;
content += `**CRITICAL:** The privacy and safety of the complainant is paramount.\n\n`;
content += `- **DO NOT** share details of the incident without express permission from the complainant\n`;
content += `- **DO NOT** reveal the complainant's identity to the respondent or others\n`;
content += `- **MONITOR** for any retaliatory behavior following a complaint\n`;
content += `- **DOCUMENT** any instances of suspected retaliation\n`;
content += `- **TREAT** retaliation as a separate, serious violation requiring immediate action\n\n`;
}
// Consequences and Actions
const selectedActions = availableActions.value.filter((a) => a.checked);
if (selectedActions.length > 0) {
content += `## Possible Outcomes\n\n`;
content += `Depending on the situation, resolution may include:\n\n`;
selectedActions.forEach((action) => {
content += `- ${action.label}\n`;
});
// SETTLEMENT & DOCUMENTATION
content += `## Settlement & Documentation\n\n`;
if (formData.value.requireMinutesOfSettlement) {
content += `### Minutes of Settlement\n`;
content += `Any resolution must be documented in "Minutes of Settlement" that:\n`;
content += `- Clearly state the agreed-upon resolution\n`;
content += `- Include commitments from all parties\n`;
content += `- Are signed by both complainant and respondent\n`;
content += `- Are kept according to our confidentiality standards\n\n`;
}
// REGARDING APOLOGIES
content += `### Regarding Apologies\n\n`;
content += `We do not require or facilitate apologies unless explicitly requested by the complainant.\n\n`;
content += `- Forced apologies can constitute continued harassment\n`;
content += `- If offered, apologies should be brief and relayed through the mediator\n`;
content += `- Apologies should not require a response from the recipient\n`;
content += `- Pressing unwanted apologies may result in further disciplinary action\n\n`;
// DOCUMENTATION & PRIVACY
if (sectionsEnabled.value.documentation) {
content += `## Documentation & Privacy\n\n`;
content += `### Record Management\n\n`;
content += `| Record Type | Retention Period | Access Level | Storage Location |\n`;
content += `|-------------|------------------|--------------|------------------|\n`;
content += `| Initial incident reports | Permanent | Committee only | Secure database |\n`;
content += `| Investigation notes | ${
formData.value.retention || "5 years"
} | Designated roles | Confidential files |\n`;
content += `| Resolution agreements | ${
formData.value.conflictFileRetention ||
formData.value.retention ||
"5 years"
} | Parties + committee | Secure archive |\n`;
content += `| Committee meeting minutes | ${
formData.value.retention || "5 years"
} | Committee members | Meeting records |\n\n`;
if (formData.value.docLevel) {
content += `**Documentation Level:** ${formData.value.docLevel}\n`;
}
if (formData.value.confidentiality) {
content += `**General Confidentiality:** ${formData.value.confidentiality}\n`;
}
if (formData.value.settlementConfidentiality) {
content += `**Settlement Confidentiality:** ${formData.value.settlementConfidentiality}\n`;
}
content += `\n`;
}
if (formData.value.appealProcess) {
content += `### Appeals Process\n\n`;
content += `Parties may request review of decisions through our appeals process.\n\n`;
// SPECIAL CIRCUMSTANCES (if enabled)
if (sectionsEnabled.value.special) {
const selectedCircumstances = specialCircumstances.value.filter(
(c) => c.checked
);
if (selectedCircumstances.length > 0) {
content += `## Special Circumstances\n\n`;
const hasImmediateRemoval = selectedCircumstances.some(
(c) =>
c.label.toLowerCase().includes("immediate removal") ||
c.label.toLowerCase().includes("safety")
);
if (hasImmediateRemoval) {
content += `### Immediate Safety Threats\n`;
content += `When anyone's physical safety is threatened:\n`;
content += `1. Immediately remove the offender from the space\n`;
content += `2. Implement permanent ban if warranted\n`;
content += `3. Notify relevant authorities if required\n`;
content += `4. Document all actions taken\n`;
content += `5. Inform stakeholders as appropriate while protecting victim privacy\n\n`;
}
// Documentation and Privacy
if (sectionsEnabled.value.documentation) {
content += `## Documentation & Privacy\n\n`;
if (formData.value.docLevel) {
content += `**Documentation Level:** ${formData.value.docLevel}\n\n`;
const hasTraumaInformed = selectedCircumstances.some((c) =>
c.label.toLowerCase().includes("trauma")
);
if (hasTraumaInformed) {
content += `### Trauma-Informed Approach\n`;
content += `All conflict resolution processes will incorporate trauma-informed principles:\n`;
content += `- Recognize the impact of trauma on behavior\n`;
content += `- Prioritize physical and emotional safety\n`;
content += `- Provide choices and restore control\n`;
content += `- Collaborate rather than prescribe solutions\n`;
content += `- Build on strengths and resilience\n\n`;
}
if (formData.value.confidentiality) {
content += `**Confidentiality:** ${formData.value.confidentiality}\n\n`;
}
if (formData.value.retention) {
content += `**Record Retention:** ${formData.value.retention}\n\n`;
}
}
// External Resources (if enabled)
// EXTERNAL RESOURCES (if enabled)
if (sectionsEnabled.value.externalResources) {
content += `## External Resources\n\n`;
content += `## External Resources & Redress\n\n`;
if (formData.value.includeHumanRights) {
content += `Individuals who are not satisfied with the outcome of a harassment or discrimination complaint may file a complaint with the [Canadian Human Rights Commission](https://www.chrc-ccdp.gc.ca/eng) or their provincial human rights tribunal.\n\n`;
content += `### Human Rights Complaints\n`;
content += `Individuals who are not satisfied with the outcome of a harassment or discrimination complaint may file a complaint with:\n`;
content += `- [Canadian Human Rights Commission](https://www.chrc-ccdp.gc.ca/eng)\n`;
content += `- Provincial human rights tribunal\n`;
content += `- Other relevant regulatory bodies\n\n`;
}
if (formData.value.additionalResources) {
@ -1504,20 +1799,30 @@ const generatePolicyDocument = () => {
}
}
// Implementation
content += `## Policy Management\n\n`;
// IMPLEMENTATION & TRAINING
content += `## Implementation\n\n`;
if (formData.value.training) {
content += `### Training Requirements\n\n`;
content += `${formData.value.training}\n\n`;
}
content += `### Review and Updates\n\n`;
content += `### Policy Management\n\n`;
content += `| Aspect | Details |\n`;
content += `|--------|----------|\n`;
if (formData.value.reviewSchedule) {
content += `This policy will be reviewed ${formData.value.reviewSchedule.toLowerCase()}.\n\n`;
content += `| Review Schedule | ${formData.value.reviewSchedule} |\n`;
}
if (formData.value.amendments) {
content += `**Amendment Process:** ${formData.value.amendments}\n\n`;
content += `| Amendment Process | ${formData.value.amendments} |\n`;
}
content += `| Last Updated | ${
formData.value.createdDate || new Date().toISOString().split("T")[0]
} |\n`;
if (formData.value.reviewDate) {
content += `| Next Review | ${formData.value.reviewDate} |\n`;
}
content += `\n`;
// Acknowledgments
if (formData.value.acknowledgments) {
@ -1558,6 +1863,8 @@ const exportData = computed(() => {
return {
section: "conflict-resolution-framework",
// Add the generated policy document content for exports
content: generatePolicyDocument(),
// Enhanced formData with processed arrays
formData: {
...formData.value,

View file

@ -440,7 +440,7 @@
:key="step"
class="flex items-start">
<span
class="text-neutral-900 dark:text-neutral-100 font-bold mr-3 mt-1"
class="text-neutral-900 dark:text-neutral-100 font-bold mr-3"
></span
>
<span class="text-neutral-700 dark:text-neutral-300">{{
@ -453,7 +453,7 @@
<div v-if="result.tips" class="section-card">
<h3
class="text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-4">
Pro tips:
Hot tips:
</h3>
<ul class="space-y-3">
<li
@ -461,7 +461,7 @@
:key="tip"
class="flex items-start">
<span
class="text-neutral-900 dark:text-neutral-100 font-bold mr-3 mt-1"
class="text-neutral-900 dark:text-neutral-100 font-bold mr-3"
></span
>
<span class="text-neutral-700 dark:text-neutral-300">{{
@ -508,13 +508,6 @@
<UButton @click="resetForm" size="lg" color="primary">
Try Another Decision
</UButton>
<UButton
@click="printResult"
size="lg"
variant="outline"
color="primary">
Print Recommendation
</UButton>
</div>
</div>
</div>
@ -522,6 +515,19 @@
</div>
</div>
</div>
<!-- Credits Section -->
<div class="mt-12 py-8 border-t border-neutral-200 dark:border-neutral-700">
<div class="text-center text-sm text-neutral-600 dark:text-neutral-400">
<p class="mb-2 font-medium">With inspiration from:</p>
<div class="space-y-1">
<p>Rocket Adrift</p>
<p>Baby Ghosts Peer Accelerator curriculum</p>
<p><a href="https://thedecider.app/" target="_blank" rel="noopener noreferrer" class="hover:text-neutral-900 dark:hover:text-neutral-100 underline">The Decider App</a></p>
<p><a href="https://patterns.sociocracy30.org/index.html" target="_blank" rel="noopener noreferrer" class="hover:text-neutral-900 dark:hover:text-neutral-100 underline">Sociocracy 3.0</a></p>
</div>
</div>
</div>
</div>
<!-- Export Options at Bottom -->
@ -571,7 +577,7 @@ const urgencyOptions = [
{
value: 5,
title: "Crisis mode",
description: "This decision is needed yesterday",
description: "We should have decided yesterday!!",
},
];
@ -602,7 +608,7 @@ const expertiseOptions = [
{
value: "multiple",
title: "Multiple experts",
description: "Several people have relevant expertise",
description: "Several people have expertise",
},
{
value: "distributed",
@ -620,7 +626,7 @@ const impactOptions = [
{
value: "narrow",
title: "One person or small team",
description: "Affects specific individuals or department",
description: "Affects specific individuals or area",
},
{
value: "wide",
@ -633,7 +639,7 @@ const optionsOptions = [
{
value: "clear",
title: "Clear choices",
description: "We know our options and their trade-offs",
description: "We know our options and their tradeoffs",
},
{
value: "emerging",
@ -768,7 +774,7 @@ function determineFramework() {
method: "Strategic Delay",
tagline: "Wait for clarity to emerge",
reasoning:
"It's not urgent, options aren't clear, and people aren't strongly invested. Sometimes the best decision is to not decide yet.",
"It's not urgent, options aren't clear, and people aren't strongly invested. Sometimes the best decision is to hold off on deciding!",
steps: [
"Acknowledge the decision exists",
"Set a future check-in date",
@ -803,7 +809,7 @@ function determineFramework() {
"This is a high-stakes, permanent decision affecting everyone who cares deeply. Take the time to get real alignment.",
steps: [
"Share context and constraints with everyone",
"Gather all perspectives (async or sync)",
"Gather all perspectives (async or live discussion)",
"Identify shared values and concerns",
"Iterate on proposals until everyone can support it",
"Document the decision and everyone's commitment",
@ -902,7 +908,7 @@ function determineFramework() {
state.reversible === "high"
) {
return {
method: "Controlled Randomness",
method: "Roll the Dice",
tagline: "Let chance break the tie",
reasoning:
"Options are equally good, stakes are low, and people aren't strongly invested. Save time and energy.",

View file

@ -207,7 +207,8 @@
</li>
<li>Values alignment conversation</li>
<li class="flex items-baseline gap-2 flex-wrap">
Optional - Equal buy-in contribution of $<UInput
Optional - Equal buy-in contribution of {{ currencySymbol
}}<UInput
v-model="formData.buyInAmount"
type="number"
placeholder="1000"
@ -307,7 +308,8 @@
</h3>
<p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
Decisions under $<UInput
Decisions under {{ currencySymbol
}}<UInput
v-model="formData.dayToDayLimit"
type="number"
placeholder="100"
@ -325,13 +327,15 @@
</h3>
<p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
Decisions between $<UInput
Decisions between {{ currencySymbol
}}<UInput
v-model="formData.regularDecisionMin"
type="number"
placeholder="100"
class="inline-field number-field w-10"
@change="autoSave" />
and $<UInput
and {{ currencySymbol
}}<UInput
v-model="formData.regularDecisionMax"
type="number"
placeholder="1000"
@ -355,7 +359,8 @@
<li>Adding or removing members</li>
<li>Changing this agreement</li>
<li class="flex items-baseline gap-2 flex-wrap">
Taking on debt over $<UInput
Taking on debt over {{ currencySymbol
}}<UInput
v-model="formData.majorDebtThreshold"
type="number"
placeholder="5000"
@ -418,8 +423,8 @@
</h3>
<p class="content-paragraph mb-3 leading-relaxed text-left">
Each member owns an equal share of
{{ getDisplayName().toLowerCase() }},
regardless of hours worked or tenure.
{{ getDisplayName().toLowerCase() }}, regardless of hours
worked or tenure.
</p>
</div>
@ -450,7 +455,8 @@
</p>
<ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap">
Base rate: $<UInput
Base rate: {{ currencySymbol
}}<UInput
v-model="formData.baseRate"
type="number"
placeholder="25"
@ -458,7 +464,8 @@
@change="autoSave" />/hour for all members
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Or: Equal monthly draw of $<UInput
Or: Equal monthly draw of {{ currencySymbol
}}<UInput
v-model="formData.monthlyDraw"
type="number"
placeholder="2000"
@ -478,7 +485,8 @@
</p>
<ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap">
Hourly rate: $<UInput
Hourly rate: {{ currencySymbol
}}<UInput
v-model="formData.hourlyRate"
type="number"
placeholder="25"
@ -506,7 +514,8 @@
</li>
<li>Regular needs assessment and adjustment process</li>
<li class="flex items-baseline gap-2 flex-wrap">
Minimum guaranteed amount: $<UInput
Minimum guaranteed amount: {{ currencySymbol
}}<UInput
v-model="formData.minGuaranteedPay"
type="number"
placeholder="1000"
@ -521,27 +530,24 @@
<p class="content-paragraph font-semibold">
Payment Schedule:
</p>
<ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap">
<p class="">
Paid on the
<USelect
<USelectMenu
v-model="formData.paymentDay"
:items="dayOptions"
placeholder="15th"
class="inline-field"
size="lg"
class="w-48"
@change="autoSave" />
of each month
</li>
<li class="flex items-baseline gap-2 flex-wrap">
Surplus (profit) distributed equally every
of each month and the surplus (profit) distributed equally
every
<UInput
v-model="formData.surplusFrequency"
placeholder="quarter"
class="inline-field"
@change="autoSave" />
</li>
</ul>
@change="autoSave" />.
</p>
</div>
</div>
@ -805,8 +811,8 @@
<div v-else class="text-neutral-600 dark:text-neutral-400 italic">
<p class="content-paragraph">
{{ getDisplayName() || "This cooperative" }} operates as
an informal collective. If we decide to register legally in the
{{ getDisplayName() || "This cooperative" }} operates as an
informal collective. If we decide to register legally in the
future, we'll update this section with our legal structure
details.
</p>
@ -826,10 +832,15 @@
<script setup>
import { ref, watch } from "vue";
import { getCurrencySymbol } from "~/utils/currency";
// Import centralized coop info
const { coopInfo, updateCoopInfo, getDisplayName } = useCoopInfo();
// Get currency symbol from global coop builder
const coop = useCoopBuilder();
const currencySymbol = computed(() => getCurrencySymbol(coop.currency.value));
definePageMeta({
layout: false,
});
@ -1092,10 +1103,13 @@ loadSavedData();
watch(
() => coopInfo.value,
(newCoopInfo) => {
formData.value.cooperativeName = newCoopInfo.cooperativeName || formData.value.cooperativeName;
formData.value.dateEstablished = newCoopInfo.dateEstablished || formData.value.dateEstablished;
formData.value.cooperativeName =
newCoopInfo.cooperativeName || formData.value.cooperativeName;
formData.value.dateEstablished =
newCoopInfo.dateEstablished || formData.value.dateEstablished;
formData.value.purpose = newCoopInfo.purpose || formData.value.purpose;
formData.value.coreValues = newCoopInfo.coreValues || formData.value.coreValues;
formData.value.coreValues =
newCoopInfo.coreValues || formData.value.coreValues;
},
{ deep: true, immediate: true }
);
@ -1349,12 +1363,6 @@ const exportData = computed(() => ({
/* Template-specific styles not in main.css */
.template-content {
max-width: none;
line-height: 1.6;
color: #1f2937;
}
/* Fiscal year group styling */
.fiscal-year-group {
display: flex;

View file

@ -142,22 +142,14 @@
<div
v-if="principleWeights[principle.id] > 0"
class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<label
:class="[
'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1',
nonNegotiables.includes(principle.id)
? 'selected'
: '',
]">
<input
type="checkbox"
:checked="nonNegotiables.includes(principle.id)"
@change="toggleNonNegotiable(principle.id)"
class="w-4 h-4" />
<span class="text-sm font-medium text-white">
Make this non-negotiable
</span>
</label>
<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 -->
@ -165,7 +157,7 @@
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-300 mb-1">
class="text-xs font-bold uppercase text-neutral-800 dark:text-neutral-300 mb-1">
Evaluation Criteria:
</div>
<div class="text-sm">
@ -213,7 +205,7 @@
: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 dark:!text-black'
? 'constraint-selected border-black dark:border-white cursor-pointer !bg-black !text-white dark:!bg-white dark:!text-black'
: 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]">
{{ option.label }}
@ -855,6 +847,32 @@ const toggleIntegration = (integration) => {
}
};
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 =

View file

@ -1,15 +1,15 @@
<template>
<div>
<div
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8"
>
class="template-container min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 py-8">
<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.
Fillable forms for cooperative documents. Data saves locally in your
browser.
</p>
</div>
@ -17,14 +17,14 @@
<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>
class="template-card h-full flex flex-col">
<div
class="relative bg-white dark:bg-neutral-950 border border-black dark:border-white p-6 h-full flex flex-col"
>
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">
<h3
class="text-xl font-semibold text-neutral-900 dark:text-white">
{{ template.name }}
</h3>
</div>
@ -35,8 +35,7 @@
<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"
>
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>
@ -51,8 +50,7 @@
<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"
>
style="font-family: 'Ubuntu Mono', monospace">
START WIZARD
</NuxtLink>
<NuxtLink
@ -60,8 +58,7 @@
: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"
>
style="font-family: 'Ubuntu Mono', monospace">
RESUME
</NuxtLink>
</div>
@ -81,7 +78,7 @@ 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.",
"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"],
@ -93,7 +90,7 @@ const templates = [
id: "conflict-resolution-framework",
name: "Conflict Resolution",
description:
"A customizable framework for handling conflicts with restorative justice principles, clear processes, and organizational values alignment.",
"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"],
@ -105,7 +102,7 @@ const templates = [
id: "tech-charter",
name: "Technology Charter",
description:
"Build technology decisions on cooperative values. Define principles, technical constraints, and evaluation criteria for vendor selection.",
"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"],
@ -117,7 +114,7 @@ const templates = [
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.",
"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"],
@ -150,45 +147,6 @@ useHead({
</script>
<style scoped>
@import url("https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap");
.dither-shadow {
background: black;
background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px;
}
@media (prefers-color-scheme: dark) {
.dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
}
:global(.dark) .dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
.dither-shadow-disabled {
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);
}
.template-card {
@apply relative;
font-family: "Ubuntu", monospace;