Files
Investidor-V2/resources/js/components/opportunity-classification.tsx
T
nala 2d23157e40
tests / ci (push) Canceled after 0s
versao nrgocios
2026-10-16 13:06:58 +02:00

927 lines
42 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { router } from '@inertiajs/react';
import { control, button } from '@/pages/management/shared';
import { valueText } from '@/lib/value-text';
type Classification = {
target_type: string;
purpose: string;
requested_state: string;
disclosure: string;
criteria?: string;
justification?: string;
source_reference?: string;
citation?: string;
authority_id?: string | null;
document_id?: string | null;
external_status?: string;
external_act_reference?: string;
production_stage?: string;
valid_from?: string | null;
valid_until?: string | null;
};
export type ClassificationRelation = {
related_id: string;
relation_type: string;
classification?: Classification;
[key: string]: unknown;
};
export type ClassificationOptions = {
dimensions: Record<string, string>;
types: Record<string, string>;
purposes: Record<string, string>;
targets: Record<string, { id: string | number; label: string; code?: string; level?: number | null; path?: { id: string; code: string; label: string }[]; source_version?: string | null; edition?: string | null }[]>;
externalStates: string[];
};
const typeLabels: Record<string, string> = {
park: 'Parque industrial — organização produtiva',
corridor: 'Corredor',
location: 'Localização',
cluster: 'Cluster',
project: 'Projeto âncora',
chain: 'Cadeia de valor',
zone: 'Zona ou parque',
sector: 'Setor',
activity: 'Subsector ou atividade',
};
export const classificationStates: Record<string, string> = {
draft: 'Rascunho',
preliminary: 'Preliminar',
in_validation: 'Em validação',
confirmed: 'Confirmado',
rejected: 'Rejeitado',
archived: 'Arquivado',
revoked: 'Revogado',
};
const externalLabels: Record<string, string> = {
not_assessed: 'Não determinado',
pending: 'Pendente',
documented_granted: 'Ato de concessão documentado',
documented_denied: 'Ato de recusa documentado',
expired: 'Caducado',
};
const help =
'Associação, publicação e ato externo têm estados separados. Localização ou proximidade não concedem licença, benefício, terreno ou capacidade. Dados ausentes permanecem por determinar. Os elos identificados como Modelo editorial não confirmam operadores, capacidade ou relações comerciais. O recorte CAE conserva cinco níveis; 600/579 permanece documentalmente aberto.';
export function ClassificationEditor({
rows,
onChange,
options,
documents,
authorities,
errors = {},
}: {
rows: ClassificationRelation[];
onChange: (rows: ClassificationRelation[]) => void;
options: ClassificationOptions;
documents: {
id: string;
payload: { title?: string };
file?: { file_name: string };
}[];
authorities: { id: string; label_pt_mz: string }[];
errors?: Record<string, string>;
}) {
const container = useRef<HTMLElement>(null);
useEffect(() => {
const key = Object.keys(errors).find((k) =>
k.startsWith('canonical.relations.'),
);
const index = key?.match(/^canonical\.relations\.(\d+)/)?.[1];
if (index)
container.current
?.querySelector<HTMLSelectElement>(
`[data-association-index="${index}"]`,
)
?.focus();
}, [errors]);
const patch = (
index: number,
fields: Partial<Classification>,
related?: string,
) =>
onChange(
rows.map((row, i) =>
i === index
? {
...row,
related_id: related ?? row.related_id,
classification: { ...row.classification!, ...fields },
}
: row,
),
);
return (
<section
ref={container}
className="space-y-4"
aria-label="Classificação multidimensional"
>
<h3 className="font-semibold">Quatro dimensões da oportunidade</h3>
<p>{help}</p>
{Object.entries(options.dimensions).map(([dimension, label]) => (
<fieldset
key={dimension}
className="min-w-0 space-y-3 rounded border p-3"
>
<legend>{label}</legend>
{rows.map((row, index) =>
row.classification &&
options.types[row.classification.target_type] ===
dimension ? (
<fieldset
key={index}
className="min-w-0 space-y-2 rounded border p-3"
>
<legend>Associação {index + 1}</legend>
<div className="grid min-w-0 gap-3 md:grid-cols-2">
<label>
Tipo
<select
className={control}
value={
row.classification.target_type
}
onChange={(e) =>
patch(
index,
{
target_type:
e.target.value,
},
'',
)
}
>
{Object.entries(options.types)
.filter(
([, d]) => d === dimension,
)
.map(([kind]) => (
<option
key={kind}
value={kind}
>
{typeLabels[kind]}
</option>
))}
</select>
</label>
<label>
Referência existente
<select
className={control}
value={row.related_id}
aria-label="Referência existente"
data-association-index={index}
aria-describedby={
errors[
`canonical.relations.${index}.related_id`
]
? `canonical.relations.${index}.related_id-error`
: undefined
}
onChange={(e) =>
patch(index, {}, e.target.value)
}
aria-invalid={
!!errors[
`canonical.relations.${index}.related_id`
]
}
>
<option value="">
Selecionar referência
</option>
{row.related_id && !(options.targets[row.classification.target_type] ?? []).some((t) => String(t.id) === String(row.related_id)) && (
<option value={row.related_id}>Referência guardada, atualmente indisponível — {row.related_id}</option>
)}
{(
options.targets[
row.classification
.target_type
] ?? []
).map((t) => (
<option
key={t.id}
value={String(t.id)}
>
{t.label}
</option>
))}
</select>
{row.related_id && !(options.targets[row.classification.target_type] ?? []).some((t) => String(t.id) === String(row.related_id)) && (
<span className="block text-sm">A referência deixou de estar disponível para novas escolhas. A relação histórica permanece guardada; não selecione outro valor para a recuperar.</span>
)}
{errors[
`canonical.relations.${index}.related_id`
] && (
<span
id={`canonical.relations.${index}.related_id-error`}
role="alert"
className="block text-red-800"
>
{
errors[
`canonical.relations.${index}.related_id`
]
}
</span>
)}
</label>
<label>
Finalidade
<select
className={control}
value={row.classification.purpose}
onChange={(e) =>
patch(index, {
purpose: e.target.value,
})
}
>
<option value="">
Selecionar finalidade
</option>
{Object.entries(
options.purposes,
).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</label>
<label>
Estado solicitado
<select
className={control}
value={
row.classification
.requested_state
}
onChange={(e) =>
patch(index, {
requested_state:
e.target.value,
})
}
>
{[
'draft',
'preliminary',
'in_validation',
].map((s) => (
<option key={s} value={s}>
{classificationStates[s]}
</option>
))}
</select>
</label>
{(
[
'criteria',
'justification',
'source_reference',
'citation',
'external_act_reference',
'production_stage',
] as const
).map((key) => (
<label key={key}>
{
{
criteria: 'Critérios',
justification:
'Justificação',
source_reference:
'Fonte identificada',
citation:
'Artigo, página ou referência',
external_act_reference:
'Referência do ato externo',
production_stage:
'Etapa produtiva documentada',
}[key]
}
<textarea
className={control}
value={
row.classification?.[key] ??
''
}
onChange={(e) =>
patch(index, {
[key]: e.target.value,
})
}
/>
</label>
))}
<label>
Documento de suporte
<select
className={control}
value={
row.classification
.document_id ?? ''
}
onChange={(e) =>
patch(index, {
document_id:
e.target.value || null,
})
}
>
<option value="">
Por associar, se aplicável
</option>
{documents.map((d) => (
<option key={d.id} value={d.id}>
{d.payload.title ??
d.file?.file_name ??
d.id}
</option>
))}
</select>
</label>
<label>
Entidade competente
<select
className={control}
value={
row.classification
.authority_id ?? ''
}
onChange={(e) =>
patch(index, {
authority_id:
e.target.value || null,
})
}
>
<option value="">
Por determinar
</option>
{authorities.map((a) => (
<option key={a.id} value={a.id}>
{a.label_pt_mz}
</option>
))}
</select>
</label>
<label>
Estado do ato externo
<select
className={control}
value={
row.classification
.external_status ??
'not_assessed'
}
onChange={(e) =>
patch(index, {
external_status:
e.target.value,
})
}
>
{options.externalStates.map((s) => (
<option key={s} value={s}>
{externalLabels[s]}
</option>
))}
</select>
</label>
<label>
Divulgação após publicação editorial
<select
className={control}
value={
row.classification.disclosure
}
onChange={(e) =>
patch(index, {
disclosure: e.target.value,
})
}
>
<option value="restricted">
Reservada
</option>
<option value="public">
Pública: referência, fonte e
citação
</option>
</select>
</label>
{(
['valid_from', 'valid_until'] as const
).map((k) => (
<label key={k}>
{k === 'valid_from'
? 'Válida desde'
: 'Válida até'}
<input
className={control}
type="date"
value={
row.classification?.[k] ??
''
}
onChange={(e) =>
patch(index, {
[k]:
e.target.value ||
null,
})
}
/>
</label>
))}
</div>
{Object.entries(errors)
.filter(
([key]) =>
!key.endsWith('.related_id') &&
key.startsWith(
`canonical.relations.${index}.`,
),
)
.map(([key, message]) => (
<p
key={key}
id={`${key}-error`}
role="alert"
>
{message}
</p>
))}
<button
type="button"
className={button}
onClick={() =>
onChange(
rows.filter((_, i) => i !== index),
)
}
>
Retirar esta associação do rascunho
</button>
</fieldset>
) : null,
)}
<button
type="button"
className={button}
onClick={() => {
const kind = Object.keys(options.types).find(
(k) => options.types[k] === dimension,
)!;
onChange([
...rows,
{
related_id: '',
relation_type: 'classification',
classification: {
target_type: kind,
purpose: '',
requested_state: 'preliminary',
disclosure: 'restricted',
external_status: 'not_assessed',
},
},
]);
}}
>
Acrescentar relação — {label}
</button>
</fieldset>
))}
{rows.some((r) => !r.classification) && (
<p>
As {rows.filter((r) => !r.classification).length} relações
anteriores sem classificação tipada permanecem guardadas. A
ausência de metadados não revoga a sua aceitação.
</p>
)}
</section>
);
}
type ReviewEvent = {
state: string;
at: string;
reason: string;
actor_id: number;
version: number;
};
type ReviewRow = {
previous_versions?: {
metadata: { id: string; version: number };
history: ReviewEvent[];
}[];
hash: string;
dimension: string;
state: string;
can_review: boolean;
relation: ClassificationRelation;
history: {
state: string;
at: string;
reason: string;
actor_id: number;
version: number;
}[];
};
export function ClassificationReview({
id,
refreshToken,
}: {
id: number;
refreshToken: string | null;
}) {
const [result, setResult] = useState<{
etag: string;
rows: ReviewRow[];
duplicates?: { id: number; reference: string; reason: string }[];
} | null>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [reason, setReason] = useState('');
useEffect(() => {
let active = true;
fetch(`/minhas-oportunidades/${id}/classification`, {
headers: { Accept: 'application/json' },
})
.then(async (r) => {
if (!r.ok)
throw new Error(
'Não foi possível recuperar as associações. Atualize a página; os dados guardados permanecem preservados.',
);
return r.json();
})
.then((data) => {
if (active) setResult(data);
})
.catch((e) => {
if (active) setError(String(e.message));
});
return () => {
active = false;
};
}, [id, refreshToken]);
async function decide(row: ReviewRow, state: string) {
setBusy(true);
setError('');
try {
const response = await fetch(
`/minhas-oportunidades/${id}/classification/review`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='))
?.slice(11) ?? '',
),
},
body: JSON.stringify({
etag: result?.etag,
hash: row.hash,
state,
reason,
}),
},
);
const data = await response.json();
if (!response.ok)
throw new Error(
data.message ??
'Decisão não gravada. Preserve o fundamento e atualize antes de repetir.',
);
setResult(data);
router.reload({ only: ['etag', 'record', 'canonicalResponse'] });
} catch (e) {
setError(
e instanceof Error ? e.message : 'Falha ao registar decisão.',
);
} finally {
setBusy(false);
}
}
return (
<section
className="min-w-0 space-y-3 rounded border p-4"
aria-label="Confirmação das associações"
>
<h2 className="text-xl font-bold">Confirmação por associação</h2>
<p>{help}</p>
{error && <p role="alert">{error}</p>}
{!result && !error && <p role="status">A recuperar associações…</p>}
{result?.rows.length === 0 && (
<p>
Sem associações tipadas guardadas. Os vínculos anteriores
continuam no respetivo cadastro.
</p>
)}
{result?.rows.some((r) => r.can_review) && (
<label>
Fundamento da decisão (mínimo 20 caracteres)
<textarea
className={control}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
)}
{result?.rows.map((row) => (
<article
key={row.hash}
className="min-w-0 space-y-2 rounded border p-3"
>
<h3 className="font-semibold">
{typeLabels[row.relation.classification!.target_type]} —{' '}
{classificationStates[row.state]}
</h3>
<p className="break-words">
Referência: {row.relation.related_id} ·{' '}
{row.relation.classification!.source_reference ||
'Fonte por indicar'}{' '}
·{' '}
{row.relation.classification!.citation ||
'Citação por indicar'}
</p>
<p>
Ato externo:{' '}
{
externalLabels[
row.relation.classification!.external_status ??
'not_assessed'
]
}
. Disponibilidade de benefícios não inferida.
</p>
{row.can_review ? (
<div className="flex flex-wrap gap-2">
{[
'in_validation',
'confirmed',
'rejected',
'archived',
'revoked',
].map((state) => (
<button
key={state}
type="button"
className={button}
disabled={busy || reason.trim().length < 20}
onClick={() => void decide(row, state)}
>
{classificationStates[state]}
</button>
))}
</div>
) : (
<p>
Decisão indisponível nesta sessão: exige Validador
no âmbito, responsabilidade correspondente, MFA e
independência.
</p>
)}
<p>
{row.relation.classification!.criteria ||
'Critérios por indicar'}{' '}
·{' '}
{row.relation.classification!.justification ||
'Justificação por indicar'}
</p>
<details>
<summary>Histórico desta versão</summary>
{row.history.length ? (
row.history.map((e) => (
<p key={e.version}>
{e.at} · v{e.version} ·{' '}
{classificationStates[e.state]} ·
responsável #{e.actor_id} · {e.reason}
</p>
))
) : (
<p>Sem decisão registada para este conteúdo.</p>
)}
</details>
{!!row.previous_versions?.length && (
<details>
<summary>Versões anteriores preservadas</summary>
{row.previous_versions.map((v, i) => (
<div key={i}>
<p>
Identidade {v.metadata.id} · v
{v.metadata.version}
</p>
{v.history.map((e) => (
<p key={e.version}>
{e.at} ·{' '}
{classificationStates[e.state]} ·
responsável #{e.actor_id} ·{' '}
{e.reason}
</p>
))}
</div>
))}
</details>
)}
</article>
))}
{!!result?.duplicates?.length && (
<aside>
<h3>Possíveis duplicados — decisão pendente</h3>
{result.duplicates.map((d) => (
<p key={d.id}>
{d.reference}: {d.reason}
</p>
))}
</aside>
)}
</section>
);
}
export type PublicClassification = {
reference_path?: { id: string; code: string; label: string }[];
dimension: string;
target_type: string;
target_id: string;
label: string;
purpose: string;
purpose_label?: string;
state: string;
source_reference?: string;
citation?: string;
external_status: string;
production_stage?: string;
};
export function ClassificationSummary({
rows,
}: {
rows: PublicClassification[];
}) {
return (
<section
className="space-y-4 rounded border p-4"
aria-label="Quatro dimensões da oportunidade"
>
<h2 className="text-xl font-bold">
Classificação multidimensional
</h2>
<p>{help}</p>
{Object.entries({
territory: 'Enquadramento territorial',
production: 'Organização produtiva',
regulation: 'Regime regulatório',
activity: 'Atividade económica',
}).map(([dimension, label]) => (
<section key={dimension}>
<h3 className="font-semibold">{label}</h3>
{rows.filter((r) => r.dimension === dimension).length ? (
rows
.filter((r) => r.dimension === dimension)
.map((r, i) => (
<p key={i} className="break-words">
{r.reference_path?.length
? r.reference_path.map((n) => `${n.code} — ${n.label}`).join(' → ')
: r.label} · {classificationStates[r.state]}{' '}
· {r.purpose_label ?? r.purpose} ·{' '}
{r.source_reference || 'Fonte por indicar'}{' '}
{r.citation} {r.production_stage} · Ato
externo: {externalLabels[r.external_status]}
. Benefício ou capacidade disponível: não
inferido.
</p>
))
) : (
<p>
Sem informação tipada publicada nesta dimensão;
consulte também as relações anteriores abaixo.
</p>
)}
</section>
))}
</section>
);
}
export function ClassificationFilters({
options,
authorities,
categories,
values,
onChange,
}: {
options: ClassificationOptions;
authorities: { id: string; label_pt_mz: string }[];
categories: string[];
values: Record<string, unknown>;
onChange: (key: string, value: string) => void;
}) {
const selectors: {
key: string;
label: string;
choices: [string, string][];
}[] = [
{
key: 'association_type',
label: 'Tipo de associação',
choices: Object.keys(options.types).map((k) => [k, typeLabels[k]]),
},
{
key: 'association_target',
label: 'Referência da associação',
choices: (
options.targets[String(values.association_type)] ?? []
).map((r) => [String(r.id), r.label]),
},
{
key: 'association_purpose',
label: 'Finalidade da associação',
choices: Object.entries(options.purposes),
},
{
key: 'association_state',
label: 'Confirmação da associação',
choices: [
'draft',
'preliminary',
'in_validation',
'confirmed',
'rejected',
].map((k) => [k, classificationStates[k]]),
},
{
key: 'chain_id',
label: 'Cadeia de valor',
choices: (options.targets.chain ?? []).map((r) => [
String(r.id),
r.label,
]),
},
{
key: 'subsector_id',
label: 'Subsector ou atividade publicada',
choices: (options.targets.activity ?? []).map((r) => [
String(r.id),
r.label,
]),
},
{
key: 'authority_id',
label: 'Entidade competente',
choices: authorities.map((a) => [a.id, a.label_pt_mz]),
},
{
key: 'category',
label: 'Modalidade da oportunidade',
choices: categories.map((c) => [c, c]),
},
{
key: 'publication_state',
label: 'Estado editorial público',
choices: [['published', 'Publicado']],
},
];
return (
<fieldset className="min-w-0 space-y-3 rounded border p-3">
<legend>Classificação multidimensional — filtros combinados</legend>
<p>
Os filtros combinam-se por «e». Tipo, referência e finalidade
identificam a mesma associação; cadeia, atividade e entidade
podem constar de outras associações da mesma oportunidade. A
pesquisa inclui apenas publicações públicas. Os vínculos
anteriores continuam nos filtros próprios.
</p>
<div className="grid min-w-0 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{selectors.map((s) => (
<label key={s.key}>
{s.label}
<select
className={control}
value={valueText(values[s.key] ?? '')}
onChange={(e) => onChange(s.key, e.target.value)}
>
<option value="">Todos</option>
{s.choices.map(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
</label>
))}
</div>
</fieldset>
);
}