瀏覽代碼

Add guided proposal creation wizard

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
Perplexity 2 小時之前
父節點
當前提交
b7ebdf7670
共有 17 個檔案被更改,包括 1202 行新增 和 92 行删除
  1. +2
    -2
      src/components/Sidebar.vue
  2. +18
    -16
      src/components/modals/Modal.vue
  3. +0
    -74
      src/components/modals/ProposalModal.vue
  4. +152
    -0
      src/components/modals/ProposalWizardModal.vue
  5. +72
    -0
      src/components/wizard/ListInput.vue
  6. +58
    -0
      src/components/wizard/WizardBasicsStep.vue
  7. +65
    -0
      src/components/wizard/WizardDecisionStep.vue
  8. +130
    -0
      src/components/wizard/WizardFundingStep.vue
  9. +26
    -0
      src/components/wizard/WizardNarrativeStep.vue
  10. +37
    -0
      src/components/wizard/WizardPolicyStep.vue
  11. +160
    -0
      src/components/wizard/WizardReviewStep.vue
  12. +90
    -0
      src/components/wizard/WizardScopeStep.vue
  13. +53
    -0
      src/components/wizard/WizardStepper.vue
  14. +269
    -0
      src/composables/useProposalWizard.ts
  15. +12
    -0
      src/generated/typescript/models/AddProposalRequest.ts
  16. +12
    -0
      src/generated/typescript/models/ProposalParagraphRequest.ts
  17. +46
    -0
      src/types/proposalWizard.ts

+ 2
- 2
src/components/Sidebar.vue 查看文件

@@ -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 = () => {


+ 18
- 16
src/components/modals/Modal.vue 查看文件

@@ -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>


+ 0
- 74
src/components/modals/ProposalModal.vue 查看文件

@@ -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>

+ 152
- 0
src/components/modals/ProposalWizardModal.vue 查看文件

@@ -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>

+ 72
- 0
src/components/wizard/ListInput.vue 查看文件

@@ -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)"
>
&times;
</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>

+ 58
- 0
src/components/wizard/WizardBasicsStep.vue 查看文件

@@ -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>

+ 65
- 0
src/components/wizard/WizardDecisionStep.vue 查看文件

@@ -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>

+ 130
- 0
src/components/wizard/WizardFundingStep.vue 查看文件

@@ -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)"
>
&times;
</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>

+ 26
- 0
src/components/wizard/WizardNarrativeStep.vue 查看文件

@@ -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>

+ 37
- 0
src/components/wizard/WizardPolicyStep.vue 查看文件

@@ -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>

+ 160
- 0
src/components/wizard/WizardReviewStep.vue 查看文件

@@ -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>

+ 90
- 0
src/components/wizard/WizardScopeStep.vue 查看文件

@@ -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)"
>
&times;
</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>

+ 53
- 0
src/components/wizard/WizardStepper.vue 查看文件

@@ -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>

+ 269
- 0
src/composables/useProposalWizard.ts 查看文件

@@ -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,
};
};

+ 12
- 0
src/generated/typescript/models/AddProposalRequest.ts 查看文件

@@ -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>;
};


+ 12
- 0
src/generated/typescript/models/ProposalParagraphRequest.ts 查看文件

@@ -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;
};


+ 46
- 0
src/types/proposalWizard.ts 查看文件

@@ -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[];
}

Loading…
取消
儲存