feat: preserve grounded sector ideas through private investment dossiers in QA

This commit is contained in:
2026-11-15 20:31:57 +02:00
parent 25d18f51bb
commit 6e7dfdabd0
17 changed files with 882 additions and 36 deletions
@@ -1,6 +1,8 @@
import type { SectorAnalysis } from './sector-content-analysis';
import { Link } from '@inertiajs/react';
export type EducationalArticle = {
sector_analysis?: SectorAnalysis;
title: string;
href: string;
summary: string;
@@ -0,0 +1,98 @@
import { Link } from '@inertiajs/react';
import { useState } from 'react';
export type SectorIdea = {
title: string;
start_href: string;
location_label: string | null;
evidence: { key: string; quote: string; section: string };
};
export type SectorAnalysis = {
ideas: SectorIdea[];
region?: string | null;
notice: string;
source_date: string;
};
/** The same evidence-to-preparation controls on sector and regional articles. */
export default function SectorContentAnalysis({
data,
}: {
data: SectorAnalysis;
}) {
const [selected, setSelected] = useState(0);
const idea = data.ideas[selected];
if (!idea) return null;
return (
<section
className="space-y-4 rounded-xl border bg-white p-4"
aria-label="Ideias fundamentadas neste contexto"
>
<h2 className="text-xl font-semibold">Do contexto à sua ideia</h2>
<p>{data.notice}</p>
<label className="grid gap-1">
Possibilidade a explorar
<select
className="min-w-0 rounded border p-3"
value={selected}
onChange={(e) => setSelected(Number(e.target.value))}
>
{data.ideas.map((item, index) => (
<option key={item.title} value={index}>
{item.title}
</option>
))}
</select>
</label>
<p>
<strong>Hipótese de negócio:</strong> {idea.title}
</p>
<p>
{idea.location_label
? `Contexto provincial: ${idea.location_label}. O distrito e a implantação serão escolhidos por si.`
: 'A localização do investimento fica por escolher. Não é necessário pertencer a uma zona demarcada.'}
</p>
<details className="rounded border p-3">
<summary>Fundamento e origem</summary>
{idea.evidence.section && <p>{idea.evidence.section}</p>}
<blockquote className="my-3 border-l-2 pl-3">
{idea.evidence.quote}
</blockquote>
<p>
Edição consultada: {data.source_date}. Afirmação atribuída à
fonte; não constitui previsão do seu negócio.
</p>
</details>
<ol className="list-decimal space-y-2 pl-5">
<li>Escolha ou confirme a localização conhecida.</li>
<li>
Compare actividades similares e complementares; confira a
cobertura dos registos.
</li>
<li>Prepare custos, receitas e cenários no mesmo dossier.</li>
</ol>
<nav
className="flex flex-wrap gap-3"
aria-label="Continuar esta ideia"
>
<Link
className="rounded bg-emerald-800 p-3 text-white"
href={idea.start_href}
>
Preparar investimento a partir deste contexto
</Link>
<Link
className="rounded border p-3"
href={`${idea.start_href}&analysis_action=compare`}
>
Comparar com a oferta existente
</Link>
</nav>
<p className="text-sm">
A comparação é feita no dossier privado, com os acessos
autorizados. Zero anúncios ou registos não prova ausência de
empresas, concorrência ou potencial.
</p>
</section>
);
}
+16 -2
View File
@@ -1,4 +1,7 @@
import SectorResourceReference, { type SectorResourceData } from './sector-resource-reference';
import SectorContentAnalysis from './sector-content-analysis';
import SectorResourceReference, {
type SectorResourceData,
} from './sector-resource-reference';
import { Link } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
@@ -45,7 +48,18 @@ export default function SectorEntry({
modalidade adequada. Cada oportunidade tem promotor, objetivo e
dados próprios.
</p>
{data.resource_reference && <SectorResourceReference data={data.resource_reference} />}
{data.resource_reference && (
<SectorResourceReference data={data.resource_reference} />
)}
{data.articles.map(
(article) =>
article.sector_analysis && (
<SectorContentAnalysis
key={article.href}
data={article.sector_analysis}
/>
),
)}
<nav aria-label="Ações no setor" className="flex flex-wrap gap-3">
{data.start_href ? (
<Link
+52 -8
View File
@@ -35,7 +35,24 @@ export type PreparationReport = {
swot: SwotDraft;
swot_review_required: boolean;
};
export type SectorAnalysisSnapshot = {
key: string;
title: string;
quote: string;
section: string;
source_sha256: string;
source_version: number;
source_date: string;
location_label: string | null;
location_precision: string;
href: string;
};
export type InvestorDossierReportInput = {
content_sources?: {
title: string;
source_url: string;
analyses?: SectorAnalysisSnapshot[];
}[];
supply_aggregates?: SupplyAggregate[];
case: { id: string; title: string; version: number };
context_opportunity: string | null;
@@ -332,6 +349,28 @@ export function createInvestorDossierReport(
origin = 'Registo guardado neste dossier',
classification = 'Preparação do investimento',
): DossierReportRow => ({ field, value, origin, classification });
const sectorEvidence = (input.content_sources ?? []).flatMap((source) =>
(source.analyses ?? []).flatMap((a) => [
row(
'Hipótese: ' + a.title,
a.quote,
`${source.title} — ${a.section}; ${source.source_url}; edição ${a.source_date}`,
'Hipótese; afirmação atribuída à fonte',
),
row(
'Âmbito territorial',
a.location_label ?? 'Âmbito da fonte; implantação por definir',
a.href,
a.location_precision,
),
row(
'Versão preservada',
`${a.source_version}; SHA256 ${a.source_sha256}`,
a.href,
'Rastreabilidade da evidência',
),
]),
);
const finance: DossierReportRow[] = [];
for (const result of input.results.filter(
(item) =>
@@ -656,21 +695,26 @@ export function createInvestorDossierReport(
},
{
id: 'market',
title: 'Análise SWOT',
title: sectorEvidence.length
? 'Mercado, evidências sectoriais e SWOT'
: 'Análise SWOT',
private: true,
paragraphs: swot?.rows.length
? [
`Objecto: ${swot.object_key}; âmbito: ${swot.scope ?? 'não indicado'}. ${preparation?.swot_review_required ? 'Revisão necessária.' : 'Revisão guardada.'}`,
]
: ['Análise SWOT ainda não produzida neste âmbito.'],
rows: (swot?.rows ?? []).map((item) =>
row(
`${dimensions[item.dimension]} — ${item.text}`,
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
item.reference || 'Fonte por confirmar',
classifications[item.classification],
rows: [
...(swot?.rows ?? []).map((item) =>
row(
`${dimensions[item.dimension]} — ${item.text}`,
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
item.reference || 'Fonte por confirmar',
classifications[item.classification],
),
),
),
...sectorEvidence,
],
},
{
id: 'sources',
@@ -1,3 +1,6 @@
import SectorContentAnalysis, {
type SectorAnalysis,
} from '@/components/sector-content-analysis';
import { router } from '@inertiajs/react';
import { useState } from 'react';
import CatalogueChoiceSelect from '@/components/catalogue-choice-select';
@@ -20,6 +23,7 @@ function DemographySelection({
filters,
territories,
sectors,
sectorReadings = [],
}: {
evidence: DemographicAnalysis;
filters: {
@@ -35,6 +39,11 @@ function DemographySelection({
parent_id: string | null;
}[];
sectors: Record<string, string>;
sectorReadings?: {
title: string;
href: string;
analysis: SectorAnalysis;
}[];
}) {
const [selected, setSelected] = useState(filters.territories ?? []);
const [province, setProvince] = useState('');
@@ -226,6 +235,15 @@ function DemographySelection({
))}
</section>
)}
{sectorReadings.map((reading) => (
<section key={reading.href} className="space-y-3">
<h2 className="text-xl font-semibold">{reading.title}</h2>
<a href={reading.href} className="underline">
Consultar contexto provincial completo
</a>
<SectorContentAnalysis data={reading.analysis} />
</section>
))}
</Frame>
);
}
+56 -4
View File
@@ -19,6 +19,8 @@ export type InvestmentContext = {
export default function InvestmentContexts({
contexts,
contentSource,
sectorIdea,
analysisAction,
sectorSource,
resourceSource,
zoneSource,
@@ -30,6 +32,15 @@ export default function InvestmentContexts({
companyComparisonAnswers?: CompanyAnswers | null;
guideSelection?: GuideSelection | null;
contentSource?: LegacyArticle;
sectorIdea?: {
title: string;
quote: string;
location_label: string | null;
evidence_key: string;
source_sha256: string;
source_version: number;
} | null;
analysisAction?: 'prepare' | 'compare';
zoneSource?: {
zone_id: string;
consulted_public_version: number;
@@ -44,10 +55,14 @@ export default function InvestmentContexts({
zone_id: zoneSource?.zone_id,
consulted_public_version: zoneSource?.consulted_public_version,
confirm: !!zoneSource,
title: '',
title: sectorIdea?.title ?? '',
client_key: key,
reuse_consent: false,
content_resource_id: contentSource?.id,
evidence: sectorIdea?.evidence_key,
source_sha256: sectorIdea?.source_sha256,
source_version: sectorIdea?.source_version,
idea: sectorIdea?.title,
sector_slug: sectorSource?.slug,
sector_reference: resourceSource
? {
@@ -101,9 +116,15 @@ export default function InvestmentContexts({
{resourceSource && (
<section className="rounded border p-4">
<h2>{resourceSource.title}</h2>
<p>{resourceSource.location_label ?? resourceSource.locations_text}</p>
<p>
{resourceSource.location_label ??
resourceSource.locations_text}
</p>
<p>{resourceSource.value_chain}</p>
<p>Esta referência será guardada apenas no dossier que escolher.</p>
<p>
Esta referência será guardada apenas no dossier que
escolher.
</p>
</section>
)}
{contentSource && (
@@ -115,6 +136,25 @@ export default function InvestmentContexts({
.
</p>
)}
{sectorIdea && (
<section className="space-y-2 rounded border p-4">
<h2>Hipótese a preparar: {sectorIdea.title}</h2>
<p>
{sectorIdea.location_label ??
'Localização por escolher'}
. Confirme a implantação no passo seguinte.
</p>
<details>
<summary>Fundamento seleccionado</summary>
<blockquote>{sectorIdea.quote}</blockquote>
</details>
<p>
{analysisAction === 'compare'
? 'Continue no seu dossier, guarde a localização e consulte a análise de mercado e oferta existente.'
: 'A ideia e a fonte ficam no mesmo dossier. Complete apenas os dados em falta.'}
</p>
</section>
)}
<p>
Prepare cada investimento no seu dossier. Pode trabalhar em
várias etapas em paralelo.
@@ -174,7 +214,13 @@ export default function InvestmentContexts({
{
version: item.version,
sector_slug: sectorSource.slug,
...(resourceSource ? { sector_reference: form.data.sector_reference } : {}),
...(resourceSource
? {
sector_reference:
form.data
.sector_reference,
}
: {}),
},
)
}
@@ -192,6 +238,12 @@ export default function InvestmentContexts({
{
version: item.version,
resource_id: contentSource.id,
evidence: sectorIdea?.evidence_key,
source_sha256:
sectorIdea?.source_sha256,
source_version:
sectorIdea?.source_version,
idea: sectorIdea?.title,
},
)
}
+50 -3
View File
@@ -1,3 +1,4 @@
import type { SectorAnalysisSnapshot } from '@/lib/investor-dossier-report';
import type { SectorResourceSelection } from '@/components/sector-resource-reference';
import { InvestorDossierReports } from '@/components/investor-dossier-reports';
import type { InvestorDossierReportInput } from '@/lib/investor-dossier-report';
@@ -120,6 +121,8 @@ type Path = {
tools: Record<string, string | null>;
results: PathResult[];
content_sources: {
analyses?: SectorAnalysisSnapshot[];
analysis_review_required?: boolean;
resource_id: number;
title: string;
return_href: string;
@@ -375,7 +378,9 @@ function Stage({ step, path }: { step: Step; path: Path }) {
}));
form.post(
`/investimentos/${path.case.id}/etapas/${step.code}`,
{ preserveScroll: true },
{
preserveScroll: true,
},
);
}}
>
@@ -851,6 +856,44 @@ export default function InvestmentPath(path: Path) {
>
Exemplar e origem
</Link>
{source.analysis_review_required && (
<p role="status">
A fonte foi alterada ou deixou de estar
disponível. Reveja a hipótese; o trecho
guardado foi preservado.
</p>
)}
{(source.analyses ?? []).map((analysis) => (
<details
className="my-2 rounded border p-3"
key={analysis.key}
>
<summary>
Hipótese guardada: {analysis.title}
</summary>
<p>
{analysis.location_label ??
'Implantação por definir'}
. Contexto da fonte, sem atribuição
distrital automática.
</p>
<blockquote>
{analysis.quote}
</blockquote>
<p>
Edição: {analysis.source_date}.
Preserve este fundamento e reveja-o
se a fonte mudar.
</p>
<Link
className="underline"
href={`/investimentos/${path.case.id}/contexto/editar`}
>
Continuar a preparação no mesmo
dossier
</Link>
</details>
))}
</li>
))}
</ul>
@@ -910,7 +953,9 @@ export default function InvestmentPath(path: Path) {
}));
facts.post(
`/investimentos/${path.case.id}/aplicabilidade`,
{ preserveScroll: true },
{
preserveScroll: true,
},
);
}}
>
@@ -1252,7 +1297,9 @@ export default function InvestmentPath(path: Path) {
}));
task.post(
`/investimentos/${path.case.id}/tarefas`,
{ preserveScroll: true },
{
preserveScroll: true,
},
);
}}
>
+7
View File
@@ -1,9 +1,13 @@
import SectorContentAnalysis, {
type SectorAnalysis,
} from '@/components/sector-content-analysis';
import { Head, Link, useForm } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from '@/components/educational-article-card';
export type LegacyArticle = {
sector_analysis?: SectorAnalysis;
summary: string;
opportunities_href?: string | null;
opportunities_label?: string | null;
@@ -72,6 +76,9 @@ export default function LegacySector({
.legacy-article ol { list-style: decimal; padding-inline-start: 1.6em; }
.legacy-article a { text-decoration: underline; }
`}</style>
{article.sector_analysis && (
<SectorContentAnalysis data={article.sector_analysis} />
)}
<nav
className="flex flex-wrap gap-4"
aria-label={article.group.label}