diff --git a/app/Http/Controllers/ClusterCompetitivenessController.php b/app/Http/Controllers/ClusterCompetitivenessController.php new file mode 100644 index 00000000..92b859b2 --- /dev/null +++ b/app/Http/Controllers/ClusterCompetitivenessController.php @@ -0,0 +1,27 @@ +json($service->management($cluster->id))->header('Cache-Control', 'private, no-store'); + } + + public function change(Request $request, EconomicCluster $cluster, string $action, ClusterCompetitiveness $service): JsonResponse + { + $data = $request->validate(['expected_version' => 'required|integer|min:0', + 'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited', + 'revision' => $action === 'restored' ? 'required|integer|min:1' : 'prohibited', + 'reason' => in_array($action, ['approved', 'withdrawn'], true) ? 'required|string|min:10|max:3000' : 'nullable|string|max:3000']); + + return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data)) + ->header('Cache-Control', 'private, no-store'); + } +} diff --git a/app/Http/Controllers/InvestmentPortfolioController.php b/app/Http/Controllers/InvestmentPortfolioController.php index 3802d203..e9c32b67 100644 --- a/app/Http/Controllers/InvestmentPortfolioController.php +++ b/app/Http/Controllers/InvestmentPortfolioController.php @@ -140,6 +140,7 @@ class InvestmentPortfolioController extends Controller return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones, 'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions, + 'competitiveness' => $record->cluster_id ? app(\App\Services\ClusterCompetitiveness::class)->publicData($record->cluster_id) : null, 'geography' => app(InvestmentRelations::class)->dossier($record, $management), 'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]); } diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php index feaf6355..092adda7 100644 --- a/app/Http/Controllers/InvestmentStructureController.php +++ b/app/Http/Controllers/InvestmentStructureController.php @@ -50,6 +50,7 @@ class InvestmentStructureController extends Controller ->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at') ->orderBy('origins.code')->orderBy('destinations.code') ->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [], + 'competitiveness' => $structure === 'clusters' && $id ? app(\App\Services\ClusterCompetitiveness::class)->management($id) : null, 'viewerId' => $request->user()->id, 'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects') ->join('projects', 'projects.id', '=', 'cluster_projects.project_id') diff --git a/app/Services/ClusterCompetitiveness.php b/app/Services/ClusterCompetitiveness.php new file mode 100644 index 00000000..521e80ba --- /dev/null +++ b/app/Services/ClusterCompetitiveness.php @@ -0,0 +1,129 @@ +where('source_system', self::SOURCE) + ->where('entity_type', 'economic_clusters')->where('entity_id', $cluster); + } + + private function decode(?object $event): array + { + return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR) + : ['version' => 0, 'draft' => null, 'approved' => null]; + } + + public function current(string $cluster): array + { + return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first()); + } + + public function management(string $cluster): array + { + $state = $this->current($cluster); + $state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get() + ->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'], + 'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all(); + + return $state; + } + + public function validateDraft(array $draft, string $cluster): array + { + $rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster, + 'sector' => 'present|nullable|string|max:200', 'region' => 'present|nullable|string|max:1000', + 'forces' => 'required|array:'.implode(',', self::FORCES)]; + foreach (self::FORCES as $force) { + $rules["forces.$force"] = 'required|array:pressure,'.implode(',', self::FIELDS); + $rules["forces.$force.pressure"] = 'present|nullable|in:low,medium,high'; + foreach (self::FIELDS as $field) { + $rules["forces.$force.$field"] = 'present|nullable|string|max:5000'; + } + } + $clean = Validator::make($draft, $rules)->validate(); + $clean['sector'] = trim($clean['sector'] ?? ''); + $clean['region'] = trim($clean['region'] ?? ''); + foreach (self::FORCES as $force) { + foreach (['pressure', ...self::FIELDS] as $field) { + $clean['forces'][$force][$field] = trim($clean['forces'][$force][$field] ?? ''); + } + } + + return $clean; + } + + private function assertComplete(?array $draft): void + { + $missing = ! $draft || ! $draft['sector'] || ! $draft['region']; + foreach (self::FORCES as $force) { + $row = $draft['forces'][$force] ?? []; + $missing = $missing || ! in_array($row['pressure'] ?? '', ['low', 'medium', 'high'], true); + foreach (self::FIELDS as $field) { + $missing = $missing || trim($row[$field] ?? '') === ''; + } + } + if ($missing) { + throw ValidationException::withMessages(['approval' => 'Complete o âmbito e as evidências, fontes, períodos, justificação, limitações e oportunidades das cinco forças antes de aprovar.']); + } + } + + /** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields. */ + public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array + { + return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) { + EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); + $before = $this->current($cluster->id); + abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.'); + $state = $before; + if ($action === 'saved') { + $state['draft'] = $this->validateDraft($input['draft'], $cluster->id); + } elseif ($action === 'restored') { + $event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first(); + abort_unless($event, 404); + $state['draft'] = $this->decode($event)['draft']; + } elseif ($action === 'approved') { + $this->assertComplete($state['draft']); + $state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actor, + 'version' => $expected + 1, 'evidence' => $input['reason']]; + } elseif ($action === 'withdrawn') { + $state['approved'] = null; + } else { + throw new \LogicException('Unsupported competitiveness action'); + } + $state['version']++; + DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'economic_clusters', + 'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor, + 'action' => $action, 'data_classification' => 'restricted', + 'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR), + 'after_data' => json_encode(['version' => $state['version'], 'restored_from' => $input['revision'] ?? null, + 'reason' => Crypt::encryptString($input['reason'] ?? ''), + 'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]); + + return $this->management($cluster->id); + }); + } + + public function publicData(string $cluster): ?array + { + $approved = $this->current($cluster)['approved']; + + // Explicit projection: never return editor identity, approval evidence, history or the unapproved draft. + return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null; + } +} diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index f30ce0ac..4a47d653 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -1842,4 +1842,9 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2 Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01. -Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega. \ No newline at end of file +Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega. +### 29/10/2026 — ACT-01, persistência e aprovação: execução em curso + +Ordem confirmada pela Tempo: 1) gravar/validar/recuperar na gestão com permissões e histórico, 2) consultar publicamente apenas conteúdo aprovado, 3) restantes alterações. A instalação da interface 23ecccfa e os 1.180 testes anteriores são provas desse incremento, não prova de persistência. Sem perda nova confirmada; preservar a versão pública anterior e os recibos de backup/restauro. O problema dos filtros provinciais permanece **separado e aberto**, sem declaração de correcção por estes trabalhos. + +Escolha técnica: reutilizar snapshots cifrados nos eventos append-only de auditoria, já usados pelo portal; bloqueio transaccional do cluster, controlo de versão e histórico imutável. Sem alteração de schema/permissões. Guardar rascunho e recuperar histórico não publica; aprovação explícita fixa a versão consultável, edições seguintes preservam a aprovação anterior até nova decisão. Rascunhos parciais são guardáveis; aprovação exige os fundamentos das cinco forças. Recuperação/conflito não deve sobrescrever edições inadvertidamente. Acesso público continua limitado às fichas publicadas. Implementação e provas específicas de persistência/consulta ainda em preparação; restantes ACT mantêm o estado anterior. \ No newline at end of file diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index ded7ce63..bbb771da 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -7400,4 +7400,9 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2 Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01. -Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega. \ No newline at end of file +Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega. +### 29/10/2026 — ACT-01, persistência e aprovação: execução em curso + +Ordem confirmada pela Tempo: 1) gravar/validar/recuperar na gestão com permissões e histórico, 2) consultar publicamente apenas conteúdo aprovado, 3) restantes alterações. A instalação da interface 23ecccfa e os 1.180 testes anteriores são provas desse incremento, não prova de persistência. Sem perda nova confirmada; preservar a versão pública anterior e os recibos de backup/restauro. O problema dos filtros provinciais permanece **separado e aberto**, sem declaração de correcção por estes trabalhos. + +Escolha técnica: reutilizar snapshots cifrados nos eventos append-only de auditoria, já usados pelo portal; bloqueio transaccional do cluster, controlo de versão e histórico imutável. Sem alteração de schema/permissões. Guardar rascunho e recuperar histórico não publica; aprovação explícita fixa a versão consultável, edições seguintes preservam a aprovação anterior até nova decisão. Rascunhos parciais são guardáveis; aprovação exige os fundamentos das cinco forças. Recuperação/conflito não deve sobrescrever edições inadvertidamente. Acesso público continua limitado às fichas publicadas. Implementação e provas específicas de persistência/consulta ainda em preparação; restantes ACT mantêm o estado anterior. \ No newline at end of file diff --git a/docs/investmoz/v2/auditoria-portal/backlog.md b/docs/investmoz/v2/auditoria-portal/backlog.md index f945871d..dde49c0c 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog.md +++ b/docs/investmoz/v2/auditoria-portal/backlog.md @@ -3022,4 +3022,9 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2 Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01. -Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega. \ No newline at end of file +Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega. +### 29/10/2026 — ACT-01, persistência e aprovação: execução em curso + +Ordem confirmada pela Tempo: 1) gravar/validar/recuperar na gestão com permissões e histórico, 2) consultar publicamente apenas conteúdo aprovado, 3) restantes alterações. A instalação da interface 23ecccfa e os 1.180 testes anteriores são provas desse incremento, não prova de persistência. Sem perda nova confirmada; preservar a versão pública anterior e os recibos de backup/restauro. O problema dos filtros provinciais permanece **separado e aberto**, sem declaração de correcção por estes trabalhos. + +Escolha técnica: reutilizar snapshots cifrados nos eventos append-only de auditoria, já usados pelo portal; bloqueio transaccional do cluster, controlo de versão e histórico imutável. Sem alteração de schema/permissões. Guardar rascunho e recuperar histórico não publica; aprovação explícita fixa a versão consultável, edições seguintes preservam a aprovação anterior até nova decisão. Rascunhos parciais são guardáveis; aprovação exige os fundamentos das cinco forças. Recuperação/conflito não deve sobrescrever edições inadvertidamente. Acesso público continua limitado às fichas publicadas. Implementação e provas específicas de persistência/consulta ainda em preparação; restantes ACT mantêm o estado anterior. \ No newline at end of file diff --git a/resources/js/components/cluster-competitiveness-public.tsx b/resources/js/components/cluster-competitiveness-public.tsx new file mode 100644 index 00000000..d43f4a90 --- /dev/null +++ b/resources/js/components/cluster-competitiveness-public.tsx @@ -0,0 +1,72 @@ +import { forceLabels, forces } from '@/lib/cluster-competitiveness'; +import type { CompetitivenessDraft } from '@/lib/cluster-competitiveness'; + +export type ApprovedCompetitiveness = { + draft: CompetitivenessDraft; + version: number; + at: string; +}; +const pressures = { + low: 'Baixa', + medium: 'Média', + high: 'Alta', + '': 'Indeterminada', +}; + +export default function ClusterCompetitivenessPublic({ + analysis, +}: { + analysis: ApprovedCompetitiveness; +}) { + return ( +
+

+ Competitividade — cinco forças de Porter +

+

+ {analysis.draft.sector} · {analysis.draft.region} · Versão + aprovada {analysis.version} · {analysis.at} +

+

+ Apreciação qualitativa fundamentada nas fontes e períodos + indicados. Não representa garantia de procura ou viabilidade. +

+ {forces.map((force) => { + const row = analysis.draft.forces[force]; + return ( +
+ + {forceLabels[force]} · Pressão{' '} + {pressures[row.pressure]} + +
+ {( + [ + ['evidence', 'Evidências'], + ['source', 'Fonte e referência'], + ['period', 'Período'], + ['rationale', 'Justificação'], + ['limitations', 'Limitações'], + [ + 'opportunity', + 'Oportunidade e fundamento', + ], + ] as const + ).map(([field, label]) => ( +
+
{label}
+
+ {row[field]} +
+
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/resources/js/pages/portfolio/show.tsx b/resources/js/pages/portfolio/show.tsx index 6d1a5104..d2a1c86f 100644 --- a/resources/js/pages/portfolio/show.tsx +++ b/resources/js/pages/portfolio/show.tsx @@ -11,6 +11,7 @@ import { Management } from '../management/shared'; import { Layout, Field, control, button, labels, kind } from './shared'; import type { Dossier } from './shared'; import InvestmentComplements, { type ComplementOption } from '@/components/investment-complements'; +import ClusterCompetitivenessPublic, { type ApprovedCompetitiveness } from '@/components/cluster-competitiveness-public'; export default function Show(props: Props) { return (

Fonte: {record.source}

+ {competitiveness && } {!management && (
diff --git a/resources/js/pages/structures/competitiveness.tsx b/resources/js/pages/structures/competitiveness.tsx index 821a07e7..cc0cdef3 100644 --- a/resources/js/pages/structures/competitiveness.tsx +++ b/resources/js/pages/structures/competitiveness.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { portalRequest, PortalApiError } from '@/lib/portal-api'; import { assessmentReady, diagnosticReady, @@ -23,18 +24,99 @@ const labels = { 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 = { + 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(() => - newCompetitivenessDraft(clusterId, sector), + const [draft, setDraft] = useState( + () => initial?.draft ?? newCompetitivenessDraft(clusterId, sector), ); + const [server, setServer] = useState( + initial ?? { version: 0, draft: null, approved: null, history: [] }, + ); + const [serverPending, setServerPending] = + useState(null); + const [restoreVersion, setRestoreVersion] = useState(null); + const [busy, setBusy] = useState(false); + const [reason, setReason] = useState(''); const [pending, setPending] = useState(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 = {}, + ) { + setBusy(true); + try { + const state = await portalRequest( + `${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, @@ -73,165 +155,347 @@ export default function Competitiveness({ pontuação automática nem média entre forças.

- Rascunho nesta página: transfira o ficheiro antes de sair. Não é - gravado no servidor nem publicado. A classificação representa a + Guarde o rascunho para o recuperar no servidor. A publicação + exige aprovação expressa; a classificação representa a apreciação do autor.

- - {draft.sector !== sector && ( -

- O sector da análise difere do sector actual do cluster ( - {sector}). Confira o âmbito antes de reutilizar conclusões. -

- )} -

- {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.'} + 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.'}

- {forces.map((force) => ( -
- - {forceLabels[force]} ·{' '} - {assessmentReady(draft.forces[force]) - ? 'Preenchida para revisão' - : 'Indeterminada / incompleta'} - -
- - {Object.entries(labels).map(([field, label]) => ( -