238 lines
9.6 KiB
TypeScript
238 lines
9.6 KiB
TypeScript
import { useState } from 'react';
|
|
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 default function Competitiveness({
|
|
clusterId,
|
|
sector,
|
|
}: {
|
|
clusterId: string;
|
|
sector: string;
|
|
}) {
|
|
const [draft, setDraft] = useState(() =>
|
|
newCompetitivenessDraft(clusterId, sector),
|
|
);
|
|
const [pending, setPending] = useState<CompetitivenessDraft | null>(null);
|
|
const [message, setMessage] = useState('');
|
|
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>
|
|
Rascunho nesta página: transfira o ficheiro antes de sair. Não é
|
|
gravado no servidor nem publicado. A classificação representa a
|
|
apreciação do autor.
|
|
</p>
|
|
<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>}
|
|
</section>
|
|
);
|
|
}
|