diff --git a/app/Http/Controllers/ProcurementController.php b/app/Http/Controllers/ProcurementController.php
new file mode 100644
index 00000000..47b1cc5d
--- /dev/null
+++ b/app/Http/Controllers/ProcurementController.php
@@ -0,0 +1,138 @@
+>'procedural_state' IN ('cancelled','results') THEN v.payload->>'procedural_state' WHEN ((v.payload->>'deadline')::timestamp AT TIME ZONE (v.payload->>'timezone'))<=CURRENT_TIMESTAMP THEN 'deadline_ended' WHEN v.payload->>'extended'='true' THEN 'extended' ELSE 'open' END";
+
+ private function query(Procurement $service)
+ {
+ abort_unless($service->available(), 404);
+
+ return DB::table('portal_tenders as t')->join('portal_tender_versions as v', fn ($q) => $q->on('v.id', '=', 't.published_version_id')->on('v.tender_id', '=', 't.id'))->whereNotNull('v.published_at');
+ }
+
+ public function index(Request $request, Procurement $service)
+ {
+ $filters = $request->validate(['q' => 'nullable|string|max:150', 'entity' => 'nullable|uuid', 'sector' => 'nullable|integer', 'territory' => 'nullable|uuid', 'modality' => 'nullable|string|max:200', 'financier' => 'nullable|string|max:200', 'state' => 'nullable|in:open,extended,deadline_ended,cancelled,results', 'deadline_before' => 'nullable|date_format:Y-m-d', 'regime' => 'nullable|in:state,external,private,other']);
+ $filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
+ $query = $this->query($service);
+ if (! empty($filters['q'])) {
+ $query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'object' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'reference' ILIKE ?", ['%'.$filters['q'].'%']));
+ }
+ foreach (['entity' => 'organization_id', 'sector' => 'sector_id', 'modality' => 'modality', 'financier' => 'financier', 'regime' => 'regime'] as $filter => $field) {
+ if (! empty($filters[$filter])) {
+ $query->whereRaw('v.payload->>? = ?', [$field, (string) $filters[$filter]]);
+ }
+ }
+ if (! empty($filters['territory'])) {
+ $query->whereRaw("v.payload->>'territory_id' IN (WITH RECURSIVE scope(id,depth) AS (SELECT id,0 FROM geo_locations WHERE id=? AND deleted_at IS NULL UNION ALL SELECT c.id,s.depth+1 FROM geo_locations c JOIN scope s ON c.parent_id=s.id WHERE c.deleted_at IS NULL AND s.depth<4) SELECT id::text FROM scope)", [$filters['territory']]);
+ }
+ if (! empty($filters['state'])) {
+ $query->whereRaw(self::STATE_SQL.' = ?', [$filters['state']]);
+ }
+ if (! empty($filters['deadline_before'])) {
+ $query->whereRaw("left(v.payload->>'deadline',10)<=?", [$filters['deadline_before']]);
+ }
+ $rows = $query->select('t.id', 'v.id as vid')->orderByDesc('v.published_at')->paginate(20)->withQueryString();
+ $rows->through(fn ($r) => ['id' => $r->id, ...$this->summary($service, $r->vid)]);
+ $facets = $this->query($service)->get(['v.payload'])->map(fn ($r) => json_decode($r->payload, true));
+ $options = [];
+ foreach (['entity' => ['organization_id', 'organization_name'], 'sector' => ['sector_id', 'sector_name'], 'territory' => ['territory_id', 'territory_name'], 'modality' => ['modality', 'modality'], 'financier' => ['financier', 'financier']] as $name => [$value,$label]) {
+ $options[$name] = $facets->filter(fn ($p) => ! empty($p[$value]))->map(fn ($p) => ['id' => (string) $p[$value], 'name' => $p[$label]])->unique('id')->values();
+ }
+
+ return Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request)->header('Cache-Control', 'private, no-store');
+ }
+
+ private function summary(Procurement $service, string $vid): array
+ {
+ $v = $service->publishedVersion($vid);
+
+ return ['publication' => $v, 'state' => $service->state($v)];
+ }
+
+ public function show(Request $request, string $id, Procurement $service)
+ {
+ return $this->detail($request, $id, $service, false);
+ }
+
+ public function print(Request $request, string $id, Procurement $service)
+ {
+ return $this->detail($request, $id, $service, true);
+ }
+
+ private function detail(Request $request, string $id, Procurement $service, bool $print)
+ {
+ $row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']);
+ $filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']);
+
+ return Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''),
+ 'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)),
+ 'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request)->header('Cache-Control', 'private, no-store');
+ }
+
+ public function manage(Request $request, Procurement $service)
+ {
+ $service->guard($request->user());
+
+ return Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request)->header('Cache-Control', 'private, no-store');
+ }
+
+ public function edit(Request $request, Procurement $service, ?string $id = null)
+ {
+ $service->guard($request->user());
+ $r = $id ? $service->record($id) : null;
+
+ return Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null,
+ 'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request)->header('Cache-Control', 'private, no-store');
+ }
+
+ public function save(Request $request, Procurement $service, ?string $id = null)
+ {
+ $id = $service->save($request->user(), $id, $request->all());
+
+ return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.');
+ }
+
+ public function preview(Request $request, string $id, Procurement $service)
+ {
+ return response()->json($service->preview($request->user(), $id, (string) $request->input('act')))->header('Cache-Control', 'private, no-store');
+ }
+
+ public function publish(Request $request, string $id, Procurement $service)
+ {
+ $service->publish($request->user(), $id, $request->all());
+
+ return redirect('/management/concursos/'.$id)->with('success', 'Acto publicado, com versão e auditoria. Nenhuma proposta recebida pelo portal.');
+ }
+
+ public function watch(Request $request, string $id, Procurement $service)
+ {
+ $row = $this->query($service)->where('t.id', $id)->firstOrFail(['v.id']);
+ DB::table('portal_tender_watches')->updateOrInsert(['tender_id' => $id, 'user_id' => $request->user()->id], ['seen_version_id' => $row->id, 'created_at' => now(), 'updated_at' => now()]);
+
+ return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.');
+ }
+
+ public function unwatch(Request $request, string $id, Procurement $service)
+ {
+ abort_unless($service->available(), 404);
+ DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete();
+
+ return back();
+ }
+
+ public function watched(Request $request, Procurement $service)
+ {
+ $rows = $this->query($service)->join('portal_tender_watches as w', 'w.tender_id', '=', 't.id')->where('w.user_id', $request->user()->id)->get(['t.id', 'v.id as vid', 'w.seen_version_id'])->map(fn ($r) => ['id' => $r->id, 'changed' => $r->seen_version_id !== $r->vid, ...$this->summary($service, $r->vid)]);
+
+ return Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request)->header('Cache-Control', 'private, no-store');
+ }
+}
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index 194e77f6..26e870a5 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -51,6 +51,7 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
+ 'procurementEnabled' => app(\App\Services\Procurement::class)->available(),
'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,
diff --git a/app/Services/ContentManagement.php b/app/Services/ContentManagement.php
index d7507492..7d3df9b2 100644
--- a/app/Services/ContentManagement.php
+++ b/app/Services/ContentManagement.php
@@ -19,6 +19,7 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor);
$result = [];
+ if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) $result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo'];
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];
diff --git a/app/Services/Procurement.php b/app/Services/Procurement.php
new file mode 100644
index 00000000..8960f5ea
--- /dev/null
+++ b/app/Services/Procurement.php
@@ -0,0 +1,287 @@
+hasVerifiedEmail()) {
+ return false;
+ }
+ $docs = app(DocumentManagementAccess::class);
+
+ return ! app(PortalProfileAccess::class)->has($actor, 'auditor') && ($docs->full($actor) || $docs->editorial($actor));
+ }
+
+ public function guard(?User $actor): void
+ {
+ abort_unless($this->available(), 404);
+ abort_unless($this->access($actor), 403, 'Use a autorização editorial já atribuída.');
+ }
+
+ public function references(User $actor): array
+ {
+ $this->guard($actor);
+ $editable = app(DocumentManagementAccess::class)->documents($actor)->select('id');
+
+ return ['regimes' => config('procurement.regimes'), 'acts' => config('procurement.acts'),
+ 'organizations' => DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->get(['id', 'name']),
+ 'sectors' => DB::table('sectors')->orderBy('name')->get(['id', 'name']),
+ 'territories' => app(InvestmentGeography::class)->references(true)->orderBy('name')->get(['id', 'name', 'location_type']),
+ 'opportunities' => app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->orderBy('title')->get(['id', 'title']),
+ 'projects' => DB::table('projects')->whereNull('deleted_at')->whereIn('data_classification', ['internal', 'public'])->orderBy('name')->get(['id', 'name']),
+ 'documents' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
+ ->where(fn ($q) => $q->whereIn('d.id', $editable)->orWhere(fn ($p) => $p->where('r.published', true)->where('d.data_classification', 'public')))
+ ->orderBy('r.title')->get(['r.id', 'r.title', 'r.published', 'd.malware_scan_status'])];
+ }
+
+ public function record(string $id): object
+ {
+ abort_unless($this->available(), 404);
+
+ return DB::table('portal_tenders')->where('id', $id)->firstOrFail();
+ }
+
+ public function save(User $actor, ?string $id, array $input): string
+ {
+ $this->guard($actor);
+ $v = Validator::make($input, ['version' => 'required|integer|min:0', 'reason' => 'required|string|min:20|max:1000', 'draft' => 'required|array']);
+ $v->validate();
+ $draft = $this->normalize($actor, $input['draft']);
+
+ return DB::transaction(function () use ($actor, $id, $input, $draft) {
+ $old = $id ? DB::table('portal_tenders')->where('id', $id)->lockForUpdate()->firstOrFail() : null;
+ abort_unless((int) ($old->version ?? 0) === (int) $input['version'], 409, 'A versão mudou; recupere o concurso antes de guardar.');
+ $id ??= (string) Str::uuid();
+ $version = ($old->version ?? 0) + 1;
+ if ($old) {
+ DB::table('portal_tenders')->where('id', $id)->update(['draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'version' => $version, 'updated_at' => now()]);
+ } else {
+ DB::table('portal_tenders')->insert(['id' => $id, 'draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'version' => $version, 'created_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
+ }
+ $this->version($actor, $id, $version, 'draft', $draft, $input['reason'], false);
+
+ return $id;
+ });
+ }
+
+ private function normalize(User $actor, array $input): array
+ {
+ $rules = array_fill_keys(self::TEXT, 'nullable|string|max:12000');
+ $rules = [...$rules, 'regime' => 'nullable|in:state,external,private,other', 'organization_id' => 'nullable|uuid|exists:organizations,id',
+ 'opportunity_id' => 'nullable|integer|exists:opportunities,id', 'project_id' => 'nullable|uuid|exists:projects,id', 'sector_id' => 'nullable|integer|exists:sectors,id',
+ 'territory_id' => 'nullable|uuid|exists:geo_locations,id', 'contact_authorized' => 'boolean', 'minimum_days' => 'nullable|integer|min:0|max:3650',
+ 'lots' => 'array|max:100', 'lots.*' => 'array:code,description,quantities', 'lots.*.code' => 'required|string|max:80|distinct', 'lots.*.description' => 'required|string|max:3000', 'lots.*.quantities' => 'nullable|string|max:1500',
+ 'attachments' => 'array|max:100', 'attachments.*' => 'array:resource_id,public,purpose', 'attachments.*.resource_id' => 'required|integer|exists:portal_resources,id', 'attachments.*.public' => 'required|boolean', 'attachments.*.purpose' => 'required|string|max:150'];
+ foreach (['origin_url', 'delivery_url', 'rule_source'] as $url) {
+ $rules[$url] = 'nullable|url:http,https|max:2000';
+ }
+ $data = Validator::make($input, $rules)->validate();
+ if (! empty($data['opportunity_id'])) {
+ abort_unless(app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->whereKey($data['opportunity_id'])->exists(), 403);
+ }
+ if (! empty($data['project_id'])) {
+ abort_unless(DB::table('projects')->where('id', $data['project_id'])->whereNull('deleted_at')->whereIn('data_classification', ['internal', 'public'])->exists(), 422);
+ }
+ foreach ($data['attachments'] ?? [] as $attachment) {
+ $resource = DB::table('portal_resources')->where('id', $attachment['resource_id'])->firstOrFail();
+ abort_unless($this->publicDocuments()->where('r.id', $resource->id)->exists() || app(DocumentManagementAccess::class)->documents($actor)->where('id', $resource->document_id)->exists(), 403);
+ }
+
+ return $data;
+ }
+
+ public function preview(User $actor, string $id, string $act): array
+ {
+ $this->guard($actor);
+ $record = $this->record($id);
+ abort_unless(array_key_exists($act, config('procurement.acts')), 422);
+ $p = json_decode($record->draft, true, 512, JSON_THROW_ON_ERROR);
+ Validator::make($p, array_fill_keys(['title', 'reference', 'organization_id', 'modality', 'object', 'regime', 'instrument', 'edition', 'rule_source', 'origin_published_at', 'deadline', 'delivery_channel', 'timezone'], 'required'))->validate();
+ Validator::make($p, ['timezone' => 'timezone', 'object_kind' => 'nullable|in:works,goods,services,mixed,other', 'guarantee_status' => 'nullable|in:required,not_required,unknown'])->validate();
+ Validator::make($p, ['public_reason' => 'required|string|min:20|max:1000'])->validate();
+ $dates = [];
+ foreach (['origin_published_at', 'clarifications_at', 'deadline', 'opening_at'] as $field) {
+ if (empty($p[$field])) {
+ continue;
+ }
+ $validator = Validator::make([$field => $p[$field]], [$field => 'date_format:Y-m-d\TH:i']);
+ $validator->validate();
+ $dates[$field] = CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $p[$field], $p['timezone']);
+ }
+ $this->require($dates['deadline']->greaterThan($dates['origin_published_at']), 'deadline', 'Entrega anterior à publicação de origem.');
+ if (isset($dates['clarifications_at'])) {
+ $this->require($dates['clarifications_at']->betweenIncluded($dates['origin_published_at'], $dates['deadline']), 'clarifications_at', 'Esclarecimentos fora do período de publicação/entrega.');
+ }
+ if (isset($dates['opening_at'])) {
+ $this->require($dates['opening_at']->greaterThanOrEqualTo($dates['deadline']), 'opening_at', 'A abertura indicada é anterior à entrega.');
+ }
+ if (($p['guarantee_status'] ?? '') === 'required') {
+ $this->require(! empty($p['guarantees']), 'guarantees', 'Descreva a garantia documentada exigida.');
+ }
+ if (isset($p['minimum_days'])) {
+ foreach (['minimum_source', 'minimum_edition', 'minimum_modality', 'minimum_day_basis'] as $field) {
+ $this->require(! empty($p[$field]), $field, 'Identifique fonte, edição, modalidade e contagem do prazo mínimo.');
+ }
+ $this->require($p['minimum_modality'] === $p['modality'], 'minimum_modality', 'A regra indicada pertence a outra modalidade.');
+ $earliest = $dates['origin_published_at'];
+ if ($p['minimum_day_basis'] === 'business') {
+ $this->require(! empty($p['minimum_calendar_source']), 'minimum_calendar_source', 'Identifique a fonte do calendário de dias úteis e feriados.');
+ $weekdays = array_filter(array_map('trim', explode(',', (string) ($p['minimum_working_days'] ?? ''))));
+ $this->require(count($weekdays) > 0 && ! array_diff($weekdays, ['1', '2', '3', '4', '5', '6', '7']), 'minimum_working_days', 'Indique os dias de trabalho documentados (1=segunda, 7=domingo).');
+ $holidays = array_values(array_filter(preg_split('/[\s,;]+/', (string) ($p['minimum_holidays'] ?? ''))));
+ Validator::make(['dates' => $holidays], ['dates' => 'array|max:1000', 'dates.*' => 'date_format:Y-m-d'])->validate();
+ for ($left = (int) $p['minimum_days'],$iterations = 0; $left > 0; $iterations++) {
+ $this->require($iterations < 36500, 'minimum_days', 'Calendário insuficiente para calcular o prazo indicado.');
+ $earliest = $earliest->addDay();
+ if (in_array((string) $earliest->isoWeekday(), $weekdays, true) && ! in_array($earliest->format('Y-m-d'), $holidays, true)) {
+ $left--;
+ }
+ }
+ } else {
+ $this->require($p['minimum_day_basis'] === 'calendar', 'minimum_day_basis', 'Identifique dias corridos ou calendário de dias úteis documentado.');
+ $earliest = $earliest->addDays((int) $p['minimum_days']);
+ }
+ $this->require($dates['deadline']->greaterThanOrEqualTo($earliest), 'deadline', 'Prazo inferior ao mínimo documentado para esta modalidade/edição/calendário.');
+ }
+ $previous = $record->published_version_id ? $this->publishedVersion($record->published_version_id) : null;
+ $this->require($previous ? $act !== 'initial' : $act === 'initial', 'act', $previous ? 'O anúncio inicial já existe; publique um acto associado.' : 'Publique primeiro o anúncio inicial.');
+ if ($previous) {
+ $before = $previous['payload'];
+ if (($before['deadline'] ?? '') !== $p['deadline'] || ($before['timezone'] ?? '') !== $p['timezone']) {
+ $this->require($act === 'extension', 'act', 'Uma mudança do prazo exige acto de prorrogação, com data anterior e nova.');
+ }
+ if ($act === 'extension') {
+ $this->require($dates['deadline']->greaterThan(CarbonImmutable::parse($before['deadline'], $before['timezone'])), 'deadline', 'A prorrogação deve avançar o prazo anterior.');
+ $p['deadline_previous'] = $before['deadline'];
+ $p['timezone_previous'] = $before['timezone'];
+ }
+ $p['extended'] = ($before['extended'] ?? false) || $act === 'extension';
+ $p['procedural_state'] = $before['procedural_state'] ?? null;
+ }
+ if ($act === 'award') {
+ $p['procedural_state'] = 'results';
+ }
+ if ($act === 'cancellation') {
+ $p['procedural_state'] = 'cancelled';
+ }
+ if (in_array($act, ['award', 'cancellation', 'other'], true)) {
+ $this->require(! empty($p['result']), 'result', 'Descreva o acto ou resultado documentado.');
+ }
+ $p['organization_name'] = DB::table('organizations')->where('id', $p['organization_id'])->whereNull('deleted_at')->value('name');
+ $this->require((bool) $p['organization_name'], 'organization_id', 'Entidade eliminada; recupere a referência.');
+ $p['sector_name'] = empty($p['sector_id']) ? null : DB::table('sectors')->where('id', $p['sector_id'])->value('name');
+ $p['territory_name'] = empty($p['territory_id']) ? null : app(InvestmentGeography::class)->references(true)->where('id', $p['territory_id'])->value('name');
+ if (! empty($p['territory_id'])) {
+ $this->require((bool) $p['territory_name'], 'territory_id', 'Referência territorial indisponível.');
+ }
+ unset($p['internal_notes']);
+ if (! ($p['contact_authorized'] ?? false)) {
+ unset($p['public_contact']);
+ }
+ $attachments = [];
+ foreach ($p['attachments'] ?? [] as $item) {
+ if (! $item['public']) {
+ continue;
+ }
+ $doc = $this->publicDocuments()->where('r.id', $item['resource_id'])->first(['r.id', 'r.title', 'd.sha256', 'd.id as document_id']);
+ $this->require((bool) $doc, 'attachments', 'Anexo público exige publicação autorizada, scan limpo e direitos sem restrição na biblioteca.');
+ $attachments[] = [...$item, 'title' => $doc->title, 'sha256' => trim($doc->sha256), 'document_id' => $doc->document_id];
+ }
+ $p['attachments'] = $attachments;
+ if (! empty($p['opportunity_id'])) {
+ $o = Opportunity::published()->whereKey($p['opportunity_id'])->first(['id', 'title', 'slug']);
+ $p['opportunity'] = $o ? ['title' => $o->title, 'url' => '/oportunidades/'.$o->slug] : null;
+ }
+ if (! empty($p['project_id'])) {
+ $p['project_name'] = DB::table('projects')->where('id', $p['project_id'])->whereNull('deleted_at')->where('data_classification', 'public')->value('name');
+ }
+ unset($p['opportunity_id'],$p['project_id']);
+ $plan = ['id' => $id, 'version' => $record->version, 'act' => $act, 'payload' => $p];
+ $plan['hash'] = hash_hmac('sha256', json_encode($plan, JSON_THROW_ON_ERROR), config('app.key'));
+
+ return $plan;
+ }
+
+ public function publish(User $actor, string $id, array $input): void
+ {
+ $this->guard($actor);
+ Validator::make($input, ['hash' => 'required|string|size:64', 'act' => 'required|string', 'reason' => 'required|string|min:20|max:1000'])->validate();
+ DB::transaction(function () use ($actor, $id, $input) {
+ $record = DB::table('portal_tenders')->where('id', $id)->lockForUpdate()->firstOrFail();
+ $plan = $this->preview($actor, $id, $input['act']);
+ abort_unless(hash_equals($plan['hash'], $input['hash']), 409, 'Prévia alterada; reveja o conteúdo antes de publicar.');
+ abort_unless($input['reason'] === $plan['payload']['public_reason'], 409, 'O motivo público deve coincidir com a prévia.');
+ $version = $record->version + 1;
+ $vid = $this->version($actor, $id, $version, $input['act'], $plan['payload'], $input['reason'], true);
+ DB::table('portal_tenders')->where('id', $id)->update(['published_version_id' => $vid, 'version' => $version, 'updated_at' => now()]);
+ });
+ }
+
+ private function version(User $actor, string $id, int $version, string $act, array $payload, string $reason, bool $published): string
+ {
+ $vid = (string) Str::uuid();
+ DB::table('portal_tender_versions')->insert(['id' => $vid, 'tender_id' => $id, 'version' => $version, 'act' => $act, 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'reason' => $reason, 'actor_id' => $actor->id, 'published_at' => $published ? now() : null, 'created_at' => now()]);
+ DB::table('audit_events')->insert(['source_system' => 'procurement', 'entity_type' => 'portal_tenders', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'action' => $published ? 'notice_published' : 'tender_draft_saved', 'data_classification' => 'internal', 'after_data' => json_encode(['version_id' => $vid, 'act' => $act, 'reason' => $reason], JSON_THROW_ON_ERROR)]);
+
+ return $vid;
+ }
+
+ public function publicDocuments(): Builder
+ {
+ return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')->where('d.data_classification', 'public')->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')
+ ->where(fn ($q) => $q->whereNull('d.source_system')->orWhereNotIn('d.source_system', ['opportunity-resources', 'opportunity-image-derivatives']))
+ ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('opportunity_documents as od')->whereColumn('od.document_id', 'd.id')->whereRaw("NOT jsonb_exists(COALESCE(od.payload,'{}'::jsonb),'library_source')"))
+ ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('opportunity_images as oi')->whereColumn('oi.document_id', 'd.id')->whereRaw("NOT jsonb_exists(COALESCE(oi.payload,'{}'::jsonb),'library_source')"));
+ }
+
+ public function publishedVersion(string $id): array
+ {
+ $v = DB::table('portal_tender_versions')->where('id', $id)->whereNotNull('published_at')->firstOrFail();
+ $p = json_decode($v->payload, true, 512, JSON_THROW_ON_ERROR);
+ foreach ($p['attachments'] ?? [] as $i => $item) {
+ $p['attachments'][$i]['available'] = $this->publicDocuments()->where('r.id', $item['resource_id'])->where('d.id', $item['document_id'])->where('d.sha256', $item['sha256'])->exists();
+ }
+
+ return ['id' => $v->id, 'version' => $v->version, 'act' => $v->act, 'payload' => $p, 'published_at' => $v->published_at, 'reason' => $v->reason];
+ }
+
+ public function state(array $version): string
+ {
+ $p = $version['payload'];
+ if (in_array($p['procedural_state'] ?? '', ['cancelled', 'results'], true)) {
+ return $p['procedural_state'];
+ }
+ if (CarbonImmutable::parse($p['deadline'], $p['timezone'])->isPast()) {
+ return 'deadline_ended';
+ }
+
+ return ($p['extended'] ?? false) ? 'extended' : 'open';
+ }
+
+ private function require(bool $condition, string $field, string $message): void
+ {
+ if (! $condition) {
+ throw ValidationException::withMessages([$field => $message]);
+ }
+ }
+}
diff --git a/config/procurement.php b/config/procurement.php
new file mode 100644
index 00000000..4e4c0d49
--- /dev/null
+++ b/config/procurement.php
@@ -0,0 +1,7 @@
+ (bool) env('PROCUREMENT_ENABLED', false),
+ 'regimes' => ['state' => 'Contratação pública do Estado', 'external' => 'Financiamento externo', 'private' => 'Concurso privado', 'other' => 'Outro regime documentado'],
+ 'acts' => ['initial' => 'Anúncio inicial', 'clarification' => 'Esclarecimento', 'correction' => 'Rectificação', 'extension' => 'Prorrogação', 'award' => 'Resultado / adjudicação', 'cancellation' => 'Cancelamento', 'other' => 'Outro acto'],
+];
diff --git a/database/migrations/2026_10_22_230000_create_portal_tenders.php b/database/migrations/2026_10_22_230000_create_portal_tenders.php
new file mode 100644
index 00000000..9585f676
--- /dev/null
+++ b/database/migrations/2026_10_22_230000_create_portal_tenders.php
@@ -0,0 +1,50 @@
+uuid('id')->primary();
+ $t->jsonb('draft');
+ $t->unsignedInteger('version')->default(1);
+ $t->uuid('published_version_id')->nullable();
+ $t->foreignId('created_by')->constrained('users')->restrictOnDelete();
+ $t->timestampsTz();
+ });
+ Schema::create('portal_tender_versions', function (Blueprint $t) {
+ $t->uuid('id')->primary();
+ $t->foreignUuid('tender_id')->constrained('portal_tenders')->restrictOnDelete();
+ $t->unsignedInteger('version');
+ $t->string('act', 24);
+ $t->jsonb('payload');
+ $t->text('reason');
+ $t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
+ $t->timestampTz('published_at')->nullable();
+ $t->timestampTz('created_at')->useCurrent();
+ $t->unique(['tender_id', 'version']);
+ });
+ Schema::create('portal_tender_watches', function (Blueprint $t) {
+ $t->foreignUuid('tender_id')->constrained('portal_tenders')->restrictOnDelete();
+ $t->foreignId('user_id')->constrained('users')->restrictOnDelete();
+ $t->uuid('seen_version_id')->nullable();
+ $t->timestampsTz();
+ $t->primary(['tender_id', 'user_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ if (DB::table('portal_tenders')->exists()) {
+ throw new RuntimeException('Preserve procedures, acts and history; use a reviewed data reversal.');
+ }
+ Schema::dropIfExists('portal_tender_watches');
+ Schema::dropIfExists('portal_tender_versions');
+ Schema::dropIfExists('portal_tenders');
+ }
+};
diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md
index 28c20be5..f36d2e49 100644
--- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md
+++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md
@@ -325,3 +325,7 @@ AUD-GEO-02/03: Recuperação da demarcação territorial — QA, 22/10/2026. 408
AUD-GEO-02/03 - 22/10/2026: Demarcação territorial reposta na homologação — 22/10/2026. 408 limites (11/133/264) importados pela conta real/MFA, com tres recibos auditados; backup/restauro de 171 tabelas conferido, 333 ficheiros verificados. Consulta do mapa demonstrada na oportunidade real #30, sem a alterar. Acessos previstos e originais privados preservados. Reposicao no destino concluida; 26+147 correspondencias e ausencia de poligonos proprios de localidades mantidas como lacunas reais. Ver fecho e guia no manual existente; sem repetir provas QA aceites.
+
+### Incremento Concursos e anúncios — QA, 22/10/2026
+
+Nos requisitos existentes de gestão/publicação `MASTER-9f6dd1d9-0001`, `MASTER-9f6dd1d9-0003` e `MASTER-02f44baf-0002`, registar o recorte solicitado pela Tempo: procedimentos com regime/edição/fontes, lotes, rascunhos recuperáveis, prévia, actos versionados, catálogo/ficha/anexos anónimos, filtros, impressão e acompanhamento por conta simples. Evidência: secção «Concursos e anúncios — implementação em QA» do manual existente e `tests/Feature/ProcurementTest.php`, com passagem adicional no navegador dos três regimes, prorrogação, cancelamento, download e acompanhamento. Reutilizadas autorizações e biblioteca; não foram criados papéis ou aprovadores. Origem funcional: pedido expresso da Tempo; flag, prévia assinada e controlo de versão são escolhas técnicas. Entrega delimitada a QA: integração em main, migração/activação no destino e anúncios reais seguem o processo existente. Não fecha requisitos integrais de contratação/recepção de propostas, nem declara parametrizado todo o articulado legal. Geometrias permanecem no recorte separado já entregue.
diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md
index fd09d9bd..6c2bfcfb 100644
--- a/docs/investmoz/unification/traceability-and-manual.md
+++ b/docs/investmoz/unification/traceability-and-manual.md
@@ -2331,3 +2331,41 @@ Entrar em [Gestão de conteúdos e oportunidades](https://teste-investidor.tempo
**Reposição concluída neste recorte.** Mantidas as 26 correspondências de distritos e 147 de postos pendentes, com nomes nos documentos/prévias existentes. O exemplar não contém polígonos próprios das localidades ou parcelas. Essas lacunas não bloqueiam os 408 limites recuperados. A data de eventual desaparecimento anterior no mesmo endereço continua não determinada. A divulgação promocional mantém o seu incremento separado; este fecho não a declara integrada.
Provas: `homologation-restored-readonly.json`, `staging-receipt.json`, `preservation-receipt.json`, `http-access-verification.json`, captura e recibos locais na pasta desta recuperação; backup/restauro e prova de activação em caminhos privados. Uma interrupção do transporte local foi resolvida por escrita atómica da mensagem, antes de qualquer importação. Não ficaram passos técnicos pendentes para esta reposição; futuras importações usam as salvaguardas de backup/MFA já existentes.
+
+## Concursos e anúncios — implementação em QA, 22/10/2026
+
+Origem: pedido expresso da Tempo no anexo «O InvesteMoz deve ter uma secção pública Concursos e anúncios», com continuação autónoma autorizada. Aplicam-se as decisões permanentes da Tempo de 12/10 e 22/10/2026. Este incremento trata a divulgação de procedimentos; não abre recepção electrónica de propostas nem altera os investimentos associados. A estimativa apresentada antes da implementação foi de 16–24 horas; não representa tempo efectivamente consumido.
+
+Reutilizados: entrada `/gestao-conteudos`, autenticação/MFA, autorização editorial e documental, entidades, oportunidades, projectos, sectores, território, biblioteca, originais privados, análise ClamAV, download verificado e `audit_events`. Acrescentados apenas três cadastros: procedimentos com rascunho, versões/actos imutáveis e acompanhamento individual. Uma oportunidade/projecto pode ter vários procedimentos; cada um admite lotes e actos associados. Não foram criados papéis, designações de funcionários ou aprovadores. Contas ainda não criadas não bloqueiam as contas já autorizadas.
+
+### Fontes, âmbito e escolhas de implementação
+
+- A UFSA identifica o Decreto n.º 79/2022 na [página oficial do regulamento](https://www.ufsa.gov.mz/divnovoreg.php). O [quadro oficial do Banco Mundial](https://www.worldbank.org/ext/en/what-we-do/project-procurement/framework) disponibiliza regulamentos e documentos normalizados, incluindo a edição de Setembro/2025. Cada procedimento regista a sua edição e acordo aplicáveis; a edição mais recente não é imposta a acordos anteriores.
+- Inventário deste recorte: instrução anexada, páginas oficiais referidas, pesquisa dos ficheiros acessíveis do repositório e referências da biblioteca existentes. Não foi identificado neste recorte um modelo oficial de concurso já fornecido como original local utilizável. Não se declarou importado ou analisado o articulado integral/modelos que não foram obtidos. Os documentos de demonstração são explicitamente sintéticos, sem classificação de autenticidade oficial. Os modelos disponibilizados pela Tempo usam a biblioteca existente, sem segunda validação genérica da credibilidade de fontes oficiais aceites.
+- Decisão expressa da Tempo: separar regimes Estado/externo/privado/outro; não generalizar UGEA, Cadastro Único, garantia ou abertura pública; guardar preparação incompleta; distinguir publicação de origem e publicação do portal; preservar actos, privacidade e auditoria.
+- Escolhas técnicas: só a prévia de publicação exige identificação, entidade, objecto, modalidade, instrumento/edição/fonte, calendário coerente, canal e motivo público. Rascunhos permanecem incompletos. Prazos mínimos são opcionais, citados por fonte/edição/modalidade; dias úteis usam exclusivamente o calendário documentado no registo. Não foi parametrizada uma tabela universal de mínimos legais nem presumido um calendário de feriados.
+- Escolhas técnicas de preservação: concorrência por versão, prévia assinada pelo servidor, nova verificação dos anexos na publicação, documentos por ID/hash e histórico imutável. Retirar ou substituir um binário na biblioteca torna indisponível o anexo antigo, sem apontar silenciosamente para outros bytes. Contacto sem autorização, notas internas e anexos privados não integram o conteúdo público. Ligações a oportunidades/projectos privados não divulgam os seus títulos ou fichas.
+- Autorizações reutilizadas: edição/publicação por contas já elegíveis para gestão documental completa ou edição editorial; auditor e conta simples não obtêm escrita. Acompanhamento exige apenas autenticação, incluindo conta sem email verificado. Estas escolhas não atribuem novos papéis e não dispensam os requisitos existentes de cada autorização.
+
+### Guia curto da equipa
+
+**Entrada QA:** [Gestão de conteúdos e oportunidades](http://127.0.0.1:18580/gestao-conteudos). Abrir **Novo concurso/anúncio**; escolher a entidade já cadastrada e, quando aplicável, oportunidade/projecto, sector e território. Indicar o regime e a edição do instrumento aplicável. Guardar mesmo com preparação incompleta; recuperar em **Gerir concursos e anúncios**. O motivo interno fica no histórico de gestão.
+
+1. Preencher objecto, lotes, participação, financiamento, calendário, fuso horário e canal efectivo de apresentação. A data de origem é a referência do procedimento; o portal regista separadamente a sua publicação.
+2. Usar **Carregar / gerir documentos e scan na biblioteca**, que abre outra página sem perder o editor. Carregar o original, acompanhar o scan, editar os metadados e disponibilizar explicitamente os documentos autorizados. Voltar ao concurso, usar **Actualizar op??es da biblioteca** e seleccionar os anexos. Marcar públicos só os destinados à divulgação; os restantes permanecem privados.
+3. Guardar, escolher **Anúncio inicial** e **Preparar prévia pública**. Rever contacto, documentos, objecto, entidade, prazo e canal. O motivo público aparece também no histórico público. Usar **Publicar este acto**; alterações posteriores no rascunho não modificam essa publicação.
+4. Para esclarecer ou rectificar, guardar a revisão e publicar o acto correspondente. Para prorrogar, indicar o novo prazo, guardar e escolher **Prorrogação**; a prévia mostra o anterior e o novo. Resultados/adjudicação ou cancelamento usam o mesmo percurso, com texto e motivo documentados. O fim do prazo muda a indicação para **Prazo de propostas terminado**, sem adjudicação automática. Actos informativos posteriores não reabrem um cancelamento/resultado.
+5. Consultar o [catálogo público QA](http://127.0.0.1:18580/concursos); pesquisar por palavras, entidade, sector, localização, modalidade, financiador, regime, estado ou prazo. Abrir a ficha e **Voltar aos resultados** conserva os filtros. **Relatório imprimível → Imprimir anúncio** permite imprimir/guardar pelo navegador, incluindo o histórico dos actos.
+6. Uma conta simples usa **Acompanhar concurso** e **Os concursos que acompanho**; a lista identifica novos actos desde o registo de acompanhamento. **Deixar de acompanhar** remove só a subscrição individual. Não há promessa de alerta por email nem entrega de proposta: apresentar a proposta no canal indicado no anúncio/documentos.
+
+Publicação documental disponibiliza um original da biblioteca; publicação de oportunidade disponibiliza a ficha pelas etapas existentes; divulgação promocional divulga o resumo autorizado de preparação; publicação de concurso divulga um procedimento e os seus actos. Nenhuma dessas operações autoriza execução do investimento, activa cálculo fiscal ou comprova envio à UFSA/financiador.
+
+### Evidência e limites de entrega
+
+Base exclusivamente QA `simulator_fiscal_delta_20261021_test`, porta 55441, com guarda de identidade antes de migração/fixtures/testes. Testes transaccionais preservam a base existente. `ProcurementTest` cobre preparação incompleta, concorrência, três regimes, filtros e retorno, prévia desactualizada, publicação anónima, prorrogação, expiração sem adjudicação, resultados, cancelamento persistente, conta simples, recusas de escrita, scan real, anexo privado, download anónimo e retirada documental. Ver resultado final em `outputs/procurement-20261022/tests.log`. Navegação existente: seis testes aprovados; compilação QA e TypeScript aprovados.
+
+Exemplos persistentes, todos **SINTÉTICO QA**, sem concursos reais: estatal `17f44d8d-d077-4dca-9a63-5eb1c85a6926` (inicial + prorrogação, prazo de 20/11 15h para 16/12 17h, Africa/Maputo); externo `e85861cd-ffbb-4348-9cce-d588a6e09b86` (aberto); privado `fe271b02-0bfd-4fe2-8db2-856567b5111f` (inicial + cancelamento). Os rascunhos foram preparados por fixture identificada; guardar/reabrir, prévias e publicações destes actos foram executados no navegador pela conta sintética existente, não por comando de publicação. Resultado/adjudicação e expiração foram demonstrados pelos testes HTTP, não por adjudicação de um registo real.
+
+Consulta anónima confirmada depois de terminar sessão: três regimes/estados no catálogo, ficha prorrogada, filtros preservados, relatório e download real do anexo 38. Anexo 39 e contacto sem autorização ficam privados. Acompanhamento confirmado pela conta simples já existente, sem MFA ou papel editorial. Evidências locais: `management.png`, `catalogue.png`, `print-notice.png`, `watch.png`, `fixtures.json` e resultados em `outputs/procurement-20261022/`. Estes artefactos QA não contêm credenciais na entrega Git.
+
+Operações correntes deste incremento são feitas pela interface: cadastrar, guardar, recuperar, carregar/editar documentos pela biblioteca, pré-visualizar, publicar, actualizar/prorrogar, publicar resultado/cancelamento, consultar/imprimir e acompanhar. Não requerem executor privado ou Codex por concurso. **Pendente técnico de disponibilização fora de QA:** revisão e integração Git, migração pelo mecanismo de entrega e activação de `PROCUREMENT_ENABLED` no destino autorizado. O código usa flag desligada por defeito. Nenhum anúncio real foi publicado, nenhuma base activa foi migrada e não há integração automática com UFSA/financiadores. Geometrias não foram alteradas.
diff --git a/resources/js/components/portal-grouped-menu.tsx b/resources/js/components/portal-grouped-menu.tsx
index 9e24c967..54567d10 100644
--- a/resources/js/components/portal-grouped-menu.tsx
+++ b/resources/js/components/portal-grouped-menu.tsx
@@ -48,6 +48,7 @@ export function PortalGroupedMenu() {
existingBusinessEnabled?: boolean;
businessReviewUrl?: string | null;
educationEnabled?: boolean;
+ procurementEnabled?: boolean;
aiEnabled?: boolean;
legacyContentEnabled?: boolean;
profileAccess?: ProfileAccess | null;
@@ -66,6 +67,7 @@ export function PortalGroupedMenu() {
businesses: props.existingBusinessEnabled,
businessReviewUrl: props.businessReviewUrl,
education: props.educationEnabled,
+ procurement: props.procurementEnabled,
ai: props.aiEnabled,
legacyContent: props.legacyContentEnabled,
opportunityManagement: props.opportunityManagement,
diff --git a/resources/js/lib/portal-navigation.ts b/resources/js/lib/portal-navigation.ts
index 7a453cdb..a38371f9 100644
--- a/resources/js/lib/portal-navigation.ts
+++ b/resources/js/lib/portal-navigation.ts
@@ -8,6 +8,7 @@ export type PortalNavItem = {
children?: PortalNavItem[];
};
export type PortalNavAccess = {
+ procurement?: boolean;
authenticated: boolean;
manager: boolean;
businesses?: boolean;
@@ -461,6 +462,7 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
),
link('sectors', 'Sectores de investimento', '/sectores'),
link('locations', 'Localizações', '/oportunidades?view=map'),
+ ...(access.procurement ? [link('procurement','Concursos e anúncios','/concursos')] : []),
{
id: 'related-catalogues',
label: 'Catálogos relacionados',
diff --git a/resources/js/pages/management/content-hub.tsx b/resources/js/pages/management/content-hub.tsx
index 9c254dbe..428ba1f2 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 = {
+ procurement?: {index:string;create:string};
opportunities?: {index: string; create: string | null; profileWorkflow: boolean};
documents?: {index: string; edit: boolean};
};
@@ -10,6 +11,7 @@ export default function ContentHub({access, counts}: {access: Access; counts: Re
return
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.
+ {access.procurement &&
Concursos e anúncios
Cadastre o procedimento e os lotes, guarde e recupere a preparação, reveja a prévia e publique os actos autorizados. Esclarecimentos, prorrogações, resultados e cancelamentos conservam versões e histórico. Divulgação de oportunidade e abertura de concurso são operações distintas.
Novo concurso/anúncioGerir concursos e anúnciosCatálogo público}
{access.opportunities &&
Oportunidades
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.
+ Os campos destinam-se à divulgação após revisão da prévia,
+ excepto as notas internas, os anexos não públicos e o contacto
+ sem autorização. O vínculo a um cadastro privado não publica os
+ seus dados.
+
+
+ O rascunho pode ficar incompleto. Alterações guardadas não
+ substituem o acto público; reveja a prévia e publique
+ explicitamente. Publicar concurso não publica a oportunidade nem
+ recebe propostas.
+
+
+ {record
+ ? `Versão guardada ${record.version}. ${saved ? 'Preparação recuperada do servidor.' : 'Há alterações por guardar.'}`
+ : 'Preparação ainda não guardada.'}
+
+ Guardar → recuperar → pré-visualizar → publicar →
+ actualizar/prorrogar → publicar resultado ou
+ cancelamento. Carregue documentos na biblioteca
+ existente, acompanhe o scan e autorize separadamente os
+ anexos públicos. O rascunho pode ficar incompleto.
+
+
+ Para actualizar, reabra o concurso, guarde as
+ alterações, escolha o acto e confira a prévia. A data de
+ publicação no portal não substitui a data de origem.
+ Acompanhar não apresenta proposta.
+
+ {states[item.state ?? '']} ·{' '}
+ {item.changed
+ ? 'Há novos actos desde que começou a acompanhar'
+ : 'Sem novos actos desde o registo de acompanhamento'}
+
+
+ O acompanhamento é consultado nesta área; não
+ afirma envio de alertas por email.
+
+
+ Consultar concurso
+
+ >
+ ) : (
+ <>
+
+ Versão {item.version} ·{' '}
+ {item.published
+ ? 'Com acto publicado; alterações em preparação são separadas'
+ : 'Rascunho privado'}
+
+
+ Recuperar e editar
+
+ {item.published && (
+
+ Consultar publicação
+
+ )}
+ >
+ )}
+
+ ))}
+
+ );
+}
diff --git a/resources/js/pages/procurement/shared.tsx b/resources/js/pages/procurement/shared.tsx
new file mode 100644
index 00000000..a86a89e8
--- /dev/null
+++ b/resources/js/pages/procurement/shared.tsx
@@ -0,0 +1,253 @@
+import type { ReactNode } from 'react';
+
+export type Lot = { code: string; description: string; quantities: string };
+export type Attachment = {
+ resource_id: number;
+ public: boolean;
+ purpose: string;
+ title?: string;
+ available?: boolean;
+};
+export type Draft = {
+ [key: string]: string | number | boolean | null | Lot[] | Attachment[];
+};
+export type Payload = Draft & {
+ opportunity?: { title: string; url: string } | null;
+};
+export type Publication = {
+ id: string;
+ version: number;
+ act: string;
+ payload: Payload;
+ published_at: string;
+ reason: string;
+};
+export const states: Record = {
+ open: 'Aberto',
+ extended: 'Prorrogado',
+ deadline_ended: 'Prazo de propostas terminado',
+ cancelled: 'Cancelado',
+ results: 'Resultados publicados',
+};
+export const acts: Record = {
+ initial: 'Anúncio inicial',
+ clarification: 'Esclarecimento',
+ correction: 'Rectificação',
+ extension: 'Prorrogação',
+ award: 'Resultado / adjudicação',
+ cancellation: 'Cancelamento',
+ other: 'Outro acto',
+};
+export const labels: Record = {
+ reference: 'Referência',
+ object: 'Objecto',
+ responsible_unit: 'Unidade responsável / UGEA quando aplicável',
+ modality: 'Modalidade',
+ object_kind: 'Natureza do objecto',
+ quantities: 'Quantidades essenciais',
+ financier: 'Financiador',
+ instrument: 'Instrumento aplicável',
+ edition: 'Edição aplicável',
+ rule_source: 'Fonte das regras',
+ financing_agreement: 'Acordo de financiamento',
+ eligibility: 'Elegibilidade',
+ requirements: 'Requisitos de participação',
+ proposal_validity: 'Validade da proposta',
+ guarantee_status: 'Aplicabilidade de garantias',
+ guarantees: 'Garantias documentadas',
+ delivery_channel: 'Canal de entrega',
+ delivery_address: 'Endereço / instruções para apresentar proposta',
+ delivery_url: 'Plataforma de entrega',
+ public_contact: 'Contacto público autorizado',
+ dissemination: 'Meios de divulgação exigidos / adoptados e comprovativos',
+ origin_url: 'Anúncio de origem',
+ origin_published_at: 'Publicação de origem — referência do prazo',
+ clarifications_at: 'Limite para esclarecimentos',
+ deadline: 'Entrega de propostas',
+ opening_at: 'Abertura (quando aplicável)',
+ timezone: 'Fuso horário do procedimento',
+ minimum_days: 'Prazo mínimo documentado (dias)',
+ minimum_source: 'Fonte / citação do prazo mínimo',
+ minimum_edition: 'Edição da regra de prazo',
+ minimum_modality: 'Modalidade da regra de prazo',
+ minimum_day_basis: 'Base de contagem do prazo',
+ minimum_calendar_source: 'Fonte do calendário de dias úteis',
+ minimum_working_days: 'Dias de trabalho (1=segunda, 7=domingo)',
+ minimum_holidays: 'Feriados e exclusões documentados',
+ result: 'Acto ou resultado documentado',
+};
+export const control =
+ 'w-full rounded border border-slate-400 bg-background p-2 text-foreground';
+export function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+export function NoticeContent({ p }: { p: Payload }) {
+ const links = ['rule_source', 'origin_url', 'delivery_url'];
+ const names: Record = {
+ works: 'Obras',
+ goods: 'Bens',
+ services: 'Serviços',
+ mixed: 'Misto',
+ other: 'Outro',
+ required: 'Exigidas',
+ not_required: 'Não exigidas',
+ unknown: 'Por determinar',
+ calendar: 'Dias corridos',
+ business: 'Dias úteis',
+ };
+ return (
+
+ {!((p.attachments as Attachment[]) ?? []).length && (
+
+ Sem anexos públicos neste acto. Consulte o canal e o
+ anúncio de origem.
+
+ )}
+
+
+ O InvesteMoz divulga este acto e não recebe propostas nesta
+ secção. Apresente a proposta no canal indicado nos documentos.
+ Esta divulgação não substitui os meios oficiais exigidos,
+ não comprova envio à UFSA ou ao financiador e não equivale à
+ execução do investimento.
+