From 1218734ecafa70811fa36970ad688073455597ab Mon Sep 17 00:00:00 2001
From: nala
Date: Fri, 13 Nov 2026 23:08:02 +0200
Subject: [PATCH] feat(editorial): separate cluster analysis approval and
publication in QA
---
.../ClusterCompetitivenessController.php | 2 +-
.../InvestmentStructureController.php | 13 +-
.../PortalEditorialWorkflowController.php | 4 +-
app/Services/ClusterCompetitiveness.php | 111 ++++++++-
app/Services/ContentManagement.php | 3 +
config/editorial_workflow.php | 1 +
.../unification/traceability-and-manual.md | 14 ++
.../v2/auditoria-portal/matriz-requisitos.csv | 1 +
resources/js/pages/management/content-hub.tsx | 16 ++
.../pages/management/editorial-workflow.tsx | 7 +
.../js/pages/structures/competitiveness.tsx | 213 +++++++++++++++---
resources/js/pages/structures/editor.tsx | 1 +
resources/js/pages/structures/index.tsx | 15 +-
routes/web.php | 2 +-
.../Feature/ClusterEditorialWorkflowTest.php | 189 ++++++++++++++++
tests/frontend/cluster-editorial.test.mjs | 112 +++++++++
16 files changed, 658 insertions(+), 46 deletions(-)
create mode 100644 tests/Feature/ClusterEditorialWorkflowTest.php
create mode 100644 tests/frontend/cluster-editorial.test.mjs
diff --git a/app/Http/Controllers/ClusterCompetitivenessController.php b/app/Http/Controllers/ClusterCompetitivenessController.php
index 92b859b2..ab8ae2f5 100644
--- a/app/Http/Controllers/ClusterCompetitivenessController.php
+++ b/app/Http/Controllers/ClusterCompetitivenessController.php
@@ -19,7 +19,7 @@ final class ClusterCompetitivenessController extends Controller
$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']);
+ 'reason' => in_array($action, ['saved', 'restored'], true) ? 'nullable|string|max:3000' : 'required|string|min:10|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/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php
index 2a339888..01b10f3d 100644
--- a/app/Http/Controllers/InvestmentStructureController.php
+++ b/app/Http/Controllers/InvestmentStructureController.php
@@ -31,8 +31,17 @@ class InvestmentStructureController extends Controller
});
}
- return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters,
- 'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
+ $records = $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString();
+ if ($structure === 'clusters' && app(ClusterCompetitiveness::class)->editorialEnabled()) {
+ $records->through(function ($record) {
+ $analysis = app(ClusterCompetitiveness::class)->current($record->id);
+ $record->setAttribute('editorial_state', $analysis['workflow']['state'] ?? ($analysis['approved'] ? 'legacy_published' : 'not_started'));
+
+ return $record;
+ });
+ }
+
+ return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters, 'records' => $records]);
}
public function edit(Request $request, string $structure, ?string $id = null): Response
diff --git a/app/Http/Controllers/PortalEditorialWorkflowController.php b/app/Http/Controllers/PortalEditorialWorkflowController.php
index 12e01942..11d4ece5 100644
--- a/app/Http/Controllers/PortalEditorialWorkflowController.php
+++ b/app/Http/Controllers/PortalEditorialWorkflowController.php
@@ -39,8 +39,8 @@ final class PortalEditorialWorkflowController extends Controller
$query->whereRaw(self::KIND.' = ?', [$filters['kind']]);
}
$items = $query->orderByDesc('e.updated_at')->paginate(25, ['r.id', DB::raw("e.payload->'resource'->>'title' AS title"), 'r.document_type', 'r.published', 'e.id as edition_id', 'e.revision_no', 'e.state', 'e.legal_required', 'u.name as responsible'])->withQueryString();
- $opportunities = app(ContentManagement::class)->access($actor)['opportunities'] ?? null;
- $data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $opportunities['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor)];
+ $destinations = app(ContentManagement::class)->access($actor);
+ $data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null];
return $this->screen($request, 'management/editorial-workflow', $data);
}
diff --git a/app/Services/ClusterCompetitiveness.php b/app/Services/ClusterCompetitiveness.php
index a1d11353..a02f7a00 100644
--- a/app/Services/ClusterCompetitiveness.php
+++ b/app/Services/ClusterCompetitiveness.php
@@ -3,7 +3,9 @@
namespace App\Services;
use App\Models\EconomicCluster;
+use App\Models\User;
use Illuminate\Database\Query\Builder;
+use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
@@ -46,6 +48,12 @@ final class ClusterCompetitiveness
public function management(string $cluster): array
{
$state = $this->current($cluster);
+ $state['editorial_enabled'] = $this->editorialEnabled();
+ if (isset($state['workflow'])) {
+ $record = EconomicCluster::findOrFail($cluster);
+ $state['workflow']['fresh'] = hash_equals($state['workflow']['hash'], $this->editionHash($record, $state['draft']));
+ $state['workflow']['independent_required'] = $this->independentRequired($state['workflow']);
+ }
$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();
@@ -106,12 +114,17 @@ final class ClusterCompetitiveness
*/
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array
{
+ abort_if(! in_array($action, ['saved', 'restored', 'approved', 'withdrawn'], true) && ! $this->editorialEnabled(), 503, 'Circuito editorial dos clusters ainda não activado.');
+
return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) {
- EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
+ $cluster = 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') {
+ if ($this->editorialEnabled() || isset($before['workflow'])) {
+ abort_unless($this->editorialEnabled(), 503, 'Reactive o circuito editorial para continuar esta análise; o histórico e a publicação foram conservados.');
+ $state = $this->editorialChange($cluster, $actor, $action, $input, $before);
+ } elseif ($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();
@@ -144,9 +157,101 @@ final class ClusterCompetitiveness
*/
public function publicData(string $cluster): ?array
{
- $approved = $this->current($cluster)['approved'];
+ $state = $this->current($cluster);
+ $approved = isset($state['workflow']) ? $state['published'] : $state['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;
}
+
+ public function editorialEnabled(): bool
+ {
+ return (bool) config('editorial_workflow.cluster_competitiveness');
+ }
+
+ /** @param array|null $draft */
+ private function editionHash(EconomicCluster $cluster, ?array $draft): string
+ {
+ $context = Arr::only($cluster->getAttributes(), ['id', 'code', 'name', 'sector', 'status', 'primary_territory_id', 'territory_version']);
+
+ return hash('sha256', json_encode(Arr::sortRecursive(['context' => $context, 'draft' => $draft]), JSON_THROW_ON_ERROR));
+ }
+
+ /** @param array $input
+ * @param array $state
+ * @return array
+ */
+ private function editorialChange(EconomicCluster $cluster, int $actorId, string $action, array $input, array $state): array
+ {
+ $actor = User::whereKey($actorId)->firstOrFail();
+ abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
+ abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
+ if (! isset($state['workflow'])) {
+ abort_unless(in_array($action, ['saved', 'restored', 'withdrawn'], true), 409, 'Guarde uma edição privada antes de iniciar o novo circuito.');
+ // An accepted legacy publication remains public; no retrospective approval is invented.
+ $state['published'] = $state['approved'];
+ $state['approved'] = null;
+ $state['workflow'] = ['state' => 'draft', 'edition' => $state['version'], 'contributors' => [], 'approval' => null,
+ 'hash' => $this->editionHash($cluster, $state['draft']), 'independent_required' => false];
+ }
+ $workflow = $state['workflow'];
+ if (in_array($action, ['saved', 'restored'], true)) {
+ if ($action === 'saved') {
+ $state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
+ $restoredContributors = [];
+ } else {
+ $event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
+ abort_unless($event !== null, 404);
+ $restored = $this->decode($event);
+ $state['draft'] = $restored['draft'];
+ $restoredContributors = $restored['workflow']['contributors'] ?? [$event->actor_user_id];
+ }
+ $state['approved'] = null;
+ $state['workflow'] = ['state' => 'draft', 'edition' => $state['version'] + 1,
+ 'contributors' => array_values(array_unique([...($workflow['state'] === 'published' ? [] : $workflow['contributors']), ...$restoredContributors, $actorId])),
+ 'hash' => $this->editionHash($cluster, $state['draft']), 'approval' => null,
+ 'independent_required' => $this->independentRequired($workflow)];
+
+ return $state;
+ }
+ validator($input, ['reason' => 'required|string|min:10|max:3000'])->validate();
+ $allowed = ['submitted' => ['draft', 'returned'], 'review_started' => ['submitted'], 'returned' => ['in_review'], 'rejected' => ['in_review'],
+ 'approved' => ['in_review'], 'published' => ['approved'], 'archived' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published', 'archived']];
+ abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409, 'Transição incompatível com o estado da edição.');
+ if (in_array($action, ['submitted', 'approved', 'published'], true)) {
+ abort_unless(hash_equals($workflow['hash'], $this->editionHash($cluster, $state['draft'])), 409, 'O contexto do cluster mudou. Guarde nova edição e volte a conferir.');
+ }
+ if ($action === 'submitted' || $action === 'approved') {
+ $this->assertComplete($state['draft']);
+ }
+ if ($action === 'approved') {
+ abort_if($this->independentRequired($workflow) && in_array($actorId, $workflow['contributors'], true), 403, 'Aprovação independente necessária para esta edição.');
+ $workflow['approval'] = ['actor' => $actorId, 'edition' => $workflow['edition'], 'hash' => $workflow['hash'], 'at' => now()->toISOString(), 'reason' => $input['reason']];
+ $state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actorId, 'version' => $state['version'] + 1, 'evidence' => $input['reason']];
+ }
+ if ($action === 'published') {
+ $decision = $workflow['approval'];
+ abort_unless($decision && $decision['edition'] === $workflow['edition'] && hash_equals($decision['hash'], $workflow['hash']), 422, 'Aprovação desta edição necessária.');
+ $reviewer = User::whereKey($decision['actor'])->first();
+ abort_unless($reviewer && $reviewer->can('manage-portal') && ! app(PortalProfileAccess::class)->has($reviewer, 'auditor'), 403, 'Autoridade da aprovação revogada.');
+ abort_if($this->independentRequired($workflow) && in_array($reviewer->id, $workflow['contributors'], true), 403, 'Aprovação independente necessária.');
+ $state['published'] = [...$state['approved'], 'at' => now()->toISOString(), 'version' => $state['version'] + 1];
+ }
+ if ($action === 'withdrawn' || ($action === 'archived' && $workflow['state'] === 'published')) {
+ $state['published'] = null;
+ }
+ $workflow['state'] = match ($action) {
+ 'submitted' => 'submitted', 'review_started' => 'in_review', 'returned' => 'returned', 'rejected' => 'rejected',
+ 'approved' => 'approved', 'published' => 'published', 'archived' => 'archived', 'withdrawn' => $workflow['state'] === 'published' ? 'archived' : $workflow['state'],
+ };
+ $state['workflow'] = $workflow;
+
+ return $state;
+ }
+
+ /** @param array $workflow */
+ private function independentRequired(array $workflow): bool
+ {
+ return ($workflow['independent_required'] ?? false) || in_array('cluster_competitiveness', config('editorial_workflow.independent_approval.kinds', []), true);
+ }
}
diff --git a/app/Services/ContentManagement.php b/app/Services/ContentManagement.php
index e0b7f532..ba46107a 100644
--- a/app/Services/ContentManagement.php
+++ b/app/Services/ContentManagement.php
@@ -24,6 +24,9 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor);
$result = [];
+ if (app(ClusterCompetitiveness::class)->editorialEnabled() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
+ $result['clusters'] = ['index' => '/management/clusters'];
+ }
if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) {
$result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo'];
}
diff --git a/config/editorial_workflow.php b/config/editorial_workflow.php
index dc1e754f..140256db 100644
--- a/config/editorial_workflow.php
+++ b/config/editorial_workflow.php
@@ -3,6 +3,7 @@
return [
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
'enabled' => env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false),
+ 'cluster_competitiveness' => env('PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED', false),
// Legal designation is explicit; management authority alone is not a legal designation.
'legal_reviewers' => [],
// Required separation is a policy, distinct from the legacy fact "independent_review".
diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md
index 0800c419..1d11708f 100644
--- a/docs/investmoz/unification/traceability-and-manual.md
+++ b/docs/investmoz/unification/traceability-and-manual.md
@@ -1,3 +1,17 @@
+## 13/11/2026 — pacote agrupado do Dashboard Colaborativo e revisão de Competitividade
+
+**Decisão do proprietário:** agrupar melhorias e funcionalidades relacionadas para uma instalação conjunta quando o conjunto estiver pronto. O pacote desta passagem reúne a Fase 1 e o primeiro recorte editorial da Fase 2: análise de Competitividade dos clusters. Não representa o fecho de toda a Fase 2 nem autorização para publicar conteúdos.
+
+**Disponível em desenvolvimento/QA:** o editor existente dos clusters permite guardar → submeter → iniciar revisão → aprovar → publicar, com devolução, rejeição, arquivo e recuperação do histórico. A aprovação deixa de publicar quando `PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED` estiver activo. Uma análise anteriormente pública continua disponível ao activar o novo circuito, guardar, rever, recuperar ou arquivar um rascunho. Publicar substitui-a explicitamente; retirar a publicação não cancela uma revisão privada em curso. Estados editoriais e estado operacional do cluster aparecem separados na lista existente, acessível pela Gestão de conteúdos/Circuito editorial.
+
+**Versões e autoridade:** o hash vincula o conteúdo das cinco forças e o contexto identificado do cluster (nome, código, sector, estado e território). Alterações posteriores exigem guardar nova edição e obter nova aprovação. Conflitos devolvem 409 sem sobrescrever texto. A política independente já configurável aceita o tipo `cluster_competitiveness` e impede aprovação por qualquer colaborador da edição quando exigida. O publicador reconfere a autoridade actual do aprovador. As escritas verificam gestão existente, exclusão de auditor e MFA da sessão no servidor. O histórico continua no mecanismo cifrado e aditivo existente; não há migração nem novo motor de Competitividade. A exposição pública conserva apenas análise publicada, versão e data, sem decisões privadas/identidades. Desactivar o circuito conserva a publicação e impede retomar por uma operação legada que contorne as decisões.
+
+**Provas aceites:** 14 testes de integração de clusters/252 asserções, incluindo os sete testes anteriores; regressão Fase 1 11/223; frontend 18 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-clusters-20261113/`. As provas anteriores de oportunidades e anexos são reutilizadas, sem repetição. Dados sintéticos em transacções revertidas na base própria de QA 55441; sem alteração operacional, novas concessões ou publicação real.
+
+**Pacote/instalação:** bundle Git conjunto a partir da base `2f1ef2fd`, manifesto com hashes e provas, preservando o bundle anterior. O executor continua a ser `deploy/homologacao/pipeline.py`; não foi alterado. Antes de instalar: confirmar revisão/base e delta, backup recuperável e ensaio de restauro isolado; tratar expressamente as duas migrações aditivas da Fase 1 e o contrato nominal de schema do destino. O executor Git não executa SQL no destino e as migrações actuais só autorizam QA: não basta enviar este pacote a `main`. Após instalação autorizada, conferir a revisão efectivamente servida, acessos e os percursos afectados, sem republicar conteúdo. Reversão pelo executor existente preserva dados; não usar rollback destrutivo de tabelas com histórico. Não foi obtido novo backup remoto nem executada instalação nesta passagem.
+
+**Saldo concreto:** Competitividade tem agora aprovação/publicação separadas demonstradas em QA. Conferência visual, preparação operacional do schema/contrato, configuração explícita de designações e instalação conjunta continuam pendentes. Versionamento editorial dos dados-base de projectos âncora/clusters, estatísticas, indicadores demográficos e conteúdos territoriais continua por implementar; notificações editoriais dos clusters não são encerradas por este recorte. A Fase 1 continua identificada separadamente no mesmo saldo. Sem alterações a dados demográficos, instituições ou restantes entregas aceites.
+
## 13/11/2026 — Dashboard Colaborativo, Fase 1 implementada em desenvolvimento/QA
**Âmbito:** artigos, legislação, documentos e integração do circuito existente de oportunidades. A versão pública de um artigo permanece integralmente inalterada ao guardar ou rever uma nova edição privada, incluindo metadados, identificador e contador públicos. Não houve intervenção na homologação ou produção nesta passagem.
diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
index 20addee9..1f8fd210 100644
--- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
+++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
@@ -11129,3 +11129,4 @@ INE-DEMO-01,"Integração nacional de dados demográficos INE na viabilidade exi
REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzidos pelas etapas, com versão, oportunidade e recuperação.",Pedido expresso do proprietário em 13/11/2026: «tem de ser resolvido».,Percurso do investidor; dossier; relatórios,/investimentos/{id}#relatorios; /investimentos/{id}/relatorios/fiscais/{reportId},"Índice e relatório consolidado no percurso existente; reutiliza os geradores Word/HTML, cálculos e cópias preservadas.",Homologação teste-investidor.tempo.co.mz instalada em 07182c7c; /investimentos/{id}#relatorios.,"21 testes frontend; regressão servidor 4/168 asserções e caso final 45 asserções; tipos,lint,PHPStan e build aprovados. HTTPS e preservação conferidos na revisão instalada.",Consolidado instalado em 07182c7c; resumos por etapa conferidos em QA; delta sem instalação,Novo dossier sintético: 19 consultas e 38 descargas reais Word/HTML conferidas; recuperação após actualização. Falta instalar o delta por etapa; saídas específicas ainda não produzidas mantêm o saldo próprio.,,"Mesmo dossier/oportunidade → índice por etapa → relatório Word/HTML → acesso a versões APIEX, fiscais e documentos → recuperação sem misturar oportunidades ou expor finanças a gestores.",Alta,,,,,Índice responsivo e exportação consolidada,"Descarga privada da cópia fiscal preservada, autorizada por titular e dossier","Reutiliza os registos existentes; associação fiscal nova conserva âmbito, sem reescrever fontes anteriores.",Aprovada em prova sintética; sem depender de acesso à simulação anónima original.,Versão/data/estado/origem; pendências e âmbito por confirmar explícitos.,,,,,,,,"Acrescentar índice e consolidar saídas existentes; sem módulo paralelo, publicação ou envio automático.",Consulta e descargas por etapa concluídas em QA; instalação do novo delta pendente,,outputs/dossier-reports-20261113/verification.json; outputs/dossier-reports-20261113/homologation-installed.json; outputs/dossier-reports-20261113/stage-authenticated-download-verification.json,,,,,,,,,,,,,,,,
ACT-20261113-PESQUISA,Pesquisa rápida: contar corredores sem misturar referências; produtos físicos e financeiros separados por categoria e localização; como licenciar empresa.,Pedidos Tempo na conversa de 13/11/2026,Pesquisa rápida,/pesquisa; /,"SearchTerms::requestIntent, GlobalSearchController, FederatedSearch, portal/search, portal-hero; reutiliza catálogos publicados existentes.",Implementado em QA; nova candidata ainda não instalada.,"outputs/quick-search-20261113/: integration.log, regression.log, unit.log, frontend.log, types.log, analysis.log, build.log",QA implementado e testes focados aprovados; instalação e visual pendentes,Instalar após autorização concreta; conferir apresentação no navegador.,,Contagem tipada e zero distinto de falha; produtos separados com localização/cobertura e contexto nos links; licenciamento no roteiro/dossier existentes; só publicados; erros de fontes explícitos; paginação preservada.,Alta,,,,,,,,,,,,,,"Conferência visual e instalação desta candidata; pesquisa territorial é textual, sem inferir elegibilidade.",,,,Implementação QA concluída neste recorte; entrega preparada.,,,,,,,,,,,,,,,,,,
ACT-20261113-EDITORIAL-F1,"Dashboard Colaborativo Fase 1: versões e autorizações; artigos, legislação, oportunidades e documentos; filas e notificações",Directiva Tempo 13/11/2026 nesta sessão; plano aprovado e autorização de implementação apenas em desenvolvimento/QA,Gestão editorial / conteúdos,/management/editorial; /management/editorial/resources/{id}; edições/anexos/notificações; editores e circuito nativo de oportunidades,Edições privadas imutáveis no circuito; versão pública preservada; aprovação/publicação separadas; decisões ligadas a conteúdo/anexos; permissões e auditoria reutilizadas; notificações com acesso revogável,Desactivado por defeito fora dos testes; estrutura instalada apenas em editorial_phase1_20261113_test:55441,outputs/editorial-phase1-20261113: integração 11/223; oportunidades 6/116 + caso reforçado 1/20; unidade 1/6; frontend 12; PHPStan/tipos/build aprovados,Implementado em QA; conferência visual e instalação operacional pendentes,Conferência visual; configuração operacional de designações e independência; instalação/activação fora do QA não executadas,Decisão concreta para instalação operacional e designações; não bloqueia as provas sintéticas QA,Editar preserva toda a publicação; JSON/prévias/anexos autorizados; conteúdo/anexos novos exigem nova decisão; aprovação distinta da publicação; filas e notificações isoladas; conflitos 409; histórico recuperável,Alta,,,,,"Fila, filtros, edição, histórico e notificações em QA","JSON nas mesmas rotas privadas, com as mesmas autorizações","Edições, decisões, notificações e auditoria; dados sintéticos revertidos pelos testes",Versões e publicação anterior recuperadas; isolamento/revogação demonstrados,Versão pública mantida durante edição e revisão; substituição só por publicação explícita,,,,"Implementação focada em lacunas, sem segundo motor de oportunidades ou alteração de permissões",Conferência visual; instalação operacional separada; Fase 2 não implementada,"DocumentManagementAccess, PortalProfileAccess, EditorialReading, VerifiedDocumentBinary, auditoria e circuito nativo de oportunidades",,Implementar em desenvolvimento/QA; não intervir em produção,Implementação aditiva e testes focados,,Recibos QA e testes em outputs/editorial-phase1-20261113; manual unificado actualizado,Dashboard Colaborativo Fase 1,13/11/2026,,,,,,,,"Reutilização dos testes nativos de oportunidade, permissão, prévias e integridade",Versão pública preservada; circuito editorial e decisões por versão/anexos; escopos de notificações e conteúdos,Sem conferência visual; sem operação em homologação/produção; tentativa inicial descartada explicitamente,,Percursos editoriais e autorizações,Artigos → legislação/documentos → oportunidades reutilizadas → fila/painel/notificações,QA demonstrado; instalação não executada; Fase 2 pendente
+ACT-20261113-EDITORIAL-CLUSTERS,"Separar aprovação/publicação da análise de Competitividade; preservar publicação, contexto, versões e autoridade; agrupar entrega relacionada","Directiva de continuidade e decisão de pacote conjunto do proprietário, 13/11/2026; Fase 2 já delimitada",Dashboard Colaborativo / Competitividade dos clusters,/management/clusters; /management/clusters/{id}/competitiveness/{action}; Gestão de conteúdos; Circuito editorial,"Extensão do circuito cifrado existente: submissão, revisão, aprovação e publicação distintas; devolução/rejeição/arquivo; versões/contexto; MFA e autoridade efectiva; navegação reutilizada",QA isolado; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-clusters-20261113: integração 14/252; regressão Fase 1 11/223; frontend 18; PHPStan, tipos e build aprovados",Recorte implementado em QA; não encerra a Fase 2,Conferência visual; notificações dos clusters; versionamento dos dados-base e outros conteúdos Fase 2; preparação/instalação operacional,Instalação agrupada exige preparar e autorizar schema/contrato da Fase 1 no destino; não bloqueia QA,Aprovar não publica; rascunho/recuperação preservam publicação; alteração do contexto invalida aprovação; independência quando exigida; revogação e conflitos bloqueados; retirar publicação preserva revisão privada,Alta,,,,,Editor e lista existentes com estados distintos; entradas de gestão integradas,"JSON autenticado, MFA e autorizações verificadas no servidor",Histórico cifrado existente; sem novas tabelas ou migrações deste recorte,Histórico recuperado como nova edição; publicação anterior preservada,Publicação só por confirmação distinta após aprovação da edição exacta,,,,Provas sintéticas do delta e regressão afectada; provas anteriores reutilizadas,Conferência visual; notificações clusters e restantes conteúdos Fase 2; instalação conjunta,"ClusterCompetitiveness, audit_events cifrados, gestão de estruturas, PortalProfileAccess, ContentManagement e circuito Fase 1",,Agrupar funcionalidades relacionadas num pacote e instalar em conjunto quando prontas; sem intervenção operacional nesta passagem,Implementação localizada sem migração; pacote Git agrupado,,outputs/editorial-clusters-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,Regressão anterior de Competitividade e Fase 1; oportunidades mantêm provas aceites,"Ciclo editorial de Competitividade, preservação pública, permissões, independência, recuperação e contexto","Sem conferência visual ou instalação operacional; não prova versão editorial dos dados-base, notificações clusters nem restantes conteúdos Fase 2",,Circuito editorial e agrupamento de entregas,Submeter → rever → aprovar → publicar; preservar a publicação e autoridade efectiva,QA demonstrado; entrega agrupada preparada; instalação pendente
diff --git a/resources/js/pages/management/content-hub.tsx b/resources/js/pages/management/content-hub.tsx
index 59501277..e430b940 100644
--- a/resources/js/pages/management/content-hub.tsx
+++ b/resources/js/pages/management/content-hub.tsx
@@ -3,6 +3,7 @@ import { Frame, card, button } from '@/pages/investment/shared';
import { states } from './shared';
type Access = {
+ clusters?: { index: string };
editorial?: { index: string };
procurement?: { index: string; create: string };
opportunities?: {
@@ -27,6 +28,21 @@ export default function ContentHub({
acção respeita a sua autorização actual.
+ {access.clusters && (
+
+
+ Competitividade dos clusters
+
+
+ Preparar a análise, submeter, rever e aprovar uma
+ edição. Publicar é uma operação separada e preserva
+ a análise anterior até à confirmação.
+
+
+ Gerir análises dos clusters
+
+
+ )}
{access.editorial && (