927 lines
42 KiB
TypeScript
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>
|
|
);
|
|
}
|