This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Navigation over existing authorisations; never grants a role or publishes content. */
|
||||
final class ContentManagement
|
||||
{
|
||||
public function access(?User $actor): array
|
||||
{
|
||||
if (! $actor || ! $actor->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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
||||
export default function ContentManagementEntry() {
|
||||
const { contentManagementHref } = usePage<{ contentManagementHref?: string | null }>().props;
|
||||
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Gestão de conteúdos e oportunidades</Link> : null;
|
||||
}
|
||||
@@ -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({
|
||||
<Head title="Painel" />
|
||||
<h1 className="text-2xl font-semibold">Painel do portal</h1>
|
||||
<ProfileNavigation />
|
||||
<ContentManagementEntry />
|
||||
{summary ? (
|
||||
<>
|
||||
{opportunityManagement && (
|
||||
|
||||
@@ -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 (
|
||||
<Frame title="Balcão Virtual — acompanhamento">
|
||||
<ContentManagementEntry />
|
||||
<nav className="flex flex-wrap gap-4">
|
||||
<Link href="/balcao-virtual/servicos" className="underline">
|
||||
Serviços
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { states } from './shared';
|
||||
|
||||
type Access = {
|
||||
opportunities?: {index: string; create: string | null; profileWorkflow: boolean};
|
||||
documents?: {index: string; edit: boolean};
|
||||
};
|
||||
export default function ContentHub({access, counts}: {access: Access; counts: Record<string, Record<string, number>>}) {
|
||||
return <Frame title="Gestão de conteúdos e oportunidades">
|
||||
<p>Use os cadastros e a biblioteca existentes. Guardar um rascunho, analisar um ficheiro e publicar são operações distintas. Cada acção respeita a sua autorização actual.</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{access.opportunities && <section className={card}>
|
||||
<h2 className="text-xl font-semibold">Oportunidades</h2>
|
||||
<p>Escolha a modalidade, o sector e a localização; preencha, guarde o rascunho e siga as acções de submissão, revisão e publicação disponíveis no seu perfil.</p>
|
||||
<p>Sector económico e modalidade são escolhas distintas. Pode guardar preparação incompleta; o editor identifica os campos e variantes ainda sem suporte de submissão.</p>
|
||||
{access.opportunities.create && <Link className={button} href={access.opportunities.create}>Nova oportunidade</Link>}
|
||||
<Link className="block underline" href={access.opportunities.index}>Gerir oportunidades e histórico</Link>
|
||||
<ul>{Object.entries(counts.opportunities ?? {}).map(([state, total]) => <li key={state}><Link className="underline" href={`${access.opportunities!.index}?status=${state}`}>{states[state] ?? state}: {total}</Link></li>)}</ul>
|
||||
<p className="text-sm">As etapas e a independência já definidas para oportunidades mantêm-se. Esta entrada não atribui autorização para rever ou publicar.</p>
|
||||
{!access.opportunities.profileWorkflow && <p>O seu acesso actual abre o cadastro legado de rascunhos. A submissão, revisão e publicação de novas oportunidades utilizam os perfis operacionais já existentes; a administração de conteúdos não substitui essa designação. Esta entrada não apresenta o rascunho legado como percurso completo de publicação.</p>}
|
||||
</section>}
|
||||
{access.documents && <section className={card}>
|
||||
<h2 className="text-xl font-semibold">Documentos e legislação</h2>
|
||||
<p>Carregue o original ou reutilize um ficheiro existente; acompanhe a análise, complete os metadados e associações e defina o acesso. Depois publique explicitamente, se autorizado.</p>
|
||||
<div className="flex flex-wrap gap-3"><Link className={button} href={`${access.documents.index}?intent=document#receber`}>Novo documento</Link>
|
||||
{access.documents.edit && <Link className={button} href={`${access.documents.index}?intent=legislation#receber`}>Nova legislação</Link>}</div>
|
||||
<Link className="block underline" href={`${access.documents.index}#conteudos`}>Gerir conteúdos, versões e histórico</Link>
|
||||
{!access.documents.edit && <p>O seu acesso permite carregar e analisar ficheiros próprios; não inclui cadastro editorial nem publicação.</p>}
|
||||
<p>Publicados: {counts.documents?.published ?? 0} · Não publicados: {counts.documents?.unpublished ?? 0}. A lista distingue rascunhos de conteúdos retirados através do histórico.</p>
|
||||
<p className="text-sm">Publicar legislação permite consultá-la. Não a activa como regra executável no simulador.</p>
|
||||
</section>}
|
||||
</div>
|
||||
<section className={card}><h2 className="font-semibold">Como concluir sem apoio técnico</h2>
|
||||
<ol className="list-decimal space-y-2 pl-5"><li>Abra a acção pretendida e use os campos do editor existente.</li><li>Guarde a preparação. As mensagens indicam o estado e o que precisa de corrigir.</li><li>Reabra pela gestão, pesquise o título e consulte o histórico.</li><li>Use as acções autorizadas de publicação ou retirada. Os originais e as revisões são preservados.</li></ol>
|
||||
</section>
|
||||
</Frame>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ClassificationReview } from '@/components/opportunity-classification';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { valueText } from '@/lib/value-text';
|
||||
import { modalityLabels } from '@/components/opportunity-search-filters';
|
||||
import OpportunityProfileActions from '@/components/opportunity-profile-actions';
|
||||
import OpportunityContactForm from '@/components/opportunity-contact-form';
|
||||
import OpportunityCanonicalFields, {
|
||||
@@ -330,7 +331,7 @@ export default function OpportunityDraft({
|
||||
{c.organization_name ??
|
||||
c.organization_id ??
|
||||
'Âmbito individual'}{' '}
|
||||
— {c.type}
|
||||
— {modalityLabels[c.type] ?? c.type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ProfileNavigation from '@/components/profile-navigation';
|
||||
import ContentManagementEntry from '@/components/content-management-entry';
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cloneElement, isValidElement, useId } from 'react';
|
||||
@@ -60,6 +61,7 @@ export function Management({
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-8">
|
||||
<Head title={title} />
|
||||
<ContentManagementEntry />
|
||||
{auth?.canManagePortal && basePath !== '/minhas-oportunidades' ? (
|
||||
<nav
|
||||
className="flex flex-wrap gap-4 text-sm underline"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { router, useForm, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Management, Field, control, button } from '../management/shared';
|
||||
|
||||
type DocumentFile = {
|
||||
@@ -164,7 +164,11 @@ export default function Manage({
|
||||
legacyContentEnabled = false,
|
||||
documentAccessOnly = false,
|
||||
editorialScopeOnly = false,
|
||||
managementFilters = {},
|
||||
associationOptions = {},
|
||||
}: {
|
||||
managementFilters?: {q?: string; state?: string; intent?: string};
|
||||
associationOptions?: Record<string, {value: string | number; label: string}[]>;
|
||||
legacyContentEnabled?: boolean;
|
||||
documentAccessOnly?: boolean;
|
||||
editorialScopeOnly?: boolean;
|
||||
@@ -206,12 +210,26 @@ export default function Manage({
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [query, setQuery] = useState(managementFilters.q ?? '');
|
||||
const [stateFilter, setStateFilter] = useState(managementFilters.state ?? '');
|
||||
const { flash } = usePage<{flash?: {uploaded_document_id?: string}}>().props;
|
||||
const upload = useForm<{
|
||||
file: File | null;
|
||||
original_source: string;
|
||||
issuer: string;
|
||||
}>({ file: null, original_source: '', issuer: '' });
|
||||
const form = useForm<ResourceForm>(emptyResource());
|
||||
intent: string;
|
||||
}>({ file: null, original_source: '', issuer: '', intent: managementFilters.intent ?? 'document' });
|
||||
const form = useForm<ResourceForm>({...emptyResource(), ...(managementFilters.intent === 'legislation' ? {document_type: 'legislation', legislation: true} : {})});
|
||||
const chooseDocument = (id: string) => {
|
||||
const document = documents.find((item) => item.id === id);
|
||||
form.setData((current) => ({...current, document_id: id,
|
||||
issuer: current.issuer || document?.issuer || '',
|
||||
title: current.title || document?.file_name || '',
|
||||
}));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (flash?.uploaded_document_id && !editingId && !form.data.document_id) chooseDocument(flash.uploaded_document_id);
|
||||
}, [flash?.uploaded_document_id]);
|
||||
const setAssociation = (
|
||||
index: number,
|
||||
key: keyof Association,
|
||||
@@ -361,6 +379,7 @@ export default function Manage({
|
||||
))}
|
||||
</section>
|
||||
<form
|
||||
id="receber"
|
||||
className="space-y-4 rounded border p-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -420,6 +439,7 @@ export default function Manage({
|
||||
</form>
|
||||
|
||||
{!documentAccessOnly && <>
|
||||
{managementFilters.intent === 'legislation' && <p className="rounded border p-4">Nova legislação: reutilize o original, complete diploma/tipo, entidade emitente, número, datas e âmbito. Registe alterações e revogações nas associações. Publicar para consulta não activa cálculos no simulador.</p>}
|
||||
<form
|
||||
className="space-y-4 rounded border p-5"
|
||||
onSubmit={(e) => {
|
||||
@@ -458,9 +478,7 @@ export default function Manage({
|
||||
required
|
||||
className={control}
|
||||
value={form.data.document_id}
|
||||
onChange={(e) =>
|
||||
form.setData('document_id', e.target.value)
|
||||
}
|
||||
onChange={(e) => chooseDocument(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar ficheiro ou registo existente
|
||||
@@ -925,6 +943,8 @@ export default function Manage({
|
||||
'document',
|
||||
'investment_type',
|
||||
'legal_source',
|
||||
'sector',
|
||||
'opportunity',
|
||||
'requirement',
|
||||
]
|
||||
.filter(
|
||||
@@ -934,8 +954,8 @@ export default function Manage({
|
||||
requirementAssociationsEnabled,
|
||||
)
|
||||
.map((v) => (
|
||||
<option key={v}>
|
||||
{v}
|
||||
<option key={v} value={v}>
|
||||
{({theme:'Tema', module:'Módulo',activity:'Actividade',stage:'Etapa',procedure:'Procedimento',entity:'Entidade',territory:'Território',document:'Documento / diploma',investment_type:'Modalidade de investimento',legal_source:'Fonte legal',requirement:'Requisito',sector:'Sector',opportunity:'Oportunidade'} as Record<string,string>)[v] ?? v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -974,6 +994,12 @@ export default function Manage({
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
) : associationOptions[association.target_type] ? (
|
||||
<select className={control} required value={association.target_key} onChange={(e) => setAssociation(index, 'target_key', e.target.value)}>
|
||||
<option value="">Seleccione um registo existente</option>
|
||||
{association.target_key && !associationOptions[association.target_type].some((o) => String(o.value) === association.target_key) && <option value={association.target_key}>Referência histórica guardada</option>}
|
||||
{associationOptions[association.target_type].map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
required
|
||||
@@ -1221,13 +1247,20 @@ export default function Manage({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<h2 className="text-xl font-bold">
|
||||
<h2 id="conteudos" className="text-xl font-bold">
|
||||
Registos, pendências e duplicados por rever
|
||||
</h2>
|
||||
<form className="flex flex-wrap gap-3 rounded border p-4" aria-label="Pesquisar conteúdos" onSubmit={(e) => {e.preventDefault(); router.get('/management/portal-services', {q:query,state:stateFilter}, {preserveState:true,preserveScroll:true});}}>
|
||||
<Field label="Pesquisar título, ficheiro, número ou entidade"><input className={control} value={query} onChange={(e) => setQuery(e.target.value)} /></Field>
|
||||
<Field label="Estado do conteúdo"><select className={control} value={stateFilter} onChange={(e) => setStateFilter(e.target.value)}><option value="">Todos</option><option value="draft">Rascunhos</option><option value="published">Publicados</option><option value="withdrawn">Retirados</option></select></Field>
|
||||
<button className={button}>Pesquisar conteúdos</button>
|
||||
</form>
|
||||
<p>Mostram-se até 200 registos por pesquisa. Refine o título ou o estado para localizar conteúdos anteriores.</p>
|
||||
{!resources.length && <p role="status">Sem conteúdos correspondentes no seu âmbito de acesso.</p>}
|
||||
{resources.map((r) => (
|
||||
<article key={r.id} className="space-y-3 rounded border p-4">
|
||||
<h3 className="font-bold">
|
||||
{r.title} · {r.published ? 'Publicado' : 'Rascunho'}
|
||||
{r.title} · {r.published ? 'Publicado' : r.revisions.some((v) => ['unpublished', 'withdrawn'].includes(v.change_type)) ? 'Retirado' : 'Rascunho'}
|
||||
</h3>
|
||||
<p>
|
||||
Ficheiro: {r.file_name} · {r.file_status} ·{' '}
|
||||
|
||||
@@ -62,6 +62,7 @@ Route::get('/explorar/{sectorSlug}', [LegacyContentController::class, 'landing']
|
||||
Route::get('/{legacySlug}', [LegacyContentController::class, 'article'])->whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
|
||||
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/gestao-conteudos', fn (\Illuminate\Http\Request $request) => Inertia::render('management/content-hub', app(\App\Services\ContentManagement::class)->view($request->user())))->name('content-management');
|
||||
Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk');
|
||||
Route::post('/parcerias', [PortalServicesController::class, 'partnership'])->middleware('throttle:10,1')->name('services.partnership');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\ContentManagement;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\OpportunityManagement;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ContentManagementEntryTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
|
||||
$this->assertSame(55441, (int) $db->port);
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
public function test_entry_does_not_grant_access_to_an_investor(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [], 'portal.document_managers' => []]);
|
||||
$this->assertSame([], app(ContentManagement::class)->access($user));
|
||||
$this->actingAs($user)->get('/gestao-conteudos')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_existing_manager_gets_reused_destinations_and_document_intent_and_search(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [$user->email]]);
|
||||
$this->actingAs($user)->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page->component('management/content-hub')
|
||||
->where('access.documents.edit', true)->where('access.opportunities.create', '/management/opportunities/create'));
|
||||
$this->get('/management/portal-services?intent=legislation&q=not-existing-qa-title')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->where('managementFilters.intent', 'legislation')->has('associationOptions.sector')->has('associationOptions.stage')->has('resources', 0));
|
||||
$this->get('/management/portal-services?state=published')->assertOk();
|
||||
$this->get('/management/portal-services?state=withdrawn')->assertOk();
|
||||
}
|
||||
|
||||
public function test_team_promoter_uses_operational_editor_without_general_administration(): void
|
||||
{
|
||||
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true, 'portal.managers' => []]);
|
||||
$actor = User::factory()->create();
|
||||
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
|
||||
$org = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $org, 'name' => 'Synthetic QA content team', 'organization_type' => 'private']);
|
||||
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $actor->id, 'organization_id' => $org, 'role' => 'officer']);
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
||||
'user_id' => $actor->id, 'role' => 'promoter', 'organization_id' => $org,
|
||||
'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => [], 'source' => 'synthetic-content-entry-test',
|
||||
]);
|
||||
$before = DB::table('portal_role_assignments')->count();
|
||||
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
|
||||
$this->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->where('access.opportunities.create', '/minhas-oportunidades/create')->where('access.opportunities.profileWorkflow', true)->missing('access.documents'));
|
||||
$this->get('/minhas-oportunidades/create')->assertOk();
|
||||
$this->get('/management/portal-services')->assertForbidden();
|
||||
$this->assertSame($before, DB::table('portal_role_assignments')->count());
|
||||
}
|
||||
|
||||
public function test_new_legislation_is_recovered_edited_published_and_withdrawn_with_history(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
||||
$this->assertTrue(app(MalwareScanner::class)->health()['available'], 'Real ClamD required for this QA demonstration');
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [$user->email]]);
|
||||
$this->actingAs($user);
|
||||
$name = 'synthetic-entry-'.Str::uuid().'.txt';
|
||||
$this->post('/management/portal-services/upload', ['intent' => 'legislation', 'file' => UploadedFile::fake()->createWithContent($name, 'Synthetic QA legislation example, no legal value.')])
|
||||
->assertSessionHasNoErrors()->assertSessionHas('uploaded_document_id')->assertRedirect('/management/portal-services?intent=legislation');
|
||||
$document = DB::table('documents')->where('file_name', $name)->first();
|
||||
$this->assertSame('clean', $document->malware_scan_status);
|
||||
$payload = ['document_id' => $document->id, 'title' => $name, 'category' => 'Synthetic QA', 'topic' => 'Synthetic QA',
|
||||
'document_type' => 'legislation', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/synthetic-qa', 'language' => 'pt', 'version_label' => 'QA-1',
|
||||
'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'proposed',
|
||||
'rights_status' => 'authorized', 'data_classification' => 'public', 'source_reference' => 'Synthetic QA example, no official provenance or legal value.',
|
||||
'legislation' => true, 'essential' => false, 'independent_review' => false, 'associations' => []];
|
||||
$this->post('/management/portal-services/resources', $payload)->assertRedirect()->assertSessionHasNoErrors();
|
||||
$resource = DB::table('portal_resources')->where('document_id', $document->id)->first();
|
||||
$this->assertNotNull($resource);
|
||||
$this->assertSame($name, $resource->title);
|
||||
$this->get('/management/portal-services?q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 1)->where('resources.0.title', $name));
|
||||
$this->put('/management/portal-services/resources/'.$resource->id, [...$payload, 'title' => $name.' edited', 'metadata_version' => $resource->metadata_version])->assertSessionHasNoErrors();
|
||||
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload($name);
|
||||
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => false])->assertSessionHasNoErrors();
|
||||
$this->get('/management/portal-services?state=withdrawn&q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 1));
|
||||
$this->get('/management/portal-services?state=draft&q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 0));
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
|
||||
$this->assertDatabaseHas('documents', ['id' => $document->id, 'sha256' => $document->sha256]);
|
||||
$this->assertSame(4, DB::table('portal_resource_revisions')->where('resource_id', $resource->id)->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user