992 lines
44 KiB
TypeScript
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>
|
|
);
|
|
}
|