Replaces the single-form New Proposal modal with a multi-step wizard aligned with the backend's structured revision data: - Basics: title and proposal type (General / Funding / Policy) - Decision: decision requested, rationale, responsible party, conflict disclosure - Scope & risks: in/out-of-scope lists and risk+mitigation pairs - Funding or Policy: type-specific structured fields (max authorization with currency, target date, finished criteria, milestones; supersedes, effective date) - Narrative: optional free-form markdown via PostBuilder, mapped to the additional context section - Review: grouped summary before submission The wizard builds commonData/typeData payloads and per-step validation, and Modal gains a footer slot used for Back/Next/Create navigation. Generated API models are extended in place with the new optional fields until the backend spec is regenerated. Verification: npx vue-tsc -b and npm run build both pass cleanly.master
| @@ -38,7 +38,7 @@ import { useRoute, useRouter } from "vue-router"; | |||||
| import ProposalContainer from "./ProposalContainer.vue"; | import ProposalContainer from "./ProposalContainer.vue"; | ||||
| import LoadingSpinner from "./LoadingSpinner.vue"; | import LoadingSpinner from "./LoadingSpinner.vue"; | ||||
| import { useLoading } from "../composables/useLoading.ts"; | import { useLoading } from "../composables/useLoading.ts"; | ||||
| import ProposalModal from "./modals/ProposalModal.vue"; | |||||
| import ProposalWizardModal from "./modals/ProposalWizardModal.vue"; | |||||
| import { ContentModal, useModal } from "../composables/useModal.ts"; | import { ContentModal, useModal } from "../composables/useModal.ts"; | ||||
| import FilterModal from "./modals/FilterModal.vue"; | import FilterModal from "./modals/FilterModal.vue"; | ||||
| import Filter from "./icons/Filter.vue"; | import Filter from "./icons/Filter.vue"; | ||||
| @@ -66,7 +66,7 @@ const handleSelection = (proposalId: number) => { | |||||
| }; | }; | ||||
| const showNewProposalModal = () => { | const showNewProposalModal = () => { | ||||
| showModal(ProposalModal as ContentModal); | |||||
| showModal(ProposalWizardModal as ContentModal); | |||||
| }; | }; | ||||
| const showFilterModal = () => { | const showFilterModal = () => { | ||||
| @@ -44,22 +44,24 @@ | |||||
| </main> | </main> | ||||
| <footer class="flex flex-col-reverse gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4 sm:flex-row sm:justify-end sm:px-6"> | <footer class="flex flex-col-reverse gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4 sm:flex-row sm:justify-end sm:px-6"> | ||||
| <button | |||||
| type="button" | |||||
| class="rounded-lg px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-200 focus:outline-none focus:ring-4 focus:ring-slate-300" | |||||
| @click="close" | |||||
| > | |||||
| Cancel | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="rounded-lg bg-amber-500 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/30 disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :disabled="submitDisabled" | |||||
| @click="handleSubmission" | |||||
| > | |||||
| {{ buttonText }} | |||||
| </button> | |||||
| <slot name="footer" :close="close" :submit="handleSubmission"> | |||||
| <button | |||||
| type="button" | |||||
| class="rounded-lg px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-200 focus:outline-none focus:ring-4 focus:ring-slate-300" | |||||
| @click="close" | |||||
| > | |||||
| Cancel | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="rounded-lg bg-amber-500 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/30 disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :disabled="submitDisabled" | |||||
| @click="handleSubmission" | |||||
| > | |||||
| {{ buttonText }} | |||||
| </button> | |||||
| </slot> | |||||
| </footer> | </footer> | ||||
| </section> | </section> | ||||
| </div> | </div> | ||||
| @@ -1,74 +0,0 @@ | |||||
| <template> | |||||
| <Modal | |||||
| v-model="isNewProposalOpen" | |||||
| header="New Proposal" | |||||
| button-text="Create proposal" | |||||
| :submit-disabled="!title.trim()" | |||||
| @submit="submit" | |||||
| > | |||||
| <div class="space-y-5"> | |||||
| <div> | |||||
| <label for="proposal-title" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Title | |||||
| </label> | |||||
| <input | |||||
| id="proposal-title" | |||||
| v-model="title" | |||||
| type="text" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="Briefly describe the proposal" | |||||
| aria-describedby="proposal-title-help" | |||||
| @keydown.meta.enter="submit" | |||||
| @keydown.ctrl.enter="submit" | |||||
| /> | |||||
| <p id="proposal-title-help" class="mt-1.5 text-xs text-slate-500"> | |||||
| Keep it concise; add supporting detail below. | |||||
| </p> | |||||
| </div> | |||||
| <div> | |||||
| <label class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Description | |||||
| </label> | |||||
| <PostBuilder @update:content="assignDescription" /> | |||||
| </div> | |||||
| </div> | |||||
| </Modal> | |||||
| </template> | |||||
| <script setup lang="ts" generic="T extends ContentModal"> | |||||
| import Modal from "./Modal.vue"; | |||||
| import { ref } from "vue"; | |||||
| import PostBuilder from "../PostBuilder.vue"; | |||||
| import { ContentModal } from "../../composables/useModal.ts"; | |||||
| import freighter from "@stellar/freighter-api"; | |||||
| import {ProposalService} from "../../generated/typescript"; | |||||
| import {useModal} from "../../composables/useModal.ts"; | |||||
| import {ProposalParagraph} from "../../types/proposal.ts"; | |||||
| const {getVisibility} = useModal(); | |||||
| const title = ref(''); | |||||
| const description = ref<ProposalParagraph[]>([]); | |||||
| const submit = async () => { | |||||
| if (await freighter.isAllowed()) { | |||||
| const addr = await freighter.getAddress(); | |||||
| await ProposalService.addProposal({creator: addr.address, name: title.value, paragraphs: description.value}); | |||||
| } | |||||
| } | |||||
| const assignDescription = (paragraphs: ProposalParagraph[]) => { | |||||
| description.value = paragraphs; | |||||
| } | |||||
| const isNewProposalOpen = ref(getVisibility()); | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,152 @@ | |||||
| <template> | |||||
| <Modal | |||||
| v-model="isWizardOpen" | |||||
| header="New Proposal" | |||||
| > | |||||
| <div class="space-y-6"> | |||||
| <WizardStepper | |||||
| :steps="steps" | |||||
| :current="stepIndex" | |||||
| :max-visited="maxVisitedIndex" | |||||
| @go-to="goTo" | |||||
| /> | |||||
| <div | |||||
| v-if="currentStepError" | |||||
| class="rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-900" | |||||
| role="status" | |||||
| > | |||||
| {{ currentStepError }} | |||||
| </div> | |||||
| <div | |||||
| v-if="submitError" | |||||
| class="rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-700" | |||||
| role="alert" | |||||
| > | |||||
| {{ submitError }} | |||||
| </div> | |||||
| <WizardBasicsStep v-if="currentStep.id === 'basics'" :draft="draft" /> | |||||
| <WizardDecisionStep v-else-if="currentStep.id === 'decision'" :draft="draft" /> | |||||
| <WizardScopeStep v-else-if="currentStep.id === 'scope'" :draft="draft" /> | |||||
| <WizardFundingStep v-else-if="currentStep.id === 'details' && draft.proposalType === 'FUNDING'" :draft="draft" /> | |||||
| <WizardPolicyStep v-else-if="currentStep.id === 'details'" :draft="draft" /> | |||||
| <WizardNarrativeStep v-else-if="currentStep.id === 'narrative'" :draft="draft" /> | |||||
| <WizardReviewStep v-else :draft="draft" :formatted-amount="formatFundingAmount()" /> | |||||
| </div> | |||||
| <template #footer="{ close }"> | |||||
| <button | |||||
| type="button" | |||||
| class="rounded-lg px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-200 focus:outline-none focus:ring-4 focus:ring-slate-300" | |||||
| :disabled="submitting" | |||||
| @click="cancel(close)" | |||||
| > | |||||
| Cancel | |||||
| </button> | |||||
| <div class="flex gap-3"> | |||||
| <button | |||||
| v-if="!isFirstStep" | |||||
| type="button" | |||||
| class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-100 focus:outline-none focus:ring-4 focus:ring-slate-300 disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :disabled="submitting" | |||||
| @click="back" | |||||
| > | |||||
| Back | |||||
| </button> | |||||
| <button | |||||
| v-if="!isLastStep" | |||||
| type="button" | |||||
| class="rounded-lg bg-amber-500 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/30 disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :disabled="!canProceed" | |||||
| @click="next" | |||||
| > | |||||
| Next | |||||
| </button> | |||||
| <button | |||||
| v-else | |||||
| type="button" | |||||
| class="rounded-lg bg-amber-500 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/30 disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :disabled="submitting" | |||||
| @click="create(close)" | |||||
| > | |||||
| {{ submitting ? "Creating..." : "Create proposal" }} | |||||
| </button> | |||||
| </div> | |||||
| </template> | |||||
| </Modal> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { ref } from "vue"; | |||||
| import freighter from "@stellar/freighter-api"; | |||||
| import Modal from "./Modal.vue"; | |||||
| import WizardStepper from "../wizard/WizardStepper.vue"; | |||||
| import WizardBasicsStep from "../wizard/WizardBasicsStep.vue"; | |||||
| import WizardDecisionStep from "../wizard/WizardDecisionStep.vue"; | |||||
| import WizardScopeStep from "../wizard/WizardScopeStep.vue"; | |||||
| import WizardFundingStep from "../wizard/WizardFundingStep.vue"; | |||||
| import WizardPolicyStep from "../wizard/WizardPolicyStep.vue"; | |||||
| import WizardNarrativeStep from "../wizard/WizardNarrativeStep.vue"; | |||||
| import WizardReviewStep from "../wizard/WizardReviewStep.vue"; | |||||
| import { ProposalService } from "../../generated/typescript"; | |||||
| import { useModal } from "../../composables/useModal.ts"; | |||||
| import { useProposalWizard } from "../../composables/useProposalWizard"; | |||||
| import { eventBus } from "../../utils/eventBus.ts"; | |||||
| const { getVisibility } = useModal(); | |||||
| const { | |||||
| draft, | |||||
| steps, | |||||
| stepIndex, | |||||
| maxVisitedIndex, | |||||
| currentStep, | |||||
| isFirstStep, | |||||
| isLastStep, | |||||
| currentStepError, | |||||
| canProceed, | |||||
| submitting, | |||||
| submitError, | |||||
| goTo, | |||||
| next, | |||||
| back, | |||||
| buildPayload, | |||||
| reset, | |||||
| formatFundingAmount, | |||||
| } = useProposalWizard(); | |||||
| const isWizardOpen = ref(getVisibility()); | |||||
| const cancel = (close: () => void) => { | |||||
| reset(); | |||||
| close(); | |||||
| }; | |||||
| const create = async (close: () => void) => { | |||||
| if (submitting.value) return; | |||||
| submitError.value = null; | |||||
| if (!(await freighter.isAllowed())) { | |||||
| submitError.value = "Connect your Freighter wallet to create a proposal."; | |||||
| return; | |||||
| } | |||||
| submitting.value = true; | |||||
| try { | |||||
| const addr = await freighter.getAddress(); | |||||
| await ProposalService.addProposal(buildPayload(addr.address)); | |||||
| eventBus.emit("proposalUpdated"); | |||||
| reset(); | |||||
| close(); | |||||
| } catch (err) { | |||||
| console.error("Failed to create proposal", err); | |||||
| submitError.value = "The proposal could not be saved. Please try again."; | |||||
| } finally { | |||||
| submitting.value = false; | |||||
| } | |||||
| }; | |||||
| </script> | |||||
| @@ -0,0 +1,72 @@ | |||||
| <template> | |||||
| <div> | |||||
| <div class="flex gap-2"> | |||||
| <input | |||||
| v-model="entry" | |||||
| type="text" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| :placeholder="placeholder" | |||||
| @keydown.enter.prevent="add" | |||||
| /> | |||||
| <button | |||||
| type="button" | |||||
| class="shrink-0 rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :disabled="!entry.trim()" | |||||
| @click="add" | |||||
| > | |||||
| {{ addLabel }} | |||||
| </button> | |||||
| </div> | |||||
| <ul v-if="modelValue.length" class="mt-2 space-y-1.5"> | |||||
| <li | |||||
| v-for="(item, index) in modelValue" | |||||
| :key="index" | |||||
| class="flex items-start justify-between gap-2 rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-800" | |||||
| > | |||||
| <span class="min-w-0 break-words">{{ item }}</span> | |||||
| <button | |||||
| type="button" | |||||
| class="shrink-0 text-slate-400 transition hover:text-slate-700" | |||||
| :aria-label="`Remove ${item}`" | |||||
| @click="remove(index)" | |||||
| > | |||||
| × | |||||
| </button> | |||||
| </li> | |||||
| </ul> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { ref } from "vue"; | |||||
| const props = withDefaults( | |||||
| defineProps<{ | |||||
| modelValue: string[]; | |||||
| placeholder?: string; | |||||
| addLabel?: string; | |||||
| }>(), | |||||
| { | |||||
| placeholder: "", | |||||
| addLabel: "Add", | |||||
| }, | |||||
| ); | |||||
| const emit = defineEmits<{ | |||||
| "update:modelValue": [value: string[]]; | |||||
| }>(); | |||||
| const entry = ref(""); | |||||
| const add = () => { | |||||
| const value = entry.value.trim(); | |||||
| if (!value) return; | |||||
| emit("update:modelValue", [...props.modelValue, value]); | |||||
| entry.value = ""; | |||||
| }; | |||||
| const remove = (index: number) => { | |||||
| emit("update:modelValue", props.modelValue.filter((_, i) => i !== index)); | |||||
| }; | |||||
| </script> | |||||
| @@ -0,0 +1,58 @@ | |||||
| <template> | |||||
| <div class="space-y-5"> | |||||
| <div> | |||||
| <label for="wizard-title" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Title | |||||
| </label> | |||||
| <input | |||||
| id="wizard-title" | |||||
| v-model="draft.title" | |||||
| type="text" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="Briefly describe the proposal" | |||||
| /> | |||||
| <p class="mt-1.5 text-xs text-slate-500"> | |||||
| Keep it concise; the next steps add the supporting detail. | |||||
| </p> | |||||
| </div> | |||||
| <fieldset> | |||||
| <legend class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| What kind of decision is this? | |||||
| </legend> | |||||
| <div class="grid gap-3 sm:grid-cols-3"> | |||||
| <label | |||||
| v-for="option in typeOptions" | |||||
| :key="option.value" | |||||
| class="flex cursor-pointer flex-col gap-1 rounded-xl border p-3.5 transition" | |||||
| :class="draft.proposalType === option.value | |||||
| ? 'border-amber-500 bg-amber-50 ring-2 ring-amber-500/20' | |||||
| : 'border-slate-300 bg-white hover:border-slate-400'" | |||||
| > | |||||
| <input | |||||
| v-model="draft.proposalType" | |||||
| type="radio" | |||||
| name="proposal-type" | |||||
| :value="option.value" | |||||
| class="sr-only" | |||||
| /> | |||||
| <span class="text-sm font-semibold text-slate-900">{{ option.label }}</span> | |||||
| <span class="text-xs leading-snug text-slate-600">{{ option.description }}</span> | |||||
| </label> | |||||
| </div> | |||||
| <p class="mt-1.5 text-xs text-slate-500"> | |||||
| The type decides which structured details the wizard asks for; you cannot leave them to | |||||
| free text once members vote. | |||||
| </p> | |||||
| </fieldset> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import type { ProposalWizardDraft } from "../../types/proposalWizard"; | |||||
| import { PROPOSAL_TYPE_OPTIONS } from "../../composables/useProposalWizard"; | |||||
| defineProps<{ draft: ProposalWizardDraft }>(); | |||||
| const typeOptions = PROPOSAL_TYPE_OPTIONS; | |||||
| </script> | |||||
| @@ -0,0 +1,65 @@ | |||||
| <template> | |||||
| <div class="space-y-5"> | |||||
| <div> | |||||
| <label for="wizard-decision" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Decision requested <span class="text-red-500">*</span> | |||||
| </label> | |||||
| <textarea | |||||
| id="wizard-decision" | |||||
| v-model="draft.decisionRequested" | |||||
| rows="2" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="e.g. Approve up to $10,000 for Harbor Trail lighting." | |||||
| /> | |||||
| <p class="mt-1.5 text-xs text-slate-500"> | |||||
| One or two sentences stating exactly what members are asked to approve. This is what a | |||||
| yes vote means. | |||||
| </p> | |||||
| </div> | |||||
| <div> | |||||
| <label for="wizard-rationale" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Why this is needed <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <textarea | |||||
| id="wizard-rationale" | |||||
| v-model="draft.rationale" | |||||
| rows="3" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="The problem this solves and why now." | |||||
| /> | |||||
| </div> | |||||
| <div> | |||||
| <label for="wizard-responsible" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Responsible party <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <input | |||||
| id="wizard-responsible" | |||||
| v-model="draft.responsibleParty" | |||||
| type="text" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="Who carries out the decision, e.g. the trails working group" | |||||
| /> | |||||
| </div> | |||||
| <div> | |||||
| <label for="wizard-conflict" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Conflict of interest disclosure <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <textarea | |||||
| id="wizard-conflict" | |||||
| v-model="draft.conflictDisclosure" | |||||
| rows="2" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="Declare anything members should know, or leave blank if there is none." | |||||
| /> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import type { ProposalWizardDraft } from "../../types/proposalWizard"; | |||||
| defineProps<{ draft: ProposalWizardDraft }>(); | |||||
| </script> | |||||
| @@ -0,0 +1,130 @@ | |||||
| <template> | |||||
| <div class="space-y-5"> | |||||
| <div class="grid gap-4 sm:grid-cols-[1fr_7rem]"> | |||||
| <div> | |||||
| <label for="wizard-amount" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Maximum authorization <span class="text-red-500">*</span> | |||||
| </label> | |||||
| <input | |||||
| id="wizard-amount" | |||||
| v-model="draft.fundingAmount" | |||||
| type="number" | |||||
| min="0" | |||||
| step="0.01" | |||||
| inputmode="decimal" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="10000.00" | |||||
| /> | |||||
| <p class="mt-1.5 text-xs text-slate-500"> | |||||
| The most this proposal allows spending. Spending cannot exceed this without an amendment. | |||||
| </p> | |||||
| </div> | |||||
| <div> | |||||
| <label for="wizard-currency" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Currency | |||||
| </label> | |||||
| <input | |||||
| id="wizard-currency" | |||||
| v-model="draft.fundingCurrency" | |||||
| type="text" | |||||
| maxlength="3" | |||||
| list="wizard-currencies" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 uppercase text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="USD" | |||||
| /> | |||||
| <datalist id="wizard-currencies"> | |||||
| <option v-for="code in currencies" :key="code" :value="code" /> | |||||
| </datalist> | |||||
| </div> | |||||
| </div> | |||||
| <div> | |||||
| <label for="wizard-target-date" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Target completion date <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <input | |||||
| id="wizard-target-date" | |||||
| v-model="draft.targetCompletionDate" | |||||
| type="date" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15 sm:w-auto" | |||||
| /> | |||||
| </div> | |||||
| <div> | |||||
| <label class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Finished criteria <span class="text-red-500">*</span> | |||||
| </label> | |||||
| <ListInput | |||||
| v-model="draft.finishedCriteria" | |||||
| placeholder="What must be true when the work is done, e.g. Twelve lights are installed and operational." | |||||
| add-label="Add" | |||||
| /> | |||||
| <p class="mt-1.5 text-xs text-slate-500"> | |||||
| Members and reviewers use these to confirm delivery before funds are fully released. | |||||
| </p> | |||||
| </div> | |||||
| <fieldset> | |||||
| <legend class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Milestones <span class="font-normal text-slate-500">(optional)</span> | |||||
| </legend> | |||||
| <div v-if="draft.milestones.length" class="space-y-3"> | |||||
| <div | |||||
| v-for="(milestone, index) in draft.milestones" | |||||
| :key="index" | |||||
| class="flex flex-col gap-2 rounded-xl border border-slate-300 p-3.5 sm:flex-row sm:items-center" | |||||
| > | |||||
| <input | |||||
| v-model="milestone.title" | |||||
| type="text" | |||||
| class="min-w-0 flex-1 rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="Milestone, e.g. Permit approval" | |||||
| :aria-label="`Milestone ${index + 1} title`" | |||||
| /> | |||||
| <input | |||||
| v-model="milestone.dueDate" | |||||
| type="date" | |||||
| class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 outline-none transition focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| :aria-label="`Milestone ${index + 1} due date`" | |||||
| /> | |||||
| <button | |||||
| type="button" | |||||
| class="self-end text-slate-400 transition hover:text-slate-700 sm:self-center" | |||||
| :aria-label="`Remove milestone ${index + 1}`" | |||||
| @click="removeMilestone(index)" | |||||
| > | |||||
| × | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-2 rounded-lg border border-dashed border-slate-300 px-3 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-400 hover:text-slate-800" | |||||
| @click="addMilestone" | |||||
| > | |||||
| + Add a milestone | |||||
| </button> | |||||
| </fieldset> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import ListInput from "./ListInput.vue"; | |||||
| import type { ProposalWizardDraft } from "../../types/proposalWizard"; | |||||
| import { CURRENCIES } from "../../composables/useProposalWizard"; | |||||
| const props = defineProps<{ draft: ProposalWizardDraft }>(); | |||||
| const currencies = CURRENCIES; | |||||
| const addMilestone = () => { | |||||
| props.draft.milestones.push({ title: "", dueDate: "" }); | |||||
| }; | |||||
| const removeMilestone = (index: number) => { | |||||
| props.draft.milestones.splice(index, 1); | |||||
| }; | |||||
| </script> | |||||
| @@ -0,0 +1,26 @@ | |||||
| <template> | |||||
| <div class="space-y-5"> | |||||
| <div> | |||||
| <label class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Supporting narrative <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <PostBuilder @update:content="assignParagraphs" /> | |||||
| <p class="mt-1.5 text-xs text-slate-500"> | |||||
| Free-form context: background, community feedback, alternatives considered. Anything | |||||
| members must be able to act on belongs in the structured steps, not only here. | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import PostBuilder from "../PostBuilder.vue"; | |||||
| import type { ProposalWizardDraft } from "../../types/proposalWizard"; | |||||
| import type { ProposalParagraph } from "../../types/proposal"; | |||||
| const props = defineProps<{ draft: ProposalWizardDraft }>(); | |||||
| const assignParagraphs = (paragraphs: ProposalParagraph[]) => { | |||||
| props.draft.paragraphs = paragraphs; | |||||
| }; | |||||
| </script> | |||||
| @@ -0,0 +1,37 @@ | |||||
| <template> | |||||
| <div class="space-y-5"> | |||||
| <div> | |||||
| <label for="wizard-supersedes" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Supersedes <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <input | |||||
| id="wizard-supersedes" | |||||
| v-model="draft.supersedes" | |||||
| type="text" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="Existing policy or rule this replaces, if any" | |||||
| /> | |||||
| </div> | |||||
| <div> | |||||
| <label for="wizard-effective-date" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Effective date <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <input | |||||
| id="wizard-effective-date" | |||||
| v-model="draft.effectiveDate" | |||||
| type="date" | |||||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15 sm:w-auto" | |||||
| /> | |||||
| <p class="mt-1.5 text-xs text-slate-500"> | |||||
| When the policy takes effect once approved. Leave blank for immediately upon approval. | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import type { ProposalWizardDraft } from "../../types/proposalWizard"; | |||||
| defineProps<{ draft: ProposalWizardDraft }>(); | |||||
| </script> | |||||
| @@ -0,0 +1,160 @@ | |||||
| <template> | |||||
| <div class="space-y-5"> | |||||
| <p class="text-sm text-slate-600"> | |||||
| Check everything below. Once the proposal is created it can be edited while it remains a | |||||
| draft, and every change is recorded as a new revision. | |||||
| </p> | |||||
| <section class="rounded-xl border border-slate-200"> | |||||
| <h3 class="border-b border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-900"> | |||||
| {{ draft.title || "Untitled proposal" }} | |||||
| </h3> | |||||
| <dl class="divide-y divide-slate-100 px-4 text-sm"> | |||||
| <div class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Type</dt> | |||||
| <dd class="text-slate-900">{{ typeLabel }}</dd> | |||||
| </div> | |||||
| <div class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Decision requested</dt> | |||||
| <dd class="whitespace-pre-wrap text-slate-900">{{ draft.decisionRequested }}</dd> | |||||
| </div> | |||||
| <div v-if="draft.rationale.trim()" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Rationale</dt> | |||||
| <dd class="whitespace-pre-wrap text-slate-900">{{ draft.rationale }}</dd> | |||||
| </div> | |||||
| <div v-if="draft.responsibleParty.trim()" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Responsible party</dt> | |||||
| <dd class="text-slate-900">{{ draft.responsibleParty }}</dd> | |||||
| </div> | |||||
| <div v-if="draft.conflictDisclosure.trim()" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Conflict disclosure</dt> | |||||
| <dd class="whitespace-pre-wrap text-slate-900">{{ draft.conflictDisclosure }}</dd> | |||||
| </div> | |||||
| </dl> | |||||
| </section> | |||||
| <section | |||||
| v-if="hasScopeOrRisks" | |||||
| class="rounded-xl border border-slate-200" | |||||
| > | |||||
| <h3 class="border-b border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-900"> | |||||
| Scope & risks | |||||
| </h3> | |||||
| <dl class="divide-y divide-slate-100 px-4 text-sm"> | |||||
| <div v-if="included.length" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">In scope</dt> | |||||
| <dd class="text-slate-900">{{ included.join("; ") }}</dd> | |||||
| </div> | |||||
| <div v-if="excluded.length" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Out of scope</dt> | |||||
| <dd class="text-slate-900">{{ excluded.join("; ") }}</dd> | |||||
| </div> | |||||
| <div v-for="(risk, index) in risks" :key="index" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Risk {{ risks.length > 1 ? index + 1 : "" }}</dt> | |||||
| <dd class="text-slate-900"> | |||||
| <span class="block">{{ risk.description }}</span> | |||||
| <span v-if="risk.mitigation.trim()" class="block text-slate-600"> | |||||
| Mitigation: {{ risk.mitigation }} | |||||
| </span> | |||||
| </dd> | |||||
| </div> | |||||
| </dl> | |||||
| </section> | |||||
| <section | |||||
| v-if="draft.proposalType === 'FUNDING'" | |||||
| class="rounded-xl border border-slate-200" | |||||
| > | |||||
| <h3 class="border-b border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-900"> | |||||
| Funding | |||||
| </h3> | |||||
| <dl class="divide-y divide-slate-100 px-4 text-sm"> | |||||
| <div class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Maximum authorization</dt> | |||||
| <dd class="font-semibold text-slate-900">{{ formattedAmount }}</dd> | |||||
| </div> | |||||
| <div v-if="draft.targetCompletionDate" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Target completion</dt> | |||||
| <dd class="text-slate-900">{{ draft.targetCompletionDate }}</dd> | |||||
| </div> | |||||
| <div class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Finished criteria</dt> | |||||
| <dd class="text-slate-900"> | |||||
| <ul class="list-disc space-y-1 pl-5"> | |||||
| <li v-for="(criterion, index) in finishedCriteria" :key="index">{{ criterion }}</li> | |||||
| </ul> | |||||
| </dd> | |||||
| </div> | |||||
| <div v-if="milestones.length" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Milestones</dt> | |||||
| <dd class="text-slate-900"> | |||||
| <ul class="list-disc space-y-1 pl-5"> | |||||
| <li v-for="(milestone, index) in milestones" :key="index"> | |||||
| {{ milestone.title }}<span v-if="milestone.dueDate" class="text-slate-600"> — {{ milestone.dueDate }}</span> | |||||
| </li> | |||||
| </ul> | |||||
| </dd> | |||||
| </div> | |||||
| </dl> | |||||
| </section> | |||||
| <section | |||||
| v-if="draft.proposalType === 'POLICY' && (draft.supersedes.trim() || draft.effectiveDate)" | |||||
| class="rounded-xl border border-slate-200" | |||||
| > | |||||
| <h3 class="border-b border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-900"> | |||||
| Policy | |||||
| </h3> | |||||
| <dl class="divide-y divide-slate-100 px-4 text-sm"> | |||||
| <div v-if="draft.supersedes.trim()" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Supersedes</dt> | |||||
| <dd class="text-slate-900">{{ draft.supersedes }}</dd> | |||||
| </div> | |||||
| <div v-if="draft.effectiveDate" class="grid gap-1 py-2.5 sm:grid-cols-[10rem_1fr]"> | |||||
| <dt class="font-medium text-slate-500">Effective date</dt> | |||||
| <dd class="text-slate-900">{{ draft.effectiveDate }}</dd> | |||||
| </div> | |||||
| </dl> | |||||
| </section> | |||||
| <section v-if="narrativeMarkdown" class="rounded-xl border border-slate-200"> | |||||
| <h3 class="border-b border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-900"> | |||||
| Narrative | |||||
| </h3> | |||||
| <!-- eslint-disable-next-line vue/no-v-html -- sanitized by renderMarkdown --> | |||||
| <div class="prose prose-sm max-w-none px-4 py-3" v-html="narrativeHtml" /> | |||||
| </section> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { computed } from "vue"; | |||||
| import type { ProposalWizardDraft } from "../../types/proposalWizard"; | |||||
| import { PROPOSAL_TYPE_OPTIONS } from "../../composables/useProposalWizard"; | |||||
| import { renderMarkdown } from "../../utils/markdown"; | |||||
| const props = defineProps<{ | |||||
| draft: ProposalWizardDraft; | |||||
| formattedAmount: string; | |||||
| }>(); | |||||
| const typeLabel = computed( | |||||
| () => PROPOSAL_TYPE_OPTIONS.find((o) => o.value === props.draft.proposalType)?.label ?? props.draft.proposalType, | |||||
| ); | |||||
| const included = computed(() => props.draft.scopeIncluded.map((s) => s.trim()).filter(Boolean)); | |||||
| const excluded = computed(() => props.draft.scopeExcluded.map((s) => s.trim()).filter(Boolean)); | |||||
| const risks = computed(() => props.draft.risks.filter((r) => r.description.trim())); | |||||
| const finishedCriteria = computed(() => props.draft.finishedCriteria.map((c) => c.trim()).filter(Boolean)); | |||||
| const milestones = computed(() => props.draft.milestones.filter((m) => m.title.trim())); | |||||
| const hasScopeOrRisks = computed( | |||||
| () => included.value.length > 0 || excluded.value.length > 0 || risks.value.length > 0, | |||||
| ); | |||||
| const narrativeMarkdown = computed(() => | |||||
| props.draft.paragraphs.map((p) => p.paragraphText).join("\n\n").trim(), | |||||
| ); | |||||
| const narrativeHtml = computed(() => renderMarkdown(narrativeMarkdown.value)); | |||||
| </script> | |||||
| @@ -0,0 +1,90 @@ | |||||
| <template> | |||||
| <div class="space-y-5"> | |||||
| <div> | |||||
| <label class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| In scope <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <ListInput | |||||
| v-model="draft.scopeIncluded" | |||||
| placeholder="What this proposal covers, e.g. Lighting" | |||||
| add-label="Add" | |||||
| /> | |||||
| </div> | |||||
| <div> | |||||
| <label class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Out of scope <span class="font-normal text-slate-500">(optional)</span> | |||||
| </label> | |||||
| <ListInput | |||||
| v-model="draft.scopeExcluded" | |||||
| placeholder="What this proposal does not cover, e.g. Maintenance after year one" | |||||
| add-label="Add" | |||||
| /> | |||||
| </div> | |||||
| <fieldset> | |||||
| <legend class="mb-1.5 block text-sm font-medium text-slate-700"> | |||||
| Risks <span class="font-normal text-slate-500">(optional)</span> | |||||
| </legend> | |||||
| <div v-if="draft.risks.length" class="space-y-3"> | |||||
| <div | |||||
| v-for="(risk, index) in draft.risks" | |||||
| :key="index" | |||||
| class="rounded-xl border border-slate-300 p-3.5" | |||||
| > | |||||
| <div class="flex items-start justify-between gap-2"> | |||||
| <label :for="`wizard-risk-${index}`" class="text-xs font-medium text-slate-600"> | |||||
| Risk {{ index + 1 }} | |||||
| </label> | |||||
| <button | |||||
| type="button" | |||||
| class="text-slate-400 transition hover:text-slate-700" | |||||
| :aria-label="`Remove risk ${index + 1}`" | |||||
| @click="removeRisk(index)" | |||||
| > | |||||
| × | |||||
| </button> | |||||
| </div> | |||||
| <input | |||||
| :id="`wizard-risk-${index}`" | |||||
| v-model="risk.description" | |||||
| type="text" | |||||
| class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="What could go wrong, e.g. Permitting may delay installation." | |||||
| /> | |||||
| <input | |||||
| v-model="risk.mitigation" | |||||
| type="text" | |||||
| class="mt-2 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||||
| placeholder="Mitigation (optional), e.g. Funds are not released until permit approval." | |||||
| :aria-label="`Mitigation for risk ${index + 1}`" | |||||
| /> | |||||
| </div> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-2 rounded-lg border border-dashed border-slate-300 px-3 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-400 hover:text-slate-800" | |||||
| @click="addRisk" | |||||
| > | |||||
| + Add a risk | |||||
| </button> | |||||
| </fieldset> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import ListInput from "./ListInput.vue"; | |||||
| import type { ProposalWizardDraft } from "../../types/proposalWizard"; | |||||
| const props = defineProps<{ draft: ProposalWizardDraft }>(); | |||||
| const addRisk = () => { | |||||
| props.draft.risks.push({ description: "", mitigation: "" }); | |||||
| }; | |||||
| const removeRisk = (index: number) => { | |||||
| props.draft.risks.splice(index, 1); | |||||
| }; | |||||
| </script> | |||||
| @@ -0,0 +1,53 @@ | |||||
| <template> | |||||
| <ol class="flex flex-wrap items-center gap-y-2"> | |||||
| <li | |||||
| v-for="(step, index) in steps" | |||||
| :key="step.id" | |||||
| class="flex items-center" | |||||
| > | |||||
| <button | |||||
| type="button" | |||||
| class="flex items-center gap-2 rounded-lg px-1.5 py-1 transition" | |||||
| :class="stepClasses(index)" | |||||
| :disabled="index > maxVisited" | |||||
| @click="emit('goTo', index)" | |||||
| > | |||||
| <span | |||||
| class="flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold" | |||||
| :class="badgeClasses(index)" | |||||
| > | |||||
| {{ index + 1 }} | |||||
| </span> | |||||
| <span class="hidden text-sm font-medium sm:inline">{{ step.label }}</span> | |||||
| </button> | |||||
| <span v-if="index < steps.length - 1" class="mx-1 h-px w-4 bg-slate-300 sm:w-6" aria-hidden="true" /> | |||||
| </li> | |||||
| </ol> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import type { WizardStep } from "../../composables/useProposalWizard"; | |||||
| const props = defineProps<{ | |||||
| steps: WizardStep[]; | |||||
| current: number; | |||||
| maxVisited: number; | |||||
| }>(); | |||||
| const emit = defineEmits<{ | |||||
| goTo: [index: number]; | |||||
| }>(); | |||||
| const stepClasses = (index: number) => { | |||||
| if (index === props.current) return "text-slate-900"; | |||||
| if (index <= props.maxVisited) return "text-slate-600 hover:bg-slate-100 cursor-pointer"; | |||||
| return "text-slate-400 cursor-not-allowed"; | |||||
| }; | |||||
| const badgeClasses = (index: number) => { | |||||
| if (index === props.current) return "bg-amber-500 text-white"; | |||||
| if (index < props.current) return "bg-slate-700 text-white"; | |||||
| return "bg-slate-200 text-slate-500"; | |||||
| }; | |||||
| </script> | |||||
| @@ -0,0 +1,269 @@ | |||||
| import { computed, reactive, ref } from "vue"; | |||||
| import type { AddProposalRequest } from "../generated/typescript"; | |||||
| import type { ProposalWizardDraft, ProposalType } from "../types/proposalWizard"; | |||||
| export type WizardStepId = "basics" | "decision" | "scope" | "details" | "narrative" | "review"; | |||||
| export interface WizardStep { | |||||
| id: WizardStepId; | |||||
| label: string; | |||||
| } | |||||
| const emptyDraft = (): ProposalWizardDraft => ({ | |||||
| title: "", | |||||
| proposalType: "GENERAL", | |||||
| decisionRequested: "", | |||||
| rationale: "", | |||||
| responsibleParty: "", | |||||
| conflictDisclosure: "", | |||||
| scopeIncluded: [], | |||||
| scopeExcluded: [], | |||||
| risks: [], | |||||
| fundingAmount: "", | |||||
| fundingCurrency: "USD", | |||||
| targetCompletionDate: "", | |||||
| finishedCriteria: [], | |||||
| milestones: [], | |||||
| supersedes: "", | |||||
| effectiveDate: "", | |||||
| paragraphs: [], | |||||
| }); | |||||
| /** ISO 4217 codes offered in the funding step; the field also accepts free entry. */ | |||||
| export const CURRENCIES = ["USD", "EUR", "GBP", "CAD", "AUD", "JPY", "XLM"] as const; | |||||
| export const PROPOSAL_TYPE_OPTIONS: { value: ProposalType; label: string; description: string }[] = [ | |||||
| { | |||||
| value: "GENERAL", | |||||
| label: "General decision", | |||||
| description: "A decision or direction for the organization that is not primarily about funds or rules.", | |||||
| }, | |||||
| { | |||||
| value: "FUNDING", | |||||
| label: "Funding request", | |||||
| description: "Authorizes spending up to a maximum amount, with milestones and finished criteria.", | |||||
| }, | |||||
| { | |||||
| value: "POLICY", | |||||
| label: "Policy change", | |||||
| description: "Creates or changes a rule, bylaw, or standing policy of the organization.", | |||||
| }, | |||||
| ]; | |||||
| const typedDetailsStep = (type: ProposalType): WizardStep => ({ | |||||
| id: "details", | |||||
| label: type === "FUNDING" ? "Funding" : "Policy", | |||||
| }); | |||||
| export const useProposalWizard = () => { | |||||
| const draft = reactive<ProposalWizardDraft>(emptyDraft()); | |||||
| const stepIndex = ref(0); | |||||
| const maxVisitedIndex = ref(0); | |||||
| const submitting = ref(false); | |||||
| const submitError = ref<string | null>(null); | |||||
| const steps = computed<WizardStep[]>(() => { | |||||
| const list: WizardStep[] = [ | |||||
| { id: "basics", label: "Basics" }, | |||||
| { id: "decision", label: "Decision" }, | |||||
| { id: "scope", label: "Scope & risks" }, | |||||
| ]; | |||||
| if (draft.proposalType !== "GENERAL") { | |||||
| list.push(typedDetailsStep(draft.proposalType)); | |||||
| } | |||||
| list.push({ id: "narrative", label: "Narrative" }); | |||||
| list.push({ id: "review", label: "Review" }); | |||||
| return list; | |||||
| }); | |||||
| const currentStep = computed<WizardStep>(() => steps.value[stepIndex.value] ?? steps.value[0]); | |||||
| const isFirstStep = computed(() => stepIndex.value === 0); | |||||
| const isLastStep = computed(() => stepIndex.value === steps.value.length - 1); | |||||
| const fundingAmountMinor = computed<number | null>(() => { | |||||
| const parsed = Number.parseFloat(draft.fundingAmount); | |||||
| if (!Number.isFinite(parsed) || parsed <= 0) return null; | |||||
| return Math.round(parsed * 100); | |||||
| }); | |||||
| /** Human-readable validation problem for the current step, or null when it is complete. */ | |||||
| const currentStepError = computed<string | null>(() => { | |||||
| switch (currentStep.value.id) { | |||||
| case "basics": | |||||
| return draft.title.trim() ? null : "A title is required."; | |||||
| case "decision": | |||||
| return draft.decisionRequested.trim() | |||||
| ? null | |||||
| : "State the decision you are asking members to make."; | |||||
| case "scope": | |||||
| return draft.risks.every((r) => r.description.trim()) | |||||
| ? null | |||||
| : "Every risk needs a description; remove empty rows."; | |||||
| case "details": { | |||||
| if (draft.proposalType === "FUNDING") { | |||||
| if (fundingAmountMinor.value === null) { | |||||
| return "Enter a maximum authorization greater than zero."; | |||||
| } | |||||
| if (!/^[A-Za-z]{3}$/.test(draft.fundingCurrency.trim())) { | |||||
| return "Currency must be a three-letter code, e.g. USD."; | |||||
| } | |||||
| if (draft.finishedCriteria.filter((c) => c.trim()).length === 0) { | |||||
| return "Add at least one finished criterion so members know when the work is done."; | |||||
| } | |||||
| if (draft.milestones.some((m) => !m.title.trim())) { | |||||
| return "Every milestone needs a title; remove empty rows."; | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| case "narrative": | |||||
| return null; | |||||
| case "review": | |||||
| return null; | |||||
| } | |||||
| }); | |||||
| const canProceed = computed(() => currentStepError.value === null); | |||||
| const goTo = (index: number) => { | |||||
| if (index < 0 || index >= steps.value.length) return; | |||||
| if (index > maxVisitedIndex.value) return; | |||||
| stepIndex.value = index; | |||||
| }; | |||||
| const next = () => { | |||||
| if (!canProceed.value) return; | |||||
| if (isLastStep.value) return; | |||||
| stepIndex.value += 1; | |||||
| maxVisitedIndex.value = Math.max(maxVisitedIndex.value, stepIndex.value); | |||||
| }; | |||||
| const back = () => { | |||||
| if (isFirstStep.value) return; | |||||
| stepIndex.value -= 1; | |||||
| }; | |||||
| const trimmedOrUndefined = (value: string): string | undefined => { | |||||
| const trimmed = value.trim(); | |||||
| return trimmed ? trimmed : undefined; | |||||
| }; | |||||
| const buildCommonData = (): Record<string, unknown> => { | |||||
| const data: Record<string, unknown> = { | |||||
| decisionRequested: draft.decisionRequested.trim(), | |||||
| }; | |||||
| const rationale = trimmedOrUndefined(draft.rationale); | |||||
| if (rationale) data.rationale = rationale; | |||||
| const included = draft.scopeIncluded.map((s) => s.trim()).filter(Boolean); | |||||
| const excluded = draft.scopeExcluded.map((s) => s.trim()).filter(Boolean); | |||||
| if (included.length || excluded.length) { | |||||
| data.scope = { included, excluded }; | |||||
| } | |||||
| const responsibleParty = trimmedOrUndefined(draft.responsibleParty); | |||||
| if (responsibleParty) data.responsibleParty = responsibleParty; | |||||
| const risks = draft.risks | |||||
| .filter((r) => r.description.trim()) | |||||
| .map((r) => { | |||||
| const mitigation = trimmedOrUndefined(r.mitigation); | |||||
| return mitigation | |||||
| ? { description: r.description.trim(), mitigation } | |||||
| : { description: r.description.trim() }; | |||||
| }); | |||||
| if (risks.length) data.risks = risks; | |||||
| const conflictDisclosure = trimmedOrUndefined(draft.conflictDisclosure); | |||||
| if (conflictDisclosure) data.conflictDisclosure = conflictDisclosure; | |||||
| return data; | |||||
| }; | |||||
| const buildTypeData = (): Record<string, unknown> => { | |||||
| if (draft.proposalType === "FUNDING") { | |||||
| const data: Record<string, unknown> = { | |||||
| maximumAuthorization: { | |||||
| amountMinor: fundingAmountMinor.value ?? 0, | |||||
| currencyCode: draft.fundingCurrency.trim().toUpperCase(), | |||||
| }, | |||||
| finishedCriteria: draft.finishedCriteria.map((c) => c.trim()).filter(Boolean), | |||||
| }; | |||||
| const targetDate = trimmedOrUndefined(draft.targetCompletionDate); | |||||
| if (targetDate) data.targetCompletionDate = targetDate; | |||||
| const milestones = draft.milestones | |||||
| .filter((m) => m.title.trim()) | |||||
| .map((m) => { | |||||
| const dueDate = trimmedOrUndefined(m.dueDate); | |||||
| return dueDate | |||||
| ? { title: m.title.trim(), dueDate } | |||||
| : { title: m.title.trim() }; | |||||
| }); | |||||
| if (milestones.length) data.milestones = milestones; | |||||
| return data; | |||||
| } | |||||
| if (draft.proposalType === "POLICY") { | |||||
| const data: Record<string, unknown> = {}; | |||||
| const supersedes = trimmedOrUndefined(draft.supersedes); | |||||
| if (supersedes) data.supersedes = supersedes; | |||||
| const effectiveDate = trimmedOrUndefined(draft.effectiveDate); | |||||
| if (effectiveDate) data.effectiveDate = effectiveDate; | |||||
| return data; | |||||
| } | |||||
| return {}; | |||||
| }; | |||||
| const buildPayload = (creator: string): AddProposalRequest => ({ | |||||
| creator, | |||||
| name: draft.title.trim(), | |||||
| proposalType: draft.proposalType, | |||||
| commonData: buildCommonData(), | |||||
| typeData: buildTypeData(), | |||||
| paragraphs: draft.paragraphs.map((p, i) => ({ | |||||
| paragraphText: p.paragraphText, | |||||
| index: i, | |||||
| })), | |||||
| }); | |||||
| const reset = () => { | |||||
| Object.assign(draft, emptyDraft()); | |||||
| stepIndex.value = 0; | |||||
| maxVisitedIndex.value = 0; | |||||
| submitting.value = false; | |||||
| submitError.value = null; | |||||
| }; | |||||
| const formatFundingAmount = (): string => { | |||||
| if (fundingAmountMinor.value === null) return ""; | |||||
| return new Intl.NumberFormat(undefined, { | |||||
| style: "currency", | |||||
| currency: draft.fundingCurrency.trim().toUpperCase() || "USD", | |||||
| }).format(fundingAmountMinor.value / 100); | |||||
| }; | |||||
| return { | |||||
| draft, | |||||
| steps, | |||||
| stepIndex, | |||||
| maxVisitedIndex, | |||||
| currentStep, | |||||
| isFirstStep, | |||||
| isLastStep, | |||||
| currentStepError, | |||||
| canProceed, | |||||
| submitting, | |||||
| submitError, | |||||
| goTo, | |||||
| next, | |||||
| back, | |||||
| buildPayload, | |||||
| reset, | |||||
| formatFundingAmount, | |||||
| }; | |||||
| }; | |||||
| @@ -6,6 +6,18 @@ import type { ProposalParagraphRequest } from './ProposalParagraphRequest'; | |||||
| export type AddProposalRequest = { | export type AddProposalRequest = { | ||||
| creator: string; | creator: string; | ||||
| name: string; | name: string; | ||||
| /** | |||||
| * Structured wizard fields shared by every proposal type | |||||
| */ | |||||
| commonData?: Record<string, any> | null; | |||||
| /** | |||||
| * Structured wizard fields specific to the proposal's type | |||||
| */ | |||||
| typeData?: Record<string, any> | null; | |||||
| /** | |||||
| * Kind of decision asked for; defaults to GENERAL | |||||
| */ | |||||
| proposalType?: string; | |||||
| paragraphs: Array<ProposalParagraphRequest>; | paragraphs: Array<ProposalParagraphRequest>; | ||||
| }; | }; | ||||
| @@ -5,5 +5,17 @@ | |||||
| export type ProposalParagraphRequest = { | export type ProposalParagraphRequest = { | ||||
| index: number; | index: number; | ||||
| paragraphText: string; | paragraphText: string; | ||||
| /** | |||||
| * Display structure: paragraph, heading, bullet_list, numbered_list, quote, callout, ... | |||||
| */ | |||||
| kind?: string; | |||||
| /** | |||||
| * Navigation and comment-targeting key; `custom_<slug>` for user-defined sections | |||||
| */ | |||||
| sectionKey?: string | null; | |||||
| /** | |||||
| * Display title of a user-defined section; null for standard paragraph content | |||||
| */ | |||||
| sectionTitle?: string | null; | |||||
| }; | }; | ||||
| @@ -0,0 +1,46 @@ | |||||
| import type { ProposalParagraph } from "./proposal"; | |||||
| export type ProposalType = "GENERAL" | "FUNDING" | "POLICY"; | |||||
| export interface WizardRisk { | |||||
| description: string; | |||||
| mitigation: string; | |||||
| } | |||||
| export interface WizardMilestone { | |||||
| title: string; | |||||
| dueDate: string; | |||||
| } | |||||
| /** | |||||
| * Form state for the add-proposal wizard. Structured fields mirror the backend's | |||||
| * `common_data` / `type_data` revision columns and are converted to the API payload | |||||
| * by `useProposalWizard`. | |||||
| */ | |||||
| export interface ProposalWizardDraft { | |||||
| title: string; | |||||
| proposalType: ProposalType; | |||||
| // Common data (shared by every proposal type) | |||||
| decisionRequested: string; | |||||
| rationale: string; | |||||
| responsibleParty: string; | |||||
| conflictDisclosure: string; | |||||
| scopeIncluded: string[]; | |||||
| scopeExcluded: string[]; | |||||
| risks: WizardRisk[]; | |||||
| // Funding type data | |||||
| fundingAmount: string; | |||||
| fundingCurrency: string; | |||||
| targetCompletionDate: string; | |||||
| finishedCriteria: string[]; | |||||
| milestones: WizardMilestone[]; | |||||
| // Policy type data | |||||
| supersedes: string; | |||||
| effectiveDate: string; | |||||
| // Narrative content blocks | |||||
| paragraphs: ProposalParagraph[]; | |||||
| } | |||||