502 lines
22 KiB
TypeScript
502 lines
22 KiB
TypeScript
import { useState } from 'react';
|
|
import { portalRequest, PortalApiError } from '@/lib/portal-api';
|
|
import {
|
|
assessmentReady,
|
|
diagnosticReady,
|
|
forceLabels,
|
|
forces,
|
|
newCompetitivenessDraft,
|
|
readCompetitivenessDraft,
|
|
} from '@/lib/cluster-competitiveness';
|
|
import type {
|
|
Assessment,
|
|
CompetitivenessDraft,
|
|
Force,
|
|
} from '@/lib/cluster-competitiveness';
|
|
import { button, control } from '../management/shared';
|
|
|
|
const labels = {
|
|
evidence: 'Observações e evidências locais',
|
|
source: 'Fonte e referência recuperável (documento, página ou endereço)',
|
|
period: 'Período dos dados e data da consulta',
|
|
rationale: 'Justificação da pressão atribuída',
|
|
limitations: 'Limitações, cobertura e pressupostos',
|
|
opportunity: 'Oportunidade de investimento e fundamento',
|
|
} as const;
|
|
|
|
export type CompetitivenessState = {
|
|
version: number;
|
|
draft: CompetitivenessDraft | null;
|
|
approved: {
|
|
draft: CompetitivenessDraft;
|
|
version: number;
|
|
at: string;
|
|
} | null;
|
|
history: { version: number; action: string; at: string; actor: number }[];
|
|
};
|
|
const actions: Record<string, string> = {
|
|
saved: 'Gravação',
|
|
approved: 'Aprovação pública',
|
|
withdrawn: 'Retirada da aprovação',
|
|
restored: 'Recuperação do histórico',
|
|
};
|
|
|
|
export default function Competitiveness({
|
|
clusterId,
|
|
sector,
|
|
initial,
|
|
}: {
|
|
clusterId: string;
|
|
sector: string;
|
|
initial?: CompetitivenessState | null;
|
|
}) {
|
|
const [draft, setDraft] = useState(
|
|
() => initial?.draft ?? newCompetitivenessDraft(clusterId, sector),
|
|
);
|
|
const [server, setServer] = useState<CompetitivenessState>(
|
|
initial ?? { version: 0, draft: null, approved: null, history: [] },
|
|
);
|
|
const [serverPending, setServerPending] =
|
|
useState<CompetitivenessState | null>(null);
|
|
const [restoreVersion, setRestoreVersion] = useState<number | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [reason, setReason] = useState('');
|
|
const [pending, setPending] = useState<CompetitivenessDraft | null>(null);
|
|
const [message, setMessage] = useState('');
|
|
const dirty =
|
|
JSON.stringify(draft) !==
|
|
JSON.stringify(
|
|
server.draft ?? newCompetitivenessDraft(clusterId, sector),
|
|
);
|
|
const endpoint = `/management/clusters/${clusterId}/competitiveness`;
|
|
function errorMessage(error: unknown) {
|
|
if (error instanceof PortalApiError) {
|
|
if (error.status === 409)
|
|
return 'Conflito de versão. Os campos actuais foram conservados. Transfira o rascunho e consulte a versão do servidor antes de reconciliar.';
|
|
if ([401, 403, 419].includes(error.status))
|
|
return 'Sessão ou permissão indisponível. Os campos foram conservados; transfira o rascunho antes de autenticar-se novamente.';
|
|
if (error.status === 422)
|
|
return (
|
|
Object.values(error.errors).flat().join(' ') ||
|
|
error.message
|
|
);
|
|
}
|
|
return 'Não foi possível concluir a operação; os campos actuais foram conservados.';
|
|
}
|
|
function apply(state: CompetitivenessState) {
|
|
setServer(state);
|
|
setDraft(state.draft ?? newCompetitivenessDraft(clusterId, sector));
|
|
setPending(null);
|
|
setServerPending(null);
|
|
setRestoreVersion(null);
|
|
}
|
|
async function command(
|
|
action: string,
|
|
extra: Record<string, unknown> = {},
|
|
) {
|
|
setBusy(true);
|
|
try {
|
|
const state = await portalRequest<CompetitivenessState>(
|
|
`${endpoint}/${action}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
expected_version: server.version,
|
|
...extra,
|
|
}),
|
|
},
|
|
);
|
|
apply(state);
|
|
setMessage(
|
|
`${actions[action]} concluída no servidor. Versão ${state.version}.`,
|
|
);
|
|
} catch (error) {
|
|
setMessage(errorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
function change(force: Force, field: keyof Assessment, value: string) {
|
|
setDraft((previous) => ({
|
|
...previous,
|
|
forces: {
|
|
...previous.forces,
|
|
[force]: { ...previous.forces[force], [field]: value },
|
|
},
|
|
}));
|
|
}
|
|
function download() {
|
|
const url = URL.createObjectURL(
|
|
new Blob([JSON.stringify(draft, null, 2)], {
|
|
type: 'application/json',
|
|
}),
|
|
);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `competitividade-${clusterId}.json`;
|
|
link.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
setMessage(
|
|
'Ficheiro preparado para transferência. Conserve-o para recuperar a análise.',
|
|
);
|
|
}
|
|
return (
|
|
<section
|
|
className="max-w-3xl space-y-4 rounded border p-4"
|
|
aria-label="Diagnóstico de competitividade"
|
|
>
|
|
<h2 className="text-xl font-semibold">
|
|
Competitividade — cinco forças de Porter
|
|
</h2>
|
|
<p>
|
|
Análise qualitativa por sector e região ligada a este cluster.
|
|
Descreva as evidências e justifique cada pressão; não existe
|
|
pontuação automática nem média entre forças.
|
|
</p>
|
|
<p>
|
|
Guarde o rascunho para o recuperar no servidor. A publicação
|
|
exige aprovação expressa; a classificação representa a
|
|
apreciação do autor.
|
|
</p>
|
|
<p role="status">
|
|
Versão no servidor: {server.version}.{' '}
|
|
{dirty
|
|
? 'Existem alterações por guardar.'
|
|
: 'Campos correspondem à versão recuperada.'}{' '}
|
|
{server.approved
|
|
? `Consulta pública: versão aprovada ${server.approved.version}; novas edições conservam essa versão até outra aprovação.`
|
|
: 'Sem análise aprovada para consulta pública.'}
|
|
</p>
|
|
<fieldset disabled={busy} className="space-y-4">
|
|
<label className="block">
|
|
Sector analisado
|
|
<input
|
|
className={control}
|
|
maxLength={200}
|
|
value={draft.sector}
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, sector: e.target.value })
|
|
}
|
|
/>
|
|
</label>
|
|
{draft.sector !== sector && (
|
|
<p>
|
|
O sector da análise difere do sector actual do cluster (
|
|
{sector}). Confira o âmbito antes de reutilizar
|
|
conclusões.
|
|
</p>
|
|
)}
|
|
<label className="block">
|
|
Região / província / distrito e abrangência
|
|
<input
|
|
className={control}
|
|
maxLength={1000}
|
|
value={draft.region}
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, region: e.target.value })
|
|
}
|
|
/>
|
|
</label>
|
|
<p role="status">
|
|
{diagnosticReady(draft)
|
|
? 'Cinco forças preenchidas para revisão; fontes e conclusões requerem leitura do autor.'
|
|
: 'Diagnóstico incompleto: indique o âmbito e complete as evidências das cinco forças.'}
|
|
</p>
|
|
{forces.map((force) => (
|
|
<details key={force} className="rounded border p-3">
|
|
<summary className="cursor-pointer font-semibold">
|
|
{forceLabels[force]} ·{' '}
|
|
{assessmentReady(draft.forces[force])
|
|
? 'Preenchida para revisão'
|
|
: 'Indeterminada / incompleta'}
|
|
</summary>
|
|
<div className="space-y-3 pt-3">
|
|
<label className="block">
|
|
Pressão competitiva
|
|
<select
|
|
className={control}
|
|
value={draft.forces[force].pressure}
|
|
onChange={(e) =>
|
|
change(
|
|
force,
|
|
'pressure',
|
|
e.target.value,
|
|
)
|
|
}
|
|
>
|
|
<option value="">Indeterminada</option>
|
|
<option value="low">Baixa</option>
|
|
<option value="medium">Média</option>
|
|
<option value="high">Alta</option>
|
|
</select>
|
|
</label>
|
|
{Object.entries(labels).map(([field, label]) => (
|
|
<label key={field} className="block">
|
|
{label}
|
|
<textarea
|
|
className={control}
|
|
rows={3}
|
|
maxLength={5000}
|
|
value={
|
|
draft.forces[force][
|
|
field as keyof typeof labels
|
|
]
|
|
}
|
|
onChange={(e) =>
|
|
change(
|
|
force,
|
|
field as keyof typeof labels,
|
|
e.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</details>
|
|
))}
|
|
<button type="button" className={button} onClick={download}>
|
|
Transferir análise / rascunho
|
|
</button>
|
|
<label className="block">
|
|
Recuperar ficheiro deste cluster
|
|
<input
|
|
className={control}
|
|
type="file"
|
|
accept=".json,application/json"
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = '';
|
|
if (!file) return;
|
|
setPending(null);
|
|
try {
|
|
if (file.size > 250_000)
|
|
throw Error(
|
|
'O ficheiro excede o limite de 250 KB.',
|
|
);
|
|
setPending(
|
|
readCompetitivenessDraft(
|
|
await file.text(),
|
|
clusterId,
|
|
),
|
|
);
|
|
setMessage(
|
|
'Ficheiro lido. Confira o âmbito e confirme a substituição dos campos actuais.',
|
|
);
|
|
} catch (error) {
|
|
setMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Não foi possível ler o ficheiro.',
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
{pending && (
|
|
<div className="space-y-2 rounded border p-3">
|
|
<p>
|
|
Recuperar análise de{' '}
|
|
{pending.sector || 'sector não indicado'} em{' '}
|
|
{pending.region || 'região não indicada'}? Os campos
|
|
actuais serão substituídos. Transfira primeiro o
|
|
rascunho actual se o quiser conservar.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
onClick={() => {
|
|
setDraft(pending);
|
|
setPending(null);
|
|
setMessage('Análise recuperada para edição.');
|
|
}}
|
|
>
|
|
Confirmar recuperação
|
|
</button>{' '}
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
onClick={() => {
|
|
setPending(null);
|
|
setMessage(
|
|
'Recuperação cancelada; campos actuais preservados.',
|
|
);
|
|
}}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
)}
|
|
{message && <p role="status">{message}</p>}
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
disabled={!dirty}
|
|
onClick={() => command('saved', { draft })}
|
|
>
|
|
Guardar rascunho no servidor
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
onClick={async () => {
|
|
setBusy(true);
|
|
try {
|
|
setServerPending(
|
|
await portalRequest<CompetitivenessState>(
|
|
endpoint,
|
|
),
|
|
);
|
|
setMessage(
|
|
'Versão do servidor lida. Confirme antes de substituir os campos.',
|
|
);
|
|
} catch (error) {
|
|
setMessage(errorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
Consultar versão actual do servidor
|
|
</button>
|
|
</div>
|
|
{serverPending && (
|
|
<div className="rounded border p-3">
|
|
<p>
|
|
Versão {serverPending.version}:{' '}
|
|
{serverPending.draft?.sector ||
|
|
'Sector não indicado'}{' '}
|
|
/{' '}
|
|
{serverPending.draft?.region ||
|
|
'Região não indicada'}
|
|
. Recuperar substitui os campos actuais. Transfira
|
|
primeiro alterações que queira conservar.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
onClick={() => {
|
|
apply(serverPending);
|
|
setMessage(
|
|
'Versão actual recuperada do servidor.',
|
|
);
|
|
}}
|
|
>
|
|
Confirmar recuperação do servidor
|
|
</button>{' '}
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
onClick={() => setServerPending(null)}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
)}
|
|
<details className="rounded border p-3">
|
|
<summary>
|
|
Histórico no servidor (últimas 50 versões)
|
|
</summary>
|
|
{!server.history.length && (
|
|
<p>Ainda não existem gravações.</p>
|
|
)}
|
|
{server.history.map((entry) => (
|
|
<p key={entry.version}>
|
|
Versão {entry.version} ·{' '}
|
|
{actions[entry.action] ?? entry.action} · {entry.at}{' '}
|
|
· Utilizador {entry.actor}{' '}
|
|
<button
|
|
type="button"
|
|
className="underline"
|
|
onClick={() => setRestoreVersion(entry.version)}
|
|
>
|
|
Recuperar como rascunho
|
|
</button>
|
|
</p>
|
|
))}
|
|
{restoreVersion !== null && (
|
|
<div>
|
|
<p>
|
|
Recuperar a versão {restoreVersion} cria uma
|
|
nova versão no servidor e substitui os campos
|
|
actuais. A versão pública aprovada mantém-se.
|
|
Transfira primeiro alterações por guardar.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
onClick={() =>
|
|
command('restored', {
|
|
revision: restoreVersion,
|
|
})
|
|
}
|
|
>
|
|
Confirmar recuperação do histórico
|
|
</button>{' '}
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
onClick={() => setRestoreVersion(null)}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
)}
|
|
</details>
|
|
<details className="rounded border p-3">
|
|
<summary>Aprovação para consulta pública</summary>
|
|
<p>
|
|
Aprovar torna a versão guardada consultável na ficha
|
|
pública deste cluster, quando essa ficha estiver
|
|
publicada. Guardar um rascunho não o publica. A
|
|
evidência de aprovação e as identidades do histórico
|
|
ficam na gestão.
|
|
</p>
|
|
<label className="block">
|
|
Evidência / motivo da aprovação ou retirada
|
|
<textarea
|
|
className={control}
|
|
maxLength={3000}
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
disabled={
|
|
dirty ||
|
|
!diagnosticReady(draft) ||
|
|
server.version === 0 ||
|
|
reason.trim().length < 10
|
|
}
|
|
onClick={() => command('approved', { reason })}
|
|
>
|
|
Aprovar versão guardada para consulta pública
|
|
</button>{' '}
|
|
<button
|
|
type="button"
|
|
className={button}
|
|
disabled={
|
|
dirty ||
|
|
!server.approved ||
|
|
reason.trim().length < 10
|
|
}
|
|
onClick={() => command('withdrawn', { reason })}
|
|
>
|
|
Retirar aprovação pública
|
|
</button>
|
|
{dirty && (
|
|
<p>
|
|
Guarde primeiro as alterações ou recupere a versão
|
|
pretendida antes de decidir a aprovação.
|
|
</p>
|
|
)}
|
|
</details>
|
|
</fieldset>
|
|
</section>
|
|
);
|
|
}
|