Pārlūkot izejas kodu

Render proposals section-by-section

ProposalDetail now displays the structured fields captured by the creation wizard
as labeled sections instead of a flat paragraph list:

- Decision requested, rationale, scope (included/excluded), risks with mitigations,
  responsible party, and conflict disclosure from commonData
- Funding details (formatted maximum authorization, target completion, milestones)
  and finished criteria from typeData for funding proposals
- Policy details (supersedes, effective date) for policy proposals
- Free-form narrative blocks remain as an 'Additional context' section, separated
  from the structured data by a rule only when both exist

Each section uses the app's existing pattern of a bold header heading with a small
uppercase eyebrow label, and empty sections are omitted so older proposals render
unchanged.
master
Perplexity pirms 3 stundām
vecāks
revīzija
84aef21291
4 mainītis faili ar 309 papildinājumiem un 13 dzēšanām
  1. +61
    -12
      src/components/ProposalDetail.vue
  2. +32
    -0
      src/components/ProposalSection.vue
  3. +215
    -0
      src/components/ProposalStructuredSections.vue
  4. +1
    -1
      tsconfig.app.tsbuildinfo

+ 61
- 12
src/components/ProposalDetail.vue Parādīt failu

@@ -143,22 +143,54 @@
role="tabpanel"
aria-labelledby="proposal-tab-proposal"
>
<section
class="max-w-3xl py-8"
aria-labelledby="proposal-content-heading"
>
<div class="max-w-3xl py-8">
<h2 id="proposal-content-heading" class="sr-only">
Proposal details
</h2>

<div class="content-body space-y-5 text-[0.98rem] leading-7 text-slate-800">
<p
v-for="paragraph in proposal.paragraphs"
:key="paragraph.id ?? paragraph.paragraphText"
v-html="renderMarkdown(paragraph.paragraphText)"
/>
</div>
</section>
<!-- Structured sections captured by the proposal wizard (empty for older proposals) -->
<ProposalStructuredSections
:proposal-type="proposal.proposalType"
:common-data="proposal.commonData"
:type-data="proposal.typeData"
/>

<!-- Narrative: additional free-form context recorded as blocks -->
<section
v-if="paragraphBlocks.length"
class="mt-8"
:class="{ 'border-t border-slate-300 pt-8': hasStructuredContent }"
aria-labelledby="additional-context-heading"
>
<div v-if="hasStructuredContent" class="mb-3 flex items-baseline justify-between gap-4">
<h3
id="additional-context-heading"
class="font-header text-xl font-bold tracking-tight text-slate-950"
>
Additional context
</h3>

<span class="shrink-0 text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500">
Free-form
</span>
</div>

<div class="content-body space-y-5 text-[0.98rem] leading-7 text-slate-800">
<template v-for="block in paragraphBlocks" :key="block.id ?? block.index">
<h4
v-if="block.kind === 'heading'"
class="pt-2 font-header text-lg font-bold tracking-tight text-slate-950"
>
{{ block.sectionTitle ?? block.paragraphText }}
</h4>
<p
v-else
v-html="renderMarkdown(block.paragraphText)"
/>
</template>
</div>
</section>
</div>

<section
class="border-t border-slate-300 py-8"
@@ -390,6 +422,7 @@ import ProposalExportCard from "./ProposalExportCard.vue";
import EditProposalModal from "./modals/EditProposalModal.vue";
import CommentThread from "./CommentThread.vue";
import ProposalRevisionDropdown from "./ProposalRevisionDropdown.vue";
import ProposalStructuredSections from "./ProposalStructuredSections.vue";
import {getUserIdFromJwt} from "../utils/auth.ts";
import {buildCommentTree} from "../utils/comments.ts";

@@ -457,6 +490,22 @@ const userVotes = ref<UserProposalVotes | null>(null);

const commentTree = computed(() => buildCommentTree(comments.value));

// Narrative content blocks, ordered by index; structured wizard data renders above them
const paragraphBlocks = computed(() =>
[...(props.proposal.paragraphs ?? [])].sort((a, b) => a.index - b.index),
);

const hasStructuredContent = computed(() => {
const common = props.proposal.commonData ?? {};
return Object.values(common).some((value) => {
if (value == null) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "object") return Object.keys(value as object).length > 0;
return true;
});
});

const isSubmitDisabled = computed(() => {
return !commentText.value.trim() || isSubmitting.value;
});


+ 32
- 0
src/components/ProposalSection.vue Parādīt failu

@@ -0,0 +1,32 @@
<template>
<section :aria-labelledby="headingId">
<div class="mb-3 flex items-baseline justify-between gap-4">
<h3
:id="headingId"
class="font-header text-xl font-bold tracking-tight text-slate-950"
>
{{ title }}
</h3>

<span
v-if="eyebrow"
class="shrink-0 text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500"
>
{{ eyebrow }}
</span>
</div>

<slot />
</section>
</template>

<script setup lang="ts">
let sectionCounter = 0;

const props = defineProps<{
title: string;
eyebrow?: string;
}>();

const headingId = `proposal-section-${props.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${++sectionCounter}`;
</script>

+ 215
- 0
src/components/ProposalStructuredSections.vue Parādīt failu

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

+ 1
- 1
tsconfig.app.tsbuildinfo
Failā izmaiņas netiks attēlotas, jo tās ir par lielu
Parādīt failu


Notiek ielāde…
Atcelt
Saglabāt