From 97d51db60a58975b4c8c847a0d133124275ae6c0 Mon Sep 17 00:00:00 2001
From: nala
Date: Thu, 22 Oct 2026 20:55:08 +0200
Subject: [PATCH] feat: add unified team content management entry in QA
---
.../Controllers/PortalServicesController.php | 45 +++++++-
app/Http/Middleware/HandleInertiaRequests.php | 3 +-
app/Services/ContentManagement.php | 49 ++++++++
app/Services/PortalProfileAccess.php | 7 +-
.../consolidated-review-matrix-2026-10-03.md | 4 +
.../unification/traceability-and-manual.md | 14 +++
resources/js/components/app-sidebar.tsx | 5 +
.../components/content-management-entry.tsx | 6 +
resources/js/pages/dashboard.tsx | 2 +
resources/js/pages/desk/panel.tsx | 2 +
resources/js/pages/management/content-hub.tsx | 38 +++++++
.../js/pages/management/opportunity-draft.tsx | 3 +-
resources/js/pages/management/shared.tsx | 2 +
resources/js/pages/portal/manage.tsx | 55 +++++++--
routes/web.php | 1 +
tests/Feature/ContentManagementEntryTest.php | 105 ++++++++++++++++++
16 files changed, 322 insertions(+), 19 deletions(-)
create mode 100644 app/Services/ContentManagement.php
create mode 100644 resources/js/components/content-management-entry.tsx
create mode 100644 resources/js/pages/management/content-hub.tsx
create mode 100644 tests/Feature/ContentManagementEntryTest.php
diff --git a/app/Http/Controllers/PortalServicesController.php b/app/Http/Controllers/PortalServicesController.php
index 7aacb939..3774dd1b 100644
--- a/app/Http/Controllers/PortalServicesController.php
+++ b/app/Http/Controllers/PortalServicesController.php
@@ -9,6 +9,7 @@ use App\Services\CountryCatalogue;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\LegacyContent;
+use App\Services\PortalProfileAccess;
use App\Services\Malware\MalwareScanner;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
@@ -297,6 +298,7 @@ class PortalServicesController extends Controller
public function manage(Request $request, MalwareScanner $scanner): Response
{
+ $managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']);
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
@@ -304,7 +306,7 @@ class PortalServicesController extends Controller
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
$editorial = $access->editorial($request->user());
- $resources = $editorial ? $access->editorialResources($request->user())->orderByDesc('r.updated_at')
+ $resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
@@ -313,6 +315,7 @@ class PortalServicesController extends Controller
$resource->revisions = $revisions->get($resource->id, collect())->values();
});
return Inertia::render('portal/manage', [
+ 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
@@ -331,7 +334,7 @@ class PortalServicesController extends Controller
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']);
$disclosureAuthorizationData = $disclosureAuthorization ? json_decode($disclosureAuthorization->after_data, true) : null;
- $resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->orderByDesc('r.updated_at')->limit(200)
+ $resources = $this->filterManagedResources(DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at'), $managementFilters)->orderByDesc('r.updated_at')->limit(200)
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']);
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
@@ -342,6 +345,7 @@ class PortalServicesController extends Controller
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
+ 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(),
@@ -367,6 +371,39 @@ class PortalServicesController extends Controller
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
}
+ private function filterManagedResources(Builder $query, array $filters): Builder
+ {
+ if (! empty($filters['q'])) {
+ $term = '%'.addcslashes($filters['q'], '%_\\').'%';
+ $query->where(fn ($q) => $q->where('r.title', 'ilike', $term)->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)->orWhere('d.file_name', 'ilike', $term));
+ }
+ if (! empty($filters['state'])) {
+ $query->where('r.published', $filters['state'] === 'published');
+ if ($filters['state'] !== 'published') {
+ $method = $filters['state'] === 'withdrawn' ? 'whereExists' : 'whereNotExists';
+ $query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn']));
+ }
+ }
+ return $query;
+ }
+
+ private function contentAssociations(Request $request): array
+ {
+ $access = app(\App\Services\DocumentManagementAccess::class);
+ $resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
+ : ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null);
+ $documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
+ return [
+ 'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
+ 'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
+ 'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
+ 'document' => $documents,
+ 'procedure' => $documents->where('document_type', 'procedure')->values(),
+ 'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
+ 'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
+ ];
+ }
+
public function upload(Request $request): RedirectResponse
{
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text'.(app(LegacyContent::class)->available() ? ',application/json' : ''), 'max:10240'],
@@ -399,7 +436,7 @@ class PortalServicesController extends Controller
ScanDocumentForMalware::dispatch($id)->afterCommit();
- return to_route('services.manage')->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
+ return to_route('services.manage', array_filter(['intent' => in_array($request->input('intent'), ['document', 'legislation'], true) ? $request->input('intent') : null]))->with('uploaded_document_id', $id)->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
}
public function resource(Request $request): RedirectResponse
@@ -663,7 +700,7 @@ class PortalServicesController extends Controller
'data_classification' => ['nullable', 'in:internal,public'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'],
- 'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
+ 'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,sector,opportunity'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
'associations.*.target_key' => ['required', 'string', 'max:180'],
'associations.*.review_status' => ['nullable', 'in:pending,confirmed'],
'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'],
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index d9f88f49..194e77f6 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -53,6 +53,7 @@ class HandleInertiaRequests extends Middleware
'name' => config('app.name'),
'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
+ 'contentManagementHref' => app(\App\Services\ContentManagement::class)->access($request->user()) !== [] ? '/gestao-conteudos' : null,
'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(),
@@ -68,7 +69,7 @@ class HandleInertiaRequests extends Middleware
'create' => ['label' => 'Nova oportunidade', 'href' => '/management/opportunities/create'],
'installationHref' => '/management/opportunity-management-install',
] : null,
- 'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning')],
+ 'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')],
'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label'=>'Zonas e parques','href'=>'/management/investment/zones'],
['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'],
diff --git a/app/Services/ContentManagement.php b/app/Services/ContentManagement.php
new file mode 100644
index 00000000..d7507492
--- /dev/null
+++ b/app/Services/ContentManagement.php
@@ -0,0 +1,49 @@
+hasVerifiedEmail()) return [];
+ $profiles = app(PortalProfileAccess::class);
+ $navigation = $profiles->navigation($actor);
+ $legacy = $profiles->legacyWriteAllowed($actor);
+ $documents = app(DocumentManagementAccess::class);
+ $editor = $documents->full($actor) || $documents->editorial($actor);
+ $upload = $editor || $documents->limited($actor);
+ $result = [];
+ if ($legacy || ($navigation['mine'] ?? null) || ($navigation['review'] ?? null)) {
+ $result['opportunities'] = ['index' => $legacy ? '/management/opportunities' : '/minhas-oportunidades',
+ 'create' => $legacy ? '/management/opportunities/create' : ($navigation['create'] ?? null), 'profileWorkflow' => ! $legacy];
+ }
+ if ($upload) $result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor];
+ return $result;
+ }
+
+ public function view(User $actor): array
+ {
+ $access = $this->access($actor);
+ abort_unless($access !== [], 403);
+ $counts = [];
+ if (isset($access['opportunities'])) {
+ $counts['opportunities'] = app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)
+ ->selectRaw("COALESCE(workflow_context->>'working_state',status) as state, count(*) as total")
+ ->groupByRaw("COALESCE(workflow_context->>'working_state',status)")->pluck('total', 'state');
+ }
+ if (isset($access['documents'])) {
+ $documents = app(DocumentManagementAccess::class);
+ $query = $documents->full($actor) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
+ : ($access['documents']['edit'] ? $documents->editorialResources($actor) : null);
+ $counts['documents'] = $query ? $query->select('r.published', DB::raw('count(*) as total'))->groupBy('r.published')->get()
+ ->mapWithKeys(fn ($row) => [$row->published ? 'published' : 'unpublished' => (int) $row->total]) : [];
+ }
+ return ['access' => $access, 'counts' => $counts];
+ }
+}
diff --git a/app/Services/PortalProfileAccess.php b/app/Services/PortalProfileAccess.php
index 90c21e74..75b656b4 100644
--- a/app/Services/PortalProfileAccess.php
+++ b/app/Services/PortalProfileAccess.php
@@ -184,7 +184,9 @@ class PortalProfileAccess
public function canCreate(User $actor, ?string $organization, string $type): bool
{
- $included = in_array($type, OpportunityCanonicalContract::BASE_CATEGORIES, true) || app(OpportunityModularProfiles::class)->enabled();
+ // Draft preparation is allowed within the assigned modality. Executable
+ // submission support is checked separately by the canonical contract.
+ $included = in_array($type, OpportunityManagement::CATEGORIES, true);
// This is creation as an authorized Promotor, never an exceptional SU act.
return $included && $this->available() && $this->scoped($actor, 'promoter', $organization, $type) && ! $this->has($actor, 'auditor');
@@ -370,7 +372,8 @@ class PortalProfileAccess
foreach (json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR) as $type) {
$key = ($grant->organization_id ?? 'individual').'|'.$type;
if (! isset($seen[$key]) && $this->canCreate($actor, $grant->organization_id, $type)) {
- $contexts[] = ['organization_id' => $grant->organization_id, 'type' => $type];
+ $contexts[] = ['organization_id' => $grant->organization_id,
+ 'organization_name' => $grant->organization_id ? DB::table('organizations')->where('id', $grant->organization_id)->value('name') : null, 'type' => $type];
$seen[$key] = true;
}
}
diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md
index 01cd5a03..0dfde85e 100644
--- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md
+++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md
@@ -315,3 +315,7 @@ Situação comum observada: `publication_status=draft`, `validation_status=valid
- Verificação: `php artisan test tests/Feature/PortalServicesTest.php` — 17 testes/497 asserções aprovados em QA; cobrem publicação autorizada com pesquisa textual e filtros combinados, ficha, download, associação ao módulo e links de requisitos com estados confirmados/pendentes. `npm.cmd run build` concluído. O build alerta sobre pacote opcional `fontaine` e chunk `services` acima de 500 KB; sem erro de compilação. Não houve browser autenticado disponível, portanto não se declara publicação, pesquisa ou download públicos concluídos.
- A autorização documental não foi aplicada a zonas, parques, negócios privados ou oportunidades de demonstração. A regra de publicação de parque não exige área confirmada; divergência de área, isoladamente, não é um gate técnico. Se a ficha avançar por decisão própria, deve omitir o total disputado ou mostrar os valores reportados com fonte/escopo, sem alegar disponibilidade. Beluluane, Munhava e Topuito requerem revisão por registo para fonte/localização pública e para retirar ou qualificar a área; Topuito só tem província associada no mestre geográfico, pelo que não se pode anunciar uma associação administrativa a Larde enquanto essa referência não existir. ZEE/ZFI continuam dependentes de acto oficial estruturado e validado, regime e relações territoriais apropriadas; uma área sem confirmação não substitui essas evidências.
- Proposta para as seis oportunidades de demonstração já públicas: preservar os registos e IDs, adicionar origem/tipo `demo` e identificá-los visivelmente como **Exemplo de demonstração**, separar a pesquisa de exemplos da pesquisa de oportunidades reais e excluir os exemplos dos resultados reais por defeito. Não os converter nem retirar em massa; cada registo real exigirá origem e proponente documentados. Nenhuma alteração foi feita nesta execução.
+
+### Referência da entrada única de gestão — QA, 22/10/2026
+
+Nos requisitos existentes `MASTER-9f6dd1d9-0001`, `MASTER-9f6dd1d9-0003` e `MASTER-02f44baf-0002`, acrescentar como evidência delimitada a secção «Entrada única de gestão — preparação em QA, 22/10/2026» do manual de rastreabilidade e `tests/Feature/ContentManagementEntryTest.php`: quatro testes/99 asserções, ClamAV real e reversão transaccional em QA. Origem do objectivo e das regras operacionais: decisão expressa da Tempo; navegação, filtros e recuperação do upload são escolhas de implementação. Reutilizados biblioteca, armazenamento, perfis e histórico. Não altera os critérios de aceitação nem fecha os requisitos integrais: integração/contas reais e categorias não demonstradas conservam o seu estado. Geometrias permanecem separadas.
\ 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 44ebe091..d21f9658 100644
--- a/docs/investmoz/unification/traceability-and-manual.md
+++ b/docs/investmoz/unification/traceability-and-manual.md
@@ -2227,3 +2227,17 @@ A ferramenta não observou o menu nativo aberto pelo rato; a tentativa de clicar
### Decisão de integração — 22/10/2026
A Tempo encerrou a revisão de `1d921c9d` com as provas existentes e autorizou integrar a candidata e o registo documental `916819eb`. A interacção exclusivamente pelo rato permanece não observada pela ferramenta, **sem carácter bloqueante**; não constitui falha comprovada do portal, não exige substituir o selector nem repetir ensaios. A ausência de geometrias administrativas de Cabo Delgado permanece independente. Executar o processo Git automático existente, preservando alterações concorrentes; não transportar fixtures QA, publicar dados ou activar regras fiscais.
+
+## Entrada única de gestão — preparação em QA, 22/10/2026
+
+Origem: prioridade expressa da Tempo para a equipa cadastrar oportunidades e carregar, editar e publicar documentos e legislação pela interface, sem depender do Codex. A decisão de 22/10 sobre fontes e execução mantém-se. Geometrias administrativas ficam no saldo separado.
+
+Implementada `/gestao-conteudos`, acessível pelo painel, menu lateral, Balcão e editores. Reutiliza os cadastros, biblioteca, armazenamento, perfis e histórico existentes. Nova oportunidade abre o editor operacional para quem tem contexto de criação; acesso legado é identificado como tal, sem o apresentar como publicação operacional completa. A entrada não atribui papéis. Escolhas técnicas deste delta: navegação agregada, contagens no âmbito do utilizador, pesquisa documental e pré-selecção do ficheiro acabado de carregar. Não são novas regras organizacionais.
+
+Percurso diário: **Gestão de conteúdos e oportunidades → Nova oportunidade**, escolher promotor/modalidade, preencher e guardar; recuperar em **Gerir oportunidades e histórico**. Para documentos: **Novo documento** ou **Nova legislação → Receber documento em privado → Guardar em quarentena → Registar documento na biblioteca**. O original recém-carregado fica seleccionado; título e emitente conhecidos são recuperados. Completar os metadados, guardar, pesquisar o registo e usar **Editar metadados**, **Publicar** ou **Retirar da publicação**. Associações apresentam nomes dos registos existentes. Campos ainda não conhecidos não são inventados. Publicação para consulta não activa cálculos.
+
+Verificação nova: quatro testes, 99 asserções, com transacções revertidas exclusivamente em `simulator_fiscal_delta_20261021_test`, loopback 55441, guarda explícita. Demonstrados acesso de equipa sem administração geral, recusa a utilizador sem autorização e ciclo documental completo (upload, ClamAV real, recuperação por pesquisa, edição, publicação/download, retirada, preservação do hash e quatro revisões). Exemplo documental exclusivamente sintético, sem origem oficial ou valor jurídico. Corrigida a classificação de retirados: o histórico existente usa `unpublished`; o filtro e o rótulo reconhecem também `withdrawn` histórico. TypeScript e build QA passaram.
+
+No navegador, login e MFA normais da conta sintética de equipa; entrada → legislação; entrada → nova oportunidade → guardar preparação incompleta → recarregar, com título recuperado e ficha QA 241. Não foi repetido o workflow integral de revisão/publicação de oportunidades já existente; a prova nova cobre a entrada e a gravação/recuperação. O ensaio documental completo foi HTTP transaccional, não uma prova visual de cada clique. A conta QA tem autorizações explicitamente sintéticas; não comprova as designações das contas reais. Não transportar fixtures, configurações QA ou concessões de acesso.
+
+Provas locais: `outputs/content-management-20261022/entrada-final.png`, `build-final.log`, e `tests/Feature/ContentManagementEntryTest.php`. Estado: candidata em branch `codex/content-management-entry-QA`, ainda sem integração ou instalação na homologação. Não declarar encerrada a autonomia operacional de todas as contas/categorias apenas por esta entrada. Não houve migration, publicação remota, alteração de papéis activos ou activação fiscal.
\ No newline at end of file
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx
index 97b2a9b5..0a34292b 100644
--- a/resources/js/components/app-sidebar.tsx
+++ b/resources/js/components/app-sidebar.tsx
@@ -26,6 +26,7 @@ export function AppSidebar() {
educationEnabled,
aiEnabled,
opportunityManagement,
+ contentManagementHref,
profileAccess,
} = usePage<{
auth: Auth;
@@ -34,6 +35,7 @@ export function AppSidebar() {
aiEnabled?: boolean;
profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null;
+ contentManagementHref?: string | null;
}>().props;
const groups = portalNavigation({
authenticated: !!auth.user,
@@ -48,6 +50,9 @@ export function AppSidebar() {
href: group.children?.find((item) => item.href)?.href ?? '/',
icon: LayoutGrid,
}));
+ if (contentManagementHref) {
+ items.unshift({ title: 'Gestão de conteúdos e oportunidades', href: contentManagementHref, icon: LayoutGrid });
+ }
if (auth.canManagePortal && opportunityManagement) {
items.push({
title: opportunityManagement.manage.label,
diff --git a/resources/js/components/content-management-entry.tsx b/resources/js/components/content-management-entry.tsx
new file mode 100644
index 00000000..c3131602
--- /dev/null
+++ b/resources/js/components/content-management-entry.tsx
@@ -0,0 +1,6 @@
+import { Link, usePage } from '@inertiajs/react';
+
+export default function ContentManagementEntry() {
+ const { contentManagementHref } = usePage<{ contentManagementHref?: string | null }>().props;
+ return contentManagementHref ? Gestão de conteúdos e oportunidades : null;
+}
diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx
index 69959e61..e2677428 100644
--- a/resources/js/pages/dashboard.tsx
+++ b/resources/js/pages/dashboard.tsx
@@ -1,4 +1,5 @@
import ProfileNavigation from '@/components/profile-navigation';
+import ContentManagementEntry from '@/components/content-management-entry';
import { Head, Link } from '@inertiajs/react';
import { dashboard } from '@/routes';
import type { InvestmentContext } from '@/pages/investor-path/index';
@@ -51,6 +52,7 @@ export default function Dashboard({
Painel do portal
+
{summary ? (
<>
{opportunityManagement && (
diff --git a/resources/js/pages/desk/panel.tsx b/resources/js/pages/desk/panel.tsx
index 7108dc06..95cab8a2 100644
--- a/resources/js/pages/desk/panel.tsx
+++ b/resources/js/pages/desk/panel.tsx
@@ -1,4 +1,5 @@
import { createOperationKey } from '@/lib/operation-key';
+import ContentManagementEntry from '@/components/content-management-entry';
import { Link, useForm, usePage } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { Frame, input, button, card } from '@/pages/investment/shared';
@@ -84,6 +85,7 @@ export default function Panel({
}, [contextTitle, form.data.title]);
return (
+