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 LoadingSpinner from "./LoadingSpinner.vue"; | |||
| 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 FilterModal from "./modals/FilterModal.vue"; | |||
| import Filter from "./icons/Filter.vue"; | |||
| @@ -66,7 +66,7 @@ const handleSelection = (proposalId: number) => { | |||
| }; | |||
| const showNewProposalModal = () => { | |||
| showModal(ProposalModal as ContentModal); | |||
| showModal(ProposalWizardModal as ContentModal); | |||
| }; | |||
| const showFilterModal = () => { | |||
| @@ -44,22 +44,24 @@ | |||
| </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"> | |||
| <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> | |||
| </section> | |||
| </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 = { | |||
| creator: 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>; | |||
| }; | |||
| @@ -5,5 +5,17 @@ | |||
| export type ProposalParagraphRequest = { | |||
| index: number; | |||
| 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[]; | |||
| } | |||