From 1bcf34c0c0f5273ad421e43a1948f285aa59c02b Mon Sep 17 00:00:00 2001 From: nala Date: Thu, 22 Oct 2026 22:41:28 +0200 Subject: [PATCH] feat: add QA tender and announcement publishing workflow --- .../Controllers/ProcurementController.php | 138 +++ app/Http/Middleware/HandleInertiaRequests.php | 1 + app/Services/ContentManagement.php | 1 + app/Services/Procurement.php | 287 ++++++ config/procurement.php | 7 + ...026_10_22_230000_create_portal_tenders.php | 50 ++ .../consolidated-review-matrix-2026-10-03.md | 4 + .../unification/traceability-and-manual.md | 38 + .../js/components/portal-grouped-menu.tsx | 2 + resources/js/lib/portal-navigation.ts | 2 + resources/js/pages/management/content-hub.tsx | 2 + resources/js/pages/procurement/edit.tsx | 832 ++++++++++++++++++ resources/js/pages/procurement/index.tsx | 196 +++++ resources/js/pages/procurement/manage.tsx | 106 +++ resources/js/pages/procurement/shared.tsx | 253 ++++++ resources/js/pages/procurement/show.tsx | 89 ++ routes/procurement.php | 20 + routes/web.php | 2 + tests/Feature/ProcurementTest.php | 217 +++++ 19 files changed, 2247 insertions(+) create mode 100644 app/Http/Controllers/ProcurementController.php create mode 100644 app/Services/Procurement.php create mode 100644 config/procurement.php create mode 100644 database/migrations/2026_10_22_230000_create_portal_tenders.php create mode 100644 resources/js/pages/procurement/edit.tsx create mode 100644 resources/js/pages/procurement/index.tsx create mode 100644 resources/js/pages/procurement/manage.tsx create mode 100644 resources/js/pages/procurement/shared.tsx create mode 100644 resources/js/pages/procurement/show.tsx create mode 100644 routes/procurement.php create mode 100644 tests/Feature/ProcurementTest.php 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.

diff --git a/resources/js/pages/procurement/edit.tsx b/resources/js/pages/procurement/edit.tsx new file mode 100644 index 00000000..45f5032c --- /dev/null +++ b/resources/js/pages/procurement/edit.tsx @@ -0,0 +1,832 @@ +import { Link, router, useForm } from '@inertiajs/react'; +import { useEffect, useState } from 'react'; +import { Frame, card, button } from '@/pages/investment/shared'; +import { + Field, + control, + labels, + acts, + NoticeContent, + type Draft, + type Lot, + type Attachment, + type Payload, +} from './shared'; +type Choice = { + id: string | number; + name?: string; + title?: string; + location_type?: string; + published?: boolean; + malware_scan_status?: string; +}; +type References = { + regimes: Record; + acts: Record; + organizations: Choice[]; + sectors: Choice[]; + territories: Choice[]; + opportunities: Choice[]; + projects: Choice[]; + documents: Choice[]; +}; +type RecordData = { + id: string; + version: number; + draft: Draft; + published: boolean; +}; +type Plan = { + id: string; + version: number; + act: string; + payload: Payload; + hash: string; +}; +const dates = [ + 'origin_published_at', + 'clarifications_at', + 'deadline', + 'opening_at', +]; +const groups: [string, string[]][] = [ + [ + 'Identificação e objecto', + ['title', 'reference', 'modality', 'object', 'quantities'], + ], + [ + 'Regime e financiamento', + [ + 'financier', + 'instrument', + 'edition', + 'rule_source', + 'financing_agreement', + ], + ], + ['Participação', ['eligibility', 'requirements', 'proposal_validity']], + [ + 'Calendário e entrega', + [ + 'origin_published_at', + 'clarifications_at', + 'deadline', + 'opening_at', + 'delivery_channel', + 'delivery_address', + 'delivery_url', + ], + ], + ['Divulgação e contacto', ['origin_url', 'dissemination']], +]; +export default function Edit({ + record, + references, + history, +}: { + record: RecordData | null; + references: References; + history: { + version: number; + act: string; + reason: string; + actor_id: number; + published_at: string | null; + created_at: string; + }[]; +}) { + const empty: Draft = { + timezone: 'Africa/Maputo', + lots: [], + attachments: [], + contact_authorized: false, + }; + const form = useForm({ + version: record?.version ?? 0, + reason: '', + draft: record?.draft ?? empty, + }); + const [act, setAct] = useState( + record?.published ? 'clarification' : 'initial', + ); + const [preview, setPreview] = useState(null); + const [message, setMessage] = useState(''); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(true); + useEffect(() => { + form.setData({ + version: record?.version ?? 0, + reason: '', + draft: record?.draft ?? empty, + }); + setPreview(null); + setSaved(true); + }, [record?.id, record?.version]); // A server version is the recovery boundary. + const change = (key: string, value: Draft[string]) => { + form.setData('draft', { ...form.data.draft, [key]: value }); + setPreview(null); + setSaved(false); + }; + const value = (key: string) => String(form.data.draft[key] ?? ''); + const select = (key: string, label: string, choices: Choice[]) => ( + + + + ); + const input = (key: string, label?: string) => ( + + {dates.includes(key) ? ( + change(key, e.target.value)} + /> + ) : ( +