Files
Investidor-V2/resources/js/pages/structures/competitiveness.tsx
T

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>
);
}