feat: add QA tender and announcement publishing workflow
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Procurement;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProcurementController extends Controller
|
||||
{
|
||||
private const STATE_SQL = "CASE WHEN v.payload->>'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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** Publication of notices; never receives bids or changes the linked investment. */
|
||||
final class Procurement
|
||||
{
|
||||
public const TEXT = ['title', 'reference', 'responsible_unit', 'modality', 'object', 'object_kind', 'quantities', 'financier', 'instrument', 'edition', 'rule_source', 'financing_agreement', 'eligibility', 'requirements', 'proposal_validity', 'guarantee_status', 'guarantees', 'delivery_channel', 'delivery_address', 'delivery_url', 'public_contact', 'dissemination', 'origin_url', 'origin_published_at', 'clarifications_at', 'deadline', 'opening_at', 'timezone', 'minimum_source', 'minimum_edition', 'minimum_modality', 'minimum_day_basis', 'result', 'internal_notes', 'public_reason', 'minimum_working_days', 'minimum_holidays', 'minimum_calendar_source'];
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return config('procurement.enabled') && Schema::hasTable('portal_tenders');
|
||||
}
|
||||
|
||||
public function access(?User $actor): bool
|
||||
{
|
||||
if (! $actor || ! $actor->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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => (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'],
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('portal_tenders', function (Blueprint $t) {
|
||||
$t->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');
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 <Frame title="Gestão de conteúdos e oportunidades">
|
||||
<p>Use os cadastros e a biblioteca existentes. Guardar um rascunho, analisar um ficheiro e publicar são operações distintas. Cada acção respeita a sua autorização actual.</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{access.procurement && <section className={card}><h2 className="text-xl font-semibold">Concursos e anúncios</h2><p>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.</p><Link className={button} href={access.procurement.create}>Novo concurso/anúncio</Link><Link className="block underline" href={access.procurement.index}>Gerir concursos e anúncios</Link><Link className="block underline" href="/concursos">Catálogo público</Link></section>}
|
||||
{access.opportunities && <section className={card}>
|
||||
<h2 className="text-xl font-semibold">Oportunidades</h2>
|
||||
<p>Escolha a modalidade, o sector e a localização; preencha, guarde o rascunho e siga as acções de submissão, revisão e publicação disponíveis no seu perfil.</p>
|
||||
|
||||
@@ -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<string, string>;
|
||||
acts: Record<string, string>;
|
||||
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<Plan | null>(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[]) => (
|
||||
<Field label={label}>
|
||||
<select
|
||||
className={control}
|
||||
value={value(key)}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
key,
|
||||
e.target.value
|
||||
? key === 'sector_id' || key === 'opportunity_id'
|
||||
? Number(e.target.value)
|
||||
: e.target.value
|
||||
: null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{choices.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name ?? o.title}
|
||||
{o.location_type ? ` · ${o.location_type}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
);
|
||||
const input = (key: string, label?: string) => (
|
||||
<Field key={key} label={label ?? labels[key] ?? key}>
|
||||
{dates.includes(key) ? (
|
||||
<input
|
||||
type="datetime-local"
|
||||
className={control}
|
||||
value={value(key)}
|
||||
onChange={(e) => change(key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className={control}
|
||||
rows={key === 'object' ? 4 : 2}
|
||||
value={value(key)}
|
||||
onChange={(e) => change(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
const lots = (form.data.draft.lots ?? []) as Lot[];
|
||||
const attachments = (form.data.draft.attachments ?? []) as Attachment[];
|
||||
const makePreview = async () => {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const token = document.cookie
|
||||
.split('; ')
|
||||
.find((v) => v.startsWith('XSRF-TOKEN='))
|
||||
?.slice(11);
|
||||
const response = await fetch(
|
||||
`/management/concursos/${record!.id}/previa`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(token ?? ''),
|
||||
},
|
||||
body: JSON.stringify({ act }),
|
||||
},
|
||||
);
|
||||
const body = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
Object.values(body.errors ?? {})
|
||||
.flat()
|
||||
.join(' ') ||
|
||||
body.message ||
|
||||
'Não foi possível preparar a prévia.',
|
||||
);
|
||||
setPreview(body);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error ? error.message : 'Erro de ligação.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Frame
|
||||
title={record ? 'Editar concurso/anúncio' : 'Novo concurso/anúncio'}
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link className="underline" href="/management/concursos">
|
||||
Recuperar concursos
|
||||
</Link>
|
||||
<Link className="underline" href="/gestao-conteudos">
|
||||
Entrada única de gestão
|
||||
</Link>
|
||||
<a
|
||||
className="underline"
|
||||
href="/management/portal-services?intent=document#receber"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Carregar / gerir documentos e scan na biblioteca (outra
|
||||
página)
|
||||
</a>
|
||||
<button
|
||||
className="underline"
|
||||
type="button"
|
||||
onClick={() => router.reload({ only: ['references'] })}
|
||||
>
|
||||
Actualizar opções da biblioteca
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p role="status">
|
||||
{record
|
||||
? `Versão guardada ${record.version}. ${saved ? 'Preparação recuperada do servidor.' : 'Há alterações por guardar.'}`
|
||||
: 'Preparação ainda não guardada.'}
|
||||
</p>
|
||||
{!!Object.keys(form.errors).length && (
|
||||
<div role="alert">
|
||||
{Object.values(form.errors).map((error, i) => (
|
||||
<p key={i}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPreview(null);
|
||||
record
|
||||
? form.put(`/management/concursos/${record.id}`)
|
||||
: form.post('/management/concursos');
|
||||
}}
|
||||
>
|
||||
<section className={`${card} grid gap-3 md:grid-cols-2`}>
|
||||
{select(
|
||||
'organization_id',
|
||||
'Entidade contratante existente',
|
||||
references.organizations,
|
||||
)}
|
||||
{select(
|
||||
'opportunity_id',
|
||||
'Oportunidade relacionada existente',
|
||||
references.opportunities,
|
||||
)}
|
||||
{select(
|
||||
'project_id',
|
||||
'Projecto existente',
|
||||
references.projects,
|
||||
)}
|
||||
{select('sector_id', 'Sector', references.sectors)}
|
||||
{select(
|
||||
'territory_id',
|
||||
'Localização existente',
|
||||
references.territories,
|
||||
)}
|
||||
<p>
|
||||
Os vínculos reutilizam os cadastros; não criam nova
|
||||
entidade, oportunidade, projecto ou limite. Referências
|
||||
privadas não são transformadas em conteúdo público.
|
||||
</p>
|
||||
</section>
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Regime do procedimento
|
||||
</h2>
|
||||
<Field label="Regime">
|
||||
<select
|
||||
className={control}
|
||||
value={value('regime')}
|
||||
onChange={(e) => change('regime', e.target.value)}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{Object.entries(references.regimes).map(
|
||||
([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</Field>
|
||||
{value('regime') === 'state' ? (
|
||||
<>
|
||||
<p>
|
||||
Contratação do Estado: identifique a modalidade,
|
||||
o instrumento e a edição aplicáveis.{' '}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://www.ufsa.gov.mz/divnovoreg.php"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
UFSA — Decreto n.º 79/2022
|
||||
</a>
|
||||
. A referência não atribui automaticamente
|
||||
requisitos ou prazos a todos os procedimentos.
|
||||
</p>
|
||||
{input(
|
||||
'responsible_unit',
|
||||
'UGEA / unidade responsável quando aplicável',
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{value('regime') === 'external' ? (
|
||||
<>
|
||||
<a
|
||||
className="underline"
|
||||
href="https://www.worldbank.org/ext/en/what-we-do/project-procurement/framework"
|
||||
>
|
||||
Quadro e documentos normalizados do
|
||||
Banco Mundial
|
||||
</a>{' '}
|
||||
são referências; indique a edição e o
|
||||
acordo efectivamente aplicáveis.
|
||||
</>
|
||||
) : (
|
||||
'Use as regras documentadas da entidade e deste procedimento.'
|
||||
)}{' '}
|
||||
UGEA, Cadastro Único, garantias e abertura
|
||||
pública não são impostos a este regime.
|
||||
</p>
|
||||
{input(
|
||||
'responsible_unit',
|
||||
'Unidade responsável (quando aplicável)',
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Field label="Obras, bens ou serviços">
|
||||
<select
|
||||
className={control}
|
||||
value={value('object_kind')}
|
||||
onChange={(e) =>
|
||||
change('object_kind', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{Object.entries({
|
||||
works: 'Obras',
|
||||
goods: 'Bens',
|
||||
services: 'Serviços',
|
||||
mixed: 'Misto',
|
||||
other: 'Outro',
|
||||
}).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</section>
|
||||
{groups.map(([title, fields]) => (
|
||||
<section className={card} key={title}>
|
||||
<h2 className="mb-3 text-xl font-semibold">{title}</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{fields.map((key) =>
|
||||
input(
|
||||
key,
|
||||
key === 'title'
|
||||
? 'Título do concurso/anúncio'
|
||||
: undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<section className={`${card} space-y-3`}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Garantias e prazo mínimo documentado
|
||||
</h2>
|
||||
<Field label="Garantias exigidas neste procedimento">
|
||||
<select
|
||||
className={control}
|
||||
value={value('guarantee_status')}
|
||||
onChange={(e) =>
|
||||
change('guarantee_status', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="unknown">
|
||||
Por determinar nos documentos
|
||||
</option>
|
||||
<option value="required">
|
||||
Exigidas nos documentos
|
||||
</option>
|
||||
<option value="not_required">Não exigidas</option>
|
||||
</select>
|
||||
</Field>
|
||||
{value('guarantee_status') === 'required' &&
|
||||
input('guarantees')}
|
||||
<Field label="Fuso horário IANA do procedimento">
|
||||
<input
|
||||
className={control}
|
||||
value={value('timezone')}
|
||||
onChange={(e) => change('timezone', e.target.value)}
|
||||
placeholder="Africa/Maputo"
|
||||
/>
|
||||
</Field>
|
||||
<p>
|
||||
As datas e horas introduzidas pertencem a este fuso.
|
||||
Publicação no InvesteMoz é registada separadamente pelo
|
||||
servidor; não reinicia o prazo.
|
||||
</p>
|
||||
<Field label="Prazo mínimo documentado em dias (opcional)">
|
||||
<input
|
||||
className={control}
|
||||
type="number"
|
||||
min="0"
|
||||
value={value('minimum_days')}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
'minimum_days',
|
||||
e.target.value === ''
|
||||
? null
|
||||
: Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{form.data.draft.minimum_days !== null &&
|
||||
form.data.draft.minimum_days !== undefined && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{input('minimum_source')}
|
||||
{input('minimum_edition')}
|
||||
{input('minimum_modality')}
|
||||
<Field label="Contagem documentada">
|
||||
<select
|
||||
className={control}
|
||||
value={value('minimum_day_basis')}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
'minimum_day_basis',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
<option value="calendar">
|
||||
Dias corridos
|
||||
</option>
|
||||
<option value="business">
|
||||
Dias úteis — calendário documentado
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
{value('minimum_day_basis') === 'business' && (
|
||||
<>
|
||||
{input(
|
||||
'minimum_calendar_source',
|
||||
'Fonte do calendário aplicável e abrangência de feriados',
|
||||
)}
|
||||
{input(
|
||||
'minimum_working_days',
|
||||
'Dias de trabalho documentados: 1=segunda a 7=domingo, separados por vírgula',
|
||||
)}
|
||||
{input(
|
||||
'minimum_holidays',
|
||||
'Feriados / exclusões documentados (AAAA-MM-DD, separados por espaço ou vírgula)',
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
Sem prazo parametrizado não é presumido um mínimo geral.
|
||||
A contagem em dias úteis usa exclusivamente os dias de
|
||||
trabalho e exclusões do calendário documentado neste
|
||||
registo; não adopta um calendário nacional por
|
||||
suposição.
|
||||
</p>
|
||||
</section>
|
||||
<section className={`${card} space-y-3`}>
|
||||
<h2 className="text-xl font-semibold">Lotes</h2>
|
||||
{lots.map((lot, i) => (
|
||||
<fieldset
|
||||
className="grid gap-3 rounded border p-3 md:grid-cols-3"
|
||||
key={i}
|
||||
>
|
||||
<legend>Lote {i + 1}</legend>
|
||||
{(
|
||||
['code', 'description', 'quantities'] as const
|
||||
).map((key) => (
|
||||
<Field
|
||||
key={key}
|
||||
label={
|
||||
{
|
||||
code: 'Referência do lote',
|
||||
description: 'Objecto do lote',
|
||||
quantities: 'Quantidades do lote',
|
||||
}[key]
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
className={control}
|
||||
value={lot[key]}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
'lots',
|
||||
lots.map((r, n) =>
|
||||
n === i
|
||||
? {
|
||||
...r,
|
||||
[key]: e.target
|
||||
.value,
|
||||
}
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
change(
|
||||
'lots',
|
||||
lots.filter((_, n) => n !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar lote do rascunho
|
||||
</button>
|
||||
</fieldset>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
change('lots', [
|
||||
...lots,
|
||||
{ code: '', description: '', quantities: '' },
|
||||
])
|
||||
}
|
||||
>
|
||||
Acrescentar lote
|
||||
</button>
|
||||
</section>
|
||||
<section className={`${card} space-y-3`}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Documentos e anexos
|
||||
</h2>
|
||||
<p>
|
||||
Carregue e classifique os originais na biblioteca
|
||||
existente pelo link acima. Reutilize-os aqui: anúncio,
|
||||
caderno, termos de referência, formulários, anexos ou
|
||||
comprovativos. Publicar exige que cada anexo marcado
|
||||
público já esteja autorizado, publicado e com scan limpo
|
||||
na biblioteca. Os restantes ficam privados no cadastro.
|
||||
</p>
|
||||
{attachments.map((item, i) => (
|
||||
<fieldset
|
||||
key={i}
|
||||
className="space-y-2 rounded border p-3"
|
||||
>
|
||||
<legend>Anexo {i + 1}</legend>
|
||||
<Field label={`Documento do anexo ${i + 1}`}>
|
||||
<select
|
||||
className={control}
|
||||
value={item.resource_id || ''}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
'attachments',
|
||||
attachments.map((r, n) =>
|
||||
n === i
|
||||
? {
|
||||
...r,
|
||||
resource_id: Number(
|
||||
e.target.value,
|
||||
),
|
||||
}
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione da biblioteca
|
||||
</option>
|
||||
{references.documents.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.title} · {d.malware_scan_status}{' '}
|
||||
·{' '}
|
||||
{d.published
|
||||
? 'publicado'
|
||||
: 'privado / rascunho'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={`Finalidade do anexo ${i + 1}`}>
|
||||
<input
|
||||
className={control}
|
||||
value={item.purpose}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
'attachments',
|
||||
attachments.map((r, n) =>
|
||||
n === i
|
||||
? {
|
||||
...r,
|
||||
purpose:
|
||||
e.target.value,
|
||||
}
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.public}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
'attachments',
|
||||
attachments.map((r, n) =>
|
||||
n === i
|
||||
? {
|
||||
...r,
|
||||
public: e.target
|
||||
.checked,
|
||||
}
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
Incluir este anexo na divulgação pública
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 underline"
|
||||
onClick={() =>
|
||||
change(
|
||||
'attachments',
|
||||
attachments.filter((_, n) => n !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar anexo do rascunho
|
||||
</button>
|
||||
</fieldset>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
change('attachments', [
|
||||
...attachments,
|
||||
{ resource_id: 0, purpose: '', public: false },
|
||||
])
|
||||
}
|
||||
>
|
||||
Reutilizar documento
|
||||
</button>
|
||||
</section>
|
||||
<section className={`${card} space-y-3`}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Contacto e acto a divulgar
|
||||
</h2>
|
||||
{input('public_contact')}
|
||||
<label className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(
|
||||
form.data.draft.contact_authorized,
|
||||
)}
|
||||
onChange={(e) =>
|
||||
change('contact_authorized', e.target.checked)
|
||||
}
|
||||
/>{' '}
|
||||
Contacto autorizado para divulgação pública
|
||||
</label>
|
||||
{input('result')}
|
||||
{input(
|
||||
'public_reason',
|
||||
'Motivo público do acto — aparece na prévia e no histórico público',
|
||||
)}
|
||||
{input(
|
||||
'internal_notes',
|
||||
'Notas internas — nunca incluídas na divulgação',
|
||||
)}
|
||||
<Field label="Motivo interno da gravação (20–1000 caracteres)">
|
||||
<textarea
|
||||
className={control}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<button className={button} disabled={form.processing}>
|
||||
Guardar rascunho
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
{record && (
|
||||
<section className={`${card} my-5 space-y-3`}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Pré-visualizar e publicar
|
||||
</h2>
|
||||
<Field label="Acto a publicar">
|
||||
<select
|
||||
className={control}
|
||||
value={act}
|
||||
onChange={(e) => {
|
||||
setAct(e.target.value);
|
||||
setPreview(null);
|
||||
}}
|
||||
>
|
||||
{Object.entries(references.acts).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<button
|
||||
className={button}
|
||||
disabled={!saved || busy}
|
||||
onClick={makePreview}
|
||||
>
|
||||
Preparar prévia pública
|
||||
</button>
|
||||
<p>
|
||||
Guarde antes da prévia. Uma prorrogação identifica o
|
||||
prazo anterior e o novo; o fim do prazo não é
|
||||
adjudicação.
|
||||
</p>
|
||||
{message && <p role="alert">{message}</p>}
|
||||
{preview && (
|
||||
<div className="space-y-3 rounded border p-4">
|
||||
<h3 className="text-xl font-semibold">
|
||||
Prévia do conteúdo público — {acts[preview.act]}
|
||||
</h3>
|
||||
<p>
|
||||
Motivo público:{' '}
|
||||
{String(preview.payload.public_reason)}
|
||||
</p>
|
||||
<NoticeContent p={preview.payload} />
|
||||
<button
|
||||
className={button}
|
||||
onClick={() =>
|
||||
router.post(
|
||||
`/management/concursos/${record.id}/publicar`,
|
||||
{
|
||||
act: preview.act,
|
||||
hash: preview.hash,
|
||||
reason: String(
|
||||
preview.payload.public_reason,
|
||||
),
|
||||
},
|
||||
{ onSuccess: () => setPreview(null) },
|
||||
)
|
||||
}
|
||||
>
|
||||
Publicar este acto
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{record.published && (
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`/concursos/${record.id}`}
|
||||
>
|
||||
Consultar anúncio público
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Histórico de preparação e publicação
|
||||
</h2>
|
||||
{history.map((v) => (
|
||||
<p key={v.version}>
|
||||
Versão {v.version} ·{' '}
|
||||
{v.act === 'draft' ? 'Rascunho' : acts[v.act]} ·
|
||||
responsável {v.actor_id} ·{' '}
|
||||
{v.published_at ?? v.created_at} · {v.reason}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { Field, control, states, type Publication } from './shared';
|
||||
type Choice = { id: string; name: string };
|
||||
export default function Index({
|
||||
items,
|
||||
filters,
|
||||
options,
|
||||
regimes,
|
||||
management,
|
||||
}: {
|
||||
items: {
|
||||
data: { id: string; publication: Publication; state: string }[];
|
||||
current_page: number;
|
||||
links: { url: string | null; label: string; active: boolean }[];
|
||||
};
|
||||
filters: Record<string, string>;
|
||||
options: Record<string, Choice[]>;
|
||||
regimes: Record<string, string>;
|
||||
management: boolean;
|
||||
}) {
|
||||
const form = useForm({
|
||||
q: filters.q ?? '',
|
||||
entity: filters.entity ?? '',
|
||||
sector: filters.sector ?? '',
|
||||
territory: filters.territory ?? '',
|
||||
modality: filters.modality ?? '',
|
||||
financier: filters.financier ?? '',
|
||||
state: filters.state ?? '',
|
||||
deadline_before: filters.deadline_before ?? '',
|
||||
regime: filters.regime ?? '',
|
||||
});
|
||||
const query = new URLSearchParams(
|
||||
Object.entries({ ...filters, page: String(items.current_page) }).filter(
|
||||
([, v]) => v != null && v !== '',
|
||||
),
|
||||
).toString();
|
||||
return (
|
||||
<Frame title="Concursos e anúncios">
|
||||
<p>
|
||||
Procedimentos e actos divulgados pelas entidades responsáveis.
|
||||
Uma oportunidade divulgada não é, por si, um concurso aberto.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link className="underline" href="/concursos/acompanhar">
|
||||
Os concursos que acompanho
|
||||
</Link>
|
||||
{management && (
|
||||
<Link className={button} href="/management/concursos">
|
||||
Gerir concursos e anúncios
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
className={`${card} grid gap-3 md:grid-cols-3`}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.get('/concursos', { preserveState: true });
|
||||
}}
|
||||
>
|
||||
<Field label="Palavras-chave">
|
||||
<input
|
||||
className={control}
|
||||
value={form.data.q}
|
||||
onChange={(e) => form.setData('q', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{(
|
||||
[
|
||||
'entity',
|
||||
'sector',
|
||||
'territory',
|
||||
'modality',
|
||||
'financier',
|
||||
] as const
|
||||
).map((key) => (
|
||||
<Field
|
||||
key={key}
|
||||
label={
|
||||
{
|
||||
entity: 'Entidade',
|
||||
sector: 'Sector',
|
||||
territory: 'Localização',
|
||||
modality: 'Modalidade',
|
||||
financier: 'Financiador',
|
||||
}[key]
|
||||
}
|
||||
>
|
||||
<select
|
||||
className={control}
|
||||
value={form.data[key]}
|
||||
onChange={(e) => form.setData(key, e.target.value)}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{(options[key] ?? []).map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
))}
|
||||
<Field label="Regime">
|
||||
<select
|
||||
className={control}
|
||||
value={form.data.regime}
|
||||
onChange={(e) => form.setData('regime', e.target.value)}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(regimes).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Estado">
|
||||
<select
|
||||
className={control}
|
||||
value={form.data.state}
|
||||
onChange={(e) => form.setData('state', e.target.value)}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(states).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Prazo até (data local do procedimento)">
|
||||
<input
|
||||
className={control}
|
||||
type="date"
|
||||
value={form.data.deadline_before}
|
||||
onChange={(e) =>
|
||||
form.setData('deadline_before', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<button className={button}>Pesquisar</button>
|
||||
</form>
|
||||
{!items.data.length && (
|
||||
<p role="status">Nenhum anúncio corresponde aos filtros.</p>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{items.data.map((item) => (
|
||||
<article key={item.id} className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/concursos/${item.id}${query ? '?' + query : ''}`}
|
||||
>
|
||||
{String(item.publication.payload.title)}
|
||||
</Link>
|
||||
</h2>
|
||||
<p>
|
||||
{String(item.publication.payload.organization_name)}{' '}
|
||||
· {states[item.state]}
|
||||
</p>
|
||||
<p>
|
||||
Entrega:{' '}
|
||||
{String(item.publication.payload.deadline).replace(
|
||||
'T',
|
||||
' ',
|
||||
)}{' '}
|
||||
· {String(item.publication.payload.timezone)}
|
||||
</p>
|
||||
<p>{String(item.publication.payload.object)}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<nav aria-label="Paginação">
|
||||
{items.links.map((link, i) =>
|
||||
link.url ? (
|
||||
<Link
|
||||
key={i}
|
||||
className={`mx-2 underline ${link.active ? 'font-bold' : ''}`}
|
||||
href={link.url}
|
||||
>
|
||||
{link.label
|
||||
.replace('«', '«')
|
||||
.replace('»', '»')}
|
||||
</Link>
|
||||
) : (
|
||||
<span key={i} className="mx-2">
|
||||
{link.label
|
||||
.replace('«', '«')
|
||||
.replace('»', '»')}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { states, type Publication } from './shared';
|
||||
export default function Manage({
|
||||
items,
|
||||
watched = false,
|
||||
}: {
|
||||
items: {
|
||||
id: string;
|
||||
title?: string;
|
||||
version?: number;
|
||||
published?: boolean;
|
||||
publication?: Publication;
|
||||
state?: string;
|
||||
changed?: boolean;
|
||||
}[];
|
||||
watched?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Frame
|
||||
title={
|
||||
watched
|
||||
? 'Concursos que acompanho'
|
||||
: 'Gestão de concursos e anúncios'
|
||||
}
|
||||
>
|
||||
<Link className="underline" href="/concursos">
|
||||
Catálogo público
|
||||
</Link>
|
||||
{!watched && (
|
||||
<>
|
||||
<Link className={button} href="/management/concursos/novo">
|
||||
Novo concurso/anúncio
|
||||
</Link>
|
||||
<Link className="ml-3 underline" href="/gestao-conteudos">
|
||||
Entrada única de gestão
|
||||
</Link>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!items.length && <p>Sem registos.</p>}
|
||||
{items.map((item) => (
|
||||
<article key={item.id} className={`${card} my-3`}>
|
||||
<h2 className="font-semibold">
|
||||
{item.title ?? String(item.publication?.payload.title)}
|
||||
</h2>
|
||||
{watched ? (
|
||||
<>
|
||||
<p>
|
||||
{states[item.state ?? '']} ·{' '}
|
||||
{item.changed
|
||||
? 'Há novos actos desde que começou a acompanhar'
|
||||
: 'Sem novos actos desde o registo de acompanhamento'}
|
||||
</p>
|
||||
<p>
|
||||
O acompanhamento é consultado nesta área; não
|
||||
afirma envio de alertas por email.
|
||||
</p>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/concursos/${item.id}`}
|
||||
>
|
||||
Consultar concurso
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Versão {item.version} ·{' '}
|
||||
{item.published
|
||||
? 'Com acto publicado; alterações em preparação são separadas'
|
||||
: 'Rascunho privado'}
|
||||
</p>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/concursos/${item.id}`}
|
||||
>
|
||||
Recuperar e editar
|
||||
</Link>
|
||||
{item.published && (
|
||||
<Link
|
||||
className="ml-3 underline"
|
||||
href={`/concursos/${item.id}`}
|
||||
>
|
||||
Consultar publicação
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
open: 'Aberto',
|
||||
extended: 'Prorrogado',
|
||||
deadline_ended: 'Prazo de propostas terminado',
|
||||
cancelled: 'Cancelado',
|
||||
results: 'Resultados publicados',
|
||||
};
|
||||
export const acts: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<label className="block space-y-1">
|
||||
<span className="font-medium">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
export function NoticeContent({ p }: { p: Payload }) {
|
||||
const links = ['rule_source', 'origin_url', 'delivery_url'];
|
||||
const names: Record<string, string> = {
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-2xl font-semibold">{String(p.title ?? '')}</h2>
|
||||
<p>
|
||||
<strong>Entidade:</strong>{' '}
|
||||
{String(p.organization_name ?? 'Por indicar')}
|
||||
</p>
|
||||
<p>
|
||||
Regime:{' '}
|
||||
{(
|
||||
{
|
||||
state: 'Contratação pública do Estado',
|
||||
external: 'Financiamento externo',
|
||||
private: 'Concurso privado',
|
||||
other: 'Outro regime documentado',
|
||||
} as Record<string, string>
|
||||
)[String(p.regime)] ?? 'Por indicar'}
|
||||
</p>
|
||||
<section>
|
||||
<h3 className="text-lg font-semibold">
|
||||
Objecto e como participar
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap">{String(p.object ?? '')}</p>
|
||||
<p className="my-2 rounded border p-3">
|
||||
<strong>Entrega:</strong>{' '}
|
||||
{String(p.deadline ?? 'Por indicar').replace('T', ' ')} ·{' '}
|
||||
{String(p.timezone ?? '')}
|
||||
<br />
|
||||
{String(p.delivery_channel ?? '')} ·{' '}
|
||||
{String(p.delivery_address ?? '')}
|
||||
</p>
|
||||
<p>
|
||||
Sector: {String(p.sector_name ?? 'Por indicar')} ·
|
||||
Localização: {String(p.territory_name ?? 'Por indicar')}
|
||||
</p>
|
||||
{p.project_name && (
|
||||
<p>
|
||||
Projecto relacionado: {String(p.project_name)}. Esta
|
||||
relação não confirma execução ou contratação.
|
||||
</p>
|
||||
)}
|
||||
{p.opportunity && (
|
||||
<a className="underline" href={p.opportunity.url}>
|
||||
Oportunidade relacionada: {p.opportunity.title}
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
{p.deadline_previous && (
|
||||
<p className="rounded border p-3">
|
||||
<strong>Prorrogação:</strong> prazo anterior{' '}
|
||||
{String(p.deadline_previous).replace('T', ' ')} (
|
||||
{String(p.timezone_previous)}) → novo prazo{' '}
|
||||
{String(p.deadline).replace('T', ' ')} ({String(p.timezone)}
|
||||
).
|
||||
</p>
|
||||
)}
|
||||
<dl className="grid gap-3 md:grid-cols-2">
|
||||
{Object.entries(labels)
|
||||
.filter(
|
||||
([key]) =>
|
||||
p[key] !== null &&
|
||||
p[key] !== undefined &&
|
||||
p[key] !== '',
|
||||
)
|
||||
.map(([key, label]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded border p-3 break-words"
|
||||
>
|
||||
<dt className="font-semibold">{label}</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{links.includes(key) ? (
|
||||
<a
|
||||
className="underline"
|
||||
href={String(p[key])}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{String(p[key])}
|
||||
</a>
|
||||
) : (
|
||||
(
|
||||
names[String(p[key])] ?? String(p[key])
|
||||
).replace(/^(\d{4}-\d{2}-\d{2})T/, '$1 ')
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<section>
|
||||
<h3 className="text-lg font-semibold">Lotes</h3>
|
||||
{((p.lots ?? []) as Lot[]).length ? (
|
||||
<ul>
|
||||
{(p.lots as Lot[]).map((lot, i) => (
|
||||
<li key={i}>
|
||||
{lot.code} — {lot.description} ·{' '}
|
||||
{lot.quantities}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Sem lotes indicados.</p>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-lg font-semibold">
|
||||
Documentos do concurso
|
||||
</h3>
|
||||
<ul>
|
||||
{((p.attachments ?? []) as Attachment[]).map((item, i) => (
|
||||
<li key={i}>
|
||||
{item.available === false ? (
|
||||
<span>
|
||||
{item.title} — documento actualmente
|
||||
indisponível
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
className="underline"
|
||||
href={`/documentos/${item.resource_id}/download`}
|
||||
>
|
||||
{item.title ??
|
||||
`Documento ${item.resource_id}`}{' '}
|
||||
— {item.purpose}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!((p.attachments as Attachment[]) ?? []).length && (
|
||||
<p>
|
||||
Sem anexos públicos neste acto. Consulte o canal e o
|
||||
anúncio de origem.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<p className="rounded border p-3">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { NoticeContent, states, acts, type Publication } from './shared';
|
||||
export default function Show({
|
||||
id,
|
||||
publication,
|
||||
state,
|
||||
history,
|
||||
watching,
|
||||
backUrl,
|
||||
printMode,
|
||||
}: {
|
||||
id: string;
|
||||
publication: Publication;
|
||||
state: string;
|
||||
history: Publication[];
|
||||
watching: boolean;
|
||||
backUrl: string;
|
||||
printMode: boolean;
|
||||
}) {
|
||||
const { auth } = usePage<{ auth: { user: unknown | null } }>().props;
|
||||
return (
|
||||
<Frame title="Concurso e anúncio">
|
||||
<Head title={String(publication.payload.title)} />
|
||||
<style>
|
||||
{
|
||||
'@media print { nav, header, aside, footer, [data-slot="sidebar"], .no-print {display:none!important} main {margin:0!important; width:100%!important} details {display:block} }'
|
||||
}
|
||||
</style>
|
||||
<div className="no-print flex flex-wrap gap-4">
|
||||
<Link className="underline" href={backUrl}>
|
||||
Voltar aos resultados
|
||||
</Link>
|
||||
<Link className="underline" href={`/concursos/${id}/imprimir`}>
|
||||
Relatório imprimível
|
||||
</Link>
|
||||
{printMode && (
|
||||
<button className={button} onClick={() => window.print()}>
|
||||
Imprimir anúncio
|
||||
</button>
|
||||
)}
|
||||
{auth?.user ? (
|
||||
<button
|
||||
className={button}
|
||||
onClick={() =>
|
||||
watching
|
||||
? router.delete(`/concursos/${id}/acompanhar`)
|
||||
: router.put(`/concursos/${id}/acompanhar`)
|
||||
}
|
||||
>
|
||||
{watching
|
||||
? 'Deixar de acompanhar'
|
||||
: 'Acompanhar concurso'}
|
||||
</button>
|
||||
) : (
|
||||
<Link className="underline" href="/login">
|
||||
Entrar para acompanhar — conta simples
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<p className="rounded border p-3">
|
||||
<strong>{states[state]}</strong> · {acts[publication.act]} ·
|
||||
Versão {publication.version}
|
||||
</p>
|
||||
<p>
|
||||
Publicação deste acto no InvesteMoz: {publication.published_at}{' '}
|
||||
(UTC). Esta data não reinicia o prazo de origem.
|
||||
</p>
|
||||
<article className={card}>
|
||||
<NoticeContent p={publication.payload} />
|
||||
</article>
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Actos publicados e histórico
|
||||
</h2>
|
||||
{history.map((v) => (
|
||||
<details key={v.id} open={printMode}>
|
||||
<summary>
|
||||
{acts[v.act]} · versão {v.version} ·{' '}
|
||||
{v.published_at} (UTC)
|
||||
</summary>
|
||||
<p className="my-2">Motivo público: {v.reason}</p>
|
||||
<NoticeContent p={v.payload} />
|
||||
</details>
|
||||
))}
|
||||
</section>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProcurementController as Procurement;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/concursos', [Procurement::class, 'index'])->name('procurement.index');
|
||||
Route::get('/concursos/acompanhar', [Procurement::class, 'watched'])->middleware('auth');
|
||||
Route::get('/concursos/{id}', [Procurement::class, 'show'])->whereUuid('id')->name('procurement.show');
|
||||
Route::get('/concursos/{id}/imprimir', [Procurement::class, 'print'])->whereUuid('id');
|
||||
Route::put('/concursos/{id}/acompanhar', [Procurement::class, 'watch'])->whereUuid('id')->middleware(['auth', 'throttle:30,1']);
|
||||
Route::delete('/concursos/{id}/acompanhar', [Procurement::class, 'unwatch'])->whereUuid('id')->middleware(['auth', 'throttle:30,1']);
|
||||
Route::middleware(['auth', 'verified'])->prefix('management/concursos')->group(function () {
|
||||
Route::get('/', [Procurement::class, 'manage']);
|
||||
Route::get('/novo', [Procurement::class, 'edit']);
|
||||
Route::post('/', [Procurement::class, 'save']);
|
||||
Route::get('/{id}', [Procurement::class, 'edit'])->whereUuid('id');
|
||||
Route::put('/{id}', [Procurement::class, 'save'])->whereUuid('id');
|
||||
Route::post('/{id}/previa', [Procurement::class, 'preview'])->whereUuid('id');
|
||||
Route::post('/{id}/publicar', [Procurement::class, 'publish'])->whereUuid('id');
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/procurement.php';
|
||||
|
||||
require __DIR__.'/existing-business.php';
|
||||
require __DIR__.'/territorial-registry.php';
|
||||
require __DIR__.'/partnerships.php';
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProcurementTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private User $editor;
|
||||
|
||||
private string $organization;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$identity = DB::selectOne('select current_database() as database,inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->database);
|
||||
$this->assertSame(55441, (int) $identity->port);
|
||||
$this->withoutVite();
|
||||
$this->editor = User::factory()->create();
|
||||
config(['procurement.enabled' => true, 'portal.managers' => [$this->editor->email]]);
|
||||
$this->organization = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $this->organization, 'name' => 'SINTÉTICO QA — Entidade de ensaio', 'organization_type' => 'private']);
|
||||
$this->actingAs($this->editor);
|
||||
}
|
||||
|
||||
private function payload(string $regime = 'state'): array
|
||||
{
|
||||
return ['title' => 'SINTÉTICO QA — '.$regime, 'reference' => 'QA-'.Str::uuid(), 'organization_id' => $this->organization,
|
||||
'regime' => $regime, 'modality' => 'Modalidade documentada de ensaio', 'object' => 'Objecto sintético de concurso para ensaio QA; não corresponde a contratação real.',
|
||||
'instrument' => $regime === 'state' ? 'Decreto n.º 79/2022 — referência do âmbito estatal' : 'Regras documentadas do procedimento sintético',
|
||||
'edition' => $regime === 'external' ? 'Banco Mundial, Setembro/2025 — exemplar de referência' : 'QA-2026',
|
||||
'rule_source' => $regime === 'state' ? 'https://www.ufsa.gov.mz/divnovoreg.php' : 'https://example.invalid/qa-rules',
|
||||
'origin_published_at' => '2026-10-20T09:00', 'deadline' => '2026-11-20T15:00', 'timezone' => 'Africa/Maputo',
|
||||
'delivery_channel' => 'Canal externo documentado', 'delivery_address' => 'Endereço sintético para ensaio; não enviar propostas.',
|
||||
'public_contact' => 'contacto-interno-que-nao-deve-sair@example.test', 'contact_authorized' => false, 'internal_notes' => 'SEGREDO-INTERNO-QA',
|
||||
'public_reason' => 'Divulgação de anúncio sintético para ensaio QA, sem contratação real.',
|
||||
'lots' => [['code' => 'L1', 'description' => 'Lote de bens sintéticos', 'quantities' => '10 unidades de ensaio']], 'attachments' => []];
|
||||
}
|
||||
|
||||
private function create(array $payload): string
|
||||
{
|
||||
$response = $this->post('/management/concursos', ['version' => 0, 'reason' => 'Preparação sintética documentada para ensaio QA.', 'draft' => $payload])->assertSessionHasNoErrors()->assertRedirect();
|
||||
|
||||
return basename(parse_url($response->headers->get('Location'), PHP_URL_PATH));
|
||||
}
|
||||
|
||||
private function save(string $id, array $payload): void
|
||||
{
|
||||
$this->put('/management/concursos/'.$id, ['version' => DB::table('portal_tenders')->where('id', $id)->value('version'), 'reason' => 'Actualização sintética de ensaio com histórico conservado.', 'draft' => $payload])->assertRedirect()->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
private function publish(string $id, string $act = 'initial'): array
|
||||
{
|
||||
$plan = $this->postJson('/management/concursos/'.$id.'/previa', ['act' => $act])->assertOk()->json();
|
||||
$this->post('/management/concursos/'.$id.'/publicar', ['act' => $act, 'hash' => $plan['hash'], 'reason' => $plan['payload']['public_reason']])->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
public function test_incomplete_draft_recovers_without_becoming_public_and_rejects_stale_save(): void
|
||||
{
|
||||
$id = $this->create(['title' => 'Preparação incompleta']);
|
||||
$this->get('/management/concursos/'.$id)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $p) => $p->component('procurement/edit')->where('record.draft.title', 'Preparação incompleta'));
|
||||
$this->get('/concursos/'.$id)->assertNotFound();
|
||||
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors(['organization_id', 'deadline', 'regime']);
|
||||
$this->putJson('/management/concursos/'.$id, ['version' => 0, 'reason' => 'Pedido concorrente obsoleto, sem sobrescrever o rascunho.', 'draft' => ['title' => 'Não guardar']])->assertConflict();
|
||||
$this->assertSame(1, DB::table('portal_tender_versions')->where('tender_id', $id)->count());
|
||||
}
|
||||
|
||||
public function test_state_external_private_regimes_and_filters_are_public_without_ug_ea_or_guarantee_defaults(): void
|
||||
{
|
||||
$ids = [];
|
||||
foreach (['state', 'external', 'private'] as $regime) {
|
||||
$p = $this->payload($regime);
|
||||
$ids[$regime] = $this->create($p);
|
||||
$plan = $this->publish($ids[$regime]);
|
||||
$this->assertArrayNotHasKey('responsible_unit', $plan['payload']);
|
||||
$this->assertArrayNotHasKey('internal_notes', $plan['payload']);
|
||||
$this->assertArrayNotHasKey('public_contact', $plan['payload']);
|
||||
}
|
||||
Auth::logout();
|
||||
$this->get('/concursos?regime=external&entity='.$this->organization.'&q=SINT')->assertOk()->assertInertia(fn (Assert $p) => $p->component('procurement/index')->where('filters.regime', 'external')->has('items.data', 1)->where('items.data.0.id', $ids['external']));
|
||||
$this->get('/concursos?q=&entity=§or=&state=open')->assertOk()->assertInertia(fn (Assert $p) => $p->where('filters', ['state' => 'open']));
|
||||
$this->get('/concursos/'.$ids['private'].'?regime=private&q=SINT&page=2')->assertOk()->assertInertia(fn (Assert $p) => $p->component('procurement/show')->where('backUrl', '/concursos?q=SINT®ime=private&page=2')->where('publication.payload.regime', 'private')->missing('publication.payload.internal_notes')->missing('publication.payload.public_contact')->has('history', 1));
|
||||
$this->get('/concursos/'.$ids['state'].'/imprimir')->assertOk()->assertInertia(fn (Assert $p) => $p->where('printMode', true));
|
||||
}
|
||||
|
||||
public function test_extension_preserves_publication_origin_and_history_and_expiry_is_not_an_award(): void
|
||||
{
|
||||
$p = $this->payload();
|
||||
$id = $this->create($p);
|
||||
$this->publish($id);
|
||||
$first = DB::table('portal_tender_versions')->where('tender_id', $id)->where('act', 'initial')->first();
|
||||
$p['deadline'] = '2026-11-25T16:00';
|
||||
$this->save($id, $p);
|
||||
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'correction'])->assertUnprocessable()->assertJsonValidationErrors('act');
|
||||
$plan = $this->publish($id, 'extension');
|
||||
$this->assertSame('2026-11-20T15:00', $plan['payload']['deadline_previous']);
|
||||
$this->assertSame('2026-10-20T09:00', $plan['payload']['origin_published_at']);
|
||||
$this->assertSame($first->payload, DB::table('portal_tender_versions')->where('id', $first->id)->value('payload'));
|
||||
$this->travelTo(now()->setDate(2026, 11, 26));
|
||||
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'deadline_ended')->where('publication.act', 'extension')->has('history', 2));
|
||||
$this->travelBack();
|
||||
$p['result'] = 'Resultado sintético de ensaio, não adjudicação real.';
|
||||
$this->save($id, $p);
|
||||
$this->publish($id, 'award');
|
||||
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'results')->has('history', 3));
|
||||
}
|
||||
|
||||
public function test_cancellation_and_later_clarification_do_not_reopen_the_procedure(): void
|
||||
{
|
||||
$p = $this->payload('private');
|
||||
$id = $this->create($p);
|
||||
$this->publish($id);
|
||||
$p['result'] = 'Cancelamento sintético fundamentado para ensaio.';
|
||||
$this->save($id, $p);
|
||||
$this->publish($id, 'cancellation');
|
||||
$p['object'] .= ' Esclarecimento posterior ao cancelamento.';
|
||||
$this->save($id, $p);
|
||||
$this->publish($id, 'clarification');
|
||||
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'cancelled')->has('history', 3));
|
||||
}
|
||||
|
||||
public function test_stale_preview_cannot_publish_new_draft_and_public_snapshot_stays_unchanged_until_explicit_publication(): void
|
||||
{
|
||||
$p = $this->payload();
|
||||
$id = $this->create($p);
|
||||
$plan = $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertOk()->json();
|
||||
$p['title'] = 'Nova edição ainda privada';
|
||||
$this->save($id, $p);
|
||||
$this->postJson('/management/concursos/'.$id.'/publicar', ['act' => 'initial', 'hash' => $plan['hash'], 'reason' => $p['public_reason']])->assertConflict();
|
||||
$this->publish($id);
|
||||
$p['title'] = 'Rascunho futuro privado';
|
||||
$this->save($id, $p);
|
||||
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.title', 'Nova edição ainda privada'));
|
||||
}
|
||||
|
||||
public function test_documented_minimum_dates_and_business_calendar_are_specific_to_the_procedure(): void
|
||||
{
|
||||
$p = $this->payload();
|
||||
$p['deadline'] = '2026-10-19T15:00';
|
||||
$id = $this->create($p);
|
||||
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('deadline');
|
||||
$p['origin_published_at'] = '2026-10-23T09:00';
|
||||
$p['deadline'] = '2026-10-27T09:00';
|
||||
$p += ['minimum_days' => 2, 'minimum_source' => 'Regra sintética de contagem, cláusula QA-1', 'minimum_edition' => 'QA-2026', 'minimum_modality' => $p['modality'], 'minimum_day_basis' => 'business', 'minimum_working_days' => '1,2,3,4,5', 'minimum_holidays' => '2026-10-26', 'minimum_calendar_source' => 'Calendário sintético QA: segunda a sexta, exclui 26/10. Não é calendário oficial.'];
|
||||
$this->save($id, $p);
|
||||
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('deadline');
|
||||
$p['deadline'] = '2026-10-28T09:00';
|
||||
$this->save($id, $p);
|
||||
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertOk();
|
||||
$p['minimum_modality'] = 'Outra modalidade';
|
||||
$this->save($id, $p);
|
||||
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('minimum_modality');
|
||||
}
|
||||
|
||||
public function test_simple_account_can_follow_but_cannot_edit_or_publish_and_tracking_is_not_a_bid(): void
|
||||
{
|
||||
$id = $this->create($this->payload());
|
||||
$this->publish($id);
|
||||
$reader = User::factory()->unverified()->create();
|
||||
$before = DB::table('portal_role_assignments')->count();
|
||||
$this->actingAs($reader)->put('/concursos/'.$id.'/acompanhar')->assertRedirect();
|
||||
$this->get('/concursos/acompanhar')->assertOk()->assertInertia(fn (Assert $p) => $p->where('watched', true)->has('items', 1)->where('items.0.changed', false));
|
||||
$this->actingAs($this->editor);
|
||||
$p = $this->payload();
|
||||
$this->save($id, $p);
|
||||
$this->publish($id, 'clarification');
|
||||
$reader->forceFill(['email_verified_at' => now()])->save();
|
||||
$this->actingAs($reader);
|
||||
$this->get('/concursos/acompanhar')->assertInertia(fn (Assert $p) => $p->where('items.0.changed', true));
|
||||
$this->get('/management/concursos')->assertForbidden();
|
||||
$this->postJson('/management/concursos/'.$id.'/publicar', [])->assertForbidden();
|
||||
$this->delete('/concursos/'.$id.'/acompanhar')->assertRedirect();
|
||||
$this->assertSame($before, DB::table('portal_role_assignments')->count());
|
||||
}
|
||||
|
||||
public function test_real_scanned_library_attachment_private_redaction_and_withdrawal_are_reused(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.socket' => null, 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
||||
$this->assertTrue(app(MalwareScanner::class)->health()['available'], 'Current real scanner required');
|
||||
$name = 'synthetic-tender-'.Str::uuid().'.txt';
|
||||
$this->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent($name, 'Exemplo sintético de anúncio QA, sem valor jurídico.')])->assertSessionHasNoErrors();
|
||||
$doc = DB::table('documents')->where('file_name', $name)->first();
|
||||
$this->assertSame('clean', $doc->malware_scan_status);
|
||||
$metadata = ['document_id' => $doc->id, 'title' => $name, 'category' => 'Synthetic QA', 'topic' => 'Tender QA', 'document_type' => 'unknown', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/qa', 'language' => 'pt', 'version_label' => 'QA-1', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'unknown', 'rights_status' => 'authorized', 'data_classification' => 'public', 'source_reference' => 'Synthetic QA only, not an official notice.', 'legislation' => false, 'essential' => false, 'independent_review' => false, 'associations' => []];
|
||||
$this->post('/management/portal-services/resources', $metadata)->assertSessionHasNoErrors();
|
||||
$resource = DB::table('portal_resources')->where('document_id', $doc->id)->first();
|
||||
$this->assertNotNull($resource);
|
||||
$p = $this->payload('external');
|
||||
$p['attachments'] = [['resource_id' => $resource->id, 'public' => true, 'purpose' => 'Anúncio de ensaio']];
|
||||
$id = $this->create($p);
|
||||
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('attachments');
|
||||
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
|
||||
$this->publish($id);
|
||||
Auth::logout();
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload($name);
|
||||
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.attachments.0.available', true));
|
||||
$this->actingAs($this->editor)->post('/management/portal-services/resources/'.$resource->id, ['published' => false])->assertSessionHasNoErrors();
|
||||
Auth::logout();
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
|
||||
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.attachments.0.available', false));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user