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

View file

@ -85,13 +85,6 @@
v-if="suggestedCategories.length > 0"> v-if="suggestedCategories.length > 0">
Consider developing: {{ suggestedCategories.join(", ") }} Consider developing: {{ suggestedCategories.join(", ") }}
</p> </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> </div>
</td> </td>
</tr> </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", id: "coop-builder",
name: "Settings", name: "Settings",
path: "/coop-builder", path: "/tools/coop-builder",
}, },
{ {
id: "budget", id: "budget",
name: "Studio Budget", name: "Studio Budget",
path: "/budget", path: "/tools/budget",
}, },
{ {
id: "project-budget", id: "project-budget",
name: "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 // Conflict Resolution Framework formatting - Complete document with all static and dynamic content
const formatConflictResolutionAsText = (data: any): string => { const formatConflictResolutionAsText = (data: any): string => {
const formData = data.formData || {}; // Use the pre-generated content from the Vue component
const cooperativeName = formData.orgName || "[Cooperative Name]"; return data.content || "No content available";
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;
}; };
const formatConflictResolutionAsMarkdown = (data: any): string => { const formatConflictResolutionAsMarkdown = (data: any): string => {
const formData = data.formData || {}; // Use the pre-generated content from the Vue component
const cooperativeName = formData.orgName || "[Cooperative Name]"; return data.content || "No content available";
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;
}; };
// Decision Framework formatting // 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,275 +2,296 @@
<div class="mx-auto"> <div class="mx-auto">
<div class="relative"> <div class="relative">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div> <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">
<!-- Controls -->
<div <div
class="p-6 border-b-1 border-black dark:border-neutral-400 bg-neutral-100 dark:bg-neutral-950"> class="relative bg-white dark:bg-neutral-950 border-1 border-black dark:border-neutral-400">
<div class="flex flex-wrap gap-4 items-center"> <!-- Controls -->
<div class="flex items-center gap-2"> <div
<UFormField label="Duration in months" class=""> class="p-6 border-b-1 border-black dark:border-neutral-400 bg-neutral-100 dark:bg-neutral-950">
<UInputNumber <div class="flex flex-wrap gap-4 items-center">
id="duration" <div class="flex items-center gap-2">
v-model="durationMonths" <UFormField label="Duration in months" class="">
:min="3" <UInputNumber
:max="24" id="duration"
size="lg" v-model="durationMonths"
class="w-full" /> :min="3"
</UFormField> :max="24"
size="lg"
class="w-full" />
</UFormField>
</div>
</div> </div>
</div> </div>
</div>
<!-- Cost Summary --> <!-- Cost Summary -->
<div <div
class="p-6 border-b-1 border-black bg-neutral-100 dark:bg-neutral-950"> class="p-6 border-b-1 border-black bg-neutral-100 dark:bg-neutral-950">
<ul class="space-y-2"> <ul class="space-y-2">
<!-- Two Column Layout --> <!-- Two Column Layout -->
<li class="pb-2"> <li class="pb-2">
<div class="grid grid-cols-2 gap-6"> <div class="grid grid-cols-2 gap-6">
<!-- Left Column: Detailed Breakdown --> <!-- Left Column: Detailed Breakdown -->
<div <div
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1"> class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<div class="font-bold font-display"> <div class="font-bold font-display">
Monthly payroll breakdown: Monthly payroll breakdown:
</div> </div>
<div> <div>
Base hourly rate: {{ currency(theoreticalHourlyRate) }}/hour Base hourly rate: {{ currency(theoreticalHourlyRate) }}/hour
</div> </div>
<div class="pl-2 space-y-0.5"> <div class="pl-2 space-y-0.5">
<div
v-for="member in props.members"
:key="member.name"
class="flex justify-between">
<span
>{{ member.name }} @ {{ member.hoursPerMonth }}h:</span
>
<span class="font-mono">{{
currency(member.hoursPerMonth * theoreticalHourlyRate)
}}</span>
</div>
</div>
<div <div
v-for="member in props.members" class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-medium">
:key="member.name" <span>Total base pay:</span>
class="flex justify-between"> <span class="font-mono">{{
currency(baseMonthlyPayroll)
}}</span>
</div>
<div class="flex justify-between">
<span <span
>{{ member.name }} @ {{ member.hoursPerMonth }}h:</span >Payroll taxes & benefits ({{
percent(props.oncostRate)
}}):</span
> >
<span class="font-mono">{{ <span class="font-mono">{{
currency(member.hoursPerMonth * theoreticalHourlyRate) currency(theoreticalOncosts)
}}</span>
</div>
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold">
<span>Total monthly payroll:</span>
<span class="font-mono">{{
currency(baseMonthlyPayroll + theoreticalOncosts)
}}</span> }}</span>
</div> </div>
</div> </div>
<!-- Right Column: Complete Project Budget Estimate -->
<div <div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-medium"> class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<span>Total base pay:</span> <div class="font-bold font-display">
<span class="font-mono">{{ Complete project budget estimate:
currency(baseMonthlyPayroll) </div>
}}</span> <p
</div> class="text-sm mb-2 italic text-neutral-500 dark:text-neutral-400">
<div class="flex justify-between"> This uses a 1.8x multiplier, based on industry standards.
<span </p>
>Payroll taxes & benefits ({{
percent(props.oncostRate) <!-- Team Payroll -->
}}):</span <div class="flex justify-between">
> <span class="font-bold">Team Payroll:</span>
<span class="font-mono">{{ <span class="font-mono font-bold">{{
currency(theoreticalOncosts) currency(projectBudget)
}}</span> }}</span>
</div> </div>
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold"> <!-- External Resources -->
<span>Total monthly payroll:</span> <div class="space-y-0.5">
<span class="font-mono">{{ <div class="flex justify-between">
currency(baseMonthlyPayroll + theoreticalOncosts) <span>External resources:</span>
}}</span> <span class="font-mono">{{
currency(externalResources)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Freelancers, contractors, consultants, voice talent
</div>
</div>
<!-- Tools & Software -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Tools and software:</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
tools/kits
</div>
</div>
<!-- Testing & QA -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Testing and QA:</span>
<span class="font-mono">{{ currency(testingQA) }}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
User testing sessions, focus groups, QA contractors,
playtesting, bug fixing
</div>
</div>
<!-- Marketing & Community -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Marketing and community:</span>
<span class="font-mono">{{
currency(marketingCommunity)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Community building, promotional materials, launch
preparation (minimum 10% for most funders)
</div>
</div>
<!-- Administration -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Administration:</span>
<span class="font-mono">{{
currency(administration)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Legal, accounting, insurance, project-specific business
costs
</div>
</div>
<!-- Subtotal -->
<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>
</div>
<!-- Contingency -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Contingency (10%):</span>
<span class="font-mono">{{ currency(contingency) }}</span>
</div>
</div>
<!-- Total -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold text-lg">
<span>TOTAL PROJECT:</span>
<span class="font-mono">{{
currency(totalProjectBudget)
}}</span>
</div>
</div> </div>
</div> </div>
<!-- Right Column: Complete Project Budget Estimate -->
<div
class="text-base text-neutral-600 dark:text-neutral-200 space-y-1">
<div class="font-bold font-display">
Complete project budget estimate:
</div>
<p
class="text-sm mb-2 italic text-neutral-500 dark:text-neutral-400">
This uses a 1.8x multiplier, based on industry standards.
</p>
<!-- Team Payroll -->
<div class="flex justify-between">
<span class="font-bold">Team Payroll:</span>
<span class="font-mono font-bold">{{
currency(projectBudget)
}}</span>
</div>
<!-- External Resources -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>External resources:</span>
<span class="font-mono">{{
currency(externalResources)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Freelancers, contractors, consultants, voice talent
</div>
</div>
<!-- Tools & Software -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Tools and software:</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
tools/kits
</div>
</div>
<!-- Testing & QA -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Testing and QA:</span>
<span class="font-mono">{{ currency(testingQA) }}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
User testing sessions, focus groups, QA contractors,
playtesting, bug fixing
</div>
</div>
<!-- Marketing & Community -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Marketing and community:</span>
<span class="font-mono">{{
currency(marketingCommunity)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Community building, promotional materials, launch
preparation (minimum 10% for most funders)
</div>
</div>
<!-- Administration -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Administration:</span>
<span class="font-mono">{{
currency(administration)
}}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Legal, accounting, insurance, project-specific business
costs
</div>
</div>
<!-- Subtotal -->
<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>
</div>
<!-- Contingency -->
<div class="space-y-0.5">
<div class="flex justify-between">
<span>Contingency (10%):</span>
<span class="font-mono">{{ currency(contingency) }}</span>
</div>
</div>
<!-- Total -->
<div
class="border-t border-neutral-300 dark:border-neutral-600 pt-1 flex justify-between font-bold text-lg">
<span>TOTAL PROJECT:</span>
<span class="font-mono">{{
currency(totalProjectBudget)
}}</span>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- Break-Even Sketch -->
<div
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>
<!-- Inputs -->
<div class="flex flex-wrap space-x-4 mb-6">
<div>
<label for="price" class="block font-bold text-sm mb-1"
>Price per copy:</label
>
<UInput
id="price"
v-model="price"
type="number"
placeholder="20.00"
size="lg"
class="w-32"
:ui="{ leading: 'pointer-events-none' }">
<template #leading>
<span class="text-sm font-mono">{{
formatCurrency(0, {
showSymbol: true,
precision: 0,
}).replace("0", "")
}}</span>
</template>
</UInput>
</div>
<div>
<label for="storeCut" class="block font-bold text-sm mb-1"
>Store cut:</label
>
<UInput
id="storeCut"
v-model="storeCutInput"
type="number"
placeholder="30"
size="lg"
class="w-24"
:ui="{ trailing: 'pointer-events-none' }">
<template #trailing>
<span class="text-sm font-mono">%</span>
</template>
</UInput>
</div>
<div>
<label for="reviewToSales" class="block font-bold text-sm mb-1"
>Sales per review:</label
>
<UInputNumber
id="reviewToSales"
v-model="reviewToSales"
:min="5"
:max="100"
size="lg" />
</div>
</div>
<!-- Outputs -->
<ul class="space-y-2 mb-4">
<li>
At {{ currency(price) }} per copy after store fees, you'd need
about
<strong>{{ unitsToBreakEven.toLocaleString() }} sales</strong> to
cover this budget.
</li>
<li>
That's roughly
<strong
>{{ reviewsToBreakEven.toLocaleString() }} Steam reviews</strong
>
( {{ reviewToSales }} sales per review).
</li> </li>
</ul> </ul>
<p class="text-base text-neutral-600 dark:text-neutral-400">
Assumes {{ percent(storeCutInput / 100) }} store fee. Taxes not
included.
</p>
</div> </div>
</div>
<!-- Break-Even Sketch -->
<div
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>
<label for="price" class="block font-bold text-sm mb-1"
>Price per copy:</label
>
<UInput
id="price"
v-model="price"
type="number"
placeholder="20.00"
size="lg"
class="w-32"
:ui="{ leading: 'pointer-events-none' }">
<template #leading>
<span class="text-sm font-mono">{{
formatCurrency(0, {
showSymbol: true,
precision: 0,
}).replace("0", "")
}}</span>
</template>
</UInput>
</div>
<div>
<label for="storeCut" class="block font-bold text-sm mb-1"
>Store cut:</label
>
<UInput
id="storeCut"
v-model="storeCutInput"
type="number"
placeholder="30"
size="lg"
class="w-24"
:ui="{ trailing: 'pointer-events-none' }">
<template #trailing>
<span class="text-sm font-mono">%</span>
</template>
</UInput>
</div>
<div>
<label for="reviewToSales" class="block font-bold text-sm mb-1"
>Sales per review:</label
>
<UInputNumber
id="reviewToSales"
v-model="reviewToSales"
:min="5"
:max="100"
size="lg" />
</div>
</div>
<!-- Outputs -->
<ul class="space-y-2 mb-4">
<li>
At {{ currency(price) }} per copy after store fees, you'd need
about
<strong class="bg-yellow-200 dark:text-black"
>{{ unitsToBreakEven.toLocaleString() }} sales</strong
>
to cover this budget.
</li>
<li>
That's roughly
<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">
Taxes not included.
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

@ -1,9 +1,8 @@
<template> <template>
<div class="mb-12"> <div class="">
<div class="w-full mx-auto"> <div class="w-full mx-auto">
<nav <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 <NuxtLink
v-for="wizard in templateWizards" v-for="wizard in templateWizards"
:key="wizard.id" :key="wizard.id"
@ -13,8 +12,7 @@
isActive(wizard.path) isActive(wizard.path)
? 'bg-black text-white dark:bg-white dark:text-black no-underline' ? 'bg-black text-white dark:bg-white dark:text-black no-underline'
: '' : ''
" ">
>
{{ wizard.name }} {{ wizard.name }}
</NuxtLink> </NuxtLink>
</nav> </nav>
@ -29,22 +27,22 @@ const templateWizards = [
{ {
id: "membership-agreement", id: "membership-agreement",
name: "Membership Agreement", name: "Membership Agreement",
path: "/templates/membership-agreement", path: "/tools/templates/membership-agreement",
}, },
{ {
id: "conflict-resolution-framework", id: "conflict-resolution-framework",
name: "Conflict Resolution", name: "Conflict Resolution",
path: "/templates/conflict-resolution-framework", path: "/tools/templates/conflict-resolution-framework",
}, },
{ {
id: "tech-charter", id: "tech-charter",
name: "Tech Charter", name: "Tech Charter",
path: "/templates/tech-charter", path: "/tools/templates/tech-charter",
}, },
{ {
id: "decision-framework", id: "decision-framework",
name: "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 = () => { 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> <template>
<section class="py-8 space-y-6"> <div class="min-h-screen">
<div class="flex items-center justify-between"> <!-- Noise overlay and effects -->
<div> <div class="noise" />
<h2 class="text-2xl font-semibold">Compensation</h2>
<div class="flex items-center gap-2 mt-1"> <div class="container">
<span class="text-xs font-mono"> <section class="coming-soon">
Runway: {{ Math.round(metrics.runway) }}mo <header>
</span> <div class="glitch-container">
</div> <div class="logo">coop.love</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('/dashboard', 'concentration')"
class="text-sm underline font-bold">
VIEW DETAILS
</button>
</div>
</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> </div>
<!-- Cushion Breach Alert --> <!-- Tools access -->
<div <div class="tools-access">
v-if="alerts.cushionBreach" <NuxtLink to="/tools" class="tools-link">
class="border-2 border-orange-600 bg-orange-50 p-4"> Access Urgent Tools
<div class="flex items-start gap-3"> </NuxtLink>
<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> </div>
</section>
<!-- 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> </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>
</div>
</section>
</template> </template>
<script setup lang="ts"> <script setup>
// Dashboard page useHead({
const { $format } = useNuxtApp(); title: 'coop.love ❤️ Cooperative Game Development',
meta: [
{ name: 'description', content: 'A new space to celebrate values-driven cooperative game development around the world.' }
]
})
</script>
// Use real store data instead of fixtures <style scoped>
const membersStore = useMembersStore(); /* Import Google Fonts for Courier Prime */
const policiesStore = usePoliciesStore(); @import url("https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap");
const streamsStore = useStreamsStore();
const budgetStore = useBudgetStore();
const cashStore = useCashStore();
// Runway composable with operating mode integration :root {
const { getDualModeRunway, getMonthlyBurn } = useRunway(); --accent-1: #333333;
--accent-2: #555555;
// Cushion forecast and savings progress --accent-3: #777777;
const { savingsProgress, cushionForecast, alerts } = useCushionForecast(); --light: #f5f5f5;
--lighter: #ffffff;
--text: #0a0a0a;
// Calculate metrics from real store data --scanline: rgba(0, 0, 0, 0.05);
const metrics = computed(() => { --font-stack: "Courier Prime", "Consolas", "Menlo", "Monaco",
const totalTargetHours = membersStore.members.reduce( "Courier New", monospace;
(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'
} }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
// Cash breach description .min-h-screen {
const cashBreachDescription = computed(() => { background-color: var(--lighter);
// Check cash store for first breach week from projections background-image: radial-gradient(
const breachWeek = cashStore.weeklyProjections.find( circle at 50% 50%,
(week) => week.breachesCushion rgba(245, 245, 245, 0.6) 0%,
rgba(250, 250, 250, 0.8) 70%,
var(--lighter) 100%
); );
if (breachWeek) { color: var(--text);
return `Week ${breachWeek.number} would drop below your minimum cushion.`; 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;
} }
return "No cushion breach currently projected."; 20%,
}); 21.999%,
63%,
63.999%,
65%,
69.999% {
opacity: 0.5;
}
}
const onExport = () => { @keyframes glitch-1 {
const data = exportAll(); 0%,
const blob = new Blob([JSON.stringify(data, null, 2)], { 100% {
type: "application/json", transform: translate(0);
}); }
const url = URL.createObjectURL(blob); 20% {
const a = document.createElement("a"); transform: translate(-2px, 2px);
a.href = url; }
a.download = "urgent-tools.json"; 40% {
a.click(); transform: translate(-2px, -2px);
URL.revokeObjectURL(url); }
}; 60% {
transform: translate(2px, 2px);
}
80% {
transform: translate(2px, -2px);
}
}
const onImport = async () => { @keyframes glitch-2 {
const input = document.createElement("input"); 0%,
input.type = "file"; 100% {
input.accept = "application/json"; transform: translate(0);
input.onchange = async () => { }
const file = input.files?.[0]; 25% {
if (!file) return; transform: translate(2px, -2px);
const text = await file.text(); }
importAll(JSON.parse(text)); 50% {
}; transform: translate(-2px, 2px);
input.click(); }
}; 75% {
transform: translate(2px, 2px);
}
}
const { exportAll, importAll } = useFixtureIO(); .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;
}
// Deferred alert logic .embeddable-buttondown-form label {
const deferredAlert = computed(() => { margin-bottom: 0.5rem;
const maxDeferredRatio = 1.5; // From CLAUDE.md - flag if >1.5× monthly payroll font-size: 1.1rem;
const monthlyPayrollCost = getMonthlyBurn() * 0.7; // Estimate payroll as 70% of burn color: var(--accent-1);
const totalDeferred = membersStore.members.reduce( }
(sum, m) => sum + (m.deferredHours || 0) * policiesStore.equalHourlyWage,
0 .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;
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 .embeddable-buttondown-form input[type="submit"]:hover:before {
function handleAlertNavigation(path: string, section?: string) { left: 100%;
// Store alert context for target page to highlight relevant section }
if (section) {
localStorage.setItem('urgent-tools-alert-context', JSON.stringify({ section, timestamp: Date.now() })); .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%;
} }
navigateTo(path);
}; header {
</script> 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> </p>
<div class="flex justify-center"> <div class="flex justify-center">
<NuxtLink <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"> 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 Complete Setup Wizard
</NuxtLink> </NuxtLink>

View file

@ -39,7 +39,7 @@
:disabled="isResetting"> :disabled="isResetting">
Start Over Start Over
</button> </button>
<button class="export-btn primary" @click="navigateTo('/budget')"> <button class="export-btn primary" @click="navigateTo('/tools/budget')">
Go to Dashboard Go to Dashboard
</button> </button>
</div> </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. No team members set up yet.
</p> </p>
<NuxtLink <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"> 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 Set up your team in Setup Wizard
</NuxtLink> </NuxtLink>

View file

@ -76,6 +76,7 @@
v-model="value.checked" v-model="value.checked"
:id="`core-value-${index}`" :id="`core-value-${index}`"
:label="value.label" :label="value.label"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</div> </div>
</div> </div>
@ -124,6 +125,7 @@
v-model="conflict.checked" v-model="conflict.checked"
:id="`conflict-type-${index}`" :id="`conflict-type-${index}`"
:label="conflict.label" :label="conflict.label"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</div> </div>
</div> </div>
@ -157,6 +159,7 @@
id="anonymous-reporting" id="anonymous-reporting"
label="Allow anonymous reporting" label="Allow anonymous reporting"
help="Members can report issues without revealing their identity" help="Members can report issues without revealing their identity"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
</div> </div>
@ -191,6 +194,7 @@
v-model="receiver.checked" v-model="receiver.checked"
:id="`report-receiver-${index}`" :id="`report-receiver-${index}`"
:label="receiver.label" :label="receiver.label"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</div> </div>
</div> </div>
@ -221,6 +225,7 @@
id="support-people" id="support-people"
label="Allow support people in mediation sessions" label="Allow support people in mediation sessions"
help="Parties can bring a trusted person for emotional support" help="Parties can bring a trusted person for emotional support"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
</div> </div>
@ -283,6 +288,7 @@
v-model="step.checked" v-model="step.checked"
:id="`process-step-${index}`" :id="`process-step-${index}`"
:label="`${index + 1}. ${step.label}`" :label="`${index + 1}. ${step.label}`"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</div> </div>
</div> </div>
@ -378,6 +384,7 @@
v-model="action.checked" v-model="action.checked"
:id="`available-action-${index}`" :id="`available-action-${index}`"
:label="action.label" :label="action.label"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</div> </div>
</div> </div>
@ -396,6 +403,7 @@
id="appeal-process" id="appeal-process"
label="Include appeals process" label="Include appeals process"
help="Parties can request review of decisions" help="Parties can request review of decisions"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
</div> </div>
@ -439,6 +447,7 @@
v-model="circumstance.checked" v-model="circumstance.checked"
:id="`special-circumstance-${index}`" :id="`special-circumstance-${index}`"
:label="circumstance.label" :label="circumstance.label"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</div> </div>
</div> </div>
@ -598,6 +607,7 @@
v-model="channel.checked" v-model="channel.checked"
:id="`comm-channel-${index}`" :id="`comm-channel-${index}`"
:label="channel.label" :label="channel.label"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</div> </div>
</div> </div>
@ -611,6 +621,7 @@
id="require-direct-attempt" id="require-direct-attempt"
label="Require direct resolution attempt before escalation" label="Require direct resolution attempt before escalation"
help="Parties must try to resolve directly before filing complaints" help="Parties must try to resolve directly before filing complaints"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
@ -621,6 +632,7 @@
id="document-direct-resolution" id="document-direct-resolution"
label="Require written record of direct resolution attempts" label="Require written record of direct resolution attempts"
help="Parties should document outcomes of direct conversations" help="Parties should document outcomes of direct conversations"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
</div> </div>
@ -699,6 +711,7 @@
v-model="element.checked" v-model="element.checked"
:id="`complaint-element-${index}`" :id="`complaint-element-${index}`"
:label="element.label" :label="element.label"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</div> </div>
</div> </div>
@ -737,6 +750,7 @@
id="require-external-advice" id="require-external-advice"
label="Require external legal advice for complex complaints" label="Require external legal advice for complex complaints"
help="Seek external expertise for multi-party or member-coordinator complaints" help="Seek external expertise for multi-party or member-coordinator complaints"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
</div> </div>
@ -753,6 +767,7 @@
id="minutes-of-settlement" id="minutes-of-settlement"
label="Require 'Minutes of Settlement' for resolved complaints" label="Require 'Minutes of Settlement' for resolved complaints"
help="Agreements must be documented in writing and signed by both parties" help="Agreements must be documented in writing and signed by both parties"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
@ -806,6 +821,7 @@
id="include-human-rights" id="include-human-rights"
label="Include Human Rights Commission information" label="Include Human Rights Commission information"
help="Reference external discrimination complaint options" help="Reference external discrimination complaint options"
class="w-full"
@change="autoSave" /> @change="autoSave" />
</UFormField> </UFormField>
@ -1041,7 +1057,7 @@ const coreValues = ref([
{ label: "Mutual Care", checked: true }, { label: "Mutual Care", checked: true },
{ label: "Transparency", checked: true }, { label: "Transparency", checked: true },
{ label: "Accountability", checked: false }, { label: "Accountability", checked: false },
{ label: "Consent-Based", checked: false }, { label: "Consent", checked: false },
{ label: "Anti-Oppression", checked: false }, { label: "Anti-Oppression", checked: false },
{ label: "Restorative Justice", checked: false }, { label: "Restorative Justice", checked: false },
{ label: "Collective Liberation", checked: false }, { label: "Collective Liberation", checked: false },
@ -1280,7 +1296,7 @@ watch(
{ deep: true } { deep: true }
); );
// Generate the complete policy document for preview and export // Comprehensive generatePolicyDocument function with procedural structure
const generatePolicyDocument = () => { const generatePolicyDocument = () => {
const cooperativeName = formData.value.orgName || "[Cooperative Name]"; const cooperativeName = formData.value.orgName || "[Cooperative Name]";
let content = `# ${cooperativeName} Conflict Resolution Policy\n\n`; let content = `# ${cooperativeName} Conflict Resolution Policy\n\n`;
@ -1293,25 +1309,93 @@ const generatePolicyDocument = () => {
} }
content += `\n---\n\n`; content += `\n---\n\n`;
// Core Values section (if enabled) // PURPOSE SECTION
if (sectionsEnabled.value.values) { content += `## Purpose\n\n`;
content += `## Our Values\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 += `This conflict resolution framework is guided by our core values:\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); const selectedValues = coreValues.value.filter((v) => v.checked);
if (selectedValues.length > 0) { if (selectedValues.length > 0) {
content += `Additionally, this framework is guided by our core values:\n\n`;
selectedValues.forEach((value) => { selectedValues.forEach((value) => {
content += `- **${value.label}**\n`; content += `- **${value.label}**\n`;
}); });
content += `\n`; content += `\n`;
} }
if (formData.value.customValues) { if (formData.value.customValues) {
content += `${formData.value.customValues}\n\n`; 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 = { const approachDescriptions = {
restorative: restorative:
"We use a **restorative/loving justice** approach that focuses on healing, understanding root causes, and repairing relationships rather than punishment.", "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`; 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) { 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 += `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 += `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 += `2. **Consider uncertainties** or misunderstandings that may have occurred.\n`;
@ -1352,20 +1436,20 @@ const generatePolicyDocument = () => {
content += `**Reflection Timing:** ${reflectionTiming}\n\n`; content += `**Reflection Timing:** ${reflectionTiming}\n\n`;
} }
// Direct Resolution (if enabled) // DIRECT RESOLUTION (if enabled)
if (sectionsEnabled.value.directResolution) { if (sectionsEnabled.value.directResolution) {
content += `## Direct Resolution\n\n`; 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 += `### 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 += `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 += `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 += `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 += `3. **The point is mutual understanding**, not determining who is right or wrong.\n`;
content += `4. **Express thoughts and feelings directly** without belittling or dismissing. Use "I" statements and active listening techniques.\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 += `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) { if (formData.value.documentDirectResolution) {
content += `7. **Keep a written record** of the resolution agreed to by both parties.\n\n`; 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 += `## 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 // Responsible Contact People
const selectedReceivers = reportReceivers.value.filter((r) => r.checked); content += `### Responsible Contact People\n\n`;
if (selectedReceivers.length > 0) { if (selectedReceivers.length > 0) {
content += `### Initial Contact Options\n\n`; content += `**Initial Contact Options:**\n`;
content += `You can report conflicts to any of the following:\n\n`;
selectedReceivers.forEach((receiver) => { selectedReceivers.forEach((receiver) => {
content += `- ${receiver.label}\n`; content += `- ${receiver.label}\n`;
}); });
content += `\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 // Mediator Structure
if (formData.value.mediatorType) { if (formData.value.mediatorType) {
content += `### Mediation/Facilitation\n\n`; content += `**Mediation Structure:** ${formData.value.mediatorType}\n`;
content += `**Structure:** ${formData.value.mediatorType}\n\n`;
if (formData.value.supportPeople) { 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 // Timeline
content += `### Response Times\n\n`; content += `### Response Timeline\n\n`;
content += `| Stage | Timeframe |\n`;
content += `|-------|----------|\n`;
if (formData.value.initialResponse) { if (formData.value.initialResponse) {
content += `- **Initial Response:** ${formData.value.initialResponse}\n`; content += `| Initial Response | ${formData.value.initialResponse} |\n`;
} }
if (formData.value.resolutionTarget) { 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 += `## 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 // Required Elements
const selectedElements = formalComplaintElements.value.filter( const selectedElements = formalComplaintElements.value.filter(
@ -1444,58 +1660,137 @@ const generatePolicyDocument = () => {
// Formal Process Timeline // Formal Process Timeline
content += `### Formal Process Timeline\n\n`; content += `### Formal Process Timeline\n\n`;
content += `| Stage | Timeframe |\n`;
content += `|-------|----------|\n`;
if (formData.value.formalAcknowledgmentTime) { if (formData.value.formalAcknowledgmentTime) {
content += `- **Acknowledgment:** ${formData.value.formalAcknowledgmentTime}\n`; content += `| Acknowledgment of complaint | ${formData.value.formalAcknowledgmentTime} |\n`;
} }
if (formData.value.formalReviewTime) { 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) { if (formData.value.requireExternalAdvice) {
content += `> **External Expertise:** For complex complaints involving multiple parties or organizational leaders, external legal advice will be sought.\n\n`; content += `> **External Expertise:** For complex complaints involving multiple parties or organizational leaders, external legal advice will be sought.\n\n`;
} }
// Settlement Documentation // PREVENTING RETALIATION (if anti-retaliation is selected)
if (formData.value.requireMinutesOfSettlement) { const hasAntiRetaliation = specialCircumstances.value.some(
content += `### Reaching Agreement\n\n`; (c) => c.checked && c.label.toLowerCase().includes("retaliation")
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`; );
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 // SETTLEMENT & DOCUMENTATION
const selectedActions = availableActions.value.filter((a) => a.checked); content += `## Settlement & Documentation\n\n`;
if (selectedActions.length > 0) {
content += `## Possible Outcomes\n\n`; if (formData.value.requireMinutesOfSettlement) {
content += `Depending on the situation, resolution may include:\n\n`; content += `### Minutes of Settlement\n`;
selectedActions.forEach((action) => { content += `Any resolution must be documented in "Minutes of Settlement" that:\n`;
content += `- ${action.label}\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`; content += `\n`;
} }
if (formData.value.appealProcess) { // SPECIAL CIRCUMSTANCES (if enabled)
content += `### Appeals Process\n\n`; if (sectionsEnabled.value.special) {
content += `Parties may request review of decisions through our appeals process.\n\n`; const selectedCircumstances = specialCircumstances.value.filter(
} (c) => c.checked
);
if (selectedCircumstances.length > 0) {
content += `## Special Circumstances\n\n`;
// Documentation and Privacy const hasImmediateRemoval = selectedCircumstances.some(
if (sectionsEnabled.value.documentation) { (c) =>
content += `## Documentation & Privacy\n\n`; c.label.toLowerCase().includes("immediate removal") ||
if (formData.value.docLevel) { c.label.toLowerCase().includes("safety")
content += `**Documentation Level:** ${formData.value.docLevel}\n\n`; );
}
if (formData.value.confidentiality) { if (hasImmediateRemoval) {
content += `**Confidentiality:** ${formData.value.confidentiality}\n\n`; content += `### Immediate Safety Threats\n`;
} content += `When anyone's physical safety is threatened:\n`;
if (formData.value.retention) { content += `1. Immediately remove the offender from the space\n`;
content += `**Record Retention:** ${formData.value.retention}\n\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`;
}
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`;
}
} }
} }
// External Resources (if enabled) // EXTERNAL RESOURCES (if enabled)
if (sectionsEnabled.value.externalResources) { if (sectionsEnabled.value.externalResources) {
content += `## External Resources\n\n`; content += `## External Resources & Redress\n\n`;
if (formData.value.includeHumanRights) { 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) { if (formData.value.additionalResources) {
@ -1504,20 +1799,30 @@ const generatePolicyDocument = () => {
} }
} }
// Implementation // IMPLEMENTATION & TRAINING
content += `## Policy Management\n\n`; content += `## Implementation\n\n`;
if (formData.value.training) { if (formData.value.training) {
content += `### Training Requirements\n\n`; content += `### Training Requirements\n\n`;
content += `${formData.value.training}\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) { 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) { 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 // Acknowledgments
if (formData.value.acknowledgments) { if (formData.value.acknowledgments) {
@ -1558,6 +1863,8 @@ const exportData = computed(() => {
return { return {
section: "conflict-resolution-framework", section: "conflict-resolution-framework",
// Add the generated policy document content for exports
content: generatePolicyDocument(),
// Enhanced formData with processed arrays // Enhanced formData with processed arrays
formData: { formData: {
...formData.value, ...formData.value,

View file

@ -440,7 +440,7 @@
:key="step" :key="step"
class="flex items-start"> class="flex items-start">
<span <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
> >
<span class="text-neutral-700 dark:text-neutral-300">{{ <span class="text-neutral-700 dark:text-neutral-300">{{
@ -453,7 +453,7 @@
<div v-if="result.tips" class="section-card"> <div v-if="result.tips" class="section-card">
<h3 <h3
class="text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-4"> class="text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-4">
Pro tips: Hot tips:
</h3> </h3>
<ul class="space-y-3"> <ul class="space-y-3">
<li <li
@ -461,7 +461,7 @@
:key="tip" :key="tip"
class="flex items-start"> class="flex items-start">
<span <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
> >
<span class="text-neutral-700 dark:text-neutral-300">{{ <span class="text-neutral-700 dark:text-neutral-300">{{
@ -508,13 +508,6 @@
<UButton @click="resetForm" size="lg" color="primary"> <UButton @click="resetForm" size="lg" color="primary">
Try Another Decision Try Another Decision
</UButton> </UButton>
<UButton
@click="printResult"
size="lg"
variant="outline"
color="primary">
Print Recommendation
</UButton>
</div> </div>
</div> </div>
</div> </div>
@ -522,6 +515,19 @@
</div> </div>
</div> </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> </div>
<!-- Export Options at Bottom --> <!-- Export Options at Bottom -->
@ -571,7 +577,7 @@ const urgencyOptions = [
{ {
value: 5, value: 5,
title: "Crisis mode", title: "Crisis mode",
description: "This decision is needed yesterday", description: "We should have decided yesterday!!",
}, },
]; ];
@ -602,7 +608,7 @@ const expertiseOptions = [
{ {
value: "multiple", value: "multiple",
title: "Multiple experts", title: "Multiple experts",
description: "Several people have relevant expertise", description: "Several people have expertise",
}, },
{ {
value: "distributed", value: "distributed",
@ -620,7 +626,7 @@ const impactOptions = [
{ {
value: "narrow", value: "narrow",
title: "One person or small team", title: "One person or small team",
description: "Affects specific individuals or department", description: "Affects specific individuals or area",
}, },
{ {
value: "wide", value: "wide",
@ -633,7 +639,7 @@ const optionsOptions = [
{ {
value: "clear", value: "clear",
title: "Clear choices", title: "Clear choices",
description: "We know our options and their trade-offs", description: "We know our options and their tradeoffs",
}, },
{ {
value: "emerging", value: "emerging",
@ -768,7 +774,7 @@ function determineFramework() {
method: "Strategic Delay", method: "Strategic Delay",
tagline: "Wait for clarity to emerge", tagline: "Wait for clarity to emerge",
reasoning: 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: [ steps: [
"Acknowledge the decision exists", "Acknowledge the decision exists",
"Set a future check-in date", "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.", "This is a high-stakes, permanent decision affecting everyone who cares deeply. Take the time to get real alignment.",
steps: [ steps: [
"Share context and constraints with everyone", "Share context and constraints with everyone",
"Gather all perspectives (async or sync)", "Gather all perspectives (async or live discussion)",
"Identify shared values and concerns", "Identify shared values and concerns",
"Iterate on proposals until everyone can support it", "Iterate on proposals until everyone can support it",
"Document the decision and everyone's commitment", "Document the decision and everyone's commitment",
@ -902,7 +908,7 @@ function determineFramework() {
state.reversible === "high" state.reversible === "high"
) { ) {
return { return {
method: "Controlled Randomness", method: "Roll the Dice",
tagline: "Let chance break the tie", tagline: "Let chance break the tie",
reasoning: reasoning:
"Options are equally good, stakes are low, and people aren't strongly invested. Save time and energy.", "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>
<li>Values alignment conversation</li> <li>Values alignment conversation</li>
<li class="flex items-baseline gap-2 flex-wrap"> <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" v-model="formData.buyInAmount"
type="number" type="number"
placeholder="1000" placeholder="1000"
@ -307,7 +308,8 @@
</h3> </h3>
<p <p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"> class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
Decisions under $<UInput Decisions under {{ currencySymbol
}}<UInput
v-model="formData.dayToDayLimit" v-model="formData.dayToDayLimit"
type="number" type="number"
placeholder="100" placeholder="100"
@ -325,13 +327,15 @@
</h3> </h3>
<p <p
class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap"> class="content-paragraph mb-3 leading-relaxed text-left flex items-baseline gap-2 flex-wrap">
Decisions between $<UInput Decisions between {{ currencySymbol
}}<UInput
v-model="formData.regularDecisionMin" v-model="formData.regularDecisionMin"
type="number" type="number"
placeholder="100" placeholder="100"
class="inline-field number-field w-10" class="inline-field number-field w-10"
@change="autoSave" /> @change="autoSave" />
and $<UInput and {{ currencySymbol
}}<UInput
v-model="formData.regularDecisionMax" v-model="formData.regularDecisionMax"
type="number" type="number"
placeholder="1000" placeholder="1000"
@ -355,7 +359,8 @@
<li>Adding or removing members</li> <li>Adding or removing members</li>
<li>Changing this agreement</li> <li>Changing this agreement</li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Taking on debt over $<UInput Taking on debt over {{ currencySymbol
}}<UInput
v-model="formData.majorDebtThreshold" v-model="formData.majorDebtThreshold"
type="number" type="number"
placeholder="5000" placeholder="5000"
@ -418,8 +423,8 @@
</h3> </h3>
<p class="content-paragraph mb-3 leading-relaxed text-left"> <p class="content-paragraph mb-3 leading-relaxed text-left">
Each member owns an equal share of Each member owns an equal share of
{{ getDisplayName().toLowerCase() }}, {{ getDisplayName().toLowerCase() }}, regardless of hours
regardless of hours worked or tenure. worked or tenure.
</p> </p>
</div> </div>
@ -450,7 +455,8 @@
</p> </p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Base rate: $<UInput Base rate: {{ currencySymbol
}}<UInput
v-model="formData.baseRate" v-model="formData.baseRate"
type="number" type="number"
placeholder="25" placeholder="25"
@ -458,7 +464,8 @@
@change="autoSave" />/hour for all members @change="autoSave" />/hour for all members
</li> </li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Or: Equal monthly draw of $<UInput Or: Equal monthly draw of {{ currencySymbol
}}<UInput
v-model="formData.monthlyDraw" v-model="formData.monthlyDraw"
type="number" type="number"
placeholder="2000" placeholder="2000"
@ -478,7 +485,8 @@
</p> </p>
<ul class="content-list my-2 pl-6 list-disc"> <ul class="content-list my-2 pl-6 list-disc">
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Hourly rate: $<UInput Hourly rate: {{ currencySymbol
}}<UInput
v-model="formData.hourlyRate" v-model="formData.hourlyRate"
type="number" type="number"
placeholder="25" placeholder="25"
@ -506,7 +514,8 @@
</li> </li>
<li>Regular needs assessment and adjustment process</li> <li>Regular needs assessment and adjustment process</li>
<li class="flex items-baseline gap-2 flex-wrap"> <li class="flex items-baseline gap-2 flex-wrap">
Minimum guaranteed amount: $<UInput Minimum guaranteed amount: {{ currencySymbol
}}<UInput
v-model="formData.minGuaranteedPay" v-model="formData.minGuaranteedPay"
type="number" type="number"
placeholder="1000" placeholder="1000"
@ -521,27 +530,24 @@
<p class="content-paragraph font-semibold"> <p class="content-paragraph font-semibold">
Payment Schedule: Payment Schedule:
</p> </p>
<ul class="content-list my-2 pl-6 list-disc"> <p class="">
<li class="flex items-baseline gap-2 flex-wrap"> Paid on the
Paid on the <USelectMenu
<USelect v-model="formData.paymentDay"
v-model="formData.paymentDay" :items="dayOptions"
:items="dayOptions" placeholder="15th"
placeholder="15th" size="lg"
class="inline-field" class="w-48"
@change="autoSave" /> @change="autoSave" />
of each month of each month and the surplus (profit) distributed equally
</li> every
<li class="flex items-baseline gap-2 flex-wrap"> <UInput
Surplus (profit) distributed equally every v-model="formData.surplusFrequency"
<UInput placeholder="quarter"
v-model="formData.surplusFrequency" class="inline-field"
placeholder="quarter" @change="autoSave" />.
class="inline-field" </p>
@change="autoSave" />
</li>
</ul>
</div> </div>
</div> </div>
@ -805,8 +811,8 @@
<div v-else class="text-neutral-600 dark:text-neutral-400 italic"> <div v-else class="text-neutral-600 dark:text-neutral-400 italic">
<p class="content-paragraph"> <p class="content-paragraph">
{{ getDisplayName() || "This cooperative" }} operates as {{ getDisplayName() || "This cooperative" }} operates as an
an informal collective. If we decide to register legally in the informal collective. If we decide to register legally in the
future, we'll update this section with our legal structure future, we'll update this section with our legal structure
details. details.
</p> </p>
@ -826,10 +832,15 @@
<script setup> <script setup>
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { getCurrencySymbol } from "~/utils/currency";
// Import centralized coop info // Import centralized coop info
const { coopInfo, updateCoopInfo, getDisplayName } = useCoopInfo(); const { coopInfo, updateCoopInfo, getDisplayName } = useCoopInfo();
// Get currency symbol from global coop builder
const coop = useCoopBuilder();
const currencySymbol = computed(() => getCurrencySymbol(coop.currency.value));
definePageMeta({ definePageMeta({
layout: false, layout: false,
}); });
@ -1092,10 +1103,13 @@ loadSavedData();
watch( watch(
() => coopInfo.value, () => coopInfo.value,
(newCoopInfo) => { (newCoopInfo) => {
formData.value.cooperativeName = newCoopInfo.cooperativeName || formData.value.cooperativeName; formData.value.cooperativeName =
formData.value.dateEstablished = newCoopInfo.dateEstablished || formData.value.dateEstablished; newCoopInfo.cooperativeName || formData.value.cooperativeName;
formData.value.dateEstablished =
newCoopInfo.dateEstablished || formData.value.dateEstablished;
formData.value.purpose = newCoopInfo.purpose || formData.value.purpose; 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 } { deep: true, immediate: true }
); );
@ -1349,12 +1363,6 @@ const exportData = computed(() => ({
/* Template-specific styles not in main.css */ /* 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 styling */
.fiscal-year-group { .fiscal-year-group {
display: flex; display: flex;

View file

@ -142,22 +142,14 @@
<div <div
v-if="principleWeights[principle.id] > 0" v-if="principleWeights[principle.id] > 0"
class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800"> class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<label <UCheckbox
:class="[ :model-value="nonNegotiables.includes(principle.id)"
'flex items-center gap-3 cursor-pointer item-label-bg px-2 py-1', @update:model-value="
nonNegotiables.includes(principle.id) (checked) =>
? 'selected' toggleNonNegotiableCheckbox(principle.id, checked)
: '', "
]"> label="Make this non-negotiable"
<input class="item-label-bg px-2 py-1" />
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>
</div> </div>
<!-- Show rubric description when selected --> <!-- Show rubric description when selected -->
@ -165,7 +157,7 @@
v-if="principleWeights[principle.id] > 0" v-if="principleWeights[principle.id] > 0"
class="mt-4 p-3 item-label-bg selected border border-neutral-200"> class="mt-4 p-3 item-label-bg selected border border-neutral-200">
<div <div
class="text-xs font-bold uppercase text-neutral-300 mb-1"> class="text-xs font-bold uppercase text-neutral-800 dark:text-neutral-300 mb-1">
Evaluation Criteria: Evaluation Criteria:
</div> </div>
<div class="text-sm"> <div class="text-sm">
@ -213,7 +205,7 @@
:class="[ :class="[
'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer', 'relative px-5 py-2 border-2 transition-all focus:outline-none focus:ring-1 focus:ring-black cursor-pointer',
constraints.sso === option.value 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', : 'border-neutral-300 hover:border-neutral-400 bg-white dark:bg-neutral-950 cursor-pointer',
]"> ]">
{{ option.label }} {{ 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 = () => { const resetForm = () => {
if (confirm("Are you sure you want to clear all form data and start over?")) { if (confirm("Are you sure you want to clear all form data and start over?")) {
charterPurpose.value = charterPurpose.value =

View file

@ -1,15 +1,15 @@
<template> <template>
<div> <div>
<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="max-w-6xl mx-auto px-4 relative">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-white mb-2"> <h1 class="text-3xl font-bold text-neutral-900 dark:text-white mb-2">
Wizards Wizards
</h1> </h1>
<p class="text-neutral-700 dark:text-neutral-200"> <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> </p>
</div> </div>
@ -17,14 +17,14 @@
<div <div
v-for="template in templates" v-for="template in templates"
:key="template.id" :key="template.id"
class="template-card h-full flex flex-col" class="template-card h-full flex flex-col">
>
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div <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"> <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 }} {{ template.name }}
</h3> </h3>
</div> </div>
@ -35,8 +35,7 @@
<span <span
v-for="tag in template.tags" v-for="tag in template.tags"
:key="tag" :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 }} {{ tag }}
</span> </span>
</div> </div>
@ -51,8 +50,7 @@
<NuxtLink <NuxtLink
:to="template.path" :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" 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 START WIZARD
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
@ -60,8 +58,7 @@
:to="template.path" :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" 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" title="Continue from saved data"
style="font-family: 'Ubuntu Mono', monospace" style="font-family: 'Ubuntu Mono', monospace">
>
RESUME RESUME
</NuxtLink> </NuxtLink>
</div> </div>
@ -81,7 +78,7 @@ const templates = [
id: "membership-agreement", id: "membership-agreement",
name: "Membership Agreement", name: "Membership Agreement",
description: 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", icon: "i-heroicons-user-group",
path: "/templates/membership-agreement", path: "/templates/membership-agreement",
tags: ["Legal", "Governance", "Membership"], tags: ["Legal", "Governance", "Membership"],
@ -93,7 +90,7 @@ const templates = [
id: "conflict-resolution-framework", id: "conflict-resolution-framework",
name: "Conflict Resolution", name: "Conflict Resolution",
description: 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", icon: "i-heroicons-scale",
path: "/templates/conflict-resolution-framework", path: "/templates/conflict-resolution-framework",
tags: ["Governance", "Process", "Care"], tags: ["Governance", "Process", "Care"],
@ -105,7 +102,7 @@ const templates = [
id: "tech-charter", id: "tech-charter",
name: "Technology Charter", name: "Technology Charter",
description: 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", icon: "i-heroicons-cog-6-tooth",
path: "/templates/tech-charter", path: "/templates/tech-charter",
tags: ["Technology", "Decision-Making", "Governance"], tags: ["Technology", "Decision-Making", "Governance"],
@ -117,7 +114,7 @@ const templates = [
id: "decision-framework", id: "decision-framework",
name: "Decision Framework Helper", name: "Decision Framework Helper",
description: 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", icon: "i-heroicons-light-bulb",
path: "/templates/decision-framework", path: "/templates/decision-framework",
tags: ["Decision-Making", "Process", "Governance"], tags: ["Decision-Making", "Process", "Governance"],
@ -150,45 +147,6 @@ useHead({
</script> </script>
<style scoped> <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 { .template-card {
@apply relative; @apply relative;
font-family: "Ubuntu", monospace; font-family: "Ubuntu", monospace;