Files
Investidor-V2/resources/js/components/opportunity-classification.tsx

992 lines
44 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 =
'Indique relações com corredores, clusters, projectos âncora, zonas, parques, cadeias e actividades, quando aplicáveis. Cada associação tem finalidade, fonte e estado próprios. A implantação, a abrangência e a proximidade são diferentes: estar num distrito não comprova pertença a um cluster, parque ou regime especial. Associação, publicação e acto externo são decisões separadas; dados ausentes permanecem por determinar.';
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">
Associações territoriais e produtivas
</h3>
<p>
Inclui relações territoriais, produtivas, regulatórias e de
actividade. Não repita aqui o endereço nem a hierarquia dos
locais. Pode preparar relações preliminares; confirmação e
publicação seguem as verificações já existentes de fonte,
citação e revisão.
</p>
<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">
Associações territoriais e produtivas
</h2>
<p>
As relações preliminares permanecem por confirmar. Uma
associação não comprova disponibilidade, incentivos ou direitos
concedidos.
</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] ??
'Por determinar'}{' '}
· {r.purpose_label ?? r.purpose} ·{' '}
{r.source_reference || 'Fonte por indicar'}{' '}
{r.citation} {r.production_stage} · Ato
externo:{' '}
{externalLabels[r.external_status] ??
'Não determinado'}
. Benefício ou capacidade disponível: não
inferido.
</p>
))
) : (
<p>Sem associações publicadas neste grupo.</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>
);
}