|
|
|
@@ -0,0 +1,215 @@ |
|
|
|
<template> |
|
|
|
<div class="space-y-8"> |
|
|
|
<!-- What this proposal asks members to decide --> |
|
|
|
<ProposalSection v-if="decisionRequested" title="Decision requested" eyebrow="The ask"> |
|
|
|
<p class="whitespace-pre-wrap text-[0.98rem] leading-7 text-slate-800"> |
|
|
|
{{ decisionRequested }} |
|
|
|
</p> |
|
|
|
</ProposalSection> |
|
|
|
|
|
|
|
<ProposalSection v-if="rationale" title="Rationale" eyebrow="Why now"> |
|
|
|
<p class="whitespace-pre-wrap text-[0.98rem] leading-7 text-slate-800"> |
|
|
|
{{ rationale }} |
|
|
|
</p> |
|
|
|
</ProposalSection> |
|
|
|
|
|
|
|
<ProposalSection v-if="hasScope" title="Scope" eyebrow="Boundaries"> |
|
|
|
<dl class="space-y-3 text-[0.98rem] leading-7"> |
|
|
|
<div v-if="scopeIncluded.length"> |
|
|
|
<dt class="text-xs font-bold uppercase tracking-[0.1em] text-slate-500">Included</dt> |
|
|
|
<dd> |
|
|
|
<ul class="mt-1 list-disc space-y-1 pl-5 text-slate-800"> |
|
|
|
<li v-for="(item, i) in scopeIncluded" :key="`in-${i}`">{{ item }}</li> |
|
|
|
</ul> |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
<div v-if="scopeExcluded.length"> |
|
|
|
<dt class="text-xs font-bold uppercase tracking-[0.1em] text-slate-500">Excluded</dt> |
|
|
|
<dd> |
|
|
|
<ul class="mt-1 list-disc space-y-1 pl-5 text-slate-800"> |
|
|
|
<li v-for="(item, i) in scopeExcluded" :key="`out-${i}`">{{ item }}</li> |
|
|
|
</ul> |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
</dl> |
|
|
|
</ProposalSection> |
|
|
|
|
|
|
|
<!-- Type-specific details: funding or policy --> |
|
|
|
<ProposalSection v-if="isFunding && hasFundingDetails" title="Funding" eyebrow="Authorization"> |
|
|
|
<dl class="space-y-3 text-[0.98rem] leading-7"> |
|
|
|
<div v-if="maximumAuthorization"> |
|
|
|
<dt class="text-xs font-bold uppercase tracking-[0.1em] text-slate-500">Maximum authorization</dt> |
|
|
|
<dd class="mt-0.5 text-lg font-semibold text-slate-950"> |
|
|
|
{{ maximumAuthorization }} |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div v-if="targetCompletionDate"> |
|
|
|
<dt class="text-xs font-bold uppercase tracking-[0.1em] text-slate-500">Target completion</dt> |
|
|
|
<dd class="mt-0.5 text-slate-800">{{ targetCompletionDate }}</dd> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div v-if="milestones.length"> |
|
|
|
<dt class="text-xs font-bold uppercase tracking-[0.1em] text-slate-500">Milestones</dt> |
|
|
|
<dd> |
|
|
|
<ul class="mt-1 list-disc space-y-1 pl-5 text-slate-800"> |
|
|
|
<li v-for="(m, i) in milestones" :key="`ms-${i}`"> |
|
|
|
{{ m.title }}<span v-if="m.dueDate" class="text-slate-600"> — due {{ m.dueDate }}</span> |
|
|
|
</li> |
|
|
|
</ul> |
|
|
|
</dd> |
|
|
|
</div> |
|
|
|
</dl> |
|
|
|
</ProposalSection> |
|
|
|
|
|
|
|
<ProposalSection v-if="isFunding && finishedCriteria.length" title="Finished criteria" eyebrow="Delivery"> |
|
|
|
<ul class="list-disc space-y-1 pl-5 text-[0.98rem] leading-7 text-slate-800"> |
|
|
|
<li v-for="(criterion, i) in finishedCriteria" :key="`fc-${i}`">{{ criterion }}</li> |
|
|
|
</ul> |
|
|
|
</ProposalSection> |
|
|
|
|
|
|
|
<ProposalSection v-if="isPolicy && hasPolicyDetails" title="Policy details" eyebrow="Enactment"> |
|
|
|
<dl class="space-y-3 text-[0.98rem] leading-7"> |
|
|
|
<div v-if="supersedes"> |
|
|
|
<dt class="text-xs font-bold uppercase tracking-[0.1em] text-slate-500">Supersedes</dt> |
|
|
|
<dd class="mt-0.5 text-slate-800">{{ supersedes }}</dd> |
|
|
|
</div> |
|
|
|
<div v-if="effectiveDate"> |
|
|
|
<dt class="text-xs font-bold uppercase tracking-[0.1em] text-slate-500">Effective date</dt> |
|
|
|
<dd class="mt-0.5 text-slate-800">{{ effectiveDate }}</dd> |
|
|
|
</div> |
|
|
|
</dl> |
|
|
|
</ProposalSection> |
|
|
|
|
|
|
|
<ProposalSection v-if="risks.length" title="Risks" eyebrow="Declared"> |
|
|
|
<ul class="space-y-3"> |
|
|
|
<li |
|
|
|
v-for="(risk, i) in risks" |
|
|
|
:key="`risk-${i}`" |
|
|
|
class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-[0.95rem] leading-6" |
|
|
|
> |
|
|
|
<p class="text-slate-800">{{ risk.description }}</p> |
|
|
|
<p v-if="risk.mitigation" class="mt-1 text-slate-600"> |
|
|
|
<span class="font-semibold text-slate-700">Mitigation:</span> {{ risk.mitigation }} |
|
|
|
</p> |
|
|
|
</li> |
|
|
|
</ul> |
|
|
|
</ProposalSection> |
|
|
|
|
|
|
|
<ProposalSection v-if="responsibleParty" title="Responsible party" eyebrow="Accountability"> |
|
|
|
<p class="text-[0.98rem] leading-7 text-slate-800">{{ responsibleParty }}</p> |
|
|
|
</ProposalSection> |
|
|
|
|
|
|
|
<ProposalSection v-if="conflictDisclosure" title="Conflict disclosure" eyebrow="Transparency"> |
|
|
|
<p class="whitespace-pre-wrap text-[0.98rem] leading-7 text-slate-800"> |
|
|
|
{{ conflictDisclosure }} |
|
|
|
</p> |
|
|
|
</ProposalSection> |
|
|
|
</div> |
|
|
|
</template> |
|
|
|
|
|
|
|
<script setup lang="ts"> |
|
|
|
import { computed } from "vue"; |
|
|
|
import ProposalSection from "./ProposalSection.vue"; |
|
|
|
|
|
|
|
const props = defineProps<{ |
|
|
|
proposalType: string; |
|
|
|
commonData: Record<string, any> | null | undefined; |
|
|
|
typeData: Record<string, any> | null | undefined; |
|
|
|
}>(); |
|
|
|
|
|
|
|
const common = computed<Record<string, any>>(() => props.commonData ?? {}); |
|
|
|
const typed = computed<Record<string, any>>(() => props.typeData ?? {}); |
|
|
|
|
|
|
|
const trimToUndef = (value: unknown): string | undefined => { |
|
|
|
if (typeof value !== "string") return undefined; |
|
|
|
const trimmed = value.trim(); |
|
|
|
return trimmed ? trimmed : undefined; |
|
|
|
}; |
|
|
|
|
|
|
|
const decisionRequested = computed(() => trimToUndef(common.value.decisionRequested)); |
|
|
|
const rationale = computed(() => trimToUndef(common.value.rationale)); |
|
|
|
const responsibleParty = computed(() => trimToUndef(common.value.responsibleParty)); |
|
|
|
const conflictDisclosure = computed(() => trimToUndef(common.value.conflictDisclosure)); |
|
|
|
|
|
|
|
const scopeIncluded = computed<string[]>(() => |
|
|
|
Array.isArray(common.value.scope?.included) |
|
|
|
? common.value.scope.included.filter((s: unknown) => typeof s === "string" && s.trim()) |
|
|
|
: [], |
|
|
|
); |
|
|
|
const scopeExcluded = computed<string[]>(() => |
|
|
|
Array.isArray(common.value.scope?.excluded) |
|
|
|
? common.value.scope.excluded.filter((s: unknown) => typeof s === "string" && s.trim()) |
|
|
|
: [], |
|
|
|
); |
|
|
|
const hasScope = computed(() => scopeIncluded.value.length > 0 || scopeExcluded.value.length > 0); |
|
|
|
|
|
|
|
interface RiskItem { |
|
|
|
description: string; |
|
|
|
mitigation?: string; |
|
|
|
} |
|
|
|
|
|
|
|
const risks = computed<RiskItem[]>(() => { |
|
|
|
if (!Array.isArray(common.value.risks)) return []; |
|
|
|
const items: RiskItem[] = []; |
|
|
|
for (const r of common.value.risks) { |
|
|
|
const description = trimToUndef((r as any)?.description); |
|
|
|
if (!description) continue; |
|
|
|
const mitigation = trimToUndef((r as any)?.mitigation); |
|
|
|
items.push(mitigation ? { description, mitigation } : { description }); |
|
|
|
} |
|
|
|
return items; |
|
|
|
}); |
|
|
|
|
|
|
|
const isFunding = computed(() => props.proposalType === "FUNDING"); |
|
|
|
const isPolicy = computed(() => props.proposalType === "POLICY"); |
|
|
|
|
|
|
|
const maximumAuthorization = computed(() => { |
|
|
|
const auth = typed.value.maximumAuthorization; |
|
|
|
const minor = auth?.amountMinor; |
|
|
|
const currency = auth?.currencyCode; |
|
|
|
if (typeof minor !== "number" || !Number.isFinite(minor) || !currency) return undefined; |
|
|
|
try { |
|
|
|
return new Intl.NumberFormat(undefined, { |
|
|
|
style: "currency", |
|
|
|
currency: String(currency).toUpperCase(), |
|
|
|
}).format(minor / 100); |
|
|
|
} catch { |
|
|
|
// Unknown currency code; fall back to a plain number |
|
|
|
return `${(minor / 100).toFixed(2)} ${String(currency).toUpperCase()}`; |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
const targetCompletionDate = computed(() => trimToUndef(typed.value.targetCompletionDate)); |
|
|
|
|
|
|
|
interface MilestoneItem { |
|
|
|
title: string; |
|
|
|
dueDate?: string; |
|
|
|
} |
|
|
|
|
|
|
|
const milestones = computed<MilestoneItem[]>(() => { |
|
|
|
if (!Array.isArray(typed.value.milestones)) return []; |
|
|
|
const items: MilestoneItem[] = []; |
|
|
|
for (const m of typed.value.milestones) { |
|
|
|
const title = trimToUndef((m as any)?.title); |
|
|
|
if (!title) continue; |
|
|
|
const dueDate = trimToUndef((m as any)?.dueDate); |
|
|
|
items.push(dueDate ? { title, dueDate } : { title }); |
|
|
|
} |
|
|
|
return items; |
|
|
|
}); |
|
|
|
|
|
|
|
const finishedCriteria = computed<string[]>(() => |
|
|
|
Array.isArray(typed.value.finishedCriteria) |
|
|
|
? typed.value.finishedCriteria.filter((c: unknown) => typeof c === "string" && c.trim()) |
|
|
|
: [], |
|
|
|
); |
|
|
|
|
|
|
|
const supersedes = computed(() => trimToUndef(typed.value.supersedes)); |
|
|
|
const effectiveDate = computed(() => trimToUndef(typed.value.effectiveDate)); |
|
|
|
|
|
|
|
const hasFundingDetails = computed( |
|
|
|
() => !!(maximumAuthorization.value || targetCompletionDate.value || milestones.value.length), |
|
|
|
); |
|
|
|
const hasPolicyDetails = computed(() => !!(supersedes.value || effectiveDate.value)); |
|
|
|
</script> |