diff --git a/app/Http/Controllers/InvestmentPortfolioController.php b/app/Http/Controllers/InvestmentPortfolioController.php index a3df88a2..d142caf0 100644 --- a/app/Http/Controllers/InvestmentPortfolioController.php +++ b/app/Http/Controllers/InvestmentPortfolioController.php @@ -13,6 +13,7 @@ use App\Services\InvestmentGeography; use App\Services\InvestmentRelations; use App\Services\InvestmentZones; use App\Services\PortalAudit; +use App\Services\PortalEditorialWorkflow; use App\Services\PublicSearchMetrics; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\Builder as QueryBuilder; @@ -99,6 +100,10 @@ class InvestmentPortfolioController extends Controller { $management = $request->routeIs('management.portfolio*'); $record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id); + $editorial = $management && app(PortalEditorialWorkflow::class)->portfolioEnabled(); + if ($editorial) { + $record = app(PortalEditorialWorkflow::class)->workingPortfolio($record); + } $relatedIds = []; foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) { foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) { @@ -147,7 +152,7 @@ class InvestmentPortfolioController extends Controller ->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at']) : collect(); - return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones, + return Inertia::render('portfolio/show', ['editorialEnabled' => $editorial, 'complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones, 'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions, 'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null, 'geography' => app(InvestmentRelations::class)->dossier($record, $management), @@ -167,6 +172,11 @@ class InvestmentPortfolioController extends Controller $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'], 'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'], 'content.*' => ['nullable', 'string', 'max:15000']]); + if (app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id)) { + app(PortalEditorialWorkflow::class)->savePortfolio($request->user(), $dossier, (int) $data['version_no'], $data); + + return back()->with('success', 'Edição privada guardada. A publicação anterior permanece disponível; continue no circuito editorial.'); + } DB::transaction(function () use ($request, $dossier, $data) { $dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail(); abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de guardar.'); @@ -179,6 +189,7 @@ class InvestmentPortfolioController extends Controller public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse { + abort_if(app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id), 409, 'Use a aprovação e publicação separadas da edição no circuito editorial.'); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]); DB::transaction(function () use ($request, $dossier, $data) { $dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail(); diff --git a/app/Http/Controllers/PortalEditorialWorkflowController.php b/app/Http/Controllers/PortalEditorialWorkflowController.php index 11d4ece5..b1294309 100644 --- a/app/Http/Controllers/PortalEditorialWorkflowController.php +++ b/app/Http/Controllers/PortalEditorialWorkflowController.php @@ -40,7 +40,8 @@ final class PortalEditorialWorkflowController extends Controller } $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(); $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]; + $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, + 'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor)]; return $this->screen($request, 'management/editorial-workflow', $data); } @@ -76,6 +77,34 @@ final class PortalEditorialWorkflowController extends Controller return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.'); } + public function portfolio(Request $request, string $id, PortalEditorialWorkflow $workflow): Response + { + $workflow->authorizePortfolio($request->user(), $id); + $record = DB::table('investment_dossiers')->where('id', $id)->first(); + abort_unless($record !== null, 404); + $selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null; + $latest = $workflow->latestPortfolio($id); + $edition = $selected ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('id', $selected)->first() : $latest; + abort_if($selected && ! $edition, 404); + $payload = $edition ? $workflow->payload($edition) : null; + $independent = ! $edition || ! in_array($request->user()->id, $payload['contributors'], true); + + return $this->screen($request, 'management/editorial-edition', ['subject' => 'portfolio', + 'resource' => ['id' => $id, 'title' => $record->title, 'published' => $record->publication_status === 'published', 'public_edition_no' => $record->publication_status === 'published' ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('state', 'published')->max('revision_no') : null], + 'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null, + 'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => true, 'canApprove' => $independent, 'manager' => true, 'legalReviewer' => false, + 'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']), + 'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->dossier_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']), + 'editions' => DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]); + } + + public function portfolioTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response + { + $result = $workflow->transition($request->user(), $id, $edition, $request->all(), true); + + return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial da carteira registada.'); + } + public function file(Request $request, int $id, string $edition, string $document, PortalEditorialWorkflow $workflow): Response { $workflow->authorize($request->user(), $id); diff --git a/app/Services/ContentManagement.php b/app/Services/ContentManagement.php index ba46107a..4e4ed0a1 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(PortalEditorialWorkflow::class)->portfolioAvailable() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) { + $result['portfolio'] = ['index' => '/management/portfolio']; + } if (app(ClusterCompetitiveness::class)->editorialEnabled() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) { $result['clusters'] = ['index' => '/management/clusters']; } diff --git a/app/Services/PortalEditorialWorkflow.php b/app/Services/PortalEditorialWorkflow.php index 83894f6a..774148e5 100644 --- a/app/Services/PortalEditorialWorkflow.php +++ b/app/Services/PortalEditorialWorkflow.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\InvestmentDossier; use App\Models\Opportunity; use App\Models\User; use Illuminate\Database\Query\Builder; @@ -56,7 +57,7 @@ final class PortalEditorialWorkflow { $p = $this->payload($e); try { - return hash_equals($e->content_hash, $this->hash($this->snapshot($p['resource'], $p['links'], $p['document_changes'], $p['contributors'], $p['policy']))); + return hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $p))); } catch (HttpException $exception) { if (in_array($exception->getStatusCode(), [404, 422], true)) { return false; @@ -154,7 +155,14 @@ final class PortalEditorialWorkflow return false; } try { - $this->authorize($actor, (int) $edition->resource_id); + if ($edition->dossier_id ?? null) { + if (! config('editorial_workflow.portfolio')) { + return false; + } + $this->authorizePortfolio($actor, $edition->dossier_id); + } else { + $this->authorize($actor, (int) $edition->resource_id); + } return true; } catch (HttpException $exception) { @@ -193,7 +201,7 @@ final class PortalEditorialWorkflow } $edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null; $result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at, - 'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : '/management/editorial/resources/'.$edition->resource_id.'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no]; + 'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->dossier_id ?? null) ? '/management/editorial/portfolio/'.$edition->dossier_id : '/management/editorial/resources/'.$edition->resource_id).'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no]; if (count($result) === 50) { break; } @@ -222,7 +230,7 @@ final class PortalEditorialWorkflow return null; } - return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor), 'unread' => $this->unreadCount($actor)]; + return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor), 'unread' => $this->unreadCount($actor)]; } /** @param array $resource @@ -266,6 +274,141 @@ final class PortalEditorialWorkflow return hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR)); } + public function portfolioEnabled(): bool + { + if (! config('editorial_workflow.portfolio')) { + return false; + } + abort_unless($this->portfolioAvailable(), 503, 'Estrutura editorial da carteira ainda não instalada.'); + + return true; + } + + public function portfolioAvailable(): bool + { + return (bool) config('editorial_workflow.portfolio') && $this->available() && Schema::hasColumn('portal_editorial_editions', 'dossier_id'); + } + + public function portfolioPending(User $actor): int + { + if (! $this->portfolioAvailable() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) { + return 0; + } + + return DB::table('portal_editorial_editions as e')->join('investment_dossiers as d', 'd.id', '=', 'e.dossier_id')->whereNull('d.deleted_at') + ->whereNotIn('e.state', ['published', 'archived'])->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.dossier_id', 'e.dossier_id')->whereColumn('later.revision_no', '>', 'e.revision_no'))->count(); + } + + public function portfolioWrites(string $id): bool + { + if ($this->portfolioEnabled()) { + return true; + } + abort_if(Schema::hasColumn('portal_editorial_editions', 'dossier_id') && DB::table('portal_editorial_editions')->where('dossier_id', $id)->exists(), 503, 'Reactive o circuito editorial para editar esta ficha; a publicação anterior está preservada.'); + + return false; + } + + public function authorizePortfolio(User $actor, string $id, bool $write = false): void + { + abort_unless($this->portfolioEnabled(), 503); + abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403); + abort_unless(DB::table('investment_dossiers')->where('id', $id)->whereNull('deleted_at')->exists(), 404); + if ($write) { + abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.'); + } + } + + public function latestPortfolio(string $id): ?\stdClass + { + return DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->first(); + } + + public function workingPortfolio(InvestmentDossier $record): InvestmentDossier + { + $edition = $this->latestPortfolio($record->id); + if (! $edition) { + return $record; + } + $copy = clone $record; + $fields = $this->payload($edition)['resource']; + $copy->forceFill(Arr::only($fields, ['title', 'source', 'content'])); + $copy->setAttribute('version_no', max((int) $record->getAttribute('version_no'), (int) $edition->revision_no)); + + return $copy; + } + + /** @param array $fields + * @param list $contributors + * @return EditorialPayload + */ + private function portfolioSnapshot(array $fields, array $contributors): array + { + if (is_string($fields['content'])) { + $fields['content'] = json_decode($fields['content'], true, 512, JSON_THROW_ON_ERROR); + } + $links = []; + foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $key => $table) { + if (! empty($fields[$key])) { + $context = DB::table($table)->where('id', $fields[$key])->whereNull('deleted_at')->first(); + abort_unless($context !== null, 422, 'Contexto da ficha indisponível.'); + $links[] = ['target_type' => $table, 'target_key' => $fields[$key], 'context' => Arr::only(get_object_vars($context), ['id', 'name', 'code', 'sector', 'status', 'description', 'primary_territory_id', 'territory_version', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])]; + } + } + abort_unless(count($links) === 1, 422, 'Contexto único da ficha necessário.'); + + return ['resource' => $fields, 'links' => $links, 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => true]]; + } + + /** @param EditorialPayload $payload + * @return EditorialPayload + */ + private function refreshPayload(\stdClass $edition, array $payload): array + { + return ($edition->dossier_id ?? null) ? $this->portfolioSnapshot($payload['resource'], $payload['contributors']) + : $this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']); + } + + /** @param array $changes */ + public function savePortfolio(User $actor, InvestmentDossier $record, int $expected, array $changes): \stdClass + { + $this->authorizePortfolio($actor, $record->id, true); + + return DB::transaction(function () use ($actor, $record, $expected, $changes) { + $record = InvestmentDossier::whereKey($record->id)->lockForUpdate()->firstOrFail(); + abort_unless((int) $this->workingPortfolio($record)->getAttribute('version_no') === $expected, 409, 'A edição mudou. Recupere a versão actual antes de guardar.'); + $previous = $this->latestPortfolio($record->id); + $public = $this->portfolioSnapshot($record->getAttributes(), []); + if (! $previous && $record->getAttribute('publication_status') === 'published') { + DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'dossier_id' => $record->id, 'revision_no' => $expected, + 'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public), + 'author_id' => $record->editor_id ?? $actor->id, 'legal_required' => false, 'published_at' => $record->getAttribute('published_at'), 'created_at' => now(), 'updated_at' => now()]); + } + $base = $previous ? $this->payload($previous) : $public; + $fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content']), ['version_no' => $expected + 1]); + $contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? $base['contributors'] : []), $actor->id])); + $snapshot = $this->portfolioSnapshot($fields, $contributors); + $id = (string) Str::uuid(); + DB::table('portal_editorial_editions')->insert(['id' => $id, 'dossier_id' => $record->id, 'revision_no' => $expected + 1, + 'state' => 'draft', 'version' => 1, 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot), + 'author_id' => $actor->id, 'legal_required' => false, 'created_at' => now(), 'updated_at' => now()]); + $edition = DB::table('portal_editorial_editions')->where('id', $id)->first(); + $this->audit($actor, $edition, 'draft_saved', 'Edição privada da carteira guardada; publicação preservada.'); + + return $edition; + }); + } + + /** @param EditorialPayload $payload */ + private function publishPortfolio(\stdClass $record, \stdClass $edition, array $payload): void + { + $approval = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR); + $fields = Arr::only($payload['resource'], ['title', 'source', 'content']); + $fields['content'] = json_encode($fields['content'], JSON_THROW_ON_ERROR); + DB::table('investment_dossiers')->where('id', $record->id)->update([...$fields, 'version_no' => max((int) $record->version_no + 1, (int) $edition->revision_no), + 'editor_id' => $edition->author_id, 'reviewer_id' => $approval['actor_id'], 'approval_basis' => 'manager', 'approval_evidence' => $approval['reason'], 'publication_status' => 'published', 'published_at' => now(), 'updated_at' => now()]); + } + /** @param array $changes * @param list>|null $links */ @@ -311,16 +454,22 @@ final class PortalEditorialWorkflow } /** @param array $input */ - public function transition(User $actor, int $resource, string $id, array $input): \stdClass + public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false): \stdClass { - $this->authorize($actor, $resource, true); - $data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => 'required|string|min:3|max:5000', 'assigned_to' => 'nullable|integer|exists:users,id'])->validate(); + if ($portfolio) { + $this->authorizePortfolio($actor, (string) $resource, true); + } else { + $this->authorize($actor, (int) $resource, true); + } + $reason = $portfolio && in_array($input['action'] ?? '', ['approve', 'publish'], true) ? 'required|string|min:15|max:3000' : 'required|string|min:3|max:5000'; + $data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => $reason, 'assigned_to' => 'nullable|integer|exists:users,id'])->validate(); - return DB::transaction(function () use ($actor, $resource, $id, $data) { - $r = DB::table('portal_resources')->where('id', $resource)->lockForUpdate()->first(); - $e = DB::table('portal_editorial_editions')->where('id', $id)->where('resource_id', $resource)->lockForUpdate()->first(); + return DB::transaction(function () use ($actor, $resource, $id, $data, $portfolio) { + $r = DB::table($portfolio ? 'investment_dossiers' : 'portal_resources')->where('id', $resource)->lockForUpdate()->first(); + $e = DB::table('portal_editorial_editions')->where('id', $id)->where($portfolio ? 'dossier_id' : 'resource_id', $resource)->lockForUpdate()->first(); abort_unless($r && $e, 404); - abort_unless((int) $e->version === (int) $data['version'] && $this->latest($resource)?->id === $id, 409, 'Versão desactualizada.'); + $latest = $portfolio ? $this->latestPortfolio((string) $resource) : $this->latest((int) $resource); + abort_unless((int) $e->version === (int) $data['version'] && $latest?->id === $id, 409, 'Versão desactualizada.'); $action = $data['action']; if (! in_array($action, ['submit', 'archive'], true)) { abort_unless(app(DocumentManagementAccess::class)->full($actor), 403, 'Responsável de revisão/publicação necessário.'); @@ -335,11 +484,11 @@ final class PortalEditorialWorkflow abort_unless(in_array($e->state, $allowed, true), 409, 'Transição incompatível com o estado actual.'); $payload = $this->payload($e); // Content is immutable; changed files invalidate the decision rather than being silently accepted. - if ($action !== 'archive') { - abort_unless(hash_equals($e->content_hash, $this->hash($this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']))), 409, 'Conteúdo ou anexos alterados. Guarde uma nova edição e obtenha nova aprovação.'); + if (in_array($action, ['submit', 'approve', 'legal_validate', 'publish'], true)) { + abort_unless(hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $payload))), 409, 'Conteúdo, contexto ou anexos alterados. Guarde uma nova edição e obtenha nova aprovação.'); } $updates = []; - if (in_array($action, ['approve', 'legal_validate'], true) && $this->requiresIndependent($e)) { + if ((in_array($action, ['approve', 'legal_validate'], true) || ($portfolio && $action === 'publish')) && $this->requiresIndependent($e)) { abort_if(in_array($actor->id, $payload['contributors'], true), 403, 'Esta edição exige revisão independente.'); } if (in_array($action, ['approve', 'legal_validate'], true)) { @@ -362,16 +511,25 @@ final class PortalEditorialWorkflow if ($e->legal_required) { $this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true); } - $this->publish($actor, $r, $e, $payload); + if ($portfolio) { + $this->publishPortfolio($r, $e, $payload); + } else { + $this->publish($actor, $r, $e, $payload); + } $updates['published_at'] = now(); } if ($action === 'submit' && isset($data['assigned_to'])) { $responsible = User::query()->whereKey($data['assigned_to'])->firstOrFail(); abort_unless(app(DocumentManagementAccess::class)->full($responsible) && ! app(PortalProfileAccess::class)->has($responsible, 'auditor'), 422, 'Responsável sem autoridade editorial.'); + abort_if($portfolio && ! $responsible->can('manage-portal'), 422, 'Responsável sem autoridade sobre a carteira.'); $updates['assigned_to'] = $responsible->id; } if ($action === 'archive' && $e->state === 'published') { - DB::table('portal_resources')->where('id', $resource)->update(['published' => false, 'metadata_version' => (int) $r->metadata_version + 1, 'updated_at' => now()]); + if ($portfolio) { + DB::table('investment_dossiers')->where('id', $resource)->update(['publication_status' => 'draft', 'version_no' => (int) $r->version_no + 1, 'updated_at' => now()]); + } else { + DB::table('portal_resources')->where('id', $resource)->update(['published' => false, 'metadata_version' => (int) $r->metadata_version + 1, 'updated_at' => now()]); + } } $updates['state'] = match ($action) { 'submit' => 'submitted', 'start_review', 'legal_validate' => 'in_review', 'return' => 'returned', 'reject' => 'rejected', 'approve' => 'approved', 'publish' => 'published', 'archive' => 'archived', @@ -396,6 +554,7 @@ final class PortalEditorialWorkflow abort_unless(($d['edition_id'] ?? null) === $e->id && ($d['hash'] ?? null) === $e->content_hash, 422, $message); $actor = User::query()->whereKey($d['actor_id'] ?? 0)->first(); abort_unless($actor && $actor->hasVerifiedEmail() && app(DocumentManagementAccess::class)->full($actor) && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403, 'Autoridade da decisão revogada.'); + abort_if(($e->dossier_id ?? null) && ! $actor->can('manage-portal'), 403, 'Autoridade sobre a carteira revogada.'); if ($legal) { abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica revogada.'); } @@ -438,7 +597,7 @@ final class PortalEditorialWorkflow private function audit(User $actor, \stdClass $e, string $action, string $reason, ?string $nextState = null): void { $current = DB::table('portal_editorial_editions')->where('id', $e->id)->first(); - app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version, + app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'dossier_id' => $e->dossier_id ?? null, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version, 'from_state' => $e->state, 'to_state' => $nextState ?? $e->state, 'result_version' => $current->version, 'approval' => $current->approval, 'legal_validation' => $current->legal_validation, 'reason' => $reason]); } } diff --git a/config/editorial_workflow.php b/config/editorial_workflow.php index 140256db..a27c7d22 100644 --- a/config/editorial_workflow.php +++ b/config/editorial_workflow.php @@ -4,6 +4,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), + 'portfolio' => env('PORTAL_EDITORIAL_PORTFOLIO_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/database/migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php b/database/migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php new file mode 100644 index 00000000..c1ea9ed2 --- /dev/null +++ b/database/migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php @@ -0,0 +1,37 @@ +unsignedBigInteger('resource_id')->nullable()->change(); + $t->foreignUuid('dossier_id')->nullable()->constrained('investment_dossiers')->restrictOnDelete(); + $t->unique(['dossier_id', 'revision_no']); + }); + DB::statement('ALTER TABLE portal_editorial_editions ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id)=1)'); + } + + public function down(): void + { + if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('dossier_id')->exists()) { + throw new RuntimeException('Preserve portfolio editorial history.'); + } + DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject'); + Schema::table('portal_editorial_editions', function (Blueprint $t): void { + $t->dropUnique(['dossier_id', 'revision_no']); + $t->dropForeign(['dossier_id']); + $t->dropColumn('dossier_id'); + $t->unsignedBigInteger('resource_id')->nullable(false)->change(); + }); + } +}; diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 1d11708f..88cec36a 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,3 +1,17 @@ +## 13/11/2026 — fichas da carteira integradas no mesmo circuito e pacote editorial + +**Lacuna corrigida em QA:** guardar uma ficha de projecto âncora, cluster ou corredor deixava de a disponibilizar ao público. Com `PORTAL_EDITORIAL_PORTFOLIO_ENABLED` activo, o editor existente guarda uma edição privada e conserva integralmente o registo público, incluindo título, conteúdo, fonte, versão e datas. A confirmação independente continua obrigatória; aprovar e publicar são operações distintas. A publicação anterior só é substituída pela publicação explícita da edição aprovada. Arquivar uma edição privada preserva-a; arquivar a edição pública actual retira-a da consulta, sem apagar dados ou históricos. + +**Reutilização:** mesmas tabelas, decisões, transições, auditoria, responsáveis, notificações e ecrã do circuito Fase 1. A migração aditiva `2026_11_13_230000_extend_editorial_editions_to_portfolio` permite que uma edição pertença a um recurso documental OU a uma ficha da carteira, com chaves estrangeiras, unicidade da revisão e exclusividade do sujeito. Não foi criado outro motor nem um segundo dossier. A estrutura foi instalada exclusivamente em `editorial_phase1_20261113_test:55441`; recibo em `outputs/editorial-portfolio-20261113/qa-installation.json`, com as contagens das 177 tabelas anteriores preservadas, excepto o incremento do livro de migrações. A reversão recusa remover histórico da carteira existente. + +**Percurso:** Gestão de conteúdos → Carteira → abrir ficha → guardar alteração → «Conferir versões, submeter, aprovar e publicar no circuito editorial». O ecrã mostra a edição exacta, a fonte e o contexto identificado, conteúdo, versões, decisão e histórico. Submeter com responsável, iniciar revisão, aprovar e publicar separadamente. O painel indica as fichas pendentes; as notificações ligam à edição concreta. Os gestores usam as autorizações existentes; todas as escritas exigem MFA válido. API/JSON e prévias verificam a autoridade no servidor. Leitura privada usa `private, no-store`; a gestão da carteira reutiliza também o histórico Inertia cifrado. + +**Integridade:** o hash liga a edição ao seu conteúdo e contexto de projecto/cluster/corredor, incluindo dados relevantes da entidade. Alterações do contexto ou da edição exigem nova aprovação. Todos os colaboradores privados são excluídos da aprovação e publicação dessa edição, preservando a independência já exigida na carteira. A publicação reconfere a autoridade do aprovador. Tentativas pela antiga operação combinada são recusadas. Desactivar o módulo não permite contornar o circuito para editar fichas com histórico novo. Um contexto desactualizado impede aprovar/publicar, mas permite devolver/rejeitar para correcção, sem prender a revisão. Versões anteriores, razões e dados deliberados ficam conservados. + +**Provas:** 5 testes novos/199 asserções; regressão Fase 1 11/223; carteira anterior 8/201; notificações nativas de oportunidades 1/20; frontend 20 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-portfolio-20261113/`, incluindo `integration-final.log`, `phase1-regression-final.log`, `native-regression.log`, `opportunity-notices-regression.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log` e `build.log`. Os testes de carteira anteriores usam a base nativa própria e descartável; os novos usam transacções revertidas. Não foram submetidos processos reais, alteradas permissões ou publicados conteúdos reais. + +**Entrega e saldo:** este avanço integra a candidata agrupada, preservando os pacotes anteriores. A instalação operacional continua por executar e inclui agora três migrações da Fase 1/carteira; as migrações só autorizam QA e o executor Git não executa SQL no destino. É necessário preparar/autorizar a aplicação nominal do schema e contrato, backup/restauro e instalação conjunta pelo processo existente. A conferência visual permanece pendente, sem invalidar as provas aceites. Este recorte versiona as fichas públicas da carteira; a edição dos dados-base de projectos/clusters, relações, estatística, indicadores demográficos e conteúdos territoriais ainda não fica encerrada, nem as notificações próprias de Competitividade. Nenhuma instalação ou activação em homologação/produção foi efectuada. + ## 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. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index 1f8fd210..f9c7938e 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -11130,3 +11130,4 @@ REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzido 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 +ACT-20261113-EDITORIAL-CARTEIRA,Preservar fichas públicas de projecto âncora/cluster/corredor enquanto novas versões seguem o circuito editorial; integrar pacote conjunto,"Directiva de continuidade e decisão de agrupar funcionalidades relacionadas, 13/11/2026",Dashboard Colaborativo / carteira de investimento,/management/portfolio/{id}; /management/editorial/portfolio/{id}; transições/notificações existentes,Edições privadas na mesma tabela da Fase 1; aprovação/publicação separadas; fonte/contexto por hash; independência de todos os colaboradores; MFA e auditoria; pendências e notificações,Estrutura e provas apenas em QA 55441; flag própria desactivada por defeito; sem instalação operacional,outputs/editorial-portfolio-20261113: novos 5/199; Fase 1 11/223; carteira anterior 8/201; notificações oportunidades 1/20; frontend 20; PHPStan/tipos/build aprovados,Fichas da carteira implementadas em QA; dados-base e restante Fase 2 pendentes,Conferência visual; preparação/instalação operacional; versionamento de dados-base e restantes conteúdos Fase 2,Schema/contrato operacional da entrega agrupada exige preparação e autorização concreta; não bloqueia QA,"Guardar/aprovar mantém todos os campos públicos; publicar aplica só edição aprovada; independência, revogação, MFA e versões verificadas no servidor; contexto alterado exige nova decisão; histórico e notificações recuperáveis",Alta,,,,,"Editor existente, ecrã de revisão reutilizado, versões/contexto, acesso pelo painel e Gestão de conteúdos",JSON/HTML protegidos pelas mesmas autorizações; leitura privada sem cache,Edições e decisões na tabela comum; migração QA aditiva com exclusividade do sujeito e rollback protegido,Rascunho e versões anteriores recuperados sem substituir publicação; edições deliberadas preservadas,Ficha pública mantida; nova publicação só por operação independente explícita,,,,Cinco percursos de integração novos e regressões afectadas aprovados; não equivale ao fecho do módulo de dados-base,"Dados-base, relações, estatística/demografia/território; conferência visual e instalação conjunta","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Integração aditiva no circuito existente; sem segundo dossier ou motor,,Recibo e provas em outputs/editorial-portfolio-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,"Regressão Fase 1, carteira anterior e notificações nativas; Competitividade mantém provas anteriores aplicáveis","Preservação pública, versões/contexto, independência, MFA, autoridade, histórico/notificações e bloqueio de bypass",Sem revisão visual ou instalação operacional; não prova versionamento dos dados-base nem restantes conteúdos Fase 2,,Carteira e continuidade do circuito editorial,Ficha existente → edição privada → submissão → revisão → aprovação → publicação; consulta pública e recuperação preservadas,QA demonstrado; integrado na candidata agrupada; instalação pendente diff --git a/resources/js/pages/management/content-hub.tsx b/resources/js/pages/management/content-hub.tsx index e430b940..73eaa911 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 = { + portfolio?: { index: string }; clusters?: { index: string }; editorial?: { index: string }; procurement?: { index: string; create: string }; @@ -28,6 +29,20 @@ export default function ContentHub({ acção respeita a sua autorização actual.

+ {access.portfolio && ( +
+

+ Carteira de investimento +

+

+ Rever versões das fichas de projectos, clusters e + corredores, preservando a publicação anterior. +

+ + Gerir fichas e revisões da carteira + +
+ )} {access.clusters && (

diff --git a/resources/js/pages/management/editorial-edition.tsx b/resources/js/pages/management/editorial-edition.tsx index 784b463d..03a400f4 100644 --- a/resources/js/pages/management/editorial-edition.tsx +++ b/resources/js/pages/management/editorial-edition.tsx @@ -1,6 +1,7 @@ import { Link, useForm } from '@inertiajs/react'; import { Management, Field, button, control } from './shared'; import { states } from './editorial-workflow'; +import { labels as portfolioLabels } from '../portfolio/shared'; type Edition = { id: string; @@ -15,6 +16,8 @@ type Edition = { title: string; description?: string; editorial_content?: string | null; + source?: string; + content?: Record; }; files: { id: string; @@ -22,6 +25,11 @@ type Edition = { version_no: number; sha256: string; }[]; + links?: { + target_type: string; + target_key: string; + context?: { name?: string; code?: string; sector?: string }; + }[]; }; approval: string | null; legal_validation: string | null; @@ -37,9 +45,11 @@ export default function EditorialEdition({ current, canApprove, independentRequired, + subject = 'resource', }: { + subject?: 'resource' | 'portfolio'; resource: { - id: number; + id: number | string; title: string; published: boolean; public_edition_no?: number | null; @@ -60,6 +70,7 @@ export default function EditorialEdition({ canApprove: boolean; independentRequired: boolean; }) { + const editionBase = `/management/editorial/${subject === 'portfolio' ? 'portfolio' : 'resources'}/${resource.id}`; const form = useForm({ version: edition?.version ?? 1, action: '', @@ -92,15 +103,31 @@ export default function EditorialEdition({ Fila e notificações - - Alterar artigo - - - Alterar metadados/documento - + {subject === 'portfolio' ? ( + + Alterar ficha da carteira + + ) : ( + <> + + Alterar artigo + + + )} + {subject === 'resource' && ( + + Alterar metadados/documento + + )}

{resource.published @@ -126,13 +153,44 @@ export default function EditorialEdition({

Identificador da edição: {edition.id}
- Hash do conteúdo e anexos: {edition.content_hash} + Hash do conteúdo e{' '} + {subject === 'portfolio' ? 'contexto' : 'anexos'}:{' '} + {edition.content_hash}

Conferir conteúdo

{edition.payload.resource.title}

{edition.payload.resource.description}

+ {subject === 'portfolio' && ( + <> +

Fonte: {edition.payload.resource.source}

+
+ {Object.entries( + edition.payload.resource.content ?? {}, + ).map(([key, value]) => ( +
+
+ {portfolioLabels[key] ?? key} +
+
+ {value} +
+
+ ))} +
+ + )} + {subject === 'portfolio' && + edition.payload.links?.map((link) => ( +

+ Contexto: {link.context?.name} ·{' '} + {link.context?.code} + {link.context?.sector + ? ` · ${link.context.sector}` + : ''} +

+ ))} {edition.payload.resource.editorial_content && (

{ @@ -193,7 +251,7 @@ export default function EditorialEdition({ assigned_to: data.assigned_to || null, })); form.post( - `/management/editorial/resources/${resource.id}/editions/${edition.id}`, + `${editionBase}/editions/${edition.id}`, { onSuccess: () => form.reset('action', 'reason'), @@ -225,8 +283,16 @@ export default function EditorialEdition({ canApprove)) && (action !== 'approve' || canApprove) && + (subject !== 'portfolio' || + action !== 'publish' || + canApprove) && (edition.fresh || - action === 'archive'), + [ + 'archive', + 'return', + 'reject', + 'start_review', + ].includes(action)), ) .map((action) => (

Versão histórica para consulta.{' '} - + Voltar à edição actual

@@ -307,7 +370,7 @@ export default function EditorialEdition({
  • Edição {e.revision_no}: {states[e.state] ?? e.state} diff --git a/resources/js/pages/management/editorial-workflow.tsx b/resources/js/pages/management/editorial-workflow.tsx index d4f8b428..ef8e5372 100644 --- a/resources/js/pages/management/editorial-workflow.tsx +++ b/resources/js/pages/management/editorial-workflow.tsx @@ -38,11 +38,15 @@ export default function EditorialWorkflow({ opportunitiesUrl, opportunityPending, clustersUrl, + portfolioUrl, + portfolioPending = 0, }: { resourceAccess: boolean; opportunitiesUrl: string | null; opportunityPending: number; clustersUrl?: string | null; + portfolioUrl?: string | null; + portfolioPending?: number; filters?: { state?: string; kind?: string }; items: { data: Item[]; current_page: number; last_page: number }; notices: { @@ -63,6 +67,11 @@ export default function EditorialWorkflow({ próprio.

    + {portfolioUrl && ( + + Carteira: {portfolioPending} fichas pendentes + + )} {clustersUrl && ( Competitividade dos clusters diff --git a/resources/js/pages/portfolio/show.tsx b/resources/js/pages/portfolio/show.tsx index d17799af..742c49dd 100644 --- a/resources/js/pages/portfolio/show.tsx +++ b/resources/js/pages/portfolio/show.tsx @@ -29,6 +29,7 @@ export default function Show(props: Props) { } type Props = { + editorialEnabled?: boolean; competitiveness?: ApprovedCompetitiveness | null; complements?: ComplementOption[]; geography: StructureGeography; @@ -63,6 +64,7 @@ type Props = { }; function Editor({ + editorialEnabled = false, competitiveness, complements = [], record, @@ -423,43 +425,57 @@ function Editor({ Guardar rascunho -
    { - e.preventDefault(); - approval.post(`${base}/${record.id}/publish`); - }} - > -

    - O gestor autorizado pode aprovar e publicar esta - ficha. Registe a referência da sua decisão. + {editorialEnabled ? ( +

    + A edição privada preserva a ficha pública anterior.{' '} + + Conferir versões, submeter, aprovar e publicar + no circuito editorial + + .

    - -