commit16
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\InvestmentOpportunityAlerts;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class DispatchInvestmentAlerts extends Command
|
||||
{
|
||||
protected $signature = 'investmoz:dispatch-opportunity-alerts';
|
||||
|
||||
protected $description = 'Generate due opt-in alerts from public catalogues, without external messages';
|
||||
|
||||
public function handle(InvestmentOpportunityAlerts $alerts): int
|
||||
{
|
||||
if (! $alerts->installed()) {
|
||||
$this->warn('Opportunity alerts schema is not installed.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$this->info('Matched public catalogue items: '.$alerts->dispatchDue());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\OpportunityMaturity;
|
||||
use App\Services\OpportunityTextSearch;
|
||||
use App\Services\SectorOrganization;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -41,16 +42,7 @@ class OpportunityController extends Controller
|
||||
}
|
||||
|
||||
if (! empty($validated['q'])) {
|
||||
$term = $validated['q'];
|
||||
$query->where(fn ($q) => $q
|
||||
->whereLike('title', "%{$term}%")
|
||||
->orWhereLike('description', "%{$term}%")
|
||||
->orWhereLike('summary', "%{$term}%")
|
||||
->orWhereLike('reference', "%{$term}%")
|
||||
->orWhereLike('province', "%{$term}%")
|
||||
->orWhereHas('sector', fn ($sectorQuery) => $sectorQuery
|
||||
->whereLike('name', "%{$term}%")
|
||||
->orWhereLike('slug', "%{$term}%")));
|
||||
app(OpportunityTextSearch::class)->constrain($query, $validated['q']);
|
||||
}
|
||||
|
||||
if (! empty($validated['sector'])) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\DeskServiceEditor;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class DeskServiceEditorController extends Controller
|
||||
{
|
||||
public function index(Request $request, DeskServiceEditor $editor): Response|JsonResponse
|
||||
{
|
||||
$data = ['services' => $editor->listing()];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/service-editor', $data);
|
||||
}
|
||||
|
||||
public function publish(Request $request, DeskServiceEditor $editor, string $code): JsonResponse|RedirectResponse
|
||||
{
|
||||
$row = $editor->publish($request->user(), $code, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201)
|
||||
: back()->with('success', 'Nova versão publicada para pedidos futuros. Os pedidos existentes conservam a sua versão.');
|
||||
}
|
||||
|
||||
public function history(Request $request, DeskServiceEditor $editor, string $code): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['page' => ['nullable', 'integer', 'min:1']]);
|
||||
|
||||
return response()->json($editor->history($code, (int) ($data['page'] ?? 1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Sector;
|
||||
use App\Services\DocumentManagementAccess;
|
||||
use App\Services\FundingProgrammeReferences;
|
||||
use App\Services\FundingProgrammes;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\Procurement;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
final class FundingProgrammeController extends Controller
|
||||
{
|
||||
public function reference(Request $request, string $code, FundingProgrammeReferences $references): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
return tap(Inertia::render('funding/reference', ['reference' => $references->preview($request->user(), $code)])->toResponse($request),
|
||||
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function prepareReference(Request $request, string $code, FundingProgrammeReferences $references): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['hash' => 'required|string|size:64']);
|
||||
$result = $references->prepare($request->user(), $code, $data['hash']);
|
||||
|
||||
return redirect('/management/fundos/'.$result['id'])->with('success', $result['created']
|
||||
? 'Rascunho preparado com as fontes indicadas. Ainda não está publicado.'
|
||||
: 'Esta referência já tem ficha. Aberta a versão existente, sem substituir alterações ou acrescentar chamadas.');
|
||||
}
|
||||
|
||||
public function manage(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$service->guard($request->user());
|
||||
$items = DB::table('funding_programmes')->orderByDesc('updated_at')->paginate(20);
|
||||
$items->through(fn ($row) => ['id' => $row->id, 'title' => json_decode($row->draft, true)['title'] ?? 'Rascunho sem título',
|
||||
'version' => (int) $row->version, 'published' => $row->published_version_id !== null]);
|
||||
|
||||
return tap(Inertia::render('funding/manage', ['items' => $items])->toResponse($request),
|
||||
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function edit(Request $request, FundingProgrammes $service, ?string $id = null): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$service->guard($request->user());
|
||||
$row = $id ? DB::table('funding_programmes')->where('id', $id)->firstOrFail() : null;
|
||||
$editable = app(DocumentManagementAccess::class)->documents($request->user())->select('id');
|
||||
$references = ['organizations' => DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->get(['id', 'name']),
|
||||
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name']),
|
||||
'territories' => app(InvestmentGeography::class)->publicReferences()->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)
|
||||
->orWhereIn('r.id', app(Procurement::class)->publicDocuments()->select('r.id')))
|
||||
->orderBy('r.title')->get(['r.id', 'r.title'])];
|
||||
|
||||
return tap(Inertia::render('funding/edit', ['record' => $row ? ['id' => $row->id, 'code' => $row->code, 'version' => (int) $row->version,
|
||||
'draft' => json_decode($row->draft, true), 'published' => $row->published_version_id !== null] : null,
|
||||
'references' => Arr::only($references, ['organizations', 'sectors', 'territories', 'documents']),
|
||||
'kinds' => FundingProgrammes::KINDS, 'windowModes' => FundingProgrammes::WINDOWS,
|
||||
'history' => $id ? DB::table('funding_programme_versions')->where('programme_id', $id)->orderByDesc('version')->get(['version', 'reason', 'published_at']) : []])->toResponse($request),
|
||||
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function save(Request $request, FundingProgrammes $service, ?string $id = null): RedirectResponse
|
||||
{
|
||||
$id = $service->save($request->user(), $id, $request->all());
|
||||
|
||||
return redirect('/management/fundos/'.$id)->with('success', 'Rascunho guardado. Confira a prévia antes de publicar.');
|
||||
}
|
||||
|
||||
public function preview(Request $request, string $id, FundingProgrammes $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->preview($request->user(), $id))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function publish(Request $request, string $id, FundingProgrammes $service): RedirectResponse
|
||||
{
|
||||
$service->publish($request->user(), $id, $request->all());
|
||||
|
||||
return back()->with('success', 'Nova versão publicada. Os interesses anteriores conservam a versão guardada.');
|
||||
}
|
||||
|
||||
public function withdraw(Request $request, string $id, FundingProgrammes $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'reason' => 'required|string|min:20|max:2000']);
|
||||
$service->withdraw($request->user(), $id, $data['version'], $data['reason']);
|
||||
|
||||
return back()->with('success', 'Publicação retirada. Versões e interesses preservados.');
|
||||
}
|
||||
|
||||
public function saved(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
abort_unless($service->installed(), 404);
|
||||
$items = DB::table('funding_programme_interests as i')
|
||||
->join('funding_programme_versions as v', fn ($join) => $join->on('v.id', '=', 'i.version_id')->on('v.programme_id', '=', 'i.programme_id'))
|
||||
->join('funding_programmes as p', 'p.id', '=', 'i.programme_id')
|
||||
->where('i.user_id', $request->user()->id)->whereNull('i.removed_at')
|
||||
->orderByDesc('i.saved_at')->orderBy('i.programme_id')
|
||||
->paginate(20, ['i.programme_id', 'i.saved_at', 'v.version', 'v.payload', 'p.published_version_id'])->withQueryString();
|
||||
$items->through(function ($row) use ($service) {
|
||||
$current = $service->publicQuery()->where('p.id', $row->programme_id)->first(['v.version']);
|
||||
$snapshot = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return ['id' => $row->programme_id, 'title' => $snapshot['title'], 'saved_version' => (int) $row->version,
|
||||
'saved_at' => $row->saved_at, 'available' => $current !== null, 'current_version' => $current ? (int) $current->version : null];
|
||||
});
|
||||
|
||||
return tap(Inertia::render('funding/saved', ['items' => $items])->toResponse($request),
|
||||
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function index(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:150', 'kind' => 'nullable|in:fund,programme,incentive',
|
||||
'territory' => ['nullable', 'uuid', Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())],
|
||||
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
|
||||
'certainty' => 'nullable|in:include_unknown,documented', 'state' => 'nullable|in:open,closed,upcoming,unknown']);
|
||||
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
|
||||
$query = $service->catalogueQuery($filters);
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['q']).'%';
|
||||
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term]));
|
||||
}
|
||||
if (! empty($filters['kind'])) {
|
||||
$query->whereRaw("v.payload->>'kind' = ?", [$filters['kind']]);
|
||||
}
|
||||
$items = $query->orderByDesc('v.published_at')->orderBy('p.id')->paginate(20, ['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at'])->withQueryString();
|
||||
$items->through(function ($row) use ($service, $filters) {
|
||||
$record = $service->present($row);
|
||||
|
||||
return [...$record, 'filter_explanation' => $service->filterExplanation($record, $filters)];
|
||||
});
|
||||
|
||||
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, 'kinds' => FundingProgrammes::KINDS,
|
||||
'options' => ['territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name']),
|
||||
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name'])], 'management' => $service->access($request->user())])
|
||||
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$record = $service->published($id);
|
||||
$saved = $request->user() ? DB::table('funding_programme_interests as i')->join('funding_programme_versions as v', 'v.id', '=', 'i.version_id')
|
||||
->where('i.programme_id', $id)->where('i.user_id', $request->user()->id)->whereNull('i.removed_at')->first(['v.version', 'i.saved_at']) : null;
|
||||
|
||||
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null])
|
||||
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
public function assess(Request $request, string $id, FundingProgrammes $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->assess($id, $request->all()))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function saveInterest(Request $request, string $id, FundingProgrammes $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['version_id' => 'required|uuid', 'consent' => 'required|accepted']);
|
||||
$service->saveInterest($request->user(), $id, $data['version_id'], true);
|
||||
|
||||
return back()->with('success', 'Programa guardado na sua área privada. Consulte a versão indicada na ficha.');
|
||||
}
|
||||
|
||||
public function removeInterest(Request $request, string $id, FundingProgrammes $service): RedirectResponse
|
||||
{
|
||||
abort_unless($service->installed(), 404);
|
||||
$service->removeInterest($request->user(), $id);
|
||||
|
||||
return back()->with('success', 'Interesse removido da sua lista.');
|
||||
}
|
||||
|
||||
public function download(string $id, string $version, int $resource, FundingProgrammes $service): Response|BinaryFileResponse
|
||||
{
|
||||
return $service->download($id, $version, $resource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestmentOpportunityAlerts;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class InvestmentAlertPreferencesController extends Controller
|
||||
{
|
||||
public function save(Request $request, InvestmentOpportunityAlerts $alerts): JsonResponse|RedirectResponse
|
||||
{
|
||||
$preferences = $alerts->save($request->user(), $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json($preferences) : back()->with('success', $preferences['enabled']
|
||||
? 'Preferências guardadas. O acompanhamento considera novas disponibilizações nos catálogos escolhidos.'
|
||||
: 'Alertas dos catálogos cancelados. As notificações anteriores foram conservadas.');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Services\InvestmentComplements;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\OpportunityCorridorExplorer;
|
||||
use App\Services\OpportunityTextSearch;
|
||||
use App\Services\SectorInvestment;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -29,12 +30,20 @@ class InvestmentDiscoveryController extends Controller
|
||||
if ($request->routeIs('investment.education')) {
|
||||
$filters['education'] = true;
|
||||
}
|
||||
$results = $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record));
|
||||
$relatedQuery = $results->total() === 0 ? app(OpportunityTextSearch::class)->relatedQuery($filters['q'] ?? '') : null;
|
||||
$relatedFilters = [...Arr::except($filters, ['page', 'q']), 'q' => $relatedQuery];
|
||||
$related = $relatedQuery === null ? null : [
|
||||
'query' => $filters['q'], 'subject' => $relatedQuery,
|
||||
'href' => ($request->routeIs('investment.education') ? '/educacao' : '/oportunidades').'?'.http_build_query($relatedFilters),
|
||||
'items' => $catalogue->query($relatedFilters)->limit(6)->get()->map(fn ($record) => $catalogue->presentLocation($record)),
|
||||
];
|
||||
|
||||
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
|
||||
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(OpportunityCorridorExplorer::class)->groups($filters) : [],
|
||||
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
|
||||
'guides' => app(SectorInvestment::class)->guides(Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
|
||||
'results' => $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)),
|
||||
'results' => $results, 'relatedSearch' => $related,
|
||||
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
|
||||
'territorial' => $this->territorial($filters)]);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\FundingProgrammes;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentOpportunityAlerts;
|
||||
use App\Services\InvestmentWorkspace;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OrganizationCountry;
|
||||
use App\Services\Procurement;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -119,14 +126,38 @@ class InvestmentWorkspaceController extends Controller
|
||||
public function notifications(Request $request): Response|JsonResponse
|
||||
{
|
||||
$query = DB::table('investment_notifications')->where('user_id', $request->user()->id);
|
||||
if (! $request->user()->can('manage-portal')) {
|
||||
$alerts = app(InvestmentOpportunityAlerts::class);
|
||||
$available = $alerts->installed();
|
||||
if ($available) {
|
||||
$query->where(function ($sources) use ($request, $alerts) {
|
||||
$sources->whereIn('opportunity_id', Opportunity::published()->select('id'))
|
||||
->orWhere(function ($legacy) use ($request) {
|
||||
$legacy->whereNotNull('submission_id');
|
||||
if (! $request->user()->can('manage-portal')) {
|
||||
$legacy->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id'));
|
||||
}
|
||||
});
|
||||
if ($alerts->tendersAvailable()) {
|
||||
$sources->orWhereIn('tender_version_id', app(Procurement::class)->readableVersions()->select('id'));
|
||||
}
|
||||
if ($alerts->fundingAvailable()) {
|
||||
$sources->orWhereIn('funding_version_id', app(FundingProgrammes::class)->readableVersions()->select('id'));
|
||||
}
|
||||
});
|
||||
} elseif (! $request->user()->can('manage-portal')) {
|
||||
$query->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id'));
|
||||
}
|
||||
$data = ['unread' => (clone $query)->whereNull('read_at')->count(), 'notifications' => $query->orderByDesc('created_at')->paginate(20)->through(function ($row) {
|
||||
$row->payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
|
||||
$data = ['alertPreferences' => $available ? $alerts->preferences($request->user()->id) : null,
|
||||
'alertOptions' => $available ? ['frequencies' => InvestmentOpportunityAlerts::FREQUENCIES,
|
||||
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['slug', 'name']),
|
||||
'provinces' => app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->orderBy('name')->get(['id', 'name']),
|
||||
'categories' => OpportunityManagement::CATEGORIES, 'tendersAvailable' => $alerts->tendersAvailable(), 'fundingAvailable' => $alerts->fundingAvailable(),
|
||||
'tenderRegimes' => config('procurement.regimes'), 'tenderActs' => config('procurement.acts')] : null,
|
||||
'unread' => (clone $query)->whereNull('read_at')->count(), 'notifications' => $query->orderByDesc('created_at')->orderByDesc('id')->paginate(20)->through(function ($row) {
|
||||
$row->payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return $row;
|
||||
})];
|
||||
return $row;
|
||||
})];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/notifications', $data);
|
||||
}
|
||||
@@ -135,7 +166,17 @@ class InvestmentWorkspaceController extends Controller
|
||||
{
|
||||
$row = DB::table('investment_notifications')->where('id', $id)->where('user_id', $request->user()->id)->first();
|
||||
abort_unless($row !== null, 404);
|
||||
$this->submission($request, $row->submission_id);
|
||||
if ($row->opportunity_id ?? null) {
|
||||
abort_unless(Opportunity::published()->whereKey($row->opportunity_id)->exists(), 404);
|
||||
} elseif ($row->tender_version_id ?? null) {
|
||||
abort_unless(app(InvestmentOpportunityAlerts::class)->tendersAvailable()
|
||||
&& app(Procurement::class)->readableVersions()->where('id', $row->tender_version_id)->exists(), 404);
|
||||
} elseif ($row->funding_version_id ?? null) {
|
||||
abort_unless(app(InvestmentOpportunityAlerts::class)->fundingAvailable()
|
||||
&& app(FundingProgrammes::class)->readableVersions()->where('id', $row->funding_version_id)->exists(), 404);
|
||||
} else {
|
||||
$this->submission($request, $row->submission_id);
|
||||
}
|
||||
DB::table('investment_notifications')->where('id', $id)->whereNull('read_at')->update(['read_at' => now()]);
|
||||
|
||||
return $request->expectsJson() ? response()->json(['read' => true]) : back();
|
||||
|
||||
@@ -13,37 +13,16 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
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): Builder
|
||||
{
|
||||
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');
|
||||
return $service->publicQuery();
|
||||
}
|
||||
|
||||
public function index(Request $request, Procurement $service): Response
|
||||
{
|
||||
$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']]);
|
||||
}
|
||||
$query = $service->publicQuery($filters);
|
||||
$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));
|
||||
|
||||
@@ -87,8 +87,17 @@ class PublicBusinessOpportunityController extends Controller
|
||||
}
|
||||
|
||||
$returnFilters = $this->filters($request);
|
||||
$similarProvince = $record->getAttribute('similar_province_id');
|
||||
if ($similarProvince !== null && ! app(InvestmentGeography::class)->publicReferences()->where('geo_locations.id', $similarProvince)->exists()) {
|
||||
$similarProvince = null;
|
||||
}
|
||||
|
||||
return Inertia::render('investment/business-opportunity', ['record' => $resource->resolve($request),
|
||||
'similarCatalogueUrl' => '/negocios?'.http_build_query(array_filter([
|
||||
'modality' => $record->modality,
|
||||
'sector' => $record->sector_id,
|
||||
'province_id' => $similarProvince,
|
||||
], fn ($value) => $value !== null && $value !== '')),
|
||||
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
|
||||
'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]);
|
||||
}
|
||||
@@ -116,7 +125,7 @@ class PublicBusinessOpportunityController extends Controller
|
||||
->leftJoin('geo_locations as locality', 'locality.id', '=', 'locations.locality_id')
|
||||
->where('locations.is_public', true)->where('locations.is_primary', true)
|
||||
->whereNull('province.deleted_at')->whereNull('district.deleted_at')->whereNull('locality.deleted_at')
|
||||
->select('locations.existing_business_id', 'province.name as province')
|
||||
->select('locations.existing_business_id', 'province.name as province', 'province.id as similar_province_id')
|
||||
->selectRaw("CASE WHEN locations.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
|
||||
->selectRaw('NULL::text AS locality');
|
||||
|
||||
@@ -127,6 +136,6 @@ class PublicBusinessOpportunityController extends Controller
|
||||
'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max',
|
||||
'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id',
|
||||
'existing_businesses.owner_user_id', 'existing_businesses.organization_id', 'existing_businesses.version_no', 'existing_businesses.expires_at', 'existing_businesses.status', 'existing_businesses.modality',
|
||||
'public_location.province', 'public_location.district', 'public_location.locality']);
|
||||
'public_location.province', 'public_location.district', 'public_location.locality', 'public_location.similar_province_id']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ class VirtualDeskController extends Controller
|
||||
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
|
||||
'owner' => $desk->owns($request->user(), $case), 'manager' => app(ScopedPortalAdministration::class)->deskDecision($request->user(), $row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
|
||||
'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []),
|
||||
'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
|
||||
'findings' => $desk->findings($payload, $definition), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
|
||||
'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body),
|
||||
'kind' => $messageKinds[$m->id] ?? 'historical_unclassified']),
|
||||
'events' => $this->events($case->id, $desk, $request->user())->filter(fn ($e) => $e['request_id'] === $id)->values(),
|
||||
@@ -245,7 +245,10 @@ class VirtualDeskController extends Controller
|
||||
$this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded');
|
||||
|
||||
if ($format === 'html') {
|
||||
return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $desk->definition($row)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
$definition = $desk->definition($row);
|
||||
|
||||
return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $definition,
|
||||
'applicableFields' => $desk->applicableFields($snapshot['payload'] ?? [], $definition)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($row, $snapshot) {
|
||||
|
||||
@@ -24,7 +24,7 @@ final class EnsureScopedPortalAdministration
|
||||
'management/economic-references/lot-01', 'management/economic-references/lot-01/*',
|
||||
'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*',
|
||||
'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*',
|
||||
'management/balcao/atribuicoes', 'management/balcao/atribuicoes/*', 'management/balcao/servicos',
|
||||
'management/balcao/atribuicoes', 'management/balcao/atribuicoes/*', 'management/balcao/servicos', 'management/balcao/servicos/*',
|
||||
'management/balcao/fontes') || ($request->isMethod('GET') && $path === 'management/balcao-install');
|
||||
$fiscal = $request->is('api/v2/investment/admin/tax-rules', 'api/v2/investment/admin/tax-rules/*',
|
||||
'api/v2/investment/admin/tax-rule-sources', 'management/investment/tax-rules/*');
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Services\ContentManagement;
|
||||
use App\Services\FundingProgrammes;
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\LegacyContent;
|
||||
use App\Services\OpportunityManagement;
|
||||
@@ -57,6 +58,7 @@ class HandleInertiaRequests extends Middleware
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'procurementEnabled' => app(Procurement::class)->available(),
|
||||
'fundingEnabled' => app(FundingProgrammes::class)->installed(),
|
||||
'homologationReserved' => (bool) config('portal.homologation_only', false),
|
||||
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
|
||||
'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null,
|
||||
|
||||
@@ -15,7 +15,7 @@ final class BusinessFormSchema
|
||||
'proponent_type' => ['step' => 1, 'pt' => 'Quem apresenta a oportunidade?', 'en' => 'Who is presenting the opportunity?', 'type' => 'select', 'max' => 30, 'options' => ['organization' => 'Empresa ou organização / Company or organization', 'sole_trader' => 'Empresário em nome individual / Sole trader', 'person' => 'Pessoa singular / Individual', 'public_entity' => 'Entidade pública / Public entity']],
|
||||
'proponent_name' => ['step' => 1, 'pt' => 'Nome ou denominação', 'en' => 'Name', 'type' => 'text', 'max' => 200],
|
||||
'country' => ['step' => 1, 'pt' => 'País de origem ou constituição', 'en' => 'Country of origin or incorporation', 'type' => 'country', 'max' => 100],
|
||||
'declared_origin_country_code' => ['step' => 1, 'pt' => 'País de origem declarado desta solicitação (opcional)', 'en' => 'Declared country of origin of this request (optional)', 'type' => 'country', 'max' => 2],
|
||||
'declared_origin_country_code' => ['step' => 1, 'pt' => 'País de origem do pedido (opcional)', 'en' => 'Country from which you submit this request (optional)', 'type' => 'country', 'max' => 2],
|
||||
'tax_identifier' => ['step' => 1, 'pt' => 'NUIT ou identificador (interno, quando aplicável)', 'en' => 'NUIT or identifier (internal, when applicable)', 'type' => 'text', 'max' => 50],
|
||||
'contact_name' => ['step' => 1, 'pt' => 'Nome da pessoa de contacto', 'en' => 'Contact person name', 'type' => 'text', 'max' => 200],
|
||||
'contact_email' => ['step' => 1, 'pt' => 'Correio electrónico (indique email ou telefone)', 'en' => 'Email (provide email or phone)', 'type' => 'email', 'max' => 254],
|
||||
@@ -23,8 +23,8 @@ final class BusinessFormSchema
|
||||
'contact_preference' => ['step' => 1, 'pt' => 'Preferência de contacto', 'en' => 'Contact preference', 'type' => 'select', 'max' => 30, 'options' => ['through_portal' => 'Receber pedidos através da InvestMoz', 'approved_business_contact' => 'Mostrar contacto empresarial aprovado']],
|
||||
'acting_capacity' => ['step' => 2, 'pt' => 'Qualidade em que actua', 'en' => 'Acting capacity', 'type' => 'select', 'max' => 30, 'options' => ['holder' => 'Titular / Holder', 'representative' => 'Representante / Representative']],
|
||||
'declared_holder' => ['step' => 2, 'pt' => 'Entidade ou pessoa representada', 'en' => 'Entity or person represented', 'type' => 'text', 'max' => 200],
|
||||
'mandate_reference' => ['step' => 2, 'pt' => 'Referência do documento de autorização', 'en' => 'Authorization document reference', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'mandate_document_reference' => ['step' => 2, 'pt' => 'Referência interna do documento de autorização (não carregue aqui)', 'en' => 'Internal authorization document reference (do not upload here)', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'mandate_reference' => ['step' => 2, 'pt' => 'Número ou referência da autorização de representação', 'en' => 'Representation authorization number or reference', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'mandate_document_reference' => ['step' => 2, 'pt' => 'Referência do comprovativo de representação no portal', 'en' => 'Reference of representation evidence in the portal', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'mandate_valid_until' => ['step' => 2, 'pt' => 'Validade do mandato', 'en' => 'Mandate expiry', 'type' => 'date', 'max' => 10, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'disclosure_scope' => ['step' => 2, 'pt' => 'Limites da autorização de divulgação', 'en' => 'Limits of disclosure authorization', 'type' => 'textarea', 'max' => 2000, 'when' => ['acting_capacity' => ['representative']]],
|
||||
'legitimacy_declaration' => ['step' => 2, 'pt' => 'Declaro legitimidade para esta submissão', 'en' => 'I declare authority to submit', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim / Yes', 'no' => 'Não / No']],
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class DeskServiceEditor
|
||||
{
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function listing(): array
|
||||
{
|
||||
$rows = DB::table('desk_service_versions')->selectRaw('DISTINCT ON (code) code, version, definition')
|
||||
->orderBy('code')->orderByDesc('version')->get()->keyBy('code');
|
||||
$services = [];
|
||||
foreach (config('virtual-desk.services') as $code => $initial) {
|
||||
$row = $rows->get($code);
|
||||
$services[] = ['code' => $code, 'version' => (int) ($row->version ?? 0), 'editable' => true,
|
||||
'fixedTypes' => array_map(fn (array $field) => $field['type'], $initial['fields']),
|
||||
'definition' => $row ? json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR) : $initial];
|
||||
}
|
||||
foreach ($rows as $code => $row) {
|
||||
if (! array_key_exists($code, config('virtual-desk.services'))) {
|
||||
$services[] = ['code' => $code, 'version' => (int) $row->version, 'editable' => false,
|
||||
'definition' => json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR)];
|
||||
}
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function history(string $code, int $requestedPage): array
|
||||
{
|
||||
abort_unless(array_key_exists($code, config('virtual-desk.services')) || DB::table('desk_service_versions')->where('code', $code)->exists(), 404);
|
||||
$query = DB::table('desk_service_versions')->where('code', $code);
|
||||
$total = (clone $query)->count();
|
||||
$pages = max(1, (int) ceil($total / 10));
|
||||
$page = min(max(1, $requestedPage), $pages);
|
||||
$rows = $query->orderByDesc('version')->forPage($page, 10)->get(['id', 'version', 'status', 'author_id', 'published_by', 'published_at', 'definition', 'source']);
|
||||
$events = DB::table('audit_events')->where('source_system', 'virtual-desk')->where('entity_type', 'desk_service_versions')
|
||||
->where('action', 'service_definition_version_published')->whereIn('entity_id', $rows->pluck('id')->all())
|
||||
->get(['entity_id', 'after_data'])->keyBy('entity_id');
|
||||
|
||||
return ['page' => $page, 'pages' => $pages, 'total' => $total, 'data' => $rows->map(function ($row) use ($events): array {
|
||||
$event = $events->get($row->id);
|
||||
$audit = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
|
||||
return ['id' => $row->id, 'version' => (int) $row->version, 'status' => $row->status,
|
||||
'author_id' => $row->author_id, 'published_by' => $row->published_by, 'published_at' => $row->published_at,
|
||||
'definition' => json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR),
|
||||
'source' => json_decode($row->source, true, 512, JSON_THROW_ON_ERROR), 'reason' => $audit['reason'] ?? null];
|
||||
})->all()];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function publish(User $actor, string $code, array $input): \stdClass
|
||||
{
|
||||
abort_unless(array_key_exists($code, config('virtual-desk.services')), 404);
|
||||
$data = Validator::make($input, [
|
||||
'base_version' => ['required', 'integer', 'min:0'], 'confirm' => ['accepted'],
|
||||
'reason' => ['required', 'string', 'min:20', 'max:2000'],
|
||||
'definition' => ['required', 'array:title,theme,profile,stage,entity,result,fields'],
|
||||
'definition.title' => ['required', 'string', 'max:200'],
|
||||
'definition.theme' => ['required', 'string', 'max:150'],
|
||||
'definition.profile' => ['required', 'string', 'max:200'],
|
||||
'definition.stage' => ['required', 'string', 'max:20'],
|
||||
'definition.entity' => ['required', 'string', 'max:300'],
|
||||
'definition.result' => ['required', 'string', 'max:2000'],
|
||||
'definition.fields' => ['required', 'array', 'min:1', 'max:60'],
|
||||
'definition.fields.*' => ['required', 'array:label,type,required,min,options,when'],
|
||||
'definition.fields.*.label' => ['required', 'string', 'max:300'],
|
||||
'definition.fields.*.type' => ['required', Rule::in(['text', 'textarea', 'email', 'tel', 'url', 'number', 'date', 'select', 'territory'])],
|
||||
'definition.fields.*.required' => ['required', 'boolean'],
|
||||
'definition.fields.*.min' => ['sometimes', 'numeric', 'min:0', 'max:999999999999'],
|
||||
'definition.fields.*.options' => ['sometimes', 'array', 'min:1', 'max:100'],
|
||||
'definition.fields.*.options.*' => ['required', 'string', 'max:300'],
|
||||
'definition.fields.*.when' => ['sometimes', 'array:field,equals', 'required_array_keys:field,equals'],
|
||||
'definition.fields.*.when.field' => ['required_with:definition.fields.*.when', 'string', 'max:64'],
|
||||
'definition.fields.*.when.equals' => ['required_with:definition.fields.*.when', 'string', 'max:1000'],
|
||||
])->validate();
|
||||
$this->checkFields($data['definition']['fields']);
|
||||
|
||||
return DB::transaction(function () use ($actor, $code, $data): \stdClass {
|
||||
DB::select('select pg_advisory_xact_lock(261008, 2)');
|
||||
$latest = DB::table('desk_service_versions')->where('code', $code)->orderByDesc('version')->first();
|
||||
$previous = $latest ? json_decode($latest->definition, true, 512, JSON_THROW_ON_ERROR) : config('virtual-desk.services.'.$code);
|
||||
if ($data['definition']['stage'] !== $previous['stage']) {
|
||||
throw ValidationException::withMessages(['definition.stage' => 'A etapa pertence ao percurso existente e não pode ser alterada neste editor.']);
|
||||
}
|
||||
foreach (config('virtual-desk.services.'.$code.'.fields') as $key => $canonical) {
|
||||
if (isset($data['definition']['fields'][$key]) && $data['definition']['fields'][$key]['type'] !== $canonical['type']) {
|
||||
throw ValidationException::withMessages(['definition.fields.'.$key.'.type' => 'Este identificador é interpretado pelo motor existente. Conserve o tipo ou acrescente um novo campo com outro identificador.']);
|
||||
}
|
||||
}
|
||||
// Retry of the same confirmed publication preserves the first version and authorship.
|
||||
if ($latest && (int) $latest->version === (int) $data['base_version'] + 1 && $latest->status === 'published'
|
||||
&& $this->definitionHash(json_decode($latest->definition, true, 512, JSON_THROW_ON_ERROR)) === $this->definitionHash($data['definition'])) {
|
||||
return $latest;
|
||||
}
|
||||
abort_unless((int) ($latest->version ?? 0) === (int) $data['base_version'], 409, 'O formulário foi alterado. Recarregue e compare a versão actual antes de publicar.');
|
||||
if ($latest && $latest->status === 'published' && $this->definitionHash($previous) === $this->definitionHash($data['definition'])) {
|
||||
throw ValidationException::withMessages(['definition' => 'Não há alterações na definição para publicar uma nova versão.']);
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('desk_service_versions')->insert([
|
||||
'id' => $id, 'code' => $code, 'version' => (int) $data['base_version'] + 1, 'status' => 'published',
|
||||
'definition' => json_encode($data['definition'], JSON_THROW_ON_ERROR),
|
||||
'source' => $latest->source ?? json_encode(config('virtual-desk.source'), JSON_THROW_ON_ERROR),
|
||||
'author_id' => $actor->id, 'published_by' => $actor->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('audit_events')->insert([
|
||||
'source_system' => 'virtual-desk', 'entity_type' => 'desk_service_versions', 'entity_id' => $id, 'actor_user_id' => $actor->id,
|
||||
'action' => 'service_definition_version_published',
|
||||
'before_data' => json_encode(['id' => $latest?->id, 'version' => (int) $data['base_version']], JSON_THROW_ON_ERROR),
|
||||
'after_data' => json_encode(['code' => $code, 'version' => (int) $data['base_version'] + 1, 'reason' => $data['reason'],
|
||||
'effect' => 'new_requests_only', 'definition_sha256' => $this->definitionHash($data['definition'])], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
return DB::table('desk_service_versions')->where('id', $id)->firstOrFail();
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<array-key,mixed> $definition */
|
||||
private function definitionHash(array $definition): string
|
||||
{
|
||||
return hash('sha256', json_encode(Arr::sortRecursive($definition), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/** @param array<string,array<string,mixed>> $fields */
|
||||
private function checkFields(array $fields): void
|
||||
{
|
||||
foreach ($fields as $key => $field) {
|
||||
$path = 'definition.fields.'.$key;
|
||||
if (! preg_match('/^[a-z][a-z0-9_]{0,63}$/D', (string) $key) || in_array($key, ['country', 'declared_origin_country_code', 'context_reuse', 'context_opportunity', 'context_opportunity_snapshot'], true)) {
|
||||
throw ValidationException::withMessages(['definition.fields' => 'Use identificadores de campo distintos dos campos comuns de país e contexto, com letras minúsculas, números e sublinhado.']);
|
||||
}
|
||||
if ($field['type'] === 'select' && empty($field['options'])) {
|
||||
throw ValidationException::withMessages([$path.'.options' => 'Indique as opções deste campo.']);
|
||||
}
|
||||
if ((isset($field['min']) && $field['type'] !== 'number') || (isset($field['options']) && $field['type'] !== 'select')) {
|
||||
throw ValidationException::withMessages([$path => 'O mínimo aplica-se apenas a números e as opções apenas a listas de respostas.']);
|
||||
}
|
||||
foreach (array_keys($field['options'] ?? []) as $option) {
|
||||
if (! preg_match('/^[a-zA-Z0-9_-]{1,80}$/D', (string) $option)) {
|
||||
throw ValidationException::withMessages([$path.'.options' => 'Use códigos de opção sem espaços ou pontuação especial.']);
|
||||
}
|
||||
}
|
||||
$visited = [$key];
|
||||
$node = $field;
|
||||
while (isset($node['when'])) {
|
||||
$parent = $node['when']['field'];
|
||||
if (! isset($fields[$parent]) || in_array($parent, $visited, true)) {
|
||||
throw ValidationException::withMessages([$path.'.when' => 'A condição deve referir outro campo existente, sem dependências circulares.']);
|
||||
}
|
||||
if ($fields[$parent]['type'] === 'number') {
|
||||
throw ValidationException::withMessages([$path.'.when' => 'As condições actuais comparam respostas exactas. Use uma lista de opções como origem da condição numérica.']);
|
||||
}
|
||||
if ($fields[$parent]['type'] === 'select' && ! array_key_exists($node['when']['equals'], $fields[$parent]['options'] ?? [])) {
|
||||
throw ValidationException::withMessages([$path.'.when' => 'O valor da condição deve corresponder a uma opção do campo escolhido.']);
|
||||
}
|
||||
$visited[] = $parent;
|
||||
$node = $fields[$parent];
|
||||
}
|
||||
}
|
||||
if (isset($fields['territory_id'])) {
|
||||
if (! isset($fields['location_source'])) {
|
||||
throw ValidationException::withMessages(['definition.fields.location_source' => 'A localização exige a respectiva fonte. Mantenha o campo da fonte ou retire também a localização.']);
|
||||
}
|
||||
$locationConditions = $this->conditions('territory_id', $fields);
|
||||
$sourceConditions = $this->conditions('location_source', $fields);
|
||||
if (array_diff_assoc($sourceConditions, $locationConditions) !== []) {
|
||||
throw ValidationException::withMessages(['definition.fields.location_source.when' => 'A fonte tem de estar disponível sempre que a localização se aplica. Use as mesmas condições ou condições mais abrangentes.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Conditions have already been checked for valid references and cycles.
|
||||
* @param array<string,array<string,mixed>> $fields
|
||||
* @return array<string,string> */
|
||||
private function conditions(string $key, array $fields): array
|
||||
{
|
||||
$conditions = [];
|
||||
while (isset($fields[$key]['when'])) {
|
||||
$when = $fields[$key]['when'];
|
||||
$conditions[$when['field']] = $when['equals'];
|
||||
$key = $when['field'];
|
||||
}
|
||||
|
||||
return $conditions;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ use Illuminate\Support\Str;
|
||||
|
||||
class DocumentManagementAccess
|
||||
{
|
||||
/** Existing editorial publication authority; it does not grant financial decision powers. */
|
||||
public function catalogueEditor(?User $actor): bool
|
||||
{
|
||||
return $actor && $actor->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->has($actor, 'auditor')
|
||||
&& ($this->full($actor) || $this->editorial($actor));
|
||||
}
|
||||
|
||||
public function full(User $actor): bool
|
||||
{
|
||||
return Gate::forUser($actor)->allows('manage-portal');
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\CheckedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Packaged official references prepare private drafts through existing editorial authority. */
|
||||
final class FundingProgrammeReferences
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(User $actor, string $code): array
|
||||
{
|
||||
app(FundingProgrammes::class)->guard($actor);
|
||||
$bytes = CheckedFile::read(database_path('data/funding-references.json'));
|
||||
$package = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless($package['format'] === 'investmoz-funding-references-v1', 503);
|
||||
$reference = $package['references'][$code] ?? null;
|
||||
abort_unless(is_array($reference), 404);
|
||||
$plan = ['code' => $code, 'edition' => $reference['edition'], 'draft' => $reference['draft'], 'provenance' => $reference['provenance'],
|
||||
'package_sha256' => hash('sha256', $bytes)];
|
||||
$plan['hash'] = hash_hmac('sha256', json_encode(Arr::sortRecursive($plan), JSON_THROW_ON_ERROR), config('app.key'));
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/** @return array{id:string,created:bool} */
|
||||
public function prepare(User $actor, string $code, string $hash): array
|
||||
{
|
||||
$plan = $this->preview($actor, $code);
|
||||
abort_unless(hash_equals($plan['hash'], $hash), 409, 'As referências mudaram. Recupere a prévia antes de preparar a ficha.');
|
||||
|
||||
return DB::transaction(function () use ($actor, $code, $plan): array {
|
||||
DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', [$code]);
|
||||
$existing = DB::table('funding_programmes')->where('code', $code)->lockForUpdate()->first();
|
||||
if ($existing) {
|
||||
return ['id' => $existing->id, 'created' => false];
|
||||
}
|
||||
$id = app(FundingProgrammes::class)->save($actor, null, ['version' => 0, 'code' => $code,
|
||||
'reason' => 'Preparação de referência oficial '.$plan['edition'].'; rascunho sem publicação automática.', 'draft' => $plan['draft']]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'funding-catalogue', 'entity_type' => 'funding_programmes', 'entity_id' => $id,
|
||||
'actor_user_id' => $actor->id, 'action' => 'reference_prepared', 'data_classification' => 'restricted',
|
||||
'after_data' => json_encode(['edition' => $plan['edition'], 'package_sha256' => $plan['package_sha256'], 'provenance' => $plan['provenance'], 'effect' => 'private_draft_only'], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return ['id' => $id, 'created' => true];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/** Informational funding catalogue, separate from applications, credit decisions and fiscal calculations. */
|
||||
final class FundingProgrammes
|
||||
{
|
||||
public const KINDS = ['fund' => 'Fundo', 'programme' => 'Programa de apoio', 'incentive' => 'Incentivo'];
|
||||
|
||||
public const WINDOWS = ['dated' => 'Datas documentadas', 'rolling' => 'Recepção contínua documentada', 'unknown' => 'Janela por confirmar', 'closed' => 'Encerrada segundo a fonte'];
|
||||
|
||||
public function installed(): bool
|
||||
{
|
||||
return Schema::hasTable('funding_programme_interests') && Schema::hasTable('funding_programme_versions');
|
||||
}
|
||||
|
||||
public function access(?User $actor): bool
|
||||
{
|
||||
return app(DocumentManagementAccess::class)->catalogueEditor($actor);
|
||||
}
|
||||
|
||||
public function guard(?User $actor): void
|
||||
{
|
||||
abort_unless($this->installed(), 404);
|
||||
abort_unless($this->access($actor), 403);
|
||||
}
|
||||
|
||||
public function publicQuery(): Builder
|
||||
{
|
||||
abort_unless($this->installed(), 404);
|
||||
|
||||
return DB::table('funding_programmes as p')->join('funding_programme_versions as v', fn ($join) => $join->on('v.id', '=', 'p.published_version_id')->on('v.programme_id', '=', 'p.id'))
|
||||
->where('v.published_at', '<=', now());
|
||||
}
|
||||
|
||||
public function readableVersions(): Builder
|
||||
{
|
||||
return DB::table('funding_programme_versions')->where('published_at', '<=', now())
|
||||
->whereIn('programme_id', $this->publicQuery()->select('p.id'));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $filters */
|
||||
public function catalogueQuery(array $filters): Builder
|
||||
{
|
||||
$query = $this->publicQuery();
|
||||
$includeUnknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
|
||||
if (! empty($filters['sector'])) {
|
||||
$query->where(function (Builder $scope) use ($filters, $includeUnknown): void {
|
||||
$scope->whereRaw("v.payload->>'sectors_scope' = 'all'")
|
||||
->orWhereJsonContains('v.payload->sector_ids', (int) $filters['sector']);
|
||||
if ($includeUnknown) {
|
||||
$scope->orWhereRaw("v.payload->>'sectors_scope' = 'unknown'");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (! empty($filters['territory'])) {
|
||||
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
|
||||
$query->where(function (Builder $scope) use ($filters, $path, $includeUnknown): void {
|
||||
$scope->whereRaw("v.payload->>'coverage' = 'national'")
|
||||
->orWhereJsonContains('v.payload->territory_ancestor_ids', $filters['territory']);
|
||||
foreach ($path as $id) {
|
||||
$scope->orWhereJsonContains('v.payload->territory_ids', $id);
|
||||
}
|
||||
if ($includeUnknown) {
|
||||
$scope->orWhereRaw("v.payload->>'coverage' = 'unknown'");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (! empty($filters['state'])) {
|
||||
$today = today()->toDateString();
|
||||
$state = $filters['state'];
|
||||
if ($state === 'unknown') {
|
||||
$query->whereRaw("(jsonb_array_length(v.payload->'windows') = 0 OR EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'unknown'))");
|
||||
} elseif ($state === 'closed') {
|
||||
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'closed' OR (w->>'mode' = 'dated' AND w->>'end_on' < ?))", [$today]);
|
||||
} elseif ($state === 'upcoming') {
|
||||
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'dated' AND w->>'start_on' > ?)", [$today]);
|
||||
} else {
|
||||
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' IN ('dated','rolling') AND COALESCE(NULLIF(w->>'start_on',''),?) <= ? AND COALESCE(NULLIF(w->>'end_on',''),?) >= ?)", [$today, $today, $today, $today]);
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array<string,string> */
|
||||
public function filterExplanation(array $record, array $filters): array
|
||||
{
|
||||
$payload = $record['payload'];
|
||||
$result = [];
|
||||
if (! empty($filters['sector'])) {
|
||||
$result['sector'] = $payload['sectors_scope'] === 'unknown'
|
||||
? 'Âmbito sectorial por confirmar; não é uma correspondência confirmada.'
|
||||
: 'O sector está abrangido pelo âmbito documentado do programa.';
|
||||
}
|
||||
if (! empty($filters['territory'])) {
|
||||
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
|
||||
$result['territory'] = $payload['coverage'] === 'unknown' ? 'Cobertura territorial por confirmar; não é uma correspondência confirmada.'
|
||||
: ($payload['coverage'] === 'national' || array_intersect($path, $payload['territory_ids']) !== []
|
||||
? 'O local está abrangido pela cobertura documentada do programa.'
|
||||
: 'O programa abrange uma parte da área seleccionada; confira os locais na ficha.');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function published(string $id): array
|
||||
{
|
||||
$row = $this->publicQuery()->where('p.id', $id)->firstOrFail(['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at']);
|
||||
|
||||
return $this->present($row);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function present(\stdClass $row): array
|
||||
{
|
||||
$payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
foreach ($payload['windows'] as &$window) {
|
||||
$window['state'] = $this->windowState($window);
|
||||
}
|
||||
unset($window);
|
||||
$payload['documents'] = array_map(fn ($document) => [...$document,
|
||||
'available' => app(Procurement::class)->publicDocuments()->where('r.id', $document['resource_id'])->where('d.id', $document['document_id'])
|
||||
->where('d.sha256', $document['sha256'])->where('d.version_no', $document['version'])->exists(),
|
||||
], $payload['document_snapshots'] ?? []);
|
||||
|
||||
return ['id' => $row->id, 'code' => $row->code, 'version_id' => $row->version_id, 'version' => (int) $row->version,
|
||||
'published_at' => $row->published_at, 'payload' => $payload];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $window */
|
||||
public function windowState(array $window): string
|
||||
{
|
||||
if ($window['mode'] === 'closed') {
|
||||
return 'closed';
|
||||
}
|
||||
if ($window['mode'] === 'unknown') {
|
||||
return 'unknown';
|
||||
}
|
||||
if (! empty($window['end_on']) && $window['end_on'] < today()->toDateString()) {
|
||||
return 'closed';
|
||||
}
|
||||
if (! empty($window['start_on']) && $window['start_on'] > today()->toDateString()) {
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
return 'open';
|
||||
}
|
||||
|
||||
/** Resolve only the exact library binary incorporated in the current public version. */
|
||||
public function download(string $id, string $version, int $resource): Response|BinaryFileResponse
|
||||
{
|
||||
$record = $this->published($id);
|
||||
abort_unless($record['version_id'] === $version, 404);
|
||||
$snapshot = null;
|
||||
foreach ($record['payload']['document_snapshots'] ?? [] as $candidate) {
|
||||
if ($candidate['resource_id'] === $resource) {
|
||||
$snapshot = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
abort_unless($snapshot !== null, 404);
|
||||
$document = app(Procurement::class)->publicDocuments()->where('r.id', $resource)
|
||||
->where('d.id', $snapshot['document_id'])->where('d.version_no', $snapshot['version'])
|
||||
->where('d.sha256', $snapshot['sha256'])->first(['d.*']);
|
||||
abort_unless($document !== null, 404);
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($document);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, ?string $id, array $input): string
|
||||
{
|
||||
$this->guard($actor);
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:0', 'code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D',
|
||||
'reason' => 'required|string|min:20|max:2000', 'draft' => 'required|array'])->validate();
|
||||
$draft = $this->normalize($actor, $data['draft'], false);
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data, $draft): string {
|
||||
DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', [$data['code']]);
|
||||
$row = $id ? DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail() : null;
|
||||
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'A ficha foi alterada. Recupere a versão actual antes de guardar.');
|
||||
abort_if($row && $row->code !== $data['code'], 422, 'O identificador da ficha é estável.');
|
||||
if (! $row && DB::table('funding_programmes')->where('code', $data['code'])->exists()) {
|
||||
throw ValidationException::withMessages(['code' => 'Este identificador já pertence a uma ficha. Abra-a para actualizar ou acrescentar uma janela.']);
|
||||
}
|
||||
$id ??= (string) Str::uuid();
|
||||
DB::table('funding_programmes')->updateOrInsert(['id' => $id], ['code' => $data['code'], 'version' => (int) $data['version'] + 1,
|
||||
'draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'created_by' => $row->created_by ?? $actor->id,
|
||||
'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
|
||||
$this->audit($actor, $id, 'draft_saved', ['version' => (int) $data['version'] + 1, 'reason' => $data['reason']]);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
private function normalize(User $actor, array $input, bool $publishing): array
|
||||
{
|
||||
$required = $publishing ? 'required' : 'nullable';
|
||||
$rules = [
|
||||
'title' => "$required|string|max:250", 'kind' => ['required', Rule::in(array_keys(self::KINDS))], 'summary' => "$required|string|max:4000",
|
||||
'manager_name' => 'nullable|string|max:250', 'manager_organization_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
|
||||
'source_url' => "$required|url:http,https|max:2000", 'source_reference' => "$required|string|max:1500", 'source_checked_on' => "$required|date_format:Y-m-d|before_or_equal:today",
|
||||
'source_published_on' => 'nullable|date_format:Y-m-d', 'source_status' => 'required|in:identified,unknown,example',
|
||||
'coverage' => 'required|in:national,territorial,unknown', 'coverage_text' => 'nullable|string|max:2000',
|
||||
'territory_ids' => 'array|max:30', 'territory_ids.*' => 'required|uuid|distinct',
|
||||
'sectors_scope' => 'required|in:all,selected,unknown', 'sectors_text' => 'nullable|string|max:2000', 'sector_ids' => 'array|max:40',
|
||||
'sector_ids.*' => ['required', 'integer', 'distinct', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
|
||||
'resource_ids' => 'array|max:30', 'resource_ids.*' => 'required|integer|distinct|exists:portal_resources,id',
|
||||
'criteria' => 'array|max:30', 'windows' => 'array|max:20',
|
||||
'windows.*' => 'required|array:code,title,mode,start_on,end_on,coverage,coverage_text,territory_ids,source_url,source_reference,checked_on,application_url,criteria',
|
||||
'windows.*.code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D|distinct', 'windows.*.title' => "$required|string|max:250",
|
||||
'windows.*.mode' => ['required', Rule::in(array_keys(self::WINDOWS))], 'windows.*.start_on' => 'nullable|date_format:Y-m-d', 'windows.*.end_on' => 'nullable|date_format:Y-m-d',
|
||||
'windows.*.coverage' => 'required|in:programme,national,territorial,unknown', 'windows.*.coverage_text' => 'nullable|string|max:2000',
|
||||
'windows.*.territory_ids' => 'array|max:30', 'windows.*.territory_ids.*' => 'required|uuid|distinct',
|
||||
'windows.*.source_url' => "$required|url:http,https|max:2000", 'windows.*.source_reference' => "$required|string|max:1500",
|
||||
'windows.*.checked_on' => "$required|date_format:Y-m-d|before_or_equal:today", 'windows.*.application_url' => 'nullable|url:http,https|max:2000', 'windows.*.criteria' => 'array|max:30',
|
||||
];
|
||||
foreach (['criteria', 'windows.*.criteria'] as $prefix) {
|
||||
$rules[$prefix.'.*'] = 'required|array:code,question,applies_to,expected,source_reference,explanation';
|
||||
$rules[$prefix.'.*.code'] = 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D';
|
||||
$rules[$prefix.'.*.question'] = "$required|string|max:500";
|
||||
$rules[$prefix.'.*.applies_to'] = 'required|in:all,individual,entity';
|
||||
$rules[$prefix.'.*.expected'] = 'required|in:yes,no';
|
||||
$rules[$prefix.'.*.source_reference'] = "$required|string|max:1500";
|
||||
$rules[$prefix.'.*.explanation'] = "$required|string|max:2000";
|
||||
}
|
||||
$data = Validator::make($input, $rules)->validate();
|
||||
$data = [...$data, 'territory_ids' => $data['territory_ids'] ?? [], 'sector_ids' => $data['sector_ids'] ?? [], 'resource_ids' => $data['resource_ids'] ?? [],
|
||||
'criteria' => $data['criteria'] ?? [], 'windows' => $data['windows'] ?? []];
|
||||
if ($data['coverage'] !== 'territorial' && $data['territory_ids'] !== []) {
|
||||
throw ValidationException::withMessages(['territory_ids' => 'Seleccione cobertura territorial para associar locais específicos.']);
|
||||
}
|
||||
if ($data['sectors_scope'] !== 'selected' && $data['sector_ids'] !== []) {
|
||||
throw ValidationException::withMessages(['sector_ids' => 'Seleccione o âmbito por sectores para associar sectores específicos.']);
|
||||
}
|
||||
if ($publishing && $data['coverage'] === 'territorial' && $data['territory_ids'] === []) {
|
||||
throw ValidationException::withMessages(['territory_ids' => 'Seleccione os locais documentados ou assinale a cobertura como por determinar.']);
|
||||
}
|
||||
if ($data['manager_organization_id'] ?? null) {
|
||||
$data['manager_name'] = DB::table('organizations')->where('id', $data['manager_organization_id'])->value('name');
|
||||
}
|
||||
if ($publishing && empty($data['manager_name'])) {
|
||||
throw ValidationException::withMessages(['manager_name' => 'Seleccione a entidade do cadastro ou identifique o gestor indicado pela fonte.']);
|
||||
}
|
||||
if ($publishing && $data['source_status'] !== 'identified') {
|
||||
throw ValidationException::withMessages(['source_status' => 'Identifique a fonte efectiva. Conteúdo de exemplo ou origem por identificar não confirma um programa real.']);
|
||||
}
|
||||
if ($publishing && $data['sectors_scope'] === 'selected' && $data['sector_ids'] === []) {
|
||||
throw ValidationException::withMessages(['sector_ids' => 'Seleccione os sectores documentados ou assinale o âmbito como por determinar.']);
|
||||
}
|
||||
$territories = $data['territory_ids'];
|
||||
$codes = [];
|
||||
foreach (array_merge([$data['criteria']], array_column($data['windows'], 'criteria')) as $criteria) {
|
||||
foreach ($criteria as $criterion) {
|
||||
if (in_array($criterion['code'], $codes, true)) {
|
||||
throw ValidationException::withMessages(['criteria' => 'Cada critério deve ter um identificador único na ficha, sem repetir perguntas comuns por janela.']);
|
||||
}
|
||||
$codes[] = $criterion['code'];
|
||||
}
|
||||
}
|
||||
foreach ($data['windows'] as $index => &$window) {
|
||||
$window['criteria'] ??= [];
|
||||
$window['territory_ids'] ??= [];
|
||||
if ($window['coverage'] !== 'territorial' && $window['territory_ids'] !== []) {
|
||||
throw ValidationException::withMessages(['windows.'.$index.'.territory_ids' => 'Associe locais apenas numa janela de cobertura territorial.']);
|
||||
}
|
||||
if ($publishing && $window['coverage'] === 'territorial' && $window['territory_ids'] === []) {
|
||||
throw ValidationException::withMessages(['windows.'.$index.'.territory_ids' => 'Seleccione os locais documentados ou assinale a cobertura como por determinar.']);
|
||||
}
|
||||
$territories = array_merge($territories, $window['territory_ids']);
|
||||
if ($publishing && $window['mode'] === 'dated' && empty($window['end_on'])) {
|
||||
throw ValidationException::withMessages(['windows.'.$index.'.end_on' => 'Uma janela com datas documentadas precisa da data-limite.']);
|
||||
}
|
||||
if (! empty($window['start_on']) && ! empty($window['end_on']) && $window['start_on'] > $window['end_on']) {
|
||||
throw ValidationException::withMessages(['windows.'.$index.'.end_on' => 'A data-limite não pode anteceder a abertura.']);
|
||||
}
|
||||
if (in_array($window['mode'], ['rolling', 'unknown'], true) && (! empty($window['start_on']) || ! empty($window['end_on']))) {
|
||||
throw ValidationException::withMessages(['windows.'.$index.'.mode' => 'Conserve datas documentadas ou escolha uma janela sem datas; não misture as duas indicações.']);
|
||||
}
|
||||
}
|
||||
unset($window);
|
||||
foreach (array_unique($territories) as $territory) {
|
||||
if (! app(InvestmentGeography::class)->publicReferences()->where('id', $territory)->exists()) {
|
||||
throw ValidationException::withMessages(['territory_ids' => 'Seleccione referências do cadastro territorial público.']);
|
||||
}
|
||||
}
|
||||
if ($publishing) {
|
||||
$data['territory_paths'] = array_map(fn ($id) => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id)), $data['territory_ids']);
|
||||
$data['territory_ancestor_ids'] = array_values(array_unique(array_merge([], ...array_map(fn ($path) => array_column($path, 'id'), $data['territory_paths']))));
|
||||
$data['sector_snapshots'] = Sector::choices()->whereIn('id', $data['sector_ids'])->get(['id', 'name'])->toArray();
|
||||
foreach ($data['windows'] as &$window) {
|
||||
$window['territory_paths'] = array_map(fn ($id) => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id)), $window['territory_ids']);
|
||||
}
|
||||
unset($window);
|
||||
}
|
||||
foreach ($data['resource_ids'] as $resource) {
|
||||
$public = app(Procurement::class)->publicDocuments()->where('r.id', $resource)->first(['r.title', 'd.id', 'd.version_no', 'd.sha256']);
|
||||
$document = DB::table('portal_resources')->where('id', $resource)->value('document_id');
|
||||
if (! $public && ($publishing || ! app(DocumentManagementAccess::class)->documents($actor)->where('id', $document)->exists())) {
|
||||
throw ValidationException::withMessages(['resource_ids' => 'Um documento publicado exige acesso público, direitos e análise de segurança válidos na biblioteca.']);
|
||||
}
|
||||
if ($publishing) {
|
||||
$data['document_snapshots'][] = ['resource_id' => $resource, 'document_id' => $public->id, 'version' => (int) $public->version_no,
|
||||
'sha256' => trim($public->sha256), 'title' => $public->title];
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preview(User $actor, string $id): array
|
||||
{
|
||||
$this->guard($actor);
|
||||
$row = DB::table('funding_programmes')->where('id', $id)->firstOrFail();
|
||||
$payload = $this->normalize($actor, json_decode($row->draft, true, 512, JSON_THROW_ON_ERROR), true);
|
||||
unset($payload['manager_organization_id']);
|
||||
$plan = ['id' => $id, 'code' => $row->code, 'version' => (int) $row->version, 'payload' => $payload];
|
||||
$plan['hash'] = hash_hmac('sha256', json_encode(Arr::sortRecursive($plan), JSON_THROW_ON_ERROR), config('app.key'));
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function publish(User $actor, string $id, array $input): string
|
||||
{
|
||||
$this->guard($actor);
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'hash' => 'required|string|size:64',
|
||||
'reason' => 'required|string|min:20|max:2000', 'confirm' => 'accepted'])->validate();
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data): string {
|
||||
$row = DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail();
|
||||
$plan = $this->preview($actor, $id);
|
||||
abort_unless((int) $row->version === (int) $data['version'] && hash_equals($plan['hash'], $data['hash']), 409, 'A prévia mudou. Confira novamente antes de publicar.');
|
||||
if ($row->published_version_id) {
|
||||
$previous = DB::table('funding_programme_versions')->where('id', $row->published_version_id)->firstOrFail();
|
||||
if (json_decode($previous->payload, false, 512, JSON_THROW_ON_ERROR) == json_decode(json_encode($plan['payload'], JSON_THROW_ON_ERROR), false, 512, JSON_THROW_ON_ERROR)) {
|
||||
return $previous->id;
|
||||
}
|
||||
}
|
||||
$version = (string) Str::uuid();
|
||||
$publicationNumber = max((int) $row->version, (int) DB::table('funding_programme_versions')->where('programme_id', $id)->max('version') + 1);
|
||||
DB::table('funding_programme_versions')->insert(['id' => $version, 'programme_id' => $id, 'version' => $publicationNumber,
|
||||
'payload' => json_encode($plan['payload'], JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'actor_id' => $actor->id, 'published_at' => now()]);
|
||||
DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => $version, 'updated_at' => now()]);
|
||||
$this->audit($actor, $id, 'published', ['version_id' => $version, 'version' => $publicationNumber, 'draft_version' => (int) $row->version, 'reason' => $data['reason'], 'effect' => 'new_public_version_only']);
|
||||
|
||||
return $version;
|
||||
});
|
||||
}
|
||||
|
||||
public function withdraw(User $actor, string $id, int $version, string $reason): void
|
||||
{
|
||||
$this->guard($actor);
|
||||
Validator::make(['reason' => $reason], ['reason' => 'required|string|min:20|max:2000'])->validate();
|
||||
DB::transaction(function () use ($actor, $id, $version, $reason): void {
|
||||
$row = DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless((int) $row->version === $version, 409);
|
||||
DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => null, 'version' => $version + 1, 'updated_at' => now()]);
|
||||
$this->audit($actor, $id, 'withdrawn', ['previous_version_id' => $row->published_version_id, 'reason' => $reason]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function assess(string $id, array $input): array
|
||||
{
|
||||
$record = $this->published($id);
|
||||
$data = Validator::make($input, ['version_id' => 'required|uuid', 'applicant_kind' => 'required|in:individual,entity,unknown',
|
||||
'window_code' => 'nullable|string|max:80', 'answers' => 'present|array|max:60', 'answers.*' => 'required|in:yes,no,unknown'])->validate();
|
||||
abort_unless($data['version_id'] === $record['version_id'], 409, 'A versão pública mudou. Recupere os critérios actuais antes de conferir.');
|
||||
$window = null;
|
||||
foreach ($record['payload']['windows'] as $candidate) {
|
||||
if ($candidate['code'] === ($data['window_code'] ?? null)) {
|
||||
$window = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
abort_if(! empty($data['window_code']) && ! $window, 422);
|
||||
$criteria = array_merge($record['payload']['criteria'], $window['criteria'] ?? []);
|
||||
abort_if(array_diff(array_keys($data['answers']), array_column($criteria, 'code')) !== [], 422, 'A resposta deve pertencer aos critérios desta versão e janela.');
|
||||
$results = [];
|
||||
foreach ($criteria as $criterion) {
|
||||
$status = 'unknown';
|
||||
if ($criterion['applies_to'] !== 'all' && $data['applicant_kind'] !== 'unknown' && $criterion['applies_to'] !== $data['applicant_kind']) {
|
||||
$status = 'not_applicable';
|
||||
} elseif (($criterion['applies_to'] === 'all' || $criterion['applies_to'] === $data['applicant_kind']) && ($data['answers'][$criterion['code']] ?? 'unknown') !== 'unknown') {
|
||||
$status = $data['answers'][$criterion['code']] === $criterion['expected'] ? 'matches' : 'does_not_match';
|
||||
}
|
||||
$results[] = [...$criterion, 'status' => $status];
|
||||
}
|
||||
|
||||
return ['version_id' => $record['version_id'], 'window_code' => $window['code'] ?? null, 'window_state' => $window ? $this->windowState($window) : 'unknown',
|
||||
'criteria' => $results, 'counts' => array_count_values(array_column($results, 'status')),
|
||||
'notice' => 'Correspondência preliminar das respostas declaradas com os critérios citados. Não é aprovação, candidatura enviada, concessão de financiamento ou benefício fiscal.'];
|
||||
}
|
||||
|
||||
public function saveInterest(User $actor, string $id, string $version, bool $consent): void
|
||||
{
|
||||
abort_unless($consent, 422, 'Confirme que pretende guardar este programa na sua área privada.');
|
||||
DB::transaction(function () use ($actor, $id, $version): void {
|
||||
// Serialize this person's bookmarks without blocking audit FK KEY SHARE locks.
|
||||
DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail();
|
||||
DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail();
|
||||
$record = $this->published($id);
|
||||
abort_unless($record['version_id'] === $version, 409, 'A versão pública mudou. Recupere a ficha antes de guardar.');
|
||||
$saved = DB::table('funding_programme_interests')->where('user_id', $actor->id)->where('programme_id', $id)->first();
|
||||
if ($saved && $saved->removed_at === null) {
|
||||
return;
|
||||
}
|
||||
DB::table('funding_programme_interests')->updateOrInsert(['user_id' => $actor->id, 'programme_id' => $id], [
|
||||
'version_id' => $version, 'saved_at' => now(), 'removed_at' => null,
|
||||
]);
|
||||
$this->audit($actor, $id, 'interest_saved', ['version_id' => $version, 'scope' => 'private_bookmark_not_application']);
|
||||
});
|
||||
}
|
||||
|
||||
public function removeInterest(User $actor, string $id): void
|
||||
{
|
||||
DB::transaction(function () use ($actor, $id): void {
|
||||
DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail();
|
||||
if (DB::table('funding_programme_interests')->where('user_id', $actor->id)->where('programme_id', $id)->whereNull('removed_at')->update(['removed_at' => now()])) {
|
||||
$this->audit($actor, $id, 'interest_removed', ['scope' => 'private_bookmark']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function audit(User $actor, string $id, string $action, array $data): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'funding-catalogue', 'entity_type' => 'funding_programmes', 'entity_id' => $id,
|
||||
'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'after_data' => json_encode($data, JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
}
|
||||
@@ -135,19 +135,7 @@ class InvestmentCatalogue
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.$filters['q'].'%';
|
||||
$query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('description', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term)
|
||||
->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term))
|
||||
->orWhereExists(fn ($g) => $g->selectRaw('1')->from('geo_locations as search_location')->whereNull('search_location.deleted_at')->where('search_location.data_classification', 'public')
|
||||
->where('search_location.name', 'ilike', $term)->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
|
||||
->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id')
|
||||
->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations as search_link')->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id'))))
|
||||
->orWhereExists(fn ($d) => $d->selectRaw('1')->from('investment_dossiers as searchable_dossier')
|
||||
->whereNull('searchable_dossier.deleted_at')->where('searchable_dossier.publication_status', 'published')
|
||||
->where(fn ($text) => $text->where('searchable_dossier.title', 'ilike', $term)->orWhereRaw('searchable_dossier.content::text ILIKE ?', [$term]))
|
||||
->where(fn ($relation) => $relation
|
||||
->whereExists(fn ($p) => $p->selectRaw('1')->from('opportunity_projects as search_project')->whereColumn('search_project.opportunity_id', 'opportunities.id')->whereColumn('search_project.project_id', 'searchable_dossier.project_id'))
|
||||
->orWhereExists(fn ($c) => $c->selectRaw('1')->from('opportunity_clusters as search_cluster')->whereColumn('search_cluster.opportunity_id', 'opportunities.id')->whereColumn('search_cluster.cluster_id', 'searchable_dossier.cluster_id')))));
|
||||
app(OpportunityTextSearch::class)->constrain($query, $filters['q']);
|
||||
}
|
||||
if (! empty($filters['sector'])) {
|
||||
SectorOrganization::constrain($query, $filters['sector']);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class InvestmentOpportunityAlerts
|
||||
{
|
||||
public const FREQUENCIES = ['as_available' => 'Na próxima verificação', 'daily' => 'De 24 em 24 horas', 'weekly' => 'De 7 em 7 dias'];
|
||||
|
||||
public function fundingAvailable(): bool
|
||||
{
|
||||
return Schema::hasTable('investment_funding_alert_seen') && Schema::hasColumn('investment_notifications', 'funding_version_id')
|
||||
&& app(FundingProgrammes::class)->installed();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $criteria
|
||||
* @return array<string,mixed> */
|
||||
private function fundingFilters(array $criteria): array
|
||||
{
|
||||
return array_filter(['sector' => isset($criteria['sector']) ? Sector::choices()->where('slug', $criteria['sector'])->value('id') : null,
|
||||
'territory' => $criteria['province_id'] ?? null, 'certainty' => $criteria['funding_certainty'] ?? 'documented',
|
||||
'state' => $criteria['funding_state'] ?? null], fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
public function tendersInstalled(): bool
|
||||
{
|
||||
return Schema::hasTable('investment_tender_alert_seen') && Schema::hasColumn('investment_alert_preferences', 'sources')
|
||||
&& Schema::hasColumn('investment_notifications', 'tender_version_id');
|
||||
}
|
||||
|
||||
public function tendersAvailable(): bool
|
||||
{
|
||||
return $this->tendersInstalled() && app(Procurement::class)->available();
|
||||
}
|
||||
|
||||
public function installed(): bool
|
||||
{
|
||||
return Schema::hasTable('investment_alert_seen') && Schema::hasTable('investment_alert_preferences') && Schema::hasColumn('investment_notifications', 'opportunity_id');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function preferences(int $user): array
|
||||
{
|
||||
$row = DB::table('investment_alert_preferences')->where('user_id', $user)->first();
|
||||
|
||||
$criteria = $row ? json_decode(Crypt::decryptString($row->criteria), true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$sources = isset($row->sources) ? json_decode($row->sources, true, 512, JSON_THROW_ON_ERROR) : ['opportunities'];
|
||||
$statuses = [];
|
||||
foreach ($sources as $source) {
|
||||
$statuses[$source] = $this->criteriaAvailable($criteria, $source) && ($source !== 'tenders' || $this->tendersAvailable())
|
||||
&& ($source !== 'funding' || $this->fundingAvailable());
|
||||
}
|
||||
|
||||
return ['version' => (int) ($row->version ?? 0), 'enabled' => (bool) ($row->enabled ?? false),
|
||||
'frequency' => $row->frequency ?? 'as_available', 'channel' => 'in_app', 'next_run_at' => $row->next_run_at ?? null,
|
||||
'criteria' => $criteria, 'sources' => $sources, 'source_statuses' => $statuses, 'criteria_available' => ! in_array(false, $statuses, true)];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $criteria */
|
||||
private function criteriaAvailable(array $criteria, string $source): bool
|
||||
{
|
||||
return (! isset($criteria['sector']) || Sector::choices()->whereNotNull('published_at')->where('slug', $criteria['sector'])->exists())
|
||||
&& (! isset($criteria['province_id']) || app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->where('id', $criteria['province_id'])->exists())
|
||||
&& ($source !== 'opportunities' || ! isset($criteria['category']) || in_array($criteria['category'], OpportunityManagement::CATEGORIES, true))
|
||||
&& ($source !== 'tenders' || ! isset($criteria['tender_regime']) || array_key_exists($criteria['tender_regime'], config('procurement.regimes')))
|
||||
&& ($source !== 'funding' || ((! isset($criteria['funding_certainty']) || in_array($criteria['funding_certainty'], ['documented', 'include_unknown'], true))
|
||||
&& (! isset($criteria['funding_state']) || in_array($criteria['funding_state'], ['open', 'closed', 'upcoming', 'unknown'], true))));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function save(User $actor, array $input): array
|
||||
{
|
||||
abort_unless($this->installed(), 503);
|
||||
$enabling = in_array($input['enabled'] ?? null, [true, 1, '1'], true);
|
||||
// Cancelling only needs the actor and version; retired form choices must not prevent it.
|
||||
$validatedInput = $enabling ? $input : ['version' => $input['version'] ?? null, 'enabled' => $input['enabled'] ?? null];
|
||||
if ($enabling) {
|
||||
$validatedInput['sources'] ??= ['opportunities'];
|
||||
if (is_array($validatedInput['sources']) && is_array($validatedInput['criteria'] ?? null)) {
|
||||
if (! in_array('opportunities', $validatedInput['sources'], true)) {
|
||||
unset($validatedInput['criteria']['category']);
|
||||
}
|
||||
if (! in_array('tenders', $validatedInput['sources'], true)) {
|
||||
unset($validatedInput['criteria']['tender_regime']);
|
||||
}
|
||||
if (! in_array('funding', $validatedInput['sources'], true)) {
|
||||
unset($validatedInput['criteria']['funding_certainty'], $validatedInput['criteria']['funding_state']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$data = Validator::make($validatedInput, [
|
||||
'version' => ['required', 'integer', 'min:0'], 'enabled' => ['required', 'boolean'],
|
||||
'frequency' => [$enabling ? 'required' : 'sometimes', Rule::in(array_keys(self::FREQUENCIES))], 'channel' => [$enabling ? 'required' : 'sometimes', 'in:in_app'],
|
||||
'consent' => $enabling ? ['accepted'] : ['nullable', 'boolean'],
|
||||
'sources' => [$enabling ? 'required' : 'sometimes', 'array', 'min:1', 'max:3'],
|
||||
'sources.*' => ['required', 'string', 'distinct', Rule::in(['opportunities', 'tenders', 'funding'])],
|
||||
'criteria' => [$enabling ? 'present' : 'sometimes', 'array:sector,province_id,category,tender_regime,funding_certainty,funding_state'],
|
||||
'criteria.sector' => ['nullable', 'string', 'max:80', ...($enabling ? [Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('slug')->all())] : [])],
|
||||
'criteria.province_id' => ['nullable', 'uuid'], 'criteria.category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)],
|
||||
'criteria.tender_regime' => ['nullable', Rule::in(array_keys(config('procurement.regimes')))],
|
||||
'criteria.funding_certainty' => ['nullable', 'in:documented,include_unknown'], 'criteria.funding_state' => ['nullable', 'in:open,closed,upcoming,unknown'],
|
||||
])->validate();
|
||||
$sources = $data['sources'] ?? ['opportunities'];
|
||||
sort($sources);
|
||||
if ($enabling && in_array('funding', $sources, true) && ! $this->fundingAvailable()) {
|
||||
throw ValidationException::withMessages(['sources' => 'O catálogo de fundos não está disponível. Pode manter as outras fontes ou cancelar.']);
|
||||
}
|
||||
if ($enabling && in_array('tenders', $sources, true) && ! $this->tendersAvailable()) {
|
||||
throw ValidationException::withMessages(['sources' => 'O catálogo de concursos não está disponível. Pode manter o acompanhamento de oportunidades ou cancelar.']);
|
||||
}
|
||||
$criteria = array_filter($data['criteria'] ?? [], fn ($value) => $value !== null && $value !== '');
|
||||
ksort($criteria);
|
||||
if ($enabling && isset($criteria['province_id']) && ! app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->where('id', $criteria['province_id'])->exists()) {
|
||||
throw ValidationException::withMessages(['criteria.province_id' => 'Seleccione uma província do cadastro público.']);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $data, $criteria, $sources): array {
|
||||
// Keep preference writes serialized without blocking notification/audit FK readers.
|
||||
DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail();
|
||||
$row = DB::table('investment_alert_preferences')->where('user_id', $actor->id)->lockForUpdate()->first();
|
||||
$before = $this->preferences($actor->id);
|
||||
if (! $data['enabled']) {
|
||||
$data['frequency'] = $before['frequency'];
|
||||
$criteria = $before['criteria'];
|
||||
$sources = $before['sources'];
|
||||
}
|
||||
$same = $row && $before['enabled'] === (bool) $data['enabled'] && $before['frequency'] === $data['frequency'] && $before['criteria'] === $criteria && $before['sources'] === $sources;
|
||||
if ($same && in_array((int) $data['version'], [$before['version'], $before['version'] - 1], true)) {
|
||||
return $before;
|
||||
}
|
||||
abort_unless((int) $data['version'] === $before['version'], 409, 'As preferências foram alteradas. Recarregue antes de guardar.');
|
||||
if ($data['enabled'] && (! $before['enabled'] || $criteria !== $before['criteria'] || $sources !== $before['sources'])) {
|
||||
// Existing public identities establish the baseline; no timestamps or backdated publications are guessed.
|
||||
if (in_array('opportunities', $sources, true)) {
|
||||
Opportunity::published()->select('id')->chunkById(500, function ($records) use ($actor): void {
|
||||
DB::table('investment_alert_seen')->insertOrIgnore($records->map(fn ($record) => [
|
||||
'user_id' => $actor->id, 'opportunity_id' => $record->id, 'observed_at' => now(),
|
||||
])->all());
|
||||
});
|
||||
}
|
||||
if (in_array('tenders', $sources, true)) {
|
||||
app(Procurement::class)->publicQuery()->select('v.id as version_id')->chunkById(500, function ($records) use ($actor): void {
|
||||
DB::table('investment_tender_alert_seen')->insertOrIgnore($records->map(fn ($record) => [
|
||||
'user_id' => $actor->id, 'tender_version_id' => $record->version_id, 'observed_at' => now(),
|
||||
])->all());
|
||||
}, 'v.id', 'version_id');
|
||||
}
|
||||
if (in_array('funding', $sources, true)) {
|
||||
app(FundingProgrammes::class)->publicQuery()->select('v.id as version_id')->chunkById(500, function ($records) use ($actor): void {
|
||||
DB::table('investment_funding_alert_seen')->insertOrIgnore($records->map(fn ($record) => [
|
||||
'user_id' => $actor->id, 'funding_version_id' => $record->version_id, 'observed_at' => now(),
|
||||
])->all());
|
||||
}, 'v.id', 'version_id');
|
||||
}
|
||||
}
|
||||
$next = ! $data['enabled'] ? null : (($same || ($row && $before['enabled'] && $before['frequency'] === $data['frequency'])) ? $row->next_run_at : $this->nextRun($data['frequency']));
|
||||
$id = $row->id ?? (string) Str::uuid();
|
||||
DB::table('investment_alert_preferences')->updateOrInsert(['user_id' => $actor->id], [
|
||||
'id' => $id,
|
||||
'version' => $before['version'] + 1, 'enabled' => (bool) $data['enabled'], 'frequency' => $data['frequency'], 'channel' => 'in_app',
|
||||
'criteria' => Crypt::encryptString(json_encode($criteria, JSON_THROW_ON_ERROR)),
|
||||
'consented_at' => $data['enabled'] ? now() : ($row->consented_at ?? null), 'next_run_at' => $next,
|
||||
'created_at' => $row->created_at ?? now(), 'updated_at' => now(),
|
||||
...(Schema::hasColumn('investment_alert_preferences', 'sources') ? ['sources' => json_encode($sources, JSON_THROW_ON_ERROR)] : []),
|
||||
]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'investment-alerts', 'entity_type' => 'investment_alert_preferences',
|
||||
'entity_id' => $id, 'actor_user_id' => $actor->id, 'action' => $data['enabled'] ? 'preferences_saved' : 'preferences_cancelled',
|
||||
'data_classification' => 'restricted', 'before_data' => json_encode(['version' => $before['version'], 'enabled' => $before['enabled']], JSON_THROW_ON_ERROR),
|
||||
'after_data' => json_encode(['version' => $before['version'] + 1, 'enabled' => (bool) $data['enabled'], 'frequency' => $data['frequency'],
|
||||
'channel' => 'in_app', 'sources' => $sources, 'criteria_sha256' => hash('sha256', json_encode($criteria, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return $this->preferences($actor->id);
|
||||
});
|
||||
}
|
||||
|
||||
private function nextRun(string $frequency): CarbonImmutable
|
||||
{
|
||||
return match ($frequency) {
|
||||
'daily' => now()->addDay(), 'weekly' => now()->addWeek(), default => now(),
|
||||
};
|
||||
}
|
||||
|
||||
public function dispatchDue(): int
|
||||
{
|
||||
if (! $this->installed()) {
|
||||
return 0;
|
||||
}
|
||||
$count = 0;
|
||||
DB::table('investment_alert_preferences')->where('enabled', true)->where('next_run_at', '<=', now())
|
||||
->whereIn('user_id', DB::table('users')->whereNotNull('email_verified_at')->select('id'))
|
||||
->orderBy('user_id')->chunkById(100, function ($preferences) use (&$count): void {
|
||||
foreach ($preferences as $preference) {
|
||||
$count += DB::transaction(function () use ($preference): int {
|
||||
$row = DB::table('investment_alert_preferences')->where('user_id', $preference->user_id)->lockForUpdate()->first();
|
||||
if (! $row || ! $row->enabled || ! $row->next_run_at || CarbonImmutable::parse($row->next_run_at)->isFuture()) {
|
||||
return 0;
|
||||
}
|
||||
$criteria = json_decode(Crypt::decryptString($row->criteria), true, 512, JSON_THROW_ON_ERROR);
|
||||
$sources = isset($row->sources) ? json_decode($row->sources, true, 512, JSON_THROW_ON_ERROR) : ['opportunities'];
|
||||
$delivered = 0;
|
||||
$more = false;
|
||||
if (in_array('opportunities', $sources, true) && $this->criteriaAvailable($criteria, 'opportunities')) {
|
||||
$records = app(InvestmentCatalogue::class)->query(Arr::except($criteria, ['tender_regime', 'funding_certainty', 'funding_state']))->whereNotExists(fn ($seen) => $seen->selectRaw('1')
|
||||
->from('investment_alert_seen')->where('user_id', $row->user_id)->whereColumn('opportunity_id', 'opportunities.id'))
|
||||
->orderBy('opportunities.id')->limit(201)->get();
|
||||
foreach ($records->take(200) as $record) {
|
||||
$notification = app(InvestmentWorkspace::class)->notifyOpportunity((int) $row->user_id, $record, (int) $row->version, $criteria);
|
||||
DB::table('investment_alert_seen')->insert(['user_id' => $row->user_id, 'opportunity_id' => $record->id, 'notification_id' => $notification, 'observed_at' => now()]);
|
||||
}
|
||||
$delivered += min(200, $records->count());
|
||||
$more = $records->count() > 200;
|
||||
}
|
||||
if (in_array('tenders', $sources, true) && $this->tendersAvailable() && $this->criteriaAvailable($criteria, 'tenders')) {
|
||||
$filters = array_filter(['territory' => $criteria['province_id'] ?? null, 'regime' => $criteria['tender_regime'] ?? null], fn ($value) => $value !== null);
|
||||
if (isset($criteria['sector'])) {
|
||||
$filters['sector_ids'] = Sector::whereIn('slug', SectorOrganization::slugs($criteria['sector']))->pluck('id')->all();
|
||||
}
|
||||
$records = app(Procurement::class)->publicQuery($filters)->whereNotExists(fn ($seen) => $seen->selectRaw('1')
|
||||
->from('investment_tender_alert_seen')->where('user_id', $row->user_id)->whereColumn('tender_version_id', 'v.id'))
|
||||
->select('t.id as tender_id', 'v.id as version_id', 'v.version', 'v.act', 'v.payload', 'v.published_at')->orderBy('v.id')->limit(201)->get();
|
||||
foreach ($records->take(200) as $record) {
|
||||
$notification = app(InvestmentWorkspace::class)->notifyTender((int) $row->user_id, $record, (int) $row->version, $criteria);
|
||||
DB::table('investment_tender_alert_seen')->insert(['user_id' => $row->user_id, 'tender_version_id' => $record->version_id, 'notification_id' => $notification, 'observed_at' => now()]);
|
||||
}
|
||||
$delivered += min(200, $records->count());
|
||||
$more = $more || $records->count() > 200;
|
||||
}
|
||||
if (in_array('funding', $sources, true) && $this->fundingAvailable() && $this->criteriaAvailable($criteria, 'funding')) {
|
||||
$filters = $this->fundingFilters($criteria);
|
||||
$records = app(FundingProgrammes::class)->catalogueQuery($filters)->whereNotExists(fn ($seen) => $seen->selectRaw('1')
|
||||
->from('investment_funding_alert_seen')->where('user_id', $row->user_id)->whereColumn('funding_version_id', 'v.id'))
|
||||
->select('p.id as programme_id', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at')->orderBy('v.id')->limit(201)->get();
|
||||
foreach ($records->take(200) as $record) {
|
||||
$notification = app(InvestmentWorkspace::class)->notifyFunding((int) $row->user_id, $record, (int) $row->version, $criteria, $filters);
|
||||
DB::table('investment_funding_alert_seen')->insert(['user_id' => $row->user_id, 'funding_version_id' => $record->version_id, 'notification_id' => $notification, 'observed_at' => now()]);
|
||||
}
|
||||
$delivered += min(200, $records->count());
|
||||
$more = $more || $records->count() > 200;
|
||||
}
|
||||
$next = $more ? now() : ($row->frequency === 'as_available' ? now()->addMinutes(5) : $this->nextRun($row->frequency));
|
||||
DB::table('investment_alert_preferences')->where('user_id', $row->user_id)->update(['next_run_at' => $next]);
|
||||
|
||||
return $delivered;
|
||||
});
|
||||
}
|
||||
}, 'user_id');
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,68 @@ namespace App\Services;
|
||||
|
||||
use App\Models\InvestmentSpecificNeed;
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\Opportunity;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class InvestmentWorkspace
|
||||
{
|
||||
/** @param array<string,mixed> $criteria
|
||||
* @param array<string,mixed> $filters */
|
||||
public function notifyFunding(int $userId, \stdClass $record, int $version, array $criteria, array $filters): string
|
||||
{
|
||||
$public = json_decode($record->payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
$event = 'public-funding-version:'.$record->version_id;
|
||||
$id = (string) Str::uuid();
|
||||
$windows = [];
|
||||
foreach ($public['windows'] as $window) {
|
||||
$windows[] = ['title' => $window['title'], 'state' => app(FundingProgrammes::class)->windowState($window), 'end_on' => $window['end_on'] ?? null];
|
||||
}
|
||||
DB::table('investment_notifications')->insertOrIgnore(['id' => $id, 'user_id' => $userId, 'submission_id' => null,
|
||||
'funding_version_id' => $record->version_id, 'kind' => 'funding_match', 'event_key' => $event,
|
||||
'payload' => Crypt::encryptString(json_encode(['title' => $public['title'], 'programme_id' => $record->programme_id,
|
||||
'publication_version' => (int) $record->version, 'source' => 'Catálogo público de fundos e apoios do InvesteMoz',
|
||||
'published_at' => CarbonImmutable::parse($record->published_at)->toIso8601String(), 'source_url' => $public['source_url'],
|
||||
'source_reference' => $public['source_reference'], 'source_checked_on' => $public['source_checked_on'], 'window_states' => $windows,
|
||||
'scope_explanation' => app(FundingProgrammes::class)->filterExplanation(['payload' => $public], $filters),
|
||||
'preference_version' => $version, 'criteria' => $criteria], JSON_THROW_ON_ERROR)), 'created_at' => now()]);
|
||||
|
||||
return DB::table('investment_notifications')->where('user_id', $userId)->where('event_key', $event)->value('id');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $criteria */
|
||||
public function notifyTender(int $userId, \stdClass $record, int $version, array $criteria): string
|
||||
{
|
||||
$public = json_decode($record->payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
$event = 'public-tender-version:'.$record->version_id;
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('investment_notifications')->insertOrIgnore(['id' => $id, 'user_id' => $userId, 'submission_id' => null,
|
||||
'tender_version_id' => $record->version_id, 'kind' => 'tender_match', 'event_key' => $event,
|
||||
'payload' => Crypt::encryptString(json_encode(['title' => $public['title'], 'reference' => $public['reference'],
|
||||
'tender_id' => $record->tender_id, 'publication_version' => (int) $record->version, 'act' => $record->act,
|
||||
'source' => 'Catálogo público de concursos do InvesteMoz', 'published_at' => CarbonImmutable::parse($record->published_at)->toIso8601String(),
|
||||
'origin_published_at' => $public['origin_published_at'] ?? null, 'preference_version' => $version,
|
||||
'criteria' => $criteria], JSON_THROW_ON_ERROR)), 'created_at' => now()]);
|
||||
|
||||
return DB::table('investment_notifications')->where('user_id', $userId)->where('event_key', $event)->value('id');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $criteria */
|
||||
public function notifyOpportunity(int $userId, Opportunity $opportunity, int $version, array $criteria): string
|
||||
{
|
||||
$event = 'public-opportunity:'.$opportunity->id;
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('investment_notifications')->insertOrIgnore(['id' => $id, 'user_id' => $userId, 'submission_id' => null,
|
||||
'opportunity_id' => $opportunity->id, 'kind' => 'opportunity_match', 'event_key' => $event,
|
||||
'payload' => Crypt::encryptString(json_encode(['title' => $opportunity->title, 'slug' => $opportunity->slug,
|
||||
'source' => 'Catálogo público do InvesteMoz', 'published_at' => $opportunity->published_at?->toIso8601String(),
|
||||
'preference_version' => $version, 'criteria' => $criteria], JSON_THROW_ON_ERROR)), 'created_at' => now()]);
|
||||
|
||||
return DB::table('investment_notifications')->where('user_id', $userId)->where('event_key', $event)->value('id');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public function notify(int $userId, InvestmentSubmission $submission, string $kind, string $eventKey, array $payload = []): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
use App\Models\Opportunity;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Public keyword search: every meaningful term is required, with explicit narrow writing variants. */
|
||||
final class OpportunityTextSearch
|
||||
{
|
||||
private const STOP_WORDS = ['a', 'as', 'o', 'os', 'de', 'da', 'das', 'do', 'dos', 'e', 'em', 'no', 'nos', 'na', 'nas', 'para', 'por', 'com', 'um', 'uma', 'uns', 'umas'];
|
||||
|
||||
private const CULTIVATION = ['plantio', 'plantacao', 'plantacoes', 'cultivo', 'cultivar', 'cultivacao', 'semeadura', 'semeio'];
|
||||
|
||||
/** @return list<string> */
|
||||
public function terms(string $text): array
|
||||
{
|
||||
$parts = preg_split('/[\s,;]+/u', Str::lower(Str::ascii(trim($text)))) ?: [];
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(fn ($part) => trim($part, "\"'"), $parts),
|
||||
fn ($part) => $part !== '' && ! in_array($part, self::STOP_WORDS, true))));
|
||||
}
|
||||
|
||||
/** A secondary query keeps the subject/location; it never relabels processing as cultivation. */
|
||||
public function relatedQuery(string $text): ?string
|
||||
{
|
||||
$terms = $this->terms($text);
|
||||
$subject = array_values(array_diff($terms, self::CULTIVATION));
|
||||
|
||||
return $subject !== [] && count($subject) < count($terms) ? implode(' ', $subject) : null;
|
||||
}
|
||||
|
||||
/** @param literal-string $column
|
||||
* @return literal-string */
|
||||
private function normalized(string $column): string
|
||||
{
|
||||
return "translate(lower(COALESCE($column, '')), 'áàâãäåéèêëíìîïóòôõöúùûüçñýÿ', 'aaaaaaeeeeiiiiooooouuuucnyy')";
|
||||
}
|
||||
|
||||
/** @param Builder<Opportunity> $query */
|
||||
public function constrain(Builder $query, string $text): void
|
||||
{
|
||||
$terms = $this->terms($text);
|
||||
if ($terms === []) {
|
||||
$query->whereRaw('false');
|
||||
|
||||
return;
|
||||
}
|
||||
foreach ($terms as $term) {
|
||||
$variants = in_array($term, self::CULTIVATION, true) ? ['plantio', 'plantacao', 'plantacoes', 'cultiv', 'semead', 'semeio'] : [$term];
|
||||
$patterns = array_map(fn ($value) => '%'.addcslashes($value, '%_\\').'%', $variants);
|
||||
$geography = app(InvestmentGeography::class);
|
||||
$locations = $geography->publicReferences()->where(function ($names) use ($patterns): void {
|
||||
foreach ($patterns as $pattern) {
|
||||
$names->orWhereRaw($this->normalized('name').' LIKE ?', [$pattern]);
|
||||
}
|
||||
})->pluck('id');
|
||||
$territories = [];
|
||||
foreach ($locations as $location) {
|
||||
$territories = array_merge($territories, $geography->descendants((string) $location));
|
||||
}
|
||||
$territories = array_values(array_unique($territories));
|
||||
$query->where(function ($matches) use ($patterns, $territories): void {
|
||||
foreach ($patterns as $pattern) {
|
||||
foreach (['opportunities.title', 'opportunities.description', 'opportunities.summary', 'opportunities.reference', 'opportunities.province', 'opportunities.district'] as $column) {
|
||||
$matches->orWhereRaw($this->normalized($column).' LIKE ?', [$pattern]);
|
||||
}
|
||||
$matches->orWhereHas('sector', fn ($sector) => $sector->whereRaw($this->normalized('name').' LIKE ?', [$pattern])
|
||||
->orWhereRaw($this->normalized('slug').' LIKE ?', [$pattern]));
|
||||
$matches->orWhereExists(fn ($dossier) => $dossier->selectRaw('1')->from('investment_dossiers as searchable_dossier')
|
||||
->whereIn('searchable_dossier.id', InvestmentPortfolioController::visible()->reorder()->select('investment_dossiers.id'))
|
||||
->where(fn ($value) => $value->whereRaw($this->normalized('searchable_dossier.title').' LIKE ?', [$pattern])
|
||||
->orWhereRaw($this->normalized('searchable_dossier.content::text').' LIKE ?', [$pattern]))
|
||||
->where(fn ($relation) => $relation
|
||||
->whereExists(fn ($project) => $project->selectRaw('1')->from('opportunity_projects as search_project')->whereColumn('search_project.opportunity_id', 'opportunities.id')->whereColumn('search_project.project_id', 'searchable_dossier.project_id'))
|
||||
->orWhereExists(fn ($cluster) => $cluster->selectRaw('1')->from('opportunity_clusters as search_cluster')->whereColumn('search_cluster.opportunity_id', 'opportunities.id')->whereColumn('search_cluster.cluster_id', 'searchable_dossier.cluster_id'))));
|
||||
}
|
||||
if ($territories !== []) {
|
||||
$matches->orWhereIn('opportunities.primary_territory_id', $territories)
|
||||
->orWhereExists(fn ($linked) => $linked->selectRaw('1')->from('opportunity_locations as search_link')
|
||||
->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereIn('search_link.geo_location_id', $territories));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,43 @@ use Illuminate\Validation\ValidationException;
|
||||
/** Publication of notices; never receives bids or changes the linked investment. */
|
||||
final class Procurement
|
||||
{
|
||||
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";
|
||||
|
||||
/** @param array<string,mixed> $filters */
|
||||
public function publicQuery(array $filters = []): Builder
|
||||
{
|
||||
abort_unless($this->available(), 404);
|
||||
$query = 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');
|
||||
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 (isset($filters['sector_ids'])) {
|
||||
$query->whereIn(DB::raw("v.payload->>'sector_id'"), array_map('strval', $filters['sector_ids']));
|
||||
}
|
||||
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']]);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** Published historical acts remain readable while their tender remains public. */
|
||||
public function readableVersions(): Builder
|
||||
{
|
||||
return DB::table('portal_tender_versions')->whereNotNull('published_at')->whereIn('tender_id', $this->publicQuery()->select('t.id'));
|
||||
}
|
||||
|
||||
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
|
||||
@@ -25,12 +62,7 @@ final class Procurement
|
||||
|
||||
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));
|
||||
return app(DocumentManagementAccess::class)->catalogueEditor($actor);
|
||||
}
|
||||
|
||||
public function guard(?User $actor): void
|
||||
|
||||
@@ -187,8 +187,12 @@ class VirtualDesk
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
$base = $this->decode($case->payload);
|
||||
$payload = ['title' => $case->title, 'description' => $base['investment']['objective'] ?? '', 'regime' => 'unknown'];
|
||||
if (config('investor-path.enabled') && $case->simulation_id && (int) $case->user_id === $actor->id) {
|
||||
$serviceFields = json_decode($service->definition, true)['fields'];
|
||||
$payload = Arr::only(['title' => $case->title, 'description' => $base['investment']['objective'] ?? ''], array_keys($serviceFields));
|
||||
if (isset($serviceFields['regime']['options']['unknown'])) {
|
||||
$payload['regime'] = 'unknown';
|
||||
}
|
||||
if (isset($serviceFields['description']) && config('investor-path.enabled') && $case->simulation_id && (int) $case->user_id === $actor->id) {
|
||||
$draftRequest = Request::create('/');
|
||||
$draftRequest->setUserResolver(fn () => $actor);
|
||||
$draft = app(SimulationFormDrafts::class)->find($case->simulation_id, $draftRequest);
|
||||
@@ -197,7 +201,7 @@ class VirtualDesk
|
||||
$this->event($case, $actor, 'request_prefill_prepared', ['draft_id' => $draft->id, 'draft_version' => $draft->version_no, 'snapshot_sha256' => $draft->snapshot_sha256, 'fields' => ['title', 'description']]);
|
||||
}
|
||||
$context = app(InvestorContext::class)->state($case);
|
||||
if (count($context['opportunity_choices'] ?? []) > 1 && ! $opportunity) {
|
||||
if (isset($serviceFields['description']) && count($context['opportunity_choices'] ?? []) > 1 && ! $opportunity) {
|
||||
$payload['description'] = '';
|
||||
}
|
||||
$mapped = app(InvestorContext::class)->prefill($context, $opportunity)['desk'];
|
||||
@@ -205,9 +209,9 @@ class VirtualDesk
|
||||
$payload['context_opportunity'] = $opportunity;
|
||||
}
|
||||
$payload = app(InvestmentConsumerScope::class)->bind($actor, $caseId, $payload);
|
||||
$serviceFields = json_decode($service->definition, true)['fields'];
|
||||
foreach (['territory_id' => 'territory_id', 'location_source' => 'location_source', 'title' => 'title', 'description' => 'description', 'sector' => 'sector', 'province_id' => 'province_id', 'district_id' => 'district_id', 'admin_post_id' => 'admin_post_id', 'locality_id' => 'locality_id'] as $field => $source) {
|
||||
if (isset($serviceFields[$field],$mapped[$source]) && (empty($payload[$field]) || in_array($field, ['title', 'description'], true) && $context['revision'] > 0)) {
|
||||
if (isset($serviceFields[$field],$mapped[$source]) && (empty($payload[$field]) || in_array($field, ['title', 'description'], true) && $context['revision'] > 0)
|
||||
&& Validator::make(['value' => $mapped[$source]], ['value' => $this->fieldRules($serviceFields[$field], false)])->passes()) {
|
||||
$payload[$field] = $mapped[$source];
|
||||
}
|
||||
}
|
||||
@@ -272,25 +276,22 @@ class VirtualDesk
|
||||
'payload.country' => ['nullable', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
|
||||
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]];
|
||||
foreach ($definition['fields'] as $key => $field) {
|
||||
$active = ! isset($field['when']) || ($payload[$field['when']['field']] ?? null) === $field['when']['equals'];
|
||||
$active = $this->fieldApplies((string) $key, $definition['fields'], $payload);
|
||||
$required = $submitting && ($field['required'] ?? false) && $active;
|
||||
$rule = [$required ? 'required' : 'nullable'];
|
||||
$rule = [...$rule, ...match ($field['type']) {
|
||||
'number' => ['numeric', 'decimal:0,2', 'min:'.($field['min'] ?? 0), 'max:999999999999'], 'date' => ['date_format:Y-m-d'],
|
||||
'select' => [Rule::in(array_keys($field['options']))], 'territory' => ['uuid'],
|
||||
default => ['string', 'max:'.($field['type'] === 'textarea' ? 15000 : 1000)],
|
||||
}];
|
||||
$rules['payload.'.$key] = $rule;
|
||||
$rules['payload.'.$key] = $this->fieldRules($field, $required);
|
||||
}
|
||||
$validated = Validator::make(['payload' => $payload], $rules)->validate()['payload'];
|
||||
foreach ($definition['fields'] as $key => $field) {
|
||||
if ($field['type'] === 'select' && isset($validated[$key])) {
|
||||
$validated[$key] = (string) $validated[$key];
|
||||
}
|
||||
if ($field['type'] === 'territory' && ! empty($validated[$key])) {
|
||||
if (! app(InvestmentGeography::class)->publicReferences()->where('id', $validated[$key])->exists()) {
|
||||
throw ValidationException::withMessages(['payload.'.$key => 'Seleccione uma localização disponível no cadastro territorial.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($validated['territory_id'] ?? null) {
|
||||
if (($validated['territory_id'] ?? null) && $this->fieldApplies('territory_id', $definition['fields'], $validated)) {
|
||||
if ($submitting && trim($validated['location_source'] ?? '') === '') {
|
||||
throw ValidationException::withMessages(['payload.location_source' => 'Indique a fonte da localização.']);
|
||||
}
|
||||
@@ -299,11 +300,56 @@ class VirtualDesk
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $field
|
||||
* @return list<mixed> */
|
||||
private function fieldRules(array $field, bool $required): array
|
||||
{
|
||||
return [$required ? 'required' : 'nullable', ...match ($field['type']) {
|
||||
'number' => ['numeric', 'decimal:0,2', 'min:'.($field['min'] ?? 0), 'max:999999999999'], 'date' => ['date_format:Y-m-d'],
|
||||
'select' => [Rule::in(array_keys($field['options']))], 'territory' => ['uuid'],
|
||||
'email' => ['string', 'email', 'max:1000'], 'url' => ['string', 'url:http,https', 'max:1000'],
|
||||
default => ['string', 'max:'.($field['type'] === 'textarea' ? 15000 : 1000)],
|
||||
}];
|
||||
}
|
||||
|
||||
/** @param array<string,array<string,mixed>> $fields
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,bool> $ancestors */
|
||||
private function fieldApplies(string $key, array $fields, array $payload, array $ancestors = []): bool
|
||||
{
|
||||
$field = $fields[$key] ?? null;
|
||||
if (! $field || isset($ancestors[$key])) {
|
||||
return false;
|
||||
}
|
||||
if (! isset($field['when'])) {
|
||||
return true;
|
||||
}
|
||||
$parent = $field['when']['field'];
|
||||
$answer = $payload[$parent] ?? null;
|
||||
if (! is_scalar($answer) || (string) $answer !== $field['when']['equals']) {
|
||||
return false;
|
||||
}
|
||||
$ancestors[$key] = true;
|
||||
|
||||
return $this->fieldApplies($parent, $fields, $payload, $ancestors);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload
|
||||
* @param array<string,mixed> $definition
|
||||
* @return array<string,array<string,mixed>> */
|
||||
public function applicableFields(array $payload, array $definition): array
|
||||
{
|
||||
return array_filter($definition['fields'], fn ($key) => $this->fieldApplies((string) $key, $definition['fields'], $payload), ARRAY_FILTER_USE_KEY);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload
|
||||
* @param array<string,mixed> $definition
|
||||
* @return list<array<string,mixed>> */
|
||||
public function findings(array $payload): array
|
||||
public function findings(array $payload, array $definition): array
|
||||
{
|
||||
$findings = [];
|
||||
$fields = $this->applicableFields($payload, $definition);
|
||||
$financeApplies = isset($fields['investment_amount'], $fields['own_finance'], $fields['other_finance']);
|
||||
$amounts = array_map(function ($key) use ($payload) {
|
||||
$value = (string) ($payload[$key] ?? '');
|
||||
if (! preg_match('/^\d{1,12}(?:\.\d{1,2})?$/', $value)) {
|
||||
@@ -313,13 +359,13 @@ class VirtualDesk
|
||||
|
||||
return (int) $whole * 100 + (int) str_pad($fraction, 2, '0');
|
||||
}, ['investment_amount', 'own_finance', 'other_finance']);
|
||||
if (! in_array(null, $amounts, true) && $amounts[0] !== $amounts[1] + $amounts[2]) {
|
||||
if ($financeApplies && ! in_array(null, $amounts, true) && $amounts[0] !== $amounts[1] + $amounts[2]) {
|
||||
$findings[] = ['code' => 'finance_difference', 'severity' => 'error', 'field' => 'investment_amount', 'explanation' => 'A soma do financiamento difere do valor do investimento.', 'recommendation' => 'Confirme valores e moeda.', 'method' => 'deterministic', 'confidence' => null];
|
||||
}
|
||||
if (in_array(null, $amounts, true)) {
|
||||
if ($financeApplies && in_array(null, $amounts, true)) {
|
||||
$findings[] = ['code' => 'finance_unknown', 'severity' => 'warning', 'field' => 'investment_amount', 'explanation' => 'A coerência do financiamento permanece indeterminada sem os três valores válidos na mesma moeda.', 'recommendation' => 'Complete as fontes de financiamento; valores ausentes não são zero.', 'method' => 'deterministic', 'confidence' => null];
|
||||
}
|
||||
if (! ($payload['territory_id'] ?? null)) {
|
||||
if (isset($fields['territory_id']) && ! ($payload['territory_id'] ?? null)) {
|
||||
$findings[] = ['code' => 'location_unknown', 'severity' => 'warning', 'field' => 'territory_id', 'explanation' => 'Localização ainda não documentada.', 'recommendation' => 'Complete quando houver fonte.', 'method' => 'deterministic', 'confidence' => null];
|
||||
}
|
||||
$findings[] = ['code' => 'official_form_pending', 'severity' => 'warning', 'field' => null, 'explanation' => 'Adaptação digital do plano; não foi identificada uma versão oficial homologada do formulário APIEX no acervo.', 'recommendation' => 'A preparação e análise interna continuam disponíveis; confirme a versão exigida pela entidade para o ato oficial.', 'method' => 'documentary', 'confidence' => null];
|
||||
@@ -364,7 +410,7 @@ class VirtualDesk
|
||||
}
|
||||
abort_if($submit && trim($data['reason'] ?? '') === '', 422, 'Justifique a correção.');
|
||||
}
|
||||
$findings = $this->findings($payload);
|
||||
$findings = $this->findings($payload, $this->definition($request));
|
||||
if ($submit && array_filter($findings, fn ($f) => $f['severity'] === 'error')) {
|
||||
throw ValidationException::withMessages(['payload.investment_amount' => $findings[0]['explanation']]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"format": "investmoz-funding-references-v1",
|
||||
"references": {
|
||||
"fdel": {
|
||||
"edition": "fdel-br43-20250305-tete20251020-v1",
|
||||
"provenance": [
|
||||
{ "url": "https://mpd.gov.mz/wp-content/uploads/2025/10/BR_43_I_SERIE_2025.pdf", "sha256": "0ea99c5f894760e3b715e377e87e9715fad9423e322e5753de1b9bb345c64753", "checked_on": "2026-11-02", "reference": "BR I Série n.º 43, 05/03/2025, pp. 157–160; Decreto n.º 4/2025 e Regulamento anexo" },
|
||||
{ "url": "https://cmct.gov.mz/fundo-de-desenvolvimento-economico-local-fdel/", "sha256": "c69c2c3883766aa6a238ae44d8ee3fc99570adca2f3e647e50df59fde2786403", "checked_on": "2026-11-02", "reference": "Aviso municipal FDEL, prazo 20/10/2025" }
|
||||
],
|
||||
"draft": {
|
||||
"title": "Fundo de Desenvolvimento Económico Local — FDEL",
|
||||
"kind": "fund",
|
||||
"summary": "Apoio reembolsável a iniciativas locais. Extracto informativo; consultar as condições completas. Não confirma candidaturas actualmente abertas.",
|
||||
"manager_name": "Gestão distrital/autárquica; tutelas previstas no Decreto",
|
||||
"source_url": "https://mpd.gov.mz/wp-content/uploads/2025/10/BR_43_I_SERIE_2025.pdf",
|
||||
"source_reference": "Decreto 4/2025; BR I/43, 05/03/2025, pp.157–160; Regulamento: arts.2,13,14,18",
|
||||
"source_checked_on": "2026-11-02",
|
||||
"source_published_on": "2025-03-05",
|
||||
"source_status": "identified",
|
||||
"coverage": "national",
|
||||
"coverage_text": "Nacional; cada chamada tem condições próprias.",
|
||||
"territory_ids": [],
|
||||
"sectors_scope": "unknown",
|
||||
"sectors_text": "Áreas do art.13; cadastro sectorial por associar.",
|
||||
"sector_ids": [],
|
||||
"resource_ids": [],
|
||||
"criteria": [
|
||||
{ "code": "nationality-individual", "question": "Tem nacionalidade moçambicana?", "applies_to": "individual", "expected": "yes", "source_reference": "Decreto n.º 4/2025, art. 3", "explanation": "Condição declarada; não substitui a apreciação competente." },
|
||||
{ "code": "economic-viability", "question": "O projecto apresenta elementos de viabilidade económica?", "applies_to": "all", "expected": "yes", "source_reference": "Decreto n.º 4/2025, art. 3; Regulamento, art. 13", "explanation": "A avaliação do projecto cabe às entidades competentes." }
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"code": "tete-municipal-2025",
|
||||
"title": "Cidade de Tete — chamada municipal de 2025",
|
||||
"mode": "dated",
|
||||
"end_on": "2025-10-20",
|
||||
"coverage": "unknown",
|
||||
"coverage_text": "Âmbito municipal da Cidade de Tete; referência cartográfica por associar. Não corresponde a toda a província.",
|
||||
"territory_ids": [],
|
||||
"source_url": "https://cmct.gov.mz/fundo-de-desenvolvimento-economico-local-fdel/",
|
||||
"source_reference": "Aviso municipal FDEL, prazo 20/10/2025",
|
||||
"checked_on": "2026-11-02",
|
||||
"application_url": "https://cmct.gov.mz/anexo-5-plano-de-negocio/",
|
||||
"criteria": [
|
||||
{ "code": "tete-nuit", "question": "Dispõe de NUIT?", "applies_to": "all", "expected": "yes", "source_reference": "Aviso municipal, Requisitos", "explanation": "Documentação indicada para esta chamada histórica." },
|
||||
{ "code": "tete-identity", "question": "Dispõe de cópia do BI?", "applies_to": "individual", "expected": "yes", "source_reference": "Aviso municipal, Candidatos individuais", "explanation": "Aplica-se a pessoas singulares nesta chamada." },
|
||||
{ "code": "tete-registration", "question": "A entidade dispõe de comprovativo de registo legal?", "applies_to": "entity", "expected": "yes", "source_reference": "Aviso municipal, Entidades", "explanation": "Aplica-se às entidades indicadas no aviso." },
|
||||
{ "code": "tete-idoneity", "question": "Dispõe de atestado de idoneidade emitido pela sede do bairro?", "applies_to": "individual", "expected": "yes", "source_reference": "Aviso municipal, Candidatos individuais", "explanation": "Documento indicado no aviso." },
|
||||
{ "code": "tete-national-entity", "question": "A entidade é constituída por cidadãos nacionais?", "applies_to": "entity", "expected": "yes", "source_reference": "Aviso municipal, Entidades", "explanation": "Condição específica desta chamada." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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::table('investment_notifications', function (Blueprint $table) {
|
||||
$table->uuid('submission_id')->nullable()->change();
|
||||
$table->foreignId('opportunity_id')->nullable()->constrained('opportunities')->restrictOnDelete();
|
||||
});
|
||||
DB::statement('ALTER TABLE investment_notifications ADD CONSTRAINT investment_notification_origin CHECK ((submission_id IS NOT NULL)::integer + (opportunity_id IS NOT NULL)::integer = 1)');
|
||||
Schema::create('investment_alert_preferences', function (Blueprint $table) {
|
||||
$table->uuid('id')->unique();
|
||||
$table->foreignId('user_id')->primary()->constrained('users')->restrictOnDelete();
|
||||
$table->unsignedInteger('version');
|
||||
$table->boolean('enabled')->default(false);
|
||||
$table->string('frequency');
|
||||
$table->string('channel')->default('in_app');
|
||||
$table->text('criteria');
|
||||
$table->timestampTz('consented_at')->nullable();
|
||||
$table->timestampTz('next_run_at')->nullable();
|
||||
$table->timestampsTz();
|
||||
$table->index(['enabled', 'next_run_at']);
|
||||
});
|
||||
DB::statement("ALTER TABLE investment_alert_preferences ADD CHECK (frequency IN ('as_available','daily','weekly')), ADD CHECK (channel = 'in_app'), ADD CHECK (NOT enabled OR consented_at IS NOT NULL)");
|
||||
Schema::create('investment_alert_seen', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('opportunity_id')->constrained('opportunities')->restrictOnDelete();
|
||||
$table->foreignUuid('notification_id')->nullable()->constrained('investment_notifications')->restrictOnDelete();
|
||||
$table->timestampTz('observed_at')->useCurrent();
|
||||
$table->primary(['user_id', 'opportunity_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_alert_preferences')->exists() || DB::table('investment_alert_seen')->exists()
|
||||
|| DB::table('investment_notifications')->whereNotNull('opportunity_id')->exists()) {
|
||||
throw new RuntimeException('Preserve alert preferences and notification history before rollback.');
|
||||
}
|
||||
Schema::drop('investment_alert_seen');
|
||||
Schema::drop('investment_alert_preferences');
|
||||
DB::statement('ALTER TABLE investment_notifications DROP CONSTRAINT investment_notification_origin');
|
||||
Schema::table('investment_notifications', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('opportunity_id');
|
||||
$table->uuid('submission_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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::table('investment_alert_preferences', function (Blueprint $table) {
|
||||
$table->jsonb('sources')->default('["opportunities"]');
|
||||
});
|
||||
DB::statement("ALTER TABLE investment_alert_preferences ADD CONSTRAINT investment_alert_sources CHECK (jsonb_typeof(sources)='array' AND jsonb_array_length(sources) BETWEEN 1 AND 2 AND sources <@ '[\"opportunities\",\"tenders\"]'::jsonb)");
|
||||
Schema::table('investment_notifications', function (Blueprint $table) {
|
||||
$table->foreignUuid('tender_version_id')->nullable()->constrained('portal_tender_versions')->restrictOnDelete();
|
||||
});
|
||||
DB::statement('ALTER TABLE investment_notifications DROP CONSTRAINT investment_notification_origin');
|
||||
DB::statement('ALTER TABLE investment_notifications ADD CONSTRAINT investment_notification_origin CHECK ((submission_id IS NOT NULL)::integer + (opportunity_id IS NOT NULL)::integer + (tender_version_id IS NOT NULL)::integer = 1)');
|
||||
Schema::create('investment_tender_alert_seen', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('tender_version_id')->constrained('portal_tender_versions')->restrictOnDelete();
|
||||
$table->foreignUuid('notification_id')->nullable()->constrained('investment_notifications')->restrictOnDelete();
|
||||
$table->timestampTz('observed_at')->useCurrent();
|
||||
$table->primary(['user_id', 'tender_version_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_tender_alert_seen')->exists() || DB::table('investment_notifications')->whereNotNull('tender_version_id')->exists()
|
||||
|| DB::table('investment_alert_preferences')->whereJsonContains('sources', 'tenders')->exists()) {
|
||||
throw new RuntimeException('Preserve tender alert preferences and notification history before rollback.');
|
||||
}
|
||||
Schema::drop('investment_tender_alert_seen');
|
||||
DB::statement('ALTER TABLE investment_notifications DROP CONSTRAINT investment_notification_origin');
|
||||
Schema::table('investment_notifications', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('tender_version_id');
|
||||
});
|
||||
DB::statement('ALTER TABLE investment_notifications ADD CONSTRAINT investment_notification_origin CHECK ((submission_id IS NOT NULL)::integer + (opportunity_id IS NOT NULL)::integer = 1)');
|
||||
DB::statement('ALTER TABLE investment_alert_preferences DROP CONSTRAINT investment_alert_sources');
|
||||
Schema::table('investment_alert_preferences', function (Blueprint $table) {
|
||||
$table->dropColumn('sources');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('funding_programmes', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('code', 80)->unique();
|
||||
$table->unsignedInteger('version')->default(1);
|
||||
$table->jsonb('draft');
|
||||
$table->uuid('published_version_id')->nullable();
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->timestampsTz();
|
||||
});
|
||||
Schema::create('funding_programme_versions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('programme_id')->constrained('funding_programmes')->restrictOnDelete();
|
||||
$table->unsignedInteger('version');
|
||||
$table->jsonb('payload');
|
||||
$table->text('reason');
|
||||
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
|
||||
$table->timestampTz('published_at')->useCurrent();
|
||||
$table->unique(['programme_id', 'version']);
|
||||
});
|
||||
Schema::create('funding_programme_interests', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('programme_id')->constrained('funding_programmes')->restrictOnDelete();
|
||||
$table->foreignUuid('version_id')->constrained('funding_programme_versions')->restrictOnDelete();
|
||||
$table->timestampTz('saved_at')->useCurrent();
|
||||
$table->timestampTz('removed_at')->nullable();
|
||||
$table->primary(['user_id', 'programme_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('funding_programmes')->exists() || DB::table('funding_programme_versions')->exists() || DB::table('funding_programme_interests')->exists()) {
|
||||
throw new RuntimeException('Preserve funding programmes, published versions and saved interests before rollback.');
|
||||
}
|
||||
Schema::drop('funding_programme_interests');
|
||||
Schema::drop('funding_programme_versions');
|
||||
Schema::drop('funding_programmes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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
|
||||
{
|
||||
DB::statement('ALTER TABLE investment_alert_preferences DROP CONSTRAINT investment_alert_sources');
|
||||
DB::statement("ALTER TABLE investment_alert_preferences ADD CONSTRAINT investment_alert_sources CHECK (jsonb_typeof(sources)='array' AND jsonb_array_length(sources) BETWEEN 1 AND 3 AND sources <@ '[\"opportunities\",\"tenders\",\"funding\"]'::jsonb)");
|
||||
Schema::table('investment_notifications', function (Blueprint $table) {
|
||||
$table->foreignUuid('funding_version_id')->nullable()->constrained('funding_programme_versions')->restrictOnDelete();
|
||||
});
|
||||
DB::statement('ALTER TABLE investment_notifications DROP CONSTRAINT investment_notification_origin');
|
||||
DB::statement('ALTER TABLE investment_notifications ADD CONSTRAINT investment_notification_origin CHECK ((submission_id IS NOT NULL)::integer + (opportunity_id IS NOT NULL)::integer + (tender_version_id IS NOT NULL)::integer + (funding_version_id IS NOT NULL)::integer = 1)');
|
||||
Schema::create('investment_funding_alert_seen', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('funding_version_id')->constrained('funding_programme_versions')->restrictOnDelete();
|
||||
$table->foreignUuid('notification_id')->nullable()->constrained('investment_notifications')->restrictOnDelete();
|
||||
$table->timestampTz('observed_at')->useCurrent();
|
||||
$table->primary(['user_id', 'funding_version_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::table('investment_funding_alert_seen')->exists() || DB::table('investment_notifications')->whereNotNull('funding_version_id')->exists()
|
||||
|| DB::table('investment_alert_preferences')->whereJsonContains('sources', 'funding')->exists()) {
|
||||
throw new RuntimeException('Preserve funding alert preferences and notification history before rollback.');
|
||||
}
|
||||
Schema::drop('investment_funding_alert_seen');
|
||||
DB::statement('ALTER TABLE investment_notifications DROP CONSTRAINT investment_notification_origin');
|
||||
Schema::table('investment_notifications', fn (Blueprint $table) => $table->dropConstrainedForeignId('funding_version_id'));
|
||||
DB::statement('ALTER TABLE investment_notifications ADD CONSTRAINT investment_notification_origin CHECK ((submission_id IS NOT NULL)::integer + (opportunity_id IS NOT NULL)::integer + (tender_version_id IS NOT NULL)::integer = 1)');
|
||||
DB::statement('ALTER TABLE investment_alert_preferences DROP CONSTRAINT investment_alert_sources');
|
||||
DB::statement("ALTER TABLE investment_alert_preferences ADD CONSTRAINT investment_alert_sources CHECK (jsonb_typeof(sources)='array' AND jsonb_array_length(sources) BETWEEN 1 AND 2 AND sources <@ '[\"opportunities\",\"tenders\"]'::jsonb)");
|
||||
}
|
||||
};
|
||||
@@ -1912,6 +1912,10 @@ Antes de executar cada recorte, reconciliar os requisitos e provas existentes pe
|
||||
| ACT-20261029-15 | Dados abertos/API | APIs e fontes existentes | Inventariar interfaces oficiais, implementar actualização/rastreabilidade e documentar API dos dados divulgáveis. | Consulta de parceiro demonstrada, com contrato, versão, origem/data e permissões respeitadas. | Interfaces oficiais disponíveis e âmbito autorizado dos dados |
|
||||
| ACT-20261029-16 | Móvel/offline/línguas | Interface móvel e provas anteriores | Conferir percursos em telemóvel/redes lentas; consulta offline seleccionada, sincronização e línguas nacionais prioritárias. | Percurso móvel utilizável; conteúdo seleccionado acessível sem rede; sincronização preserva edições; idiomas escolhidos demonstrados. | Definir línguas prioritárias e conteúdos offline; tradução e regras de sincronização |
|
||||
|
||||
02/11/2026 — ACT-20261029-16, primeiro recorte local parcial: copia HTML de uma ficha publica de negocio seleccionada expressamente, legivel sem rede, com data de captura e origem. Tres testes dirigidos de fidelidade, exclusao de dados privados, proteccao de texto e descarga passaram. Sem instalacao ou prova visual; sincronizacao/edicao offline, cobertura geral e novas linguas continuam pendentes. Ver o manual existente; este recorte nao encerra o criterio amplo.
|
||||
|
||||
Continuidade 02/11/2026: copia seleccionada tambem disponivel na ficha publica de oportunidade, com gerador comum, fonte limitada ao portal e projecao explicita sem dossier/anexos privados. Cinco testes offline dos dois formatos passaram; desconhecidos e precisao preservados. Nao encerra sincronizacao, verificacao movel ou idiomas; conferencia visual anterior interrompida pela politica de URLs da ferramenta, sem contorno.
|
||||
|
||||
## 29/10/2026 — auditoria fiscal InvesteMoz / SGF
|
||||
|
||||
Pedido expresso da Tempo: auditar ambos os sistemas e identificar como incorporar no SGF a componente fiscal do InvesteMoz. Auditoria local de código, contratos e recibos concluída; sem prova operacional nova. Relatório e critérios na [especificação fiscal existente do SGF](C:/laragon/www/sgf-tempo/docs/SERVICE_INVOICE_CALCULATION_SPEC.md#10-auditoria-de-incorporação-fiscal-investemoz--sgf--29102026), secção 10. Não altera os 16 recortes ACT-20261029 já registados nem os fechos aceites.
|
||||
|
||||
@@ -1515,5 +1515,770 @@
|
||||
"preservation": "Existing territorial queries, publication/permissions, geometry/source/date values and unlocated links retained. No backend/schema/active data changes, new factual imports or fiscal changes. Prior proofs reused.",
|
||||
"visual_acceptance": "Pending team candidate browser session and recording for portfolio, opportunity and investment-zone map consumers.",
|
||||
"installation": "Not triggered; main and active release unchanged."
|
||||
},
|
||||
"desk_forms_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T16:20:05.819Z",
|
||||
"status": "local_qa_uncommitted_not_installed",
|
||||
"requirement": "AUD-CHECKLIST-01",
|
||||
"scope": [
|
||||
"administrative readonly definition history",
|
||||
"cascading applicability of nested questions",
|
||||
"prefill respects version fields and option values"
|
||||
],
|
||||
"feature": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 22,
|
||||
"passed": 22,
|
||||
"assertions": 416,
|
||||
"duration_ms": 261036,
|
||||
"log": "storage/logs/desk-forms-combined-feature-20261102.log",
|
||||
"sha256": "557b6d77488f08213df4dc7959462979811bf8c87f0dd60d2a66dd516d976550"
|
||||
},
|
||||
"frontend": {
|
||||
"tests": 4,
|
||||
"passed": 4,
|
||||
"command": "node --test tests/frontend/desk-request-conditions.test.mjs tests/frontend/desk-service-editor.test.mjs tests/frontend/desk-service-history.test.mjs",
|
||||
"log": "storage/logs/desk-forms-frontend-20261102.log",
|
||||
"sha256": "39ebbb60e749b473dc4f885b3a0d9264063dfc789c617692393401d5fc529aed"
|
||||
},
|
||||
"static_checks": {
|
||||
"phpstan": "passed",
|
||||
"types": "passed",
|
||||
"lint_and_format": "passed_without_warnings",
|
||||
"build": "passed_with_existing_font_and_chunk_advisories"
|
||||
},
|
||||
"regressions_before": [
|
||||
"storage/logs/desk-request-conditions-before-20261102.log",
|
||||
"storage/logs/desk-schema-prefill-before-20261102.log"
|
||||
],
|
||||
"qa": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 55441,
|
||||
"databases": [
|
||||
"desk_editor_20261102_test",
|
||||
"desk_prefill_20261102_test"
|
||||
],
|
||||
"data": "synthetic_disposable",
|
||||
"active_writes": 0
|
||||
},
|
||||
"limitations": [
|
||||
"visual verification pending browser control limitation",
|
||||
"not installed",
|
||||
"other checklists and transverse configuration not proven",
|
||||
"global fiscal integration not passed"
|
||||
],
|
||||
"source_sha256": {
|
||||
"app/Services/VirtualDesk.php": "efbe2873fdef1d940098cfae89528812b43ef035e15cf2335ffa66d33b25605d",
|
||||
"app/Services/DeskServiceEditor.php": "f9665f562f6626873948b797174dead2e6cbd67ac20406b397ead5e96e828af9",
|
||||
"app/Http/Controllers/DeskServiceEditorController.php": "d71cc6903a3c7bc95b3fe8f2c3dacb62d144567cfa00d1317c75f80e2d815382",
|
||||
"resources/js/lib/desk-field-visibility.ts": "44a9f024b826b6e41e7d6fd39fb384ebe8d01bc1c42d9eadd9775c1cf64d5aed",
|
||||
"resources/js/pages/desk/request.tsx": "cf3483b63a331cbcd829869d6ca310dc9331e91fa6df4035177e41ca02e2d7e6",
|
||||
"resources/js/pages/desk/service-editor.tsx": "1b17962425ae395a62e36b890798c3dcc5bbfe8d4374b8392e31b6089e24ca4e",
|
||||
"resources/js/components/desk-service-history.tsx": "0eb79aea6d90c8b6e02bd1ad5a98dfa11dcaad63d44e3cbc46f9974fff5479f6",
|
||||
"tests/Feature/DeskServiceEditorTest.php": "f711c850fd2f2f54333593927a18d0e061dc64f702afba53c22d8cac43d7e26b",
|
||||
"tests/Feature/DeskServiceSchemaPrefillTest.php": "e993086c87830a4ab98de0329fc762f4f7e0169cf4d77344fdbaf4d05c5c9a93",
|
||||
"tests/frontend/desk-request-conditions.test.mjs": "fb7b7088e3bb47c6e431ee8b12acd23efeae32c3056b4e78164ac2f3e3e7d7ac",
|
||||
"tests/frontend/desk-service-editor.test.mjs": "f88235311267301a73a7a46c7f5d3be671b78d02a6dab9a3b671dc28e3f89328",
|
||||
"tests/frontend/desk-service-history.test.mjs": "fd0d560876d05b9b9a42c9f404e1cd6ff32e451712d029637fe51a103a56dffa"
|
||||
}
|
||||
},
|
||||
"desk_analysis_scope_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T16:29:07.797Z",
|
||||
"status": "local_qa_uncommitted_not_installed",
|
||||
"requirements": [
|
||||
"AUD-CHECKLIST-01",
|
||||
"Tempo adaptive path 2026-10-23"
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 25,
|
||||
"passed": 25,
|
||||
"assertions": 467,
|
||||
"duration_ms": 146834,
|
||||
"log": "storage/logs/desk-analysis-schema-combined-20261102.log",
|
||||
"sha256": "c027a928e85d4ec01d55055460a9360e6551043b88bcf4bc01be21558906623f"
|
||||
},
|
||||
{
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 6,
|
||||
"duration_ms": 908,
|
||||
"log": "storage/logs/desk-receipt-compatibility-20261102.log",
|
||||
"sha256": "ca76c288728835716bd5e2fab8c3369ce68d88b6e36e5a03fd231193976ed9e3"
|
||||
},
|
||||
{
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 5,
|
||||
"duration_ms": 19429,
|
||||
"log": "storage/logs/desk-location-applicability-20261102.log",
|
||||
"sha256": "13f102f6352d9cab5852fc2fdd1cdbf43cd3efc9b9a8e3b77010d54cd3993934"
|
||||
}
|
||||
],
|
||||
"static_checks": {
|
||||
"phpstan": "passed",
|
||||
"pint": "passed",
|
||||
"js_checks": "prior accepted evidence reused; no JavaScript delta"
|
||||
},
|
||||
"regressions_before": [
|
||||
"storage/logs/desk-form-analysis-before-20261102.log",
|
||||
"storage/logs/desk-location-schema-before-20261102.log"
|
||||
],
|
||||
"invariants": [
|
||||
"applicable finance contradictions still block",
|
||||
"applicable location still requires source",
|
||||
"hidden answers and submitted snapshot preserved",
|
||||
"old version labels retained",
|
||||
"TXT original and access controls unchanged",
|
||||
"direct receipt rendering remains compatible",
|
||||
"no active data writes"
|
||||
],
|
||||
"limitations": [
|
||||
"visual verification pending browser control limitation",
|
||||
"not installed",
|
||||
"other checklist consumers and fiscal integration still partial"
|
||||
],
|
||||
"source_sha256": {
|
||||
"app/Services/VirtualDesk.php": "88f5fb02a7d1d50a5b21c1906f98ecb51fc5d41422665ceae6c477dd29a7a748",
|
||||
"app/Services/DeskServiceEditor.php": "783a64dbb746416e3ea24f71b8343b188a98817f4b58c7d908805ed92565efb5",
|
||||
"app/Http/Controllers/VirtualDeskController.php": "c3d4f8a6be31c1478c9d08424f877ff18f3d1dd5dd94246d068601a900bdac4a",
|
||||
"resources/views/desk/receipt.blade.php": "ee18970047050a92d55e3a39c62961b97fbcea9e9d323e35d2bb1d2ec9da6604",
|
||||
"resources/views/desk/receipt-answers.blade.php": "e1aa2c3c6695942b76e1bc41510a74827d5759be862635c4c9aa7b9004f779b3",
|
||||
"tests/Feature/DeskFormAnalysisScopeTest.php": "a20e58fadce7d4ecd65e0e59499e698ce834c22721284acca0ae690fbc8c8bd4",
|
||||
"tests/Feature/DeskServiceEditorTest.php": "9591a94dc9384ebf61529444e90c28f05ef0fd3cf7d41b3577f3d1aa91cf8c25",
|
||||
"tests/Feature/DeskReceiptCompatibilityTest.php": "7b353020e710c26bc0a830337d1795f0e5e871608f450b9725ae77f9e700cec9",
|
||||
"tests/Feature/DeskLocationApplicabilityTest.php": "aa111a6aa63c70556f42f7253782c8e30ff20f5fe66d7158ebbcbe65f32b82f2"
|
||||
}
|
||||
},
|
||||
"opportunity_alerts_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T16:51:35.750Z",
|
||||
"status": "local_qa_uncommitted_not_installed",
|
||||
"requirements": [
|
||||
"ACT-20261029-11",
|
||||
"MASTER-471012ab-0124"
|
||||
],
|
||||
"feature": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 16,
|
||||
"passed": 16,
|
||||
"assertions": 253,
|
||||
"duration_ms": 85835,
|
||||
"log": "storage/logs/opportunity-alerts-verified-feature-20261102.log",
|
||||
"sha256": "8d464dca578858c90e1c76049f039a6c15e513d2829578e6ae22a6646e3199a3"
|
||||
},
|
||||
"frontend": {
|
||||
"tests": 2,
|
||||
"passed": 2,
|
||||
"log": "storage/logs/opportunity-alerts-frontend-20261102.log",
|
||||
"sha256": "675fd6293fb7095d651e72c47ecf0d1020af3cf60a99065a6fcf9b1ab93d1a7a"
|
||||
},
|
||||
"checks": {
|
||||
"phpstan": "passed",
|
||||
"types": "passed",
|
||||
"lint_and_format": "passed_without_warnings",
|
||||
"build": "passed_with_existing_advisories",
|
||||
"diff": "passed"
|
||||
},
|
||||
"qa": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 55441,
|
||||
"databases": [
|
||||
"desk_editor_20261102_test",
|
||||
"desk_prefill_20261102_test"
|
||||
],
|
||||
"data": "synthetic_disposable",
|
||||
"active_writes": 0,
|
||||
"external_messages": 0
|
||||
},
|
||||
"coverage": [
|
||||
"explicit opt-in and cancellation",
|
||||
"own account only",
|
||||
"encrypted versioned preferences and audit",
|
||||
"same public catalogue matching rules",
|
||||
"old-public baseline without timestamp cursor",
|
||||
"future/private/incompatible exclusions",
|
||||
"idempotent notifications",
|
||||
"frequency and resubscription baseline",
|
||||
"withdrawn sources not exposed",
|
||||
"unavailable references do not block other recipients",
|
||||
"201-item batch without loss",
|
||||
"atomic failure/retry",
|
||||
"legacy notification migration and guarded rollback",
|
||||
"legacy manifestation notifications retained"
|
||||
],
|
||||
"limitations": [
|
||||
"public opportunity catalogue only; other tender/incentive sources pending",
|
||||
"in-app channel only",
|
||||
"scheduler activation in destination not verified",
|
||||
"visual verification pending browser control limitation",
|
||||
"not installed"
|
||||
],
|
||||
"source_sha256": {
|
||||
"database/migrations/2026_11_02_190000_investment_opportunity_alerts.php": "df8494f35f06be6a355f4e71d266c1ad65bfdb65e03ac805fe832f99dbe0ab10",
|
||||
"app/Services/InvestmentOpportunityAlerts.php": "81b610345f2acb48b1f9a0e468fc5c57fe3716a1f1310535e1c81e909daee911",
|
||||
"app/Services/InvestmentWorkspace.php": "07b72139cd53f225403608f2e2c4c0caaf7e49e38c3844da62d0ff60b5180fed",
|
||||
"app/Http/Controllers/InvestmentWorkspaceController.php": "a10afdd5e577c2636fa7048097193155d18b57ab0d28d74cc6b3b5bbade5939f",
|
||||
"app/Http/Controllers/InvestmentAlertPreferencesController.php": "b7e54a303a3cf170c3e96bcc66e65aa0d0fe194bdfa261df322754e04aa12d8a",
|
||||
"app/Console/Commands/DispatchInvestmentAlerts.php": "e8f8d0868cdb4c26988b7a958a6ccd48aac9d902108020baf62db7e7af76445d",
|
||||
"routes/investment.php": "4b3ded8f81a347948278ffc677861d2b9d269f537cafcba61927332c9b470579",
|
||||
"routes/console.php": "d0703e20fdeff03067521ccc5458cb98bd3fe6fcca58ea2b75e6775ab6ff205c",
|
||||
"resources/js/components/opportunity-alert-preferences.tsx": "923a67920d75ff2b9326d516303366868e2f75537849850de63a2e3fc553cc04",
|
||||
"resources/js/pages/investment/notifications.tsx": "8fdc5acd0fcb716adc0032660493d3549038c9bc63cb5dbf89d16bc8a7384e11",
|
||||
"tests/Feature/InvestmentOpportunityAlertsTest.php": "bd98ab57e307c20f6dee0f2390aab69ac6da33febc4f82da65f179ab97dc34d0",
|
||||
"tests/frontend/opportunity-alert-preferences.test.mjs": "3f63d1c8a24b35bd4ced8f186acdfbe1dcf6bd81666dee6104dc2373b0fc7246"
|
||||
}
|
||||
},
|
||||
"quick_opportunity_search_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T17:14:06.679Z",
|
||||
"status": "local_qa_uncommitted_not_installed",
|
||||
"requirement": "MASTER-471012ab-0124",
|
||||
"user_example": "plantio de algodao",
|
||||
"feature_checks": [
|
||||
{
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 3,
|
||||
"passed": 3,
|
||||
"assertions": 93,
|
||||
"duration_ms": 19975,
|
||||
"log": "storage/logs/quick-opportunity-search-verified-feature-20261102.log",
|
||||
"sha256": "a6af91950dc9724853ed1c9042a385cafbbf7662816c4208eae1bdea3e8bf595"
|
||||
},
|
||||
{
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 6,
|
||||
"passed": 6,
|
||||
"assertions": 112,
|
||||
"duration_ms": 7165,
|
||||
"log": "storage/logs/quick-opportunity-search-compatibility-20261102.log",
|
||||
"sha256": "0541cc518435451204af413dff521f0fee2b1ebd1306ed495358ff51bdadba20"
|
||||
}
|
||||
],
|
||||
"frontend": {
|
||||
"tests": 5,
|
||||
"passed": 5,
|
||||
"log": "storage/logs/quick-opportunity-search-frontend-20261102.log",
|
||||
"sha256": "e157ff7b14f43458cce046839326847ddfa2a1ad9e93e388904692ad26d3512c"
|
||||
},
|
||||
"static_checks": {
|
||||
"phpstan": "passed",
|
||||
"types": "passed",
|
||||
"lint_and_format": "passed_without_warnings",
|
||||
"build": "passed_with_existing_advisories",
|
||||
"diff": "passed"
|
||||
},
|
||||
"coverage": [
|
||||
"native GET and actual form/autofill value",
|
||||
"all meaningful words across public fields",
|
||||
"accent and case normalization",
|
||||
"narrow cultivation wording variants",
|
||||
"canonical public province descendants",
|
||||
"legacy and current API consistency",
|
||||
"page map and export consistency",
|
||||
"literal wildcard characters",
|
||||
"related processing separated from cultivation",
|
||||
"additional filters retained",
|
||||
"pending/error related results hidden",
|
||||
"no data mutation"
|
||||
],
|
||||
"limitations": [
|
||||
"QA uses synthetic fiches, not proof of a real available cultivation offer",
|
||||
"existing cotton source descriptions include processing; no facts or publication invented",
|
||||
"visual verification pending browser control limitation",
|
||||
"not installed remotely",
|
||||
"tender alert integration still in development"
|
||||
],
|
||||
"source_sha256": {
|
||||
"app/Services/OpportunityTextSearch.php": "5db4e6abe69be0b345c4bc81c5243b6278993f883dc38a5ae1a279fa13e44ab6",
|
||||
"app/Services/InvestmentCatalogue.php": "ef4b28fa8dd060f8b420437aa748a5bba7a184864e2e86dc128881e85caca6d5",
|
||||
"app/Http/Controllers/InvestmentDiscoveryController.php": "b59d458b4b8a1fb666d8176feba98efa7603cf396cc30df3f9d156d2a5a45753",
|
||||
"app/Http/Controllers/Api/V1/OpportunityController.php": "6d86f06234238e9dd99dc19169d4f2cd64a287d02f3d6c3eeb7f0f89fbd07b36",
|
||||
"resources/js/components/portal-hero.tsx": "214fa7ee656148ed05dae49ff7ce27c09623c7f43f29f51f957bcb0da4891489",
|
||||
"resources/js/pages/PortalInvestidor.tsx": "3d2804221b4ef3e284c951b8b772db35fa53a9bf0481146420932e10b5c90fe4",
|
||||
"resources/js/components/opportunity-related-results.tsx": "7d161f39b5e1b20c7ecca32b3ef8589d572981d4d3585f91b957a76e04dd3ae1",
|
||||
"resources/js/pages/investment/catalogue.tsx": "3371679dc219332fa630075bcbe0219fde882c66a250c25adfc7ac3f5929a41c",
|
||||
"tests/Feature/QuickOpportunitySearchTest.php": "78888769a1a93d95f2487ed05e3ff025ad77e986b6809936db51e3a319211a5b",
|
||||
"tests/frontend/quick-opportunity-search.test.mjs": "45d6760e02cfb8deb78cde45faa35149d2259718bc3c74046a79f57a527099cf"
|
||||
}
|
||||
},
|
||||
"tender_alerts_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T17:29:34.033Z",
|
||||
"status": "local_qa_uncommitted_not_installed",
|
||||
"requirements": [
|
||||
"ACT-20261029-11",
|
||||
"MASTER-471012ab-0124"
|
||||
],
|
||||
"feature_checks": [
|
||||
{
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 21,
|
||||
"passed": 21,
|
||||
"assertions": 315,
|
||||
"duration_ms": 151505,
|
||||
"log": "storage/logs/tender-alerts-verified-feature-20261102.log",
|
||||
"sha256": "b6028debfd46056dd6c7844984f6e9898445e86cd9457f1703120b596e37e68b"
|
||||
},
|
||||
{
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 1,
|
||||
"passed": 1,
|
||||
"assertions": 7,
|
||||
"duration_ms": 12434,
|
||||
"log": "storage/logs/tender-alerts-batch-fairness-20261102.log",
|
||||
"sha256": "7bb4dba3fd698021abf678f65a5eb13a26d093e24f8fa14396a2a808fabf0bb2"
|
||||
},
|
||||
{
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 8,
|
||||
"passed": 8,
|
||||
"assertions": 285,
|
||||
"duration_ms": 87484,
|
||||
"log": "storage/logs/tender-alerts-procurement-compatibility-20261102.log",
|
||||
"sha256": "3d47c295e7fb8441ab46487f450918d0cc81869436dbd0737b9b525bb1e6a78e"
|
||||
}
|
||||
],
|
||||
"frontend": {
|
||||
"tests": 6,
|
||||
"passed": 6,
|
||||
"log": "storage/logs/tender-alerts-frontend-20261102.log",
|
||||
"sha256": "dc796f6446da72a9449ebb1ad9aa6ef80afdab46419a7e4c041b39907e09b9cc"
|
||||
},
|
||||
"static_checks": {
|
||||
"phpstan": "passed",
|
||||
"types": "passed",
|
||||
"lint_and_format": "passed_without_warnings",
|
||||
"build": "passed_with_existing_advisories",
|
||||
"diff": "passed"
|
||||
},
|
||||
"coverage": [
|
||||
"explicit source opt-in; old preferences remain opportunities only",
|
||||
"shared public procurement query and existing filters",
|
||||
"source-specific applicable fields",
|
||||
"published current acts only, not drafts",
|
||||
"immutable public notification projection and provenance",
|
||||
"historical acts remain readable with a public tender",
|
||||
"withdrawal and catalogue availability respected",
|
||||
"other source continues while tenders unavailable",
|
||||
"cancellation independent of catalogue choices",
|
||||
"both sources roll back on delivery failure",
|
||||
"bounded batch per catalogue and no starvation",
|
||||
"legacy preference and notification migration preservation",
|
||||
"rollback guard preserves tender preferences/history",
|
||||
"legacy procurement, manifestation and quick-search checks retained"
|
||||
],
|
||||
"qa": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 55441,
|
||||
"databases": [
|
||||
"desk_editor_20261102_test",
|
||||
"desk_prefill_20261102_test",
|
||||
"simulator_fiscal_delta_20261021_test"
|
||||
],
|
||||
"data": "synthetic_isolated",
|
||||
"active_writes": 0,
|
||||
"external_messages": 0
|
||||
},
|
||||
"limitations": [
|
||||
"only the current published tender act is considered at each matching run; not every intermediate act",
|
||||
"incentive catalogues and external delivery channels pending",
|
||||
"not installed remotely; destination scheduler not verified",
|
||||
"visual verification pending browser control limitation"
|
||||
],
|
||||
"source_sha256": {
|
||||
"database/migrations/2026_11_02_200000_investment_tender_alerts.php": "02fda526c23e68775fa21d7576f9208e73ba7363980c17c53b6b8eba49054200",
|
||||
"app/Services/Procurement.php": "9199928aad5ec0abe5718e02a3b52b93dc4ee18834dc453ab354cfe0c81ff2a9",
|
||||
"app/Http/Controllers/ProcurementController.php": "08c3604a4722096ee9d4baf6c4708b7517d04d3173cb750d2c3f9628f38fa626",
|
||||
"app/Services/InvestmentOpportunityAlerts.php": "33a8e58836843a0675164f55cbb5d8676c8043656f05d4d5d8da5fd90b6e2729",
|
||||
"app/Services/InvestmentWorkspace.php": "d3c20ff3c46f471fb32a87931b501419c2e0d5edcf9370b2258a7d28b17f5d8e",
|
||||
"app/Http/Controllers/InvestmentWorkspaceController.php": "236ee42f818a005f5da911cd8f2e6b58f1d8a6caf3e2a0d01486cfd53c482c96",
|
||||
"app/Http/Controllers/InvestmentAlertPreferencesController.php": "619a5ba3578c94ec62fa60c56c587b0b31e5942ce70763035b115e19bbd0bf3a",
|
||||
"app/Console/Commands/DispatchInvestmentAlerts.php": "8c7c0a5dfa3b6c55ee4d9c0c748883b46c84cdc008b698ae702a753e11ae0322",
|
||||
"resources/js/components/opportunity-alert-preferences.tsx": "b13d184bc5b32f07f2237d7b3a0432d5ea0be66ed77121dbd075215a5112bb4f",
|
||||
"resources/js/pages/investment/notifications.tsx": "869e40dc5055a2ca16417ea5a5dd53418b59cdd7576d79c1be99bd79189fe4fe",
|
||||
"tests/Feature/InvestmentTenderAlertsTest.php": "68b381b4832db5b93b0900b2d39210d5e2daa7e596558d7c5a543eb65e1a1f8a",
|
||||
"tests/Feature/InvestmentOpportunityAlertsTest.php": "948506d2c664bd26a66f12de1980997d16e4ca0035aef3c752c5b860202f7ca6",
|
||||
"tests/frontend/opportunity-alert-preferences.test.mjs": "faf08301565fa8c865685f48362be2b96cad16f6240fea8b97633ce334016ff3"
|
||||
}
|
||||
},
|
||||
"funding_editor_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T18:01:06.038Z",
|
||||
"status": "partial_local_qa_not_installed",
|
||||
"requirement": "AUD-FUND-01",
|
||||
"feature": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 4,
|
||||
"passed": 4,
|
||||
"assertions": 91,
|
||||
"duration_ms": 29506,
|
||||
"log": "storage/logs/funding-editor-feature-20261102.log",
|
||||
"sha256": "236b615981c50b34f289c66ce994ee4a93b399a6a9530b9d1b95702e3adc8568"
|
||||
},
|
||||
"types": "passed",
|
||||
"limitations": [
|
||||
"territorial and sector filters pending",
|
||||
"specific document tests and concurrency pending",
|
||||
"real funding references pending",
|
||||
"visual verification pending"
|
||||
],
|
||||
"source_sha256": {
|
||||
"app/Services/FundingProgrammes.php": "f0b546d9897ebf2ed1bf43b649e088af8463684aea3b70d68fe75c587921458d",
|
||||
"app/Http/Controllers/FundingProgrammeController.php": "8b94bd0b5bfe17071e4252c998817846d3836950ba92e90b808921ad5b649ab1",
|
||||
"routes/funding.php": "215d5d383920faf3d9554426fa907f06c1a8bacb3fcbaf93785ee31f6ac644cd",
|
||||
"resources/js/pages/funding/edit.tsx": "52c834f8c2eb4acc693a319f1d72cdc1435b6acbbd03dd2323db899b284ea509",
|
||||
"tests/Feature/FundingProgrammesTest.php": "39a5f8fa9d8f775886ee29c32b418a3926a0fcab8da104605b8667fbb87c2092"
|
||||
},
|
||||
"static_checks": {
|
||||
"phpstan": "passed",
|
||||
"types": "passed",
|
||||
"lint_format": "passed_without_warnings",
|
||||
"build": "passed_with_existing_advisories",
|
||||
"diff": "passed"
|
||||
},
|
||||
"check_logs": {
|
||||
"funding-editor-final-phpstan-20261102.log": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2",
|
||||
"funding-editor-final-check-20261102.log": "581fd061da7b8d09b02f783d74065580f6451bce8207e850a52041d4c913bbe3",
|
||||
"funding-editor-final-types-20261102.log": "5346ac8a6566c1c38cb0bbda6a029579df58cd78af78e2c4845d9c82b5d1d76c",
|
||||
"funding-editor-build-20261102.log": "e262422c86f54d9c3f09e5d3935bff84d01b4806b0d34fb69b9e026408b25808"
|
||||
}
|
||||
},
|
||||
"funding_filters_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T18:07:36.596Z",
|
||||
"requirement": "AUD-FUND-01",
|
||||
"status": "partial_local_qa_not_installed",
|
||||
"feature": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 6,
|
||||
"passed": 6,
|
||||
"assertions": 204,
|
||||
"duration_ms": 54070
|
||||
},
|
||||
"static_checks": {
|
||||
"phpstan": "passed",
|
||||
"types": "passed",
|
||||
"lint_format": "passed_without_warnings",
|
||||
"build": "passed_with_existing_advisories",
|
||||
"diff": "passed"
|
||||
},
|
||||
"logs": {
|
||||
"funding-filters-documents-feature-20261102.log": "ae531b17b9ac4cb58907f41a00811e681ffb4998a1736e8709384eedfe2d1859",
|
||||
"funding-filters-phpstan-20261102.log": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2",
|
||||
"funding-filters-types-20261102.log": "5346ac8a6566c1c38cb0bbda6a029579df58cd78af78e2c4845d9c82b5d1d76c",
|
||||
"funding-filters-check-20261102.log": "130f12c92dcd68efcf711f13685b60ba1e1d967d2ea250df32b4c6e1163c0399",
|
||||
"funding-filters-build-20261102.log": "45bfb9a5a1e8724c231905912200889347059eb1cbc02aa26966536b295305b7"
|
||||
},
|
||||
"coverage": [
|
||||
"programme scope filters with explicit unknowns",
|
||||
"national, ancestor and partial territorial coverage",
|
||||
"window dates and unknown windows",
|
||||
"immutable territory names and hierarchy snapshots",
|
||||
"document metadata and byte integrity rechecked",
|
||||
"withdrawn, restricted and pending-scan documents refused",
|
||||
"stale document preview refused; previous publication preserved"
|
||||
],
|
||||
"limitations": [
|
||||
"synthetic metadata states do not prove a real antivirus scan",
|
||||
"no concurrent worker stress test",
|
||||
"real funding references not yet incorporated",
|
||||
"visual and destination deployment pending",
|
||||
"incentive notification integration pending"
|
||||
],
|
||||
"source_sha256": {
|
||||
"app/Services/FundingProgrammes.php": "b15b76f3265bd764464af391608a9b8eb8234e1aaf0cf12880f96305d6d479fa",
|
||||
"app/Http/Controllers/FundingProgrammeController.php": "a336c733a8fc8090a205edc831351bde7c692c13984bf5e20812f1b8a386a58c",
|
||||
"resources/js/pages/funding/index.tsx": "e3111eb5da3bb49c4b46b4e07908cce723174eb91bc4d532e84aea547050a881",
|
||||
"resources/js/pages/funding/show.tsx": "496fae40f450666a280c8550bff3adc26a9b54eaa9ebf050ad4d5849e2f6f920",
|
||||
"resources/js/pages/funding/shared.ts": "d4c2951b877ec95df076dd1a0a8f0abee008a0ba6cf0a504aa691d7e066296da",
|
||||
"tests/Feature/FundingProgrammesTest.php": "e479973de9ec053af4380171728206a28a0c4502658cac871a1729ae01226062"
|
||||
}
|
||||
},
|
||||
"funding_references_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T18:14:49.937Z",
|
||||
"status": "partial_local_qa_not_installed",
|
||||
"requirement": "AUD-FUND-01",
|
||||
"feature": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 7,
|
||||
"passed": 7,
|
||||
"assertions": 235,
|
||||
"duration_ms": 122371,
|
||||
"log": "storage/logs/funding-references-feature-20261102.log",
|
||||
"sha256": "cc3e54e4a78ace00d1a1fa2272d700599d2e34ba2c44f1bb7a18fdff6fd86a79"
|
||||
},
|
||||
"static_checks": {
|
||||
"phpstan": "passed",
|
||||
"types": "passed",
|
||||
"lint_format": "passed_without_warnings",
|
||||
"build": "passed_with_existing_advisories",
|
||||
"diff": "passed"
|
||||
},
|
||||
"source_manifest": {
|
||||
"recorded_at": "2026-11-02T18:13:31.540Z",
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://mpd.gov.mz/wp-content/uploads/2025/10/BR_43_I_SERIE_2025.pdf",
|
||||
"sha256": "0ea99c5f894760e3b715e377e87e9715fad9423e322e5753de1b9bb345c64753",
|
||||
"checked_on": "2026-11-02",
|
||||
"reference": "BR I Série n.º 43, 05/03/2025, pp. 157–160; Decreto n.º 4/2025 e Regulamento anexo",
|
||||
"path": "storage/app/private/funding-sources-20261102/decreto-4-2025.pdf",
|
||||
"size_bytes": 635378,
|
||||
"classification": "private_development_source_copy",
|
||||
"library_published": false,
|
||||
"antivirus_scan": "not_asserted"
|
||||
},
|
||||
{
|
||||
"url": "https://cmct.gov.mz/fundo-de-desenvolvimento-economico-local-fdel/",
|
||||
"sha256": "c69c2c3883766aa6a238ae44d8ee3fc99570adca2f3e647e50df59fde2786403",
|
||||
"checked_on": "2026-11-02",
|
||||
"reference": "Aviso municipal FDEL, prazo 20/10/2025",
|
||||
"path": "storage/app/private/funding-sources-20261102/tete-fdel-2025.html",
|
||||
"size_bytes": 99145,
|
||||
"classification": "private_development_source_copy",
|
||||
"library_published": false,
|
||||
"antivirus_scan": "not_asserted"
|
||||
}
|
||||
]
|
||||
},
|
||||
"source_sha256": {
|
||||
"database/data/funding-references.json": "eb340ab228367327bda4a2231c88d056d74cd1b8811006e9343c761cd7cc1f26",
|
||||
"app/Services/FundingProgrammeReferences.php": "a479ce5b645f6b48f2df412c9bf2bda77db1c89d28528e887812f264750eab25",
|
||||
"app/Http/Controllers/FundingProgrammeController.php": "bdbc975c23775094f0597e72d369f2ce4ab003a37950dfe2903433b3a1f0e781",
|
||||
"routes/funding.php": "faaeb262ea7f488d640e2c142262505ba60c69008315081ad3726d3c5a0ed1b7",
|
||||
"resources/js/pages/funding/reference.tsx": "99a62c7d0815fc414f4ae989e2584a2a796ec4e499e10621c8b0a0dc2914b752",
|
||||
"resources/js/pages/funding/manage.tsx": "514b9330750bf919b5cf56972390814819071f2518745ca2cb81dff53d0f2f28",
|
||||
"tests/Feature/FundingProgrammesTest.php": "4c65ba40a5237241f94901e135151090ce3cb422fbda3a0ca449536f4e73176d"
|
||||
},
|
||||
"limitations": [
|
||||
"private source copies are not published or antivirus scanned",
|
||||
"territorial and sector registry associations not inferred",
|
||||
"no concurrent worker test",
|
||||
"visual, notification integration and destination deployment pending"
|
||||
],
|
||||
"logs": {
|
||||
"funding-references-feature-20261102.log": "cc3e54e4a78ace00d1a1fa2272d700599d2e34ba2c44f1bb7a18fdff6fd86a79",
|
||||
"funding-references-phpstan-20261102.log": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2",
|
||||
"funding-references-types-20261102.log": "5346ac8a6566c1c38cb0bbda6a029579df58cd78af78e2c4845d9c82b5d1d76c",
|
||||
"funding-references-check-20261102.log": "2fbbedf07f705d3016fe827a296b9f1674db6cb69704a29eca349eafbaec08e0",
|
||||
"funding-references-build-20261102.log": "3dcb04bbd5db34360a853e88863596c3abbeff20d6d844a31572d4fb609b6378"
|
||||
}
|
||||
},
|
||||
"funding_concurrency_increment_20261102": {
|
||||
"recorded_at": "2026-11-02T18:22:27.583Z",
|
||||
"status": "partial_local_qa_not_installed",
|
||||
"requirement": "AUD-FUND-01",
|
||||
"initial_harness_failure": "PG statistics snapshot retained inside observer transaction; corrected by clearing statistics snapshot",
|
||||
"reproduced_product_failure": "SQLSTATE 40P01: account FOR UPDATE lock conflicts with audit foreign-key KEY SHARE during withdrawal vs interest",
|
||||
"correction": "FOR NO KEY UPDATE serializes account bookmarks while permitting audit FK reads",
|
||||
"concurrent": {
|
||||
"result": "passed",
|
||||
"database": "funding_concurrency_20261102c_test",
|
||||
"synthetic": true,
|
||||
"phases": {
|
||||
"reference": {
|
||||
"overlap_observed": [
|
||||
{
|
||||
"pid": 25356,
|
||||
"wait_event": "advisory",
|
||||
"blockers": "{7860}"
|
||||
},
|
||||
{
|
||||
"pid": 9824,
|
||||
"wait_event": "advisory",
|
||||
"blockers": "{7860,25356}"
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"id": "f2343e31-141c-4c4c-b01e-05373bb2dc19",
|
||||
"created": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"id": "f2343e31-141c-4c4c-b01e-05373bb2dc19",
|
||||
"created": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"draft": {
|
||||
"overlap_observed": [
|
||||
{
|
||||
"pid": 15432,
|
||||
"wait_event": "advisory",
|
||||
"blockers": "{7860}"
|
||||
},
|
||||
{
|
||||
"pid": 14596,
|
||||
"wait_event": "advisory",
|
||||
"blockers": "{7860,15432}"
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"id": "f2343e31-141c-4c4c-b01e-05373bb2dc19"
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": "conflict",
|
||||
"code": 409
|
||||
}
|
||||
]
|
||||
},
|
||||
"publication": {
|
||||
"overlap_observed": [
|
||||
{
|
||||
"pid": 23252,
|
||||
"wait_event": "transactionid",
|
||||
"blockers": "{7860}"
|
||||
},
|
||||
{
|
||||
"pid": 27504,
|
||||
"wait_event": "tuple",
|
||||
"blockers": "{23252}"
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"version_id": "ba64414b-ec6e-4473-be6c-44fe0615aa21"
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"version_id": "ba64414b-ec6e-4473-be6c-44fe0615aa21"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"interest": {
|
||||
"overlap_observed": [
|
||||
{
|
||||
"pid": 26936,
|
||||
"wait_event": "transactionid",
|
||||
"blockers": "{7860}"
|
||||
},
|
||||
{
|
||||
"pid": 24988,
|
||||
"wait_event": "tuple",
|
||||
"blockers": "{26936}"
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"saved": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"saved": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"withdrawal_vs_interest": {
|
||||
"overlap_observed": [
|
||||
{
|
||||
"pid": 25628,
|
||||
"wait_event": "transactionid",
|
||||
"blockers": "{7860}"
|
||||
},
|
||||
{
|
||||
"pid": 24716,
|
||||
"wait_event": "tuple",
|
||||
"blockers": "{25628}"
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"withdrawn": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": "unavailable",
|
||||
"code": 404
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"active_writes": 0,
|
||||
"external_messages": 0
|
||||
},
|
||||
"preservation": {
|
||||
"programmes": 1,
|
||||
"versions": 1,
|
||||
"interests": 1,
|
||||
"private_reference_preserved": 1,
|
||||
"public_programmes": 0,
|
||||
"audit": {
|
||||
"draft_saved": 2,
|
||||
"interest_saved": 1,
|
||||
"published": 1,
|
||||
"reference_prepared": 1,
|
||||
"withdrawn": 1
|
||||
}
|
||||
},
|
||||
"feature": {
|
||||
"tool": "phpunit",
|
||||
"result": "passed",
|
||||
"tests": 7,
|
||||
"passed": 7,
|
||||
"assertions": 235,
|
||||
"duration_ms": 44679
|
||||
},
|
||||
"checks": {
|
||||
"phpstan": "passed",
|
||||
"pint": "passed",
|
||||
"diff": "passed",
|
||||
"frontend": "unchanged; previous reference increment proof retained"
|
||||
},
|
||||
"guards": [
|
||||
"wrong environment refused before database access",
|
||||
"nonempty disposable database refused without resetting records"
|
||||
],
|
||||
"logs": {
|
||||
"funding-concurrency-before-20261102.log": "6050d857774ae59c2c88a87d4046da108311ae68865dfcce53113968b49fa1e3",
|
||||
"funding-concurrency-baseline-20261102.log": "decdaf79cc743e11666ab59276751aff4d9b877171006f3e9a17423720a8c329",
|
||||
"funding-concurrency-after-20261102.log": "20ff2c0247b5caabd95eb8a1387fad4a023c755ae1c297c0319e05a3003e4c67",
|
||||
"funding-concurrency-feature-20261102.log": "299ad46e67780b013ed9045531be150f41ee9c12030ebb91670b81239d782c72",
|
||||
"funding-concurrency-phpstan-20261102.log": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2",
|
||||
"funding-concurrency-guard-environment-20261102.log": "3046bfdcaa79a08b0fe157b356c86e2c0354436e5795deaa1561dc4e5fcee93c",
|
||||
"funding-concurrency-guard-existing-20261102.log": "bcfdd95824c0cafc853a8f7be1883451d8882ed393c97f314241c6f8c106ed5e",
|
||||
"funding-concurrency-preservation-20261102.json": "414747162b39d7ac9c1ff9de68198dcf5c52b29518bcde5985edaff1eb01f6ea"
|
||||
},
|
||||
"source_sha256": {
|
||||
"app/Services/FundingProgrammes.php": "b756c530e0fcd1f0ddaab5be422487d746e11e390d41f07fcc89f90fa1e315ae",
|
||||
"tools/verify-funding-concurrency.php": "3c296bd556a908cd0357a29fa4cf2a05ba550d88cbafa3b2196dde2fcc63f1ec"
|
||||
},
|
||||
"limitations": [
|
||||
"two processes per scenario; not a load test or every interleaving",
|
||||
"visual, reference registry associations, incentive alerts and destination deployment pending"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,138 @@
|
||||
# Rastreabilidade, utilização e formação
|
||||
|
||||
## 02/11/2026 - reconciliacao das pre-condicoes da suite ampla
|
||||
|
||||
A classificacao das falhas foi confrontada com os testes e com a configuracao efectiva. Os quatro 403 do intake de negocios pertenciam ao percurso de listas legadas, mas herdavam a activacao de perfis do ambiente. Com variante legada explicita, passaram nove testes/248 assercoes do intake e das operacoes documentais reais; GD/WebP foi activado apenas pelo argumento PHP -d extension=gd, sem alterar o runtime partilhado. O proprio ExistingBusinessIntakeTest passou a declarar essa variante no setUp, conservando todas as recusas de isolamento, MFA, designacao, auto-aprovacao e estados. Verificacao sem flags externas de perfis: sete testes/154 assercoes passaram. Nenhuma politica do produto foi alargada.
|
||||
|
||||
Criadas duas copias descartaveis da base QA sintetica desta sessao, com as identidades exigidas pelos grupos historicos: simulator_fiscal_delta_20261021_test e proactive_investment_20261027_test, sempre em 127.0.0.1:55441. Nenhuma base activa foi lida ou importada. A primeira execucao dos 115 ficheiros na identidade fiscal correcta reportou 186 testes, 32 aprovados e falhas restantes predominantemente por referencias/corpus ausentes; nao foi declarada aprovada. A preparacao passou a reutilizar os seeders de referencias versionadas existentes, com 1921 registos territoriais, sectores e propostas documentais; nao foram inventadas coordenadas ou publicadas ofertas reais.
|
||||
|
||||
Os quatro testes IRPC da segunda base exigiam o actor sintetico 1021. A ferramenta tools/prepare-fiscal-preservation-fixture.php cria apenas uma identidade sintetica nominativa nesse alvo QA, sem credencial divulgada nem concessoes; recusa substituir uma identidade diferente. Acrescenta uma base nao vazia nas sete tabelas cujos hashes os testes comparam. O rascunho de base e arquivado: um rascunho activo do mesmo actor bloquearia correctamente a criacao de outro. Documentos dessa base sao metadados sinteticos, sem alegar ficheiros descarregaveis ou scan real. A ferramenta verifica CLI, testing, identidade/host/porta/DB_URL/cache e recusa alvos fora da lista; uma tentativa na base intake_env_20261102_test foi recusada com codigo 1. Referencias-only reutiliza apenas os seeders existentes, nas duas bases QA nomeadas. Ferramenta idempotente no alvo preparado; sem migracoes ou alteracoes activas.
|
||||
|
||||
Resultado terminal dos quatro testes IRPC: quatro aprovados/602 assercoes, incluindo conservacao dos livros, historicos e dos registos de base. PHPStan da ferramenta e Pint passaram. A nova execucao dos 110 ficheiros que continham falhas continua no processo proprio (integration-fiscal-reference-retry-20261102.log); nao foi reiniciada por ausencia temporaria de output. Antes das execucoes foram copiadas e calculadas as hashes SHA-256 de 63 provas existentes que os testes podem regravar. As copias estao em storage/app/private/integration-proof-preservation-20261102, manifesto em storage/logs/integration-output-preservation-20261102.json. Conferir e preservar separadamente o resultado novo antes de repor quaisquer bytes originais alterados, quando a execucao terminar. Proxima accao: concluir essa reconciliacao e tratar o saldo real de fixtures/funcionalidades, sem promover falha de ambiente a perda de funcionalidade. Sem publicacao.
|
||||
|
||||
## 02/11/2026 - AUD-CHECKLIST-01: editor de formularios versionados do Balcao
|
||||
|
||||
Inventario: desk_service_versions e o vinculo desk_requests.service_version_id ja preservavam definicoes por versao; a publicacao administrativa existente lia exclusivamente config/virtual-desk.php. Confirmada a lacuna de edicao pela interface, sem perda das versoes ou dos pedidos anteriores. Implementado /management/balcao/servicos, acessivel pela administracao do Balcao, usando as mesmas competencias administrativas existentes. A nova subrota herda o mesmo ambito; nenhum perfil ou concessao paralela foi criado.
|
||||
|
||||
O editor permite alterar identificacao, perguntas, obrigatoriedade, listas/opcoes, limites numericos e condicoes de aplicabilidade, acrescentar/retirar campos e conferir uma comparacao anterior/nova antes da confirmacao. Motivo, versao de origem, autor/publicador e hash canonico ficam auditados. A nova definicao e inserida em desk_service_versions; nao actualiza definicoes antigas, respostas ou snapshots. Fonte anterior conservada; a mudanca e registada como publicacao de definicao interna, nao como aprovacao externa. Rascunhos do editor usam a memoria do formulario por servico/versao; edicoes invalidam a confirmacao precedente.
|
||||
|
||||
Servidor verifica competencias pela rota existente, versao concorrente, repeticao idempotente, campos reservados, tipos suportados, opcoes validas e condicoes sem ciclos/referencias ausentes. Etapa do workflow permanece intacta. Identificadores canonicos interpretados pelo motor conservam os seus tipos; outros tipos exigem campo novo, para nao contornar validacao financeira/territorial. Nao sao novas regras organizacionais: sao contratos tecnicos do motor reutilizado. Publicacao sem alteracoes e recusada, e propriedades de configuracao sem efeito no tipo de campo sao rejeitadas. Campos novos de email/URL recebem validacao correspondente no servidor.
|
||||
|
||||
Provas iniciais: 18 Feature/325 assercoes do editor e percurso de orientacao passaram em desk_editor_20261102_test:55441, base descartavel distinta da suite ampla em curso. Teste final dirigido comprovou dois percursos/48 assercoes antes da adicao da guarda contra publicacao sem alteracoes; nova verificacao desse delta em curso no log desk-service-editor-final-feature-20261102.log. Inclui pedido v1 efectivamente submetido, publicacao/repeticao v2 sem duplicacao, payload e submitted_snapshot v1 identicos, recuperacao das duas definicoes e submissao v2 com campo email novo valido; estados incompletos/invalidos recusados. Um teste frontend real do editor passou: fonte imutavel, comparacao anterior/nova, confirmacao invalidada e destino/versao do envio. TypeScript, lint e PHPStan dirigidos passaram nas verificacoes registadas; verificacoes finais do delta acompanhadas separadamente. Sem migracoes, escrita activa, instalacao ou prova visual; AUD-CHECKLIST-01 permanece parcial para outros tipos de checklist e configuracao transversal.
|
||||
|
||||
ACT-16, resposta do proprietario nesta continuidade: «ainda nao» quanto a linguas prioritarias/traducao aprovada. Essa definicao continua pendente; nao foram inventadas traducoes nem suspenso o desenvolvimento independente.
|
||||
|
||||
Verificacao final deste incremento: 19 Feature/339 assercoes passaram em desk_editor_20261102_test, incluindo o percurso existente de orientacao e a nova prova de escolhas numericas recebidas pela API. Estas escolhas sao conservadas como codigos textuais e nao contornam perguntas condicionais obrigatorias; formulario e servidor usam a mesma comparacao. O editor nao configura comparadores numericos: condicoes actuais sao de igualdade de respostas, com listas de opcoes para categorias numericas. TypeScript, PHPStan e lint finais passaram (675 ficheiros formatados; 653 sem avisos/erros). Compilacao ainda em execucao no processo observado; nao se declara resultado antes do termo. A suite Feature ampla anterior continua independente, com resultado ainda pendente. Logs desk-service-editor-final-*20261102.log preservam as provas e os testes iniciais falhados nao sao apresentados como aprovacoes.
|
||||
|
||||
Resultados terminais posteriores: compilacao final do editor terminou com codigo 0. A suite ampla development-feature-20261102.log terminou com 707 testes reportados, 467 aprovados, 198 falhados e 7653 assercoes; nao e aceite como suite global aprovada. Classificacao inicial das 198 falhas: 190 associadas a exigencia de identidade/fixture de bases QA historicas, quatro respostas 403 no intake de negocios, uma dependencia GD/WebP e tres fixtures/cobertura territorial. Isto nao demonstra 198 funcionalidades por implementar nem autoriza ignorar as falhas. Proxima accao: recuperar as pre-condicoes QA dos grupos, conferir os 403 com autoridade equivalente e disponibilizar o suporte de imagem exigido, sem relaxar guardas ou usar dados activos. A execucao ampla ocorreu durante evolucao local e nao certifica uma candidata imutavel; as provas dirigidas do editor referem-se ao delta concluido. Objectivo global permanece activo.
|
||||
|
||||
## 02/11/2026 - continuidade: fonte dos semelhantes e consulta offline de oportunidades
|
||||
|
||||
Delta de classificacao no mesmo consumidor: escolha explicita entre classificacoes divulgadas da ficha. A consulta transporta tipo, alvo e finalidade pelos filtros do servidor existentes; finalidades diferentes para o mesmo alvo nao sao deduplicadas nem confundidas. Mostra a fonte da classificacao, quando conhecida; relacao documental nao prova implantacao/propriedade/capacidade. Mudar de ficha elimina a seleccao anterior. Quatro testes do consumidor passaram, total dirigido desta continuidade dez testes com offline e associacoes. Lint final sem avisos/erros; TypeScript e build finais acompanhados pelos processos da ronda, sem repetir a suite PHP ampla em curso.
|
||||
|
||||
Fecho tecnico deste delta: TypeScript e compilacao finais terminaram com codigo 0; git diff --check passou. Lint: 673 ficheiros formatados e 651 sem avisos/erros. Logs development-classification-check-20261102.log e development-classification-build-20261102.log em storage/logs. A suite Feature ampla mantem processo proprio e ainda nao tem resultado terminal; nao foi declarada aprovada. A pergunta sobre linguas prioritarias/traducao aprovada foi enviada sem suspender os restantes incrementos. Objectivo global permanece em execucao, nao concluido.
|
||||
|
||||
AUD-MARKET-01: cada resultado semelhante identifica agora o catalogo publico como fonte e a data de publicacao quando fornecida, separada da data dos dados de origem. Data ausente permanece desconhecida. Resposta com ficha malformada gera erro recuperavel, em vez de aparente ausencia de negocios. Tres testes do consumidor passaram, incluindo esta regressao. Proveniencia externa, periodo dos dados de origem e raio continuam pendentes; nao se promove publicacao a validacao factual.
|
||||
|
||||
ACT-20261029-16: consulta offline alargada a ficha publica de oportunidade, usando o mesmo gerador HTML das fichas de negocio. Extraido renderer comum de texto escapado, politica restritiva, origem HTTP/HTTPS e ligacao limitada ao proprio portal. A projecao explicita inclui referencia, resumo, descricao, investimento estimado com precisao original, sector, preparacao, contexto territorial indicado, financiamento, impacto esperado e ultima validacao quando conhecida. Nao transporta queries de dossier, anexos, mapas ou propriedades privadas adicionais; contexto territorial nao e confirmado como implantacao. Mensagem apenas confirma preparacao do ficheiro, nao descarga concluida.
|
||||
|
||||
Nove testes frontend dirigidos passaram: cinco de ficheiros offline, tres dos semelhantes e um de continuidade das associacoes. A tentativa de conferencia visual anterior terminou por limitacao de politica de URLs do navegador Windows; nao foi contornada. Revisao visual e abertura real sem rede continuam pendentes. Suite PHP Feature iniciada anteriormente permanece em execucao na QA isolada; consultas PostgreSQL activas observadas, sem reiniciar ou declarar resultado prematuro. Nao houve instalacao, publicacao ou alteracao de dados activos.
|
||||
|
||||
## 02/11/2026 - ACT-20261029-16: consulta offline seleccionada de ficha publica
|
||||
|
||||
Inventario anterior preservado: ficha publica, contacto/interesse, origem, classificacao e catalogo continuam disponiveis. Nao existia consumidor offline neste ecrã. Primeiro recorte implementado localmente: Guardar ficha para consulta offline (.html). Um ficheiro independente contem somente a projeccao explicita dos dados publicos apresentados, valores decimais integrais, localizacao divulgada, publicacao quando conhecida, data da copia e ligacao a ficha de origem. Sem scripts, fontes/imagens remotas, cache geral ou serializacao do objecto/payload privado. Textos escapados, origem HTTP/HTTPS validada, slug codificado, politica de conteudo restritiva e libertacao do URL temporario apos descarga. Resultado do clique informa que o ficheiro foi preparado, sem afirmar que o navegador o guardou.
|
||||
|
||||
Tres testes dirigidos passaram: fidelidade/ausencias/precisao, escape de conteudo hostil e exclusao de campos privados adicionais, descarga e libertacao do recurso. Nenhuma escrita no servidor, permissao, publicacao ou dependencia de IA. ACT-16 permanece parcial: nao implementa edicao/sincronizacao offline, novos idiomas ou consulta geral sem rede; nenhuma lingua prioritaria foi inventada. A conferencia real da abertura sem rede e da apresentacao movel continua pendente. Backlog e matriz consolidada actualizados no mesmo ID, conservando criterios e provas anteriores.
|
||||
|
||||
## 02/11/2026 - desenvolvimento continuo: primeiro consumidor de negocios semelhantes
|
||||
|
||||
Delta territorial posterior no mesmo recorte: a consulta usa tambem a provincia principal divulgada quando essa referencia e consultavel na hierarquia publica existente; local privado, inexistente ou referencia territorial fora desse catalogo nao gera filtro invalido nem criterio geografico apresentado. Teste positivo e teste de local reservado/expiracao/fallback passaram: quatro Feature/190 assercoes no total. A primeira fixture territorial nao pertencia ao catalogo aceite e devolveu 422; a prova levou a proteger tambem o encaminhamento real, preservando a validacao do endpoint em vez de a relaxar. Area provincial nao e distancia nem raio. Fontes/datas individuais e raio continuam pendentes.
|
||||
|
||||
Conferidos o backlog, a matriz e os recibos posteriores antes de desenvolver. Existem funcionalidades parciais/por implementar: comparacao de negocios por area/raio/classificacao, dados factuais das camadas do mapa, recomendacoes de nichos e perfis, indicadores/assimetrias, preferencias de alertas e outros recortes ACT. Nem todas as pendencias documentais representam codigo em falta; as provas e entregas anteriores de competitividade, territorio, fiscalidade e navegacao permanecem aceites. Nao foi detectada perda de codigo neste recorte; nao se afirma comparacao integral dos dados activos.
|
||||
|
||||
AUD-MARKET-01: confirmada ausencia de consulta de negocios semelhantes na ficha publica. Implementado primeiro consumidor que reutiliza o endpoint, filtros, restricoes de publicacao e recurso publico existentes: mesma modalidade e, quando conhecido, mesmo sector. Mostra ate seis fichas, exclui a origem e repeticoes, explica os criterios e oferece catalogo filtrado; liga ao detalhe conservando filtros. Estados de carregamento, erro/tentativa explicita e ausencia de correspondencias distintos; mudanca de contexto oculta resultados antigos e ignora respostas tardias. Nao cria recomendacao financeira nem infere distancia, localizacao ou ausencia de negocios no mercado. Sem acesso ao payload reservado, novas permissoes ou novo motor de pesquisa. O requisito permanece parcial: area/raio, dados individuais de fonte/data e cobertura geografica ainda por desenvolver; nao se declara a funcionalidade ampla concluida.
|
||||
|
||||
Provas: dois testes frontend do componente; tres testes Feature/158 assercoes do catalogo, incluindo o novo vinculo na ficha; TypeScript, lint/formatacao, Pint dirigido, PHPStan dirigido e build passaram. A indisponibilidade QA anterior foi ultrapassada com PostgreSQL 17 descartavel em storage/app/private/qa-database-20261102, limitado a loopback 127.0.0.1:55441; identidade SQL investmoz_test confirmada antes dos testes. Nao foi reutilizada nem alterada a base activa, e as guardas de testing/_test/porta permanecem. Sem publicacao ou prova visual. Registos similar-businesses-*20261102.log em storage/logs. Proxima accao: continuar os recortes autorizados e conferir cobertura territorial publica sem inventar coordenadas ou regras de distancia.
|
||||
|
||||
## 02/11/2026 - formularios: redundancias e clareza do preenchimento
|
||||
|
||||
Pesquisa estatica transversal a resources/js: 136 declaracoes form/Form e 1963 ocorrencias de controlos/componentes de campos em 199 ficheiros, incluindo pesquisa, autenticacao, perfis, gestao, investimento, balcao, parcerias, contratacao e simulacao. Inventario de caminhos e rotulos literais em docs/investmoz/v2/auditoria-portal/formularios-20261102.json; nao e uma segunda matriz de requisitos. Os numeros sao ocorrencias no codigo, nao formularios distintos apresentados a um perfil. Incluida revisao dirigida dos rotulos dinamicos de BusinessFormSchema. Isto nao demonstra revisao visual nem cobertura semantica integral dos campos gerados por configuracao/servidor.
|
||||
|
||||
Corrigidos 16 ficheiros: mensagem duplicada de pesquisa sem paises; linguagem tecnica de snapshots/cadastro mestre nas instrucoes; explicacao de contacto do promotor; pergunta de finalidade do perfil; organizacao representada nas parcerias; modalidade de negocio com rotulos portugueses existentes; pergunta sobre participacao na oportunidade; identificacao visivel do pais do requerente; fuso horario com exemplo utilizavel. Uniformizado o rotulo opcional de origem do pedido e substituidas explicacoes sobre IP/estatisticas por instrucoes de preenchimento. A referencia da autorizacao de representacao distingue-se agora da referencia do comprovativo no portal. Mantidos os dois campos, a origem distinta da residencia/constituicao, valores, consentimentos, obrigatoriedade, regras, permissoes e historico. Nenhuma recolha existente foi removida por mera semelhanca de nome.
|
||||
|
||||
Verificacao: 26 testes frontend existentes passaram; TypeScript, sintaxe PHP, formatacao e lint passaram, sem avisos/erros de lint. Compilacao de producao concluida com codigo 0; conservados avisos existentes de tamanho de chunks e fontaine opcional. git diff --check passou. Sem escrita em dados activos ou publicacao. Integração dependente da base QA indisponivel ja registada na ronda anterior; nao repetida para alteracoes editoriais. Proxima accao: conferencia visual por perfil, incluindo campos dinamicos e estado vazio/erro; nao se declara que todos os formularios estejam livres de redundancias.
|
||||
|
||||
## 02/11/2026 - pesquisa transversal de erros e correccoes reproduzidas
|
||||
|
||||
Pedido da Tempo: pesquisar erros em todo o portal e corrigir os confirmados. Inventario anterior preservado; a pesquisa abrangeu a suite frontend completa, testes unitarios PHP, analise estatica PHP, formatacao/lint e os transportes partilhados. A suite frontend inicial passou com 1230 testes; os 94 testes unitarios PHP passaram com 410 assercoes; PHPStan terminou sem erros. Isto nao equivale a uma inspecao visual integral nem encerra requisitos funcionais da matriz.
|
||||
|
||||
Reproduzidas antes da correccao: o transporte publico ignorava CSRF em cookies separados sem espaco, expunha URIError para cookie malformado e a apresentacao de validacao falhava quando a API devolvia errors:null. O contexto de investimento tinha o mesmo problema de separadores/descodificacao. Agora preservam tokens validos, recusam escrita com mensagem de recuperacao da sessao e mantem consultas independentes do cookie. Nenhum pedido de escrita e repetido automaticamente; autorizacao e validacao CSRF no servidor mantidas. No formulario de interesse, armazenamento bloqueado impedia a navegacao para autenticacao e podia transformar uma submissao confirmada em erro ao limpar o rascunho; ambas as operacoes toleram agora a indisponibilidade da persistencia, sem prometer recuperacao entre paginas.
|
||||
|
||||
Provas dirigidas posteriores: 40 testes dos transportes, mapas e seleccao passaram, e passou o novo teste de armazenamento bloqueado no formulario de interesse. Os avisos de promessas nao aguardadas nos testes recentes foram corrigidos; verificacao final com 666 ficheiros formatados e 644 ficheiros sem avisos/erros de lint. TypeScript e git diff --check passaram. Compilacao final confirmada com codigo de saida 0 em 24,97 s, com os avisos existentes de chunks e fontaine opcional. Registos desta ronda em storage/logs/*audit*20261102.log, build-audit-verified-20261102.log e frontend-check-final-20261102.log; nao constituem publicacao. Sem alteracao de schema, permissoes ou dados activos.
|
||||
|
||||
Limitacao concreta: a suite Feature foi iniciada com a configuracao QA existente mas nao terminou; o processo foi observado com ligacao TCP em SynSent para 127.0.0.1:55441 e log sem resultado. Interrompida apenas esta tentativa de teste. Nao foi usada outra base nem relaxadas as guardas. Proxima accao: restabelecer a disponibilidade da base QA isolada e executar Feature; conferir visualmente os percursos afectados. Nao se declara integracao aprovada nesta ronda nem ausencia de erros em todo o portal.
|
||||
|
||||
## 02/11/2026 - retoma e verificacao consolidada do delta local
|
||||
|
||||
Retomado o trabalho nesta pasta, preservando as alteracoes em curso e o inventario anterior. Passaram os 40 testes dirigidos de camadas cartograficas, erros do catalogo, seleccao de oportunidades, transporte dos perfis/contexto e navegacao. TypeScript (`npm.cmd run types:check`), compilacao de producao (`npm.cmd run build`, 37,31 s) e `git diff --check` passaram. Usado npm.cmd devido ao bloqueio de npm.ps1 pela politica do PowerShell, sem alterar a politica do sistema. A compilacao conserva avisos de chunks superiores a 500 kB e da dependencia opcional fontaine; nao impediram a geracao dos artefactos. Esta verificacao nao e uma nova suite integral, revisao visual ou publicacao. Proxima accao: conferir visualmente os percursos cartograficos e de regresso ao catalogo ja descritos abaixo; mantem-se pendentes os criterios globais da matriz.
|
||||
|
||||
## 02/11/2026 - regresso a pagina do catalogo
|
||||
|
||||
Corrigida perda da pagina ao consultar fichas da carteira e zonas: titulo e Consultar ficha conservam agora page no catalogue_query, mantendo filtros aplicados e destino local. Abrange catalogos publicos e de gestao; nao copia parametros alheios da URL nem altera exportacoes. Valores de pagina nao positivos, fraccionarios ou fora do intervalo inteiro seguro sao ignorados. 20 testes de navegacao e territorio e verificacao TypeScript aprovados. Nenhuma alteracao de dados, schema, permissoes ou publicacao; revisao visual pendente.
|
||||
|
||||
|
||||
## 02/11/2026 - retoma: sessao dos formularios de perfis
|
||||
|
||||
Estado anterior preservado. Leituras profileJson deixam de depender da descodificacao do cookie CSRF; escritas com cookie malformado devolvem ProfileValidationError legivel antes do transporte, mantendo a validacao CSRF do servidor. Cookies separados sem espaco passam a ser lidos correctamente. Credenciais same-origin explicitas. 14 testes focados aprovados, incluindo duas novas regressoes; sem operacoes reais, dados, schema, direitos ou publicacao. Sem nova verificacao visual.
|
||||
|
||||
|
||||
## 02/11/2026 - seleccao de oportunidades resiliente
|
||||
|
||||
Inventario preservado: escolha multipla, corredores, preparacao com IDs escolhidos e limpeza da seleccao. Corrigido JSON valido com tipo errado que podia bloquear o catalogo: somente listas de inteiros positivos seguros, sem duplicados, sao recuperadas. Falhas de leitura/gravacao/limpeza do sessionStorage nao interrompem a pagina; a escolha em memoria permanece utilizavel e a falha de persistencia e indicada sem prometer conservacao entre paginas. Mantida a chave existente; nenhum outro dado do navegador e apagado. 19 testes focados e TypeScript aprovados. Sem alteracoes de servidor, dados activos, permissoes ou publicacao; revisao visual nao realizada.
|
||||
|
||||
|
||||
## 02/11/2026 - robustez fora dos menus: perfis e contexto de investimento
|
||||
|
||||
Inventario preservado: formularios e erros por campo de perfis, sinal de reautenticacao, payload Unicode, credenciais, CSRF e limite partilhado de keepalive dos contextos. Corrigidas respostas HTML/JSON invalido nos perfis (mensagem util em vez de SyntaxError), explicacao de sessao expirada e sucesso sem conteudo 204. O contexto de investimento rejeita corpo invalido mesmo com HTTP 200, sem confirmar sucesso nem repetir escritas; a reserva de keepalive e libertada tambem nessa falha. Nenhuma alteracao de dados, schema, direitos ou regras de negocio. 26 testes frontend focados aprovados, incluindo cinco novas regressoes; TypeScript e build de producao aprovados. Evidencia parcial acrescentada ao RF-MN-002 existente, sem encerrar o requisito global. Sem revisao visual ou publicacao; cobertura parcial da aplicacao.
|
||||
|
||||
|
||||
## 02/11/2026 - segunda pesquisa de redundancias na navegacao
|
||||
|
||||
Conferidos rodape, navegacao lateral, menus por perfil e atalhos do catalogo. Corrigida a designacao Contacto do rodape para Apoio ao investidor, mantendo /apoio; Contacto institucional continua no menu principal. Confirmada coexistencia de navegacao completa no cabecalho e barra lateral das paginas de gestao; mantida nesta ronda para nao retirar destinos sem verificar a experiencia responsiva. Atalhos por dimensao e fichas de estruturas nao sao duplicados exactos e permanecem disponiveis. Pesquisa pelo codigo; sem nova evidencia visual, dados, permissoes ou publicacao.
|
||||
|
||||
|
||||
## 02/11/2026 - simplificacao do menu principal
|
||||
|
||||
Inventario preservado: destinos publicos e privados, autorizacoes, conta, manifestacoes, idioma e contactos. Entrada de investimento consolidada em Realizacao; removidos do cabecalho meteorologia, redes sociais (mantidas no rodape) e atalho com contador local indevidamente descrito como pedidos recebidos. Manifestacoes continuam acessiveis na conta. Nenhum endpoint, dado ou direito removido. Testes de navegacao e TypeScript; revisao visual indisponivel por falha do host do navegador.
|
||||
|
||||
|
||||
## 02/11/2026 - robustez da pesquisa e das respostas da API
|
||||
|
||||
Inventario incremental: preservados filtros, resultados, vistas, exportacao, paginacao e mensagens de validacao existentes. A paginacao do catalogo agora oculta ligacoes da pesquisa anterior durante carregamento ou erro, em coerencia com os resultados. O helper API partilhado trata respostas HTML, sessao expirada (419), JSON ausente e sucesso sem conteudo (204); conserva mensagens JSON do servidor, credenciais e CSRF nas escritas, incluindo multipart. GET publico nao depende da descodificacao do cookie CSRF. Cookie malformado em escrita apresenta recuperacao da sessao sem enviar a operacao.
|
||||
|
||||
Provas: 25 testes frontend seleccionados aprovados, incluindo 3 novos casos de regressao; TypeScript e build de producao aprovados (29,95 s; aviso de tamanho de chunks). Dados, schema e permissoes inalterados. Sem publicacao ou revisao visual; nao constitui auditoria integral. Evidencia acrescentada ao registo existente RF-GEO-009 para o consumidor catalogo, sem encerrar requisitos globais nem apagar provas anteriores.
|
||||
|
||||
|
||||
## 02/11/2026 — revisão dos restantes consumidores cartográficos (RF-GEO-009)
|
||||
|
||||
Inventário do delta após o catálogo: o painel partilhado das fichas de oportunidades, zonas públicas (ZEE/ZFI/parques) e investimentos relacionados na carteira repetia o mapa administrativo; o catálogo de parcerias também compunha dois mapas. Foram substituídos pelo mesmo mapa com camadas, fontes expansíveis e avisos, preservando fichas sem coordenadas, contagens/proveniência, truncagem e novas tentativas. A cobertura distingue os registos da pesquisa dos limites adicionais. As caixas antigas do painel não foram mantidas em paralelo com os controlos Leaflet. Ao mudar filtros, dados da resposta anterior ficam ocultos até à resposta correspondente; um geo_location_id genérico já não é tratado como território administrativo sem identificação explícita na hierarquia.
|
||||
|
||||
Confirmados dois erros de significado: o perímetro declarado pelo Promotor usava o título/contagem «Contexto administrativo», e locais documentados de projectos/clusters/parcerias usavam «Mapa dos investimentos». A apresentação agora distingue `declared_parcel`, `documented_locations`, limites administrativos e investimentos, incluindo legendas e descrições dos polígonos. Não altera coordenadas, cadastros ou estatutos legais, nem cria pontos para clusters/parcerias sem localização própria.
|
||||
|
||||
Mantidos mapas separados quando representam objectos distintos: geografia documental de uma estrutura versus investimentos relacionados na carteira; contextos de locais/parcelas distintos nos formulários; mapa institucional de contacto. A aproximação de um perímetro declarado a um limite administrativo não os funde nem constitui confirmação cadastral. Conservados os endpoints públicos/de gestão/de perfil, as permissões e as fontes existentes; nenhuma alteração de backend/schema/dados.
|
||||
|
||||
Verificação local: 17 testes frontend focados aprovados, incluindo composição única nas parcerias, manutenção do território aplicado durante edição dos filtros, máscara de dados antigos, cobertura/fichas, ausência de inferência administrativa, semântica dos três tipos de mapas e regressões territoriais; TypeScript e build de produção aprovados. Matriz existente ampliada no mesmo RF-GEO-009 sem encerrar critérios globais nem apagar as provas anteriores. Sem publicação adicional ou revisão visual do domínio.
|
||||
|
||||
## 02/11/2026 — mapa único no catálogo de oportunidades (RF-GEO-009)
|
||||
|
||||
Delta autorizado pela Tempo: substituir os dois mapas simultâneos do catálogo por um mapa principal com camadas independentes de oportunidades com coordenadas confirmadas, áreas de investimento cadastradas e limites administrativos. Inventário preservado: filtros/territory_basis, lista e fichas, oportunidades sem coordenadas, proveniência/edição e avisos de cobertura; os restantes ecrãs continuam a poder usar a pré-visualização territorial independente. Não foi identificada perda de dados ou funcionalidade a repor; a duplicação era composição do ecrã.
|
||||
|
||||
Nas vistas «Mapa» e «Lista e mapa», as caixas de camadas permitem mostrar/ocultar cada conjunto sem nova pesquisa nem redefinir o zoom. A legenda distingue pontos verdes, áreas em âmbar e limites azuis tracejados. A geometria seleccionada enquadra o mapa; quando só há limites superiores, o aviso é visível e esses limites não são apresentados como a selecção. Nenhuma coordenada é derivada de limites ou centróides. Seleccionar uma oportunidade da lista activa a camada dos pontos se estava oculta. «Fontes e contexto do mapa» expande proveniência, edição e cobertura, preservando as ligações às fichas das áreas.
|
||||
|
||||
Reutilizado o endpoint territorial e as suas permissões, com um hook comum e cancelamento/máscara de respostas antigas ao mudar ou limpar a selecção. Falha dos limites mantém as oportunidades acessíveis; falha do mapa de oportunidades não impede consultar o contexto administrativo autorizado. Sem alterações de API, schema, dados, permissões ou classificações territoriais.
|
||||
|
||||
Verificação local: 15 testes frontend focados aprovados (incluindo composição única, grupos Leaflet, duplicação de limites, foco territorial, ausência de pontos, fontes/links, respostas tardias e fallback), TypeScript e compilação de produção. Matriz existente actualizada somente em RF-GEO-009, conservando o estado global e as provas anteriores. A compilação local não constitui publicação adicional no servidor nem revisão visual do endereço público; nenhuma dessas validações é declarada concluída neste delta.
|
||||
|
||||
## 29/10/2026 — reconciliação da pasta principal
|
||||
|
||||
Reconciliação local solicitada pela Tempo: ramo
|
||||
@@ -7735,3 +7868,148 @@ Dois testes frontend dirigidos passaram: contagens/proveniência desconhecida/re
|
||||
**ACT-02 permanece parcial:** este recorte implementa controlo das camadas já existentes e explicitação de cobertura. Não cria camadas factuais de recursos, infra-estruturas, mão-de-obra ou potencial económico, nem declara completa a cobertura provincial/distrital. Backend, geometrias, divulgação, permissões, fontes, datas e cálculo preservados; sem importações ou escrita no activo.
|
||||
|
||||
**Visual pendente pela equipa:** na candidata, conferir os mapas da ficha de oportunidade, carteira e zona de investimento; ocultar/repor cada camada e ambas, conferir contagens de investimentos/marcadores, fonte/data ausentes e fichas sem coordenadas, e mudar filtros territoriais. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. O painel de contexto administrativo separado mantém o seu âmbito. Sem instalação ou avanço de main.
|
||||
### 02/11/2026 — histórico administrativo dos formulários do Balcão
|
||||
|
||||
AUD-CHECKLIST-01, incremento local parcial: o editor permite consultar definições anteriores, dez por página, com perguntas, condições, fonte, data, motivo e identificadores dos intervenientes quando registados. Inclui serviços históricos retirados da configuração actual, exclusivamente para consulta; não os torna novamente publicáveis. A consulta reutiliza a autorização administrativa existente e não devolve respostas dos requerentes. Pedidos anteriores conservam a versão e o conteúdo submetido. Respostas tardias de outro serviço/página não substituem a selecção actual; falha, histórico vazio e repetição têm estados distintos.
|
||||
|
||||
Provas: quatro testes Feature/91 asserções (`storage/logs/desk-service-history-feature-20261102.log`), três testes frontend do editor/histórico, PHPStan sem erros, tipos e lint/formatação aprovados. A conferência visual permanece pendente pela limitação do controlo do navegador já registada. Sem instalação, alterações no activo ou encerramento da parametrização transversal.
|
||||
|
||||
Reconciliação da integração: a repetição fiscal com referências preparadas em QA resultou em 162 testes, 106 aprovados, nove falhas e 47 erros (`integration-fiscal-reference-retry-20261102.log`). Persistem dependências de acervos/identidades históricas e casos a investigar; não constitui aprovação global. Conferidos por SHA-256 os 63 artefactos históricos protegidos: quatro alterados pelos ensaios foram repostos, preservando separadamente os resultados novos; zero divergências finais (`integration-output-reconciliation-20261102.json`). Não foram inventadas oportunidades publicadas ou decisões para satisfazer testes. ACT-16: o proprietário respondeu «ainda nao» quanto a prioridades e traduções de línguas nacionais; essa definição continua pendente, sem impedir os restantes desenvolvimentos.
|
||||
|
||||
### 02/11/2026 — perguntas encadeadas e preenchimento conforme a versão do formulário
|
||||
|
||||
AUD-CHECKLIST-01, continuidade local: reproduzidos dois erros no código anterior. Uma resposta conservada num campo oculto activava uma pergunta descendente na página e tornava-a obrigatória no servidor. A avaliação percorre agora as condições dos antecessores, independentemente da ordem das perguntas; ocultar uma pergunta conserva os valores, e voltar a activar o ramo recupera-os. Perguntas efectivamente aplicáveis continuam obrigatórias na submissão, com rascunhos incompletos permitidos. Não se alteram as definições ou submissões históricas.
|
||||
|
||||
A criação de pedidos também preenchia `title`, `description` e `regime` mesmo depois de retirados da nova definição, e podia escolher `unknown` quando essa opção já não existia. O preenchimento respeita agora os campos e opções da versão utilizada; a reutilização do contexto só aplica valores compatíveis com a validação do campo. Não escolhe arbitrariamente outra opção. A validação de dados introduzidos pelo requerente permanece no servidor, incluindo a recusa de campos não configurados. O ensaio operacional reproduziu a falha anterior e demonstrou criação, gravação e submissão das novas versões, opção obrigatória por escolher e preservação integral do pedido antigo.
|
||||
|
||||
Provas: regressões anteriores em `storage/logs/desk-request-conditions-before-20261102.log` e `desk-schema-prefill-before-20261102.log`; quatro testes frontend do pedido/editor/histórico aprovados, PHPStan, tipos, lint/formatação e compilação local aprovados. Resultados Feature individuais: 21 testes/382 asserções no Balcão após a correcção das condições e um teste/34 asserções do percurso de preenchimento por versão. A verificação conjunta final usa `desk-forms-combined-feature-20261102.log`. As provas são técnicas e sintéticas em bases descartáveis, porta 55441; não equivalem a conferência visual ou aceitação por requerentes reais. Sem instalação ou escrita no activo; outras checklists, consumidores e critérios transversais continuam parciais.
|
||||
|
||||
Verificação conjunta final deste incremento: **22 testes Feature/416 asserções aprovados** em `storage/logs/desk-forms-combined-feature-20261102.log`. Os quatro testes frontend, PHPStan, tipos, lint sem avisos e compilação também passaram. Recibo e SHA-256 das fontes em `desk_forms_increment_20261102` de `local-validation-20261029.json`; resultados anteriores preservados nos campos próprios. Não altera o estado parcial do requisito nem substitui as verificações visuais pendentes.
|
||||
|
||||
### 02/11/2026 — verificações e recibos respeitam as perguntas aplicáveis
|
||||
|
||||
Continuidade local de AUD-CHECKLIST-01 e do percurso adaptativo de 23/10: reproduzidos avisos para completar financiamento em formulários sem essas perguntas e bloqueio da submissão por valores conservados em perguntas financeiras ocultas. A análise passa a utilizar a definição imutável do pedido e a mesma avaliação dos antecessores usada na validação. Só compara investimento/financiamentos quando as três perguntas se aplicam; não solicita localização ausente ou não aplicável. As diferenças financeiras aplicáveis continuam a impedir submissão, e dados aplicáveis em falta continuam indeterminados, sem substituição por zero.
|
||||
|
||||
O recibo HTML distingue os dados aplicáveis das respostas conservadas de perguntas não aplicáveis, preservando todos os valores e o escape de conteúdo. Usa as condições e rótulos da versão submetida, mesmo após publicação de outra versão. A descarga técnica original, o conteúdo cifrado da submissão e as permissões permanecem preservados. Consumidores que renderizam directamente a vista continuam a funcionar com o contrato anterior. Esta separação é uma apresentação do histórico, não a eliminação das respostas.
|
||||
|
||||
Corrigida também a possibilidade de publicar uma definição impossível de preencher: conservar a localização retirando a sua fonte, ou fazendo a fonte depender de condições mais restritas. A publicação exige que a fonte esteja disponível sempre que a localização se aplica, permitindo as mesmas condições, condições de antecessores mais abrangentes ou ausência de condição. Retirar ambas as perguntas continua permitido. A exigência de fonte da localização já existia no servidor; este incremento valida a sua coerência antes da publicação, sem criar uma nova competência ou regra organizacional. Uma localização conservada num ramo não aplicável não exige fonte oculta; quando aplicável, mantém essa exigência.
|
||||
|
||||
Provas técnicas em QA descartável, porta 55441: **25 testes conjuntos/467 asserções**, um teste de compatibilidade do recibo/seis asserções e um teste de localização condicional/cinco asserções, todos aprovados. PHPStan sem erros e Pint aprovados. Logs: `desk-analysis-schema-combined-20261102.log`, `desk-receipt-compatibility-20261102.log`, `desk-location-applicability-20261102.log` e `desk-analysis-schema-phpstan-20261102.log`, em `storage/logs`. Falhas anteriores reproduzidas nos logs `desk-form-analysis-before-20261102.log` e `desk-location-schema-before-20261102.log`. Referências territoriais do teste reutilizam o pacote já verificado pelo seeder existente; não representam uma implantação real. Recibo e hashes em `desk_analysis_scope_increment_20261102` de `local-validation-20261029.json`.
|
||||
|
||||
Reutilizadas as provas anteriores de tipos, frontend e compilação: este delta só altera PHP, vistas Blade e testes. Conferência visual dos recibos e do percurso permanece pendente pela limitação de controlo do navegador registada. Não houve instalação, novas permissões, migração de schema ou escrita no activo. O requisito transversal continua parcial; esta prova não encerra outras checklists nem a integração fiscal global.
|
||||
|
||||
### 02/11/2026 — ACT-20261029-11: preferências e alertas do catálogo público
|
||||
|
||||
Incremento local de ACT-11 e MASTER-471012ab-0124: as notificações de manifestações já existentes foram preservadas e passaram a receber também correspondências do catálogo público de oportunidades. Em `/investidor/notifications`, cada conta verificada pode activar voluntariamente, escolher sector, província e modalidade, definir frequência, recuperar/alterar preferências e cancelar. O servidor utiliza sempre a conta autenticada, valida os códigos contra as opções divulgáveis e exige confirmação para activar; cancelar exige apenas a versão, sem depender de opções entretanto retiradas. Preferências cifradas, versão, consentimento e auditoria nominativa; repetição da mesma gravação não cria outra versão. Alterações concorrentes incompatíveis devolvem conflito, sem substituir silenciosamente a configuração guardada.
|
||||
|
||||
O gerador reutiliza `InvestmentCatalogue::query`, incluindo hierarquia territorial, publicação, exclusão de exemplos, dados privados e publicações futuras. A activação, reactivação ou mudança de critérios regista as identidades actualmente públicas como ponto de partida, sem avisos retroactivos. As novas disponibilizações compatíveis geram uma notificação por conta/oportunidade, mesmo quando a data documental de publicação é anterior à activação; não se usa essa data como cursor de descoberta. A correspondência conserva fonte, data publicada quando disponível, versão e critérios usados, sem copiar dados privados ou afirmar rentabilidade/elegibilidade. Uma oportunidade retirada deixa de aparecer ou ser aberta através da notificação; o registo e o histórico não são apagados. Referência territorial/sectorial entretanto indisponível suspende apenas a correspondência afectada, com informação visível e possibilidade de cancelar; não bloqueia os restantes subscritores.
|
||||
|
||||
Frequências implementadas: próxima verificação, intervalos de 24 horas ou de sete dias. Canal disponível: notificações no próprio portal. `investmoz:dispatch-opportunity-alerts` está registado no agendador existente a cada cinco minutos, sem sobreposição; a execução periódica depende do agendador da instalação. O limite técnico de 200 correspondências por conta e execução mantém o restante lote pendente para a execução seguinte, sem o consumir. A entrega e o marcador de descoberta partilham transacção; falha não consome a oportunidade nem avança a próxima execução. Cancelamento e geração serializam a alteração da mesma preferência; notificações anteriores e acompanhamento de manifestações permanecem disponíveis.
|
||||
|
||||
Migração instalável `2026_11_02_190000_investment_opportunity_alerts.php`: estende as notificações existentes com uma origem alternativa, preserva as linhas antigas e cria preferências/marcadores de descoberta. A origem de cada notificação é exactamente uma manifestação ou oportunidade. A reversão recusa eliminar preferências ou histórico de alertas existentes. O ensaio de actualização foi executado com uma notificação anterior não vazia e conservou todos os seus valores. Sem migração, a página antiga permanece operacional; a instalação do incremento segue as migrações normais do produto, não caminhos ou contas de desenvolvimento.
|
||||
|
||||
Âmbito ainda parcial: esta entrega não integra concursos ou incentivos de outros catálogos, não disponibiliza email/SMS, não confirma canais externos e não instala o agendador no destino. Esses percursos e a conferência visual continuam pendentes; a geração no portal não os substitui. Desenvolvimento e dados sintéticos apenas em QA isolada, porta 55441. Sem instalação, mensagens externas, novas competências organizacionais ou alterações no activo. Provas técnicas e hashes conservados no campo `opportunity_alerts_increment_20261102` do recibo existente `local-validation-20261029.json`.
|
||||
|
||||
Verificação final: **16 testes Feature/253 asserções**, incluindo os nove testes existentes da área de manifestações, e **dois testes frontend**, todos aprovados. PHPStan, tipos, lint sem avisos, formatação e compilação passaram; `git diff --check` sem erros. Logs em `storage/logs/opportunity-alerts-verified-feature-20261102.log`, `opportunity-alerts-frontend-20261102.log`, `opportunity-alerts-verified-phpstan-20261102.log`, `opportunity-alerts-final-types-20261102.log`, `opportunity-alerts-final-check-20261102.log` e `opportunity-alerts-final-build-20261102.log`. Fonte candidata fixada por hashes no recibo; provas anteriores preservadas, sem revisão do estado do activo por inferência.
|
||||
|
||||
### 02/11/2026 — pesquisa rápida: «plantio de algodao»
|
||||
|
||||
Pedido específico da Tempo durante a continuidade: a barra «Pesquisa rápida de oportunidades» deve trazer os resultados solicitados; exemplo fornecido pelo proprietário, «plantio de algodao». A barra já encaminhava `q` para `/oportunidades`, mas os motores tratavam a frase inteira como uma substring e distinguiam a acentuação. A correspondência territorial pelo texto também não abrangia o caso de uma oportunidade localizada num distrito cujo nome provincial só estivesse no cadastro.
|
||||
|
||||
O motor comum `OpportunityTextSearch` é agora usado pelo catálogo e pela API anterior: combina todas as palavras significativas, ignora preposições/artigos correntes, normaliza maiúsculas e acentos e reconhece as variantes delimitadas plantio/plantação/cultivo/semeadura. Não usa «produção» isolada como sinónimo de plantio: produção de óleo, fiação e descaroçamento não passam a cultivo por essa aproximação. Procura apenas os campos publicados, sectores e relações/territórios públicos autorizados; uma província abrange os seus descendentes documentados. `%` e `_` introduzidos pelo utilizador são caracteres literais, não curingas de SQL. Os filtros adicionais, ordenação, paginação, publicação, privacidade e exclusão de exemplos continuam a aplicar-se.
|
||||
|
||||
Quando todos os termos não têm correspondência e existe um termo de cultivo acompanhado de assunto/localização, a página pode apresentar até seis fichas relacionadas com o assunto numa secção separada. O total principal continua zero e os filtros não são alargados. As fichas são identificadas como relacionadas, com resumo visível e possibilidade de consultar explicitamente essa pesquisa; não são reclassificadas como plantio. Nas fontes incluídas em `corridor-investment-opportunities.json`, as três entradas de algodão descrevem descaroçamento, fiação/óleo ou transformação, não uma ficha específica de cultivo. Nenhuma nova oportunidade, parcela, disponibilidade, promotor ou aprovação foi inventada para preencher resultados.
|
||||
|
||||
A barra usa o valor efectivo do formulário, incluindo preenchimento automático do navegador, e conserva o limite de 120 caracteres do servidor. O formulário possui destino GET próprio para funcionar também sem a intercepção JavaScript. O texto pesquisado acompanha a consulta e o regresso das fichas relacionadas; resultados relacionados anteriores ficam ocultos durante carregamento ou erro.
|
||||
|
||||
Provas dirigidas: três testes Feature/93 asserções em QA descartável, incluindo o exemplo do proprietário, variantes de cultivo, acentos, palavras em campos distintos, província/distrito, duas APIs, página, mapa, exportação, fonte privada/futura/eliminada, curingas literais e separação entre cultivo e transformação. Mais seis testes de compatibilidade existentes/112 asserções passaram na QA com as referências já preparadas; dados protegidos permaneceram intactos. Cinco testes frontend passaram, cobrindo barra/autofill/destino nativo, apresentação dos relacionados e transporte/paginação existentes. PHPStan, tipos, formatação e lint sem avisos aprovados. Logs `quick-opportunity-search-*20261102.log` em `storage/logs`; recibo final e hashes em `quick_opportunity_search_increment_20261102` de `local-validation-20261029.json`.
|
||||
|
||||
Âmbito local: testes com fichas sintéticas, sem alteração de cadastros ou publicações no activo, sem instalação remota e sem nova prova visual. O incremento não declara existir uma oportunidade real de plantio quando a fonte não a fornece. A integração dos alertas de concursos estava em desenvolvimento quando este pedido recebeu prioridade; o incremento anterior de alertas de oportunidades conserva a sua prova própria e o alargamento a concursos ainda não é declarado concluído.
|
||||
|
||||
Compilação final deste recorte de pesquisa aprovada (`quick-opportunity-search-build-20261102.log`), com os avisos de fontes/chunks já conhecidos. Resultado final: nove testes Feature/205 asserções e cinco frontend aprovados, PHPStan/tipos/lint/formatação aprovados; recibo `quick_opportunity_search_increment_20261102` emitido com os hashes das dez fontes directamente alteradas. O exemplo «plantio de algodao» foi conferido novamente na fonte final. Sem instalação no activo nem declaração de nova oportunidade real por inferência.
|
||||
|
||||
### 02/11/2026 — ACT-11: alertas de concursos integrados nas preferências existentes
|
||||
|
||||
Retomado o incremento interrompido pela correcção da pesquisa rápida. A pendência de concursos do recorte anterior passa a ter implementação local demonstrada: em `/investidor/notifications`, a pessoa escolhe oportunidades, concursos ou ambos, com confirmação explícita. Preferências anteriores continuam exclusivamente em oportunidades; a migração não acrescenta subscrições. Sector e província são comuns; modalidade de oportunidade só se aplica a oportunidades e regime só a concursos. O servidor remove os filtros sem aplicação e valida a fonte escolhida. Cancelar conserva as notificações e não depende da disponibilidade do catálogo ou das opções antigas.
|
||||
|
||||
`Procurement::publicQuery` concentra a consulta pública já utilizada pelo catálogo, agora partilhada com o gerador. Mantém filtros, hierarquia territorial, configuração de disponibilidade e a ligação à versão publicada; rascunhos e alterações ainda não publicadas ficam fora dos alertas. O gerador considera o acto actualmente publicado de cada concurso, não promete entregar todos os actos intermédios que possam ser substituídos entre verificações. A activação, reactivação ou alteração de fontes/critérios estabelece como ponto de partida as versões actualmente públicas, sem avisos retroactivos. Cada conta/versão de acto tem uma identidade de entrega única. Não cria acompanhamentos em `portal_tender_watches`, propostas, candidaturas ou decisões.
|
||||
|
||||
O aviso conserva concurso, versão/acto, referência, origem, data de publicação e versão/critérios da preferência. Usa uma projecção fixa do conteúdo público, sem notas internas, contactos não autorizados, rascunhos ou anexos. A consulta distingue data de publicação e recepção do aviso, liga ao concurso e pede a leitura das condições actuais. Actos anteriores continuam legíveis enquanto o concurso tem publicação pública, sem substituir os respectivos avisos por dados da versão nova. Retirada da publicação ou indisponibilidade do módulo oculta os avisos afectados e impede a leitura pela rota de marcação; o histórico permanece guardado. Indisponibilidade de concursos não suspende as oportunidades seleccionadas.
|
||||
|
||||
Mantidos canal no portal, frequência escolhida, autorização da própria conta e motor transaccional existentes. Cada catálogo tem um lote limitado a 200 correspondências por execução; ambos recebem processamento no mesmo ciclo, evitando que uma fila de oportunidades impeça concursos. Restantes itens continuam pendentes, sem avanço silencioso. Falha da entrega de concursos reverte também as entregas/marcadores de oportunidades desse ciclo da pessoa e permite repetir sem duplicações. O comando e agendador existentes continuam a ser usados, sem segundo serviço de notificações.
|
||||
|
||||
Migração adicional `2026_11_02_200000_investment_tender_alerts.php`: fontes seleccionadas, origem alternativa da notificação e marcador por versão de concurso. Preserva o conteúdo, versão, consentimento, identificador e fontes anteriores; a origem de cada notificação continua exactamente uma. Reversão recusa eliminar preferências de concursos ou histórico. A sequência de actualização/reversão das duas migrações foi conferida com preferências e notificações anteriores não vazias, sem converter o ensaio sintético em aceitação real.
|
||||
|
||||
Provas finais: **21 testes conjuntos/315 asserções**, **um teste de processamento de lotes/sete asserções** e **oito testes de compatibilidade do módulo de concursos/285 asserções**, todos aprovados: 30 testes distintos/607 asserções. O teste de lotes usa 201 oportunidades e um concurso, confirma entrega de concurso no primeiro ciclo e conservação da oportunidade restante. Seis testes frontend (quatro de alertas e dois da pesquisa rápida), PHPStan, tipos, lint sem avisos, Pint e compilação aprovados. Logs `tender-alerts-verified-feature-20261102.log`, `tender-alerts-batch-fairness-20261102.log`, `tender-alerts-procurement-compatibility-20261102.log`, `tender-alerts-frontend-20261102.log`, `tender-alerts-verified-phpstan-20261102.log`, `tender-alerts-final-types-20261102.log`, `tender-alerts-final-check-20261102.log` e `tender-alerts-build-20261102.log`, em `storage/logs`. Recibo/hashes em `tender_alerts_increment_20261102` de `local-validation-20261029.json`.
|
||||
|
||||
ACT-11 permanece parcial: incentivos de outros catálogos e canais externos não implementados neste incremento. Disponibilização, activação do agendador no destino e conferência visual permanecem pendentes. Somente código local e ensaios sintéticos em bases isoladas na porta 55441; sem instalação, dados reais, mensagens externas, novas competências ou alteração da configuração activa. As provas anteriores de pesquisa, oportunidades e concursos mantêm o seu âmbito; o saldo de concursos do recorte anterior foi actualizado, sem apagar o histórico.
|
||||
|
||||
### 02/11/2026 — AUD-FUND-01: núcleo do catálogo de fundos em desenvolvimento
|
||||
|
||||
Criados serviço FundingProgrammes e migração 2026_11_02_210000_funding_programme_catalogue.php, ainda sem instalação no activo. Rascunho separado de versões publicadas imutáveis, prévia vinculada por hash, controlo de concorrência, publicação idempotente, retirada auditada e interesses privados por pessoa/programa, conservando a versão guardada. Usa a competência editorial existente; não atribui competência para concessão de financiamento. Critérios distinguem correspondência, divergência, informação desconhecida e não aplicabilidade, sem excluir automaticamente ou enviar candidatura. Janelas históricas encerradas não representam encerramento do próprio fundo. Cobertura territorial e sectores rejeitam combinações incoerentes.
|
||||
|
||||
Documentos são incorporados por identificador, versão e checksum. A resolução de descarga confere novamente publicação, direitos e segurança na biblioteca e reutiliza VerifiedDocumentBinary; a substituição do documento não altera silenciosamente o binário citado. Este percurso ainda precisa de testes específicos de descarga e ligação HTTP.
|
||||
|
||||
Prova local sintética em PostgreSQL isolado: FundingProgrammesTest, dois testes/dez asserções aprovados (storage/logs/funding-programmes-feature-20261102.log). Cobrem publicação repetida sem duplicação, rascunho sem alteração da versão pública, nova versão sem reescrever interesse anterior, remoção limitada à própria pessoa, janela histórica, aplicabilidade desconhecida e bloqueio de publicação de conteúdo explicitamente demonstrativo. PHPStan sem erros (funding-programmes-phpstan-20261102.log), Pint e git diff --check aprovados. Não constituem prova de todo o catálogo.
|
||||
|
||||
AUD-FUND-01 continua incompleto: faltam controladores/rotas, catálogo e editor pela interface, filtros, testes de autorização/conflitos/documentos e validação visual. Referências reais FDEL ainda não incorporadas nem publicadas; não inferir janela aberta ou condições financeiras de páginas demonstrativas. Próxima acção: completar o percurso público/editorial e as provas, mantendo ACT-12 e alertas de incentivos pendentes até integração efectiva.
|
||||
|
||||
### 02/11/2026 — AUD-FUND-01: primeiro percurso HTTP público
|
||||
|
||||
Ligadas rotas /fundos, ficha por UUID, conferência de critérios, guardar/remover interesse autenticado e descarga por programa/versão/recurso. Controlador FundingProgrammeController e páginas funding/index/show reutilizam a estrutura visual e o transporte CSRF existentes. Consulta publicada paginada, palavras-chave e tipo de apoio; critérios por tipo de candidato e janela seleccionada, respostas não persistidas, explicação e fonte por critério. A ficha distingue janela encerrada de programa, mostra datas e canal externo documentados, disponibilidade dos documentos e versão privada guardada, incluindo indicação de publicação posterior. Respostas HTTP com estado pessoal ou resultados de conferência usam private/no-store. Não existe envio externo ou candidatura automática.
|
||||
|
||||
Provas incrementais: FundingProgrammesTest com dois testes/25 asserções aprovados, incluindo rotas públicas, pesquisa sem resultado, exigência de autenticação para guardar e divergência de critério. PHPStan do serviço/controlador e types:check aprovados. Logs funding-programmes-http-20261102.log, funding-programmes-http-phpstan-20261102.log e funding-programmes-types-20261102.log. A compilação e lint são registados separadamente após conclusão; não deduzir aprovação do arranque.
|
||||
|
||||
Continua em desenvolvimento: editor editorial, lista dos interesses, navegação, filtros territoriais/sectoriais explicados e respectivos testes, testes de descarga/versionamento/conflitos, referências reais FDEL e validação visual. Nada publicado ou migrado no activo. Este incremento não encerra AUD-FUND-01, ACT-12 ou a auditoria geral.
|
||||
|
||||
### 02/11/2026 — AUD-FUND-01: lista privada e navegação
|
||||
|
||||
Implementada /fundos/guardados, autenticada e verificada, paginada e limitada no servidor à pessoa da sessão. Lista conserva título e versão efectivamente guardados, sem projectar rascunho ou substituir a referência pela publicação nova. Após retirada pública conserva apenas a referência privada mínima, informa indisponibilidade e permite remoção. Não expõe corpo/documentos da publicação retirada. Guardar continua distinto de candidatura ou contacto externo. Links no catálogo/ficha e navegação; menu público depende da instalação das tabelas, link da lista privada depende também de autenticação.
|
||||
|
||||
FundingProgrammesTest: dois testes/71 asserções aprovados, incluindo isolamento entre contas, versão preservada, indisponibilidade pública após retirada e remoção pela própria conta. PHPStan serviço/controlador sem erros, log funding-programmes-saved-phpstan-20261102.log; testes HTTP em funding-programmes-saved-20261102.log. A compilação iniciada no incremento anterior terminou com código zero (funding-programmes-build-20261102.log); não comprova automaticamente os ficheiros posteriores desta lista. Verificações frontend finais deste incremento registadas nos logs funding-navigation-20261102.log e funding-programmes-saved-*. Mantêm-se pendentes editor, filtros geográficos/sectoriais, testes de documentos/conflitos/permissões e validação visual; sem instalação no activo.
|
||||
|
||||
### 02/11/2026 — AUD-FUND-01: edição editorial ligada ao catálogo
|
||||
|
||||
Implementadas gestão paginada, criação/edição de rascunhos, prévia, publicação e retirada em /management/fundos. Reutiliza DocumentManagementAccess::catalogueEditor no servidor em cada operação; não atribui competência financeira nem depende da activação de concursos. Referências limitadas a cadastros e documentos autorizados/públicos; formulário reutiliza entidade gestora, sectores, territórios e biblioteca, com critérios comuns e critérios por janela, fonte/edição/data, âmbito e datas aplicáveis. Mudança para janela desconhecida/contínua limpa datas inaplicáveis. Publicação exige rascunho guardado, prévia conferida, confirmação expressa, motivo e hash válido; efeitos sobre versões e interesses explicados. Histórico de publicação disponível no editor. Link de gestão no catálogo só para competência efectiva.
|
||||
|
||||
Publicações recebem sequência própria crescente quando a prévia muda por actualização autorizada do cadastro, mesmo sem alteração do rascunho; snapshots anteriores não são reescritos. O hash da prévia inclui os dados efectivamente reutilizados. Controlo de versão do rascunho continua independente e obrigatório.
|
||||
|
||||
Prova: FundingProgrammesTest passou quatro testes/91 asserções (funding-editor-feature-20261102.log), incluindo formulários/gestão, editor sem módulo de concursos, recusa de conta sem autoridade, hash incorrecto, gravação obsoleta e nova publicação com alteração do nome no cadastro sem colisão ou reescrita histórica. Tipos aprovados (funding-editor-final-types-20261102.log); verificação estática final, lint após correcção de um aviso de tipagem e compilação ainda em acompanhamento. A compilação anterior da lista privada terminou com código zero; não comprova automaticamente este editor.
|
||||
|
||||
Ainda pendentes: filtros territoriais/sectoriais com tratamento explícito de desconhecidos, ensaios completos de documentos/versões/concorrência e visual, referências FDEL reais e integração de incentivos nos alertas. Editor implementado localmente não encerra AUD-FUND-01 ou ACT-12. Sem migração, publicação ou alteração de contas no activo.
|
||||
|
||||
Fecho das verificações do editor em 02/11/2026: PHPStan sem erros, lint/formatação sem avisos e compilação terminaram aprovados (funding-editor-final-phpstan-20261102.log, funding-editor-final-check-20261102.log e funding-editor-build-20261102.log). Tipos aprovados no processo final 44973. Matriz AUD-FUND-01 reconciliada como parcial, preservando histórico; recibo funding_editor_increment_20261102 com hashes das fontes/prova, sem declaração de cobertura integral. Próxima acção: filtros territoriais/sectoriais e testes específicos de documentos/versionamento.
|
||||
|
||||
### 02/11/2026 — AUD-FUND-01: filtros territoriais e sectoriais
|
||||
|
||||
Catálogo público passou a filtrar por sector, local, situação de uma janela e inclusão explícita de âmbitos desconhecidos. Cobertura nacional abrange os locais; cobertura provincial permite pesquisar os seus distritos; um programa restrito a distrito aparece como cobertura parcial quando se pesquisa a província. Origem por confirmar é assinalada e pode ser excluída pela opção «apenas âmbito documentado». Não transforma ausência de informação em correspondência confirmada nem concessão de elegibilidade.
|
||||
|
||||
O filtro territorial/sectorial refere-se ao programa, não a todas as chamadas. O texto da interface explica a distinção e o filtro de estado significa pelo menos uma janela nesse estado, sem confirmar abertura no local escolhido. Janelas fechadas, futuras, com data-limite no próprio dia e sem data confirmada são tratadas separadamente; programas sem janelas entram apenas no estado por confirmar. Fontes territoriais/sectoriais utilizadas são preservadas por versão, incluindo nomes e caminhos hierárquicos, sem reescrever snapshots quando o cadastro muda. A ficha mostra os locais e sectores efectivamente incorporados, também nas janelas locais.
|
||||
|
||||
Prova inicial: cinco testes/184 asserções aprovados em funding-filters-feature-20261102.log, abrangendo os quatro testes anteriores e as combinações dos filtros; PHPStan, tipos e lint sem avisos aprovados (funding-filters-phpstan-20261102.log, funding-filters-types-20261102.log, funding-filters-check-20261102.log). Teste adicional de documentos em acompanhamento; primeira execução corrigida por campo obrigatório ausente na fixture sintética, sem mudança de regra do produto. Compilação em acompanhamento. Sem escrita no activo.
|
||||
|
||||
Fecho do incremento de filtros/documentos, 02/11/2026: **seis testes/204 asserções aprovados**, incluindo o teste específico de descargas (funding-filters-documents-feature-20261102.log). Binário alterado sem nova análise, metadados de análise pendente/checksum divergente, direitos restritos, classificação privada, recurso retirado e versão substituída recusados; prévia anterior à mudança do documento rejeitada, publicação nova distinta e snapshot anterior conservado. Estes estados são fixtures sintéticas, não prova de execução de antivírus real. PHPStan, tipos, lint sem avisos/formatação e compilação aprovados; processos terminais conferidos, logs funding-filters-*. Recibo funding_filters_increment_20261102 com hashes, matriz AUD-FUND-01 reconciliada sem apagar provas anteriores. Ainda não demonstra concorrência com trabalhadores simultâneos, fontes reais incorporadas, visual ou instalação no destino. Próxima acção: preparar as referências oficiais FDEL já identificadas, distinguindo programa de chamada histórica, e completar os ensaios restantes; incentivos nos alertas continuam pendentes.
|
||||
|
||||
### 02/11/2026 — AUD-FUND-01: referência oficial FDEL preparada pela interface
|
||||
|
||||
Incluído pacote instalável database/data/funding-references.json e serviço FundingProgrammeReferences, com edição, URLs, referências, datas efectivas de consulta e hashes dos exemplares. Usa o Decreto 4/2025 (BR I/43, 05/03/2025) e aviso municipal CMCT com prazo 20/10/2025. Um programa com chamada histórica associada; não cria duas fichas, não transforma o aviso numa chamada actual e não atribui o município a toda a província. Vínculo cartográfico municipal e associação ao cadastro sectorial permanecem explícitos por completar. Não foram transportadas taxas ou montantes para motores financeiros/fiscais; critérios são um extracto informativo com acesso às fontes completas.
|
||||
|
||||
Percurso /management/fundos/referencias/fdel: prévia legível das condições/fontes, seguida de preparação de rascunho pela competência editorial existente. Hash vincula o pacote exibido; referência alterada exige nova prévia. Código estável e bloqueio transaccional reutilizam ficha existente sem sobrescrever edições ou acrescentar chamadas. Preparação auditada conserva edição, hash do pacote e hashes das fontes; publicação continua separada no editor existente. Não há consulta remota obrigatória no cliente, novas contas, destinatários ou envio de candidatura.
|
||||
|
||||
Exemplares recolhidos e conferidos em storage/app/private/funding-sources-20261102 (manifest.json); PDF e HTML com SHA-256 correspondente ao pacote. São cópias privadas de desenvolvimento, sem afirmação de antivírus executado e sem publicação automática na biblioteca. A página municipal falhou no leitor web mas a recolha HTTP directa devolveu o conteúdo esperado (título, prazo e requisitos); não confundir falha de ferramenta com indisponibilidade da fonte.
|
||||
|
||||
Prova: FundingProgrammesTest passou sete testes/235 asserções (funding-references-feature-20261102.log), incluindo preparação sem publicação, recusa de hash errado, chamada histórica fechada, referência cartográfica desconhecida, proveniência auditada, repetição sem duplicação/substituição e conta sem competência recusada. PHPStan e lint/formatação sem avisos aprovados; tipos e compilação ainda em acompanhamento nos respectivos processos. Continuidade: verificar os resultados terminais, manter concorrência e visual pendentes, completar a correspondência territorial/sectorial pelas fontes autorizadas e integrar os incentivos nos alertas. Sem alteração no activo.
|
||||
|
||||
Fecho das verificações das referências em 02/11/2026: sete testes/235 asserções, PHPStan, tipos, lint sem avisos/formatação e compilação aprovados, com processos terminais conferidos. Logs funding-references-*; recibo funding_references_increment_20261102 inclui hashes e manifesto de origem. AUD-FUND-01 continua parcial, com concorrência, visual, vínculos ao cadastro, alertas e disponibilização ainda pendentes. Próxima acção: ensaiar concorrência/repetição em base descartável e completar os restantes percursos, sem alterar o activo.
|
||||
|
||||
### 02/11/2026 — AUD-FUND-01: concorrência real e impasse corrigido
|
||||
|
||||
Criado tools/verify-funding-concurrency.php, exclusivamente CLI, com ambiente testing, PostgreSQL em 127.0.0.1:55441 e nome ancorado funding_concurrency_20261102[a-z]?_test. Confere ligação efectiva, recusa configuração em cache, obtém a guarda de execução QA e exige base sem pessoas/fichas antes de criar cenário. Não repõe, apaga ou redefine registos existentes; contexto de cada trabalhador fica privado e vinculado à mesma base e identidade sintética. Recusa sem ambiente explícito e recusa de repetição numa base não vazia foram verificadas. Migrações apenas em três bases novas descartáveis, mantendo os resultados anteriores.
|
||||
|
||||
Cinco cenários, cada um com dois processos PHP e sessões PostgreSQL independentes: preparação da mesma referência, gravação com a mesma versão, publicação da mesma prévia, guardar interesse da mesma pessoa e retirada contra interesse da mesma pessoa. O controlador de ensaio mantém um bloqueio até observar ambos os processos à espera no PostgreSQL; só depois liberta. A primeira tentativa do verificador falhou por reter o snapshot estatístico na transacção de observação; foi corrigida com pg_stat_clear_snapshot, preservando o log inicial em vez de declarar falha do produto.
|
||||
|
||||
Com a observação corrigida, reproduzido **SQLSTATE 40P01** no quinto cenário: interesse conservava bloqueio FOR UPDATE na conta enquanto esperava pela ficha, e retirada conservava a ficha enquanto a inserção de auditoria precisava de KEY SHARE na conta. A correcção técnica usa **FOR NO KEY UPDATE** para serializar interesses/remover interesse pela pessoa, permitindo a referência de auditoria, sem mudar competências, decisões ou chaves da conta. A base de reprodução e o log de falha permanecem preservados.
|
||||
|
||||
Após a correcção, os cinco cenários passaram em funding_concurrency_20261102c_test: uma referência, uma versão publicada, um interesse e uma auditoria por preparação/publicação/interesse/retirada. A gravação concorrente recusou uma versão obsoleta; retirada concluiu e o interesse concorrente recebeu indisponibilidade, preservando a referência anterior. Consulta final confirma uma referência privada válida e nenhuma publicação activa. São pares de processos com sobreposição observada, não prova de carga nem de todas as intercalações possíveis.
|
||||
|
||||
Provas: funding-concurrency-before-20261102.log (verificador inicial), funding-concurrency-baseline-20261102.log (impasse do produto), funding-concurrency-after-20261102.log (cinco cenários aprovados), funding-concurrency-preservation-20261102.json, e logs guard-environment/guard-existing. Sete Feature/235 asserções de compatibilidade aprovados (funding-concurrency-feature-20261102.log), PHPStan sem erros e Pint/sintaxe/diff aprovados. Frontend não alterado; conservadas provas de tipos/lint/compilação do incremento anterior. Matriz e recibo funding_concurrency_increment_20261102 reconciliados. Sem alterações no activo. Próxima acção: integração dos fundos/incentivos nos alertas existentes; vínculos de referência, visual e disponibilização continuam pendentes.
|
||||
|
||||
@@ -1058,6 +1058,9 @@ A fonte única continua `matriz-requisitos.csv`; `backlog-priorizado.csv` tem os
|
||||
- **AUD-CAPITAL-02 — Guião de admissão à Bolsa de Valores de Moçambique, distinguindo Bolsa, regulador e operadores**: Guião da Bolsa por segmento, intervenientes e requisitos não implementado. Reutilizar: Biblioteca, checklist e percurso existentes. 16–32 h após correspondências normativas; não preencher taxas/prazos ausentes. Fonte/secção e aceitação completas na linha existente.
|
||||
- **AUD-CHECKLIST-01 — Editor de checklists e formulários configuráveis, publicação de versões e preservação de submissões anteriores**: Checklists existem em configuração; editor/versionamento administrativo sem código e adaptação de submissões antigas não demonstrados. Reutilizar: Configurações existentes, schema, biblioteca e auditoria. 24–40 h após política de alteração; reaproveitar motor, sem catálogo paralelo. Fonte/secção e aceitação completas na linha existente.
|
||||
|
||||
Continuidade 02/11/2026: primeiro editor de formularios do Balcao implementado localmente, reutilizando versoes imutaveis, validacao e ambito administrativo existentes. Conferencia anterior/nova e efeito sobre pedidos futuros; pedidos/submissoes v1 preservados apos v2. 18 Feature/325 assercoes iniciais e um teste frontend passaram. Provas finais e limites no manual; sem instalacao ou prova visual, restantes tipos/configuracao transversal parciais.
|
||||
|
||||
|
||||
### Decisões que não bloqueiam trabalho independente
|
||||
|
||||
- **AUD-REL-02**: Os pares das seis oportunidades legadas com projetos/âncoras/clusters continuam indeterminados. Indicar pares com fundamento documental; não bloquear as oportunidades independentes.
|
||||
@@ -3033,6 +3036,10 @@ Execução detalhada e critérios: [matriz consolidada](../../consolidated-revie
|
||||
| ACT-20261029-15 | Dados abertos/API | APIs e fontes existentes | Inventariar interfaces oficiais, implementar actualização/rastreabilidade e documentar API dos dados divulgáveis. | Consulta de parceiro demonstrada, com contrato, versão, origem/data e permissões respeitadas. | Interfaces oficiais disponíveis e âmbito autorizado dos dados |
|
||||
| ACT-20261029-16 | Móvel/offline/línguas | Interface móvel e provas anteriores | Conferir percursos em telemóvel/redes lentas; consulta offline seleccionada, sincronização e línguas nacionais prioritárias. | Percurso móvel utilizável; conteúdo seleccionado acessível sem rede; sincronização preserva edições; idiomas escolhidos demonstrados. | Definir línguas prioritárias e conteúdos offline; tradução e regras de sincronização |
|
||||
|
||||
02/11/2026 — ACT-20261029-16, primeiro recorte local parcial: a ficha publica de negocio permite guardar explicitamente uma copia HTML independente para leitura sem rede. Projeccao fixa dos campos publicos, origem e data de captura, sem scripts/recursos remotos nem dados privados; tres testes dirigidos passaram. Sem instalacao ou prova visual. Nao inclui cache geral, edicao/sincronizacao offline nem novas linguas; mantem-se a dependencia de prioridades/traducao dessas linguas. Detalhes e provas no manual existente.
|
||||
|
||||
Continuidade 02/11/2026: mesmo recorte estendido a ficha publica de oportunidade, reutilizando renderer e descarga comuns; cinco testes dos dois formatos passaram. Origem limitada ao portal, desconhecidos preservados, anexos/contexto privado excluidos; nao converte contexto territorial em implantacao. Criterios amplos de sincronizacao/movel/idiomas continuam parciais, sem nova demonstracao visual.
|
||||
|
||||
## 29/10/2026 — auditoria fiscal InvesteMoz / SGF
|
||||
|
||||
Pedido expresso da Tempo: auditar ambos os sistemas e identificar como incorporar no SGF a componente fiscal do InvesteMoz. Auditoria local de código, contratos e recibos concluída; sem prova operacional nova. Relatório e critérios na [especificação fiscal existente do SGF](C:/laragon/www/sgf-tempo/docs/SERVICE_INVOICE_CALCULATION_SPEC.md#10-auditoria-de-incorporação-fiscal-investemoz--sgf--29102026), secção 10. Não altera os 16 recortes ACT-20261029 já registados nem os fechos aceites.
|
||||
@@ -3146,3 +3153,27 @@ Recibo: docs/investmoz/unification/province-search-qa-20261029.json. Instalaçã
|
||||
**Separação mantida:** este resultado encerra a lacuna técnica de compatibilidade/expansão provincial descrita acima, sem declarar conferência visual dos controlos e sem encerrar os requisitos amplos ACT-02/V2-07/V2-10. A conferência visual autenticada de Competitividade continua **pendente — execução e registo pela equipa**. As provas aceites dessa funcionalidade não foram reabertas nem substituídas pela correcção dos filtros. Diagnóstico da ligação Edge continua separado; leitura de URL não declarada recuperada. Nenhum processo local foi terminado nem porta libertada; InvesteMoz e SGF preservados.
|
||||
|
||||
Para o registo da equipa: no cluster autorizado, editar conteúdo legítimo → Guardar rascunho no servidor → sair e reabrir → comparar campos/versão recuperados → conferir na ficha pública exclusivamente a versão aprovada. Registar utilizador/âmbito autorizado, URL/cluster, versão instalada, versões guardada/aprovada, resultado e divergências. Uma nova gravação não deve substituir a aprovação pública existente; nova aprovação depende da decisão de conteúdo aplicável. Sem exemplar aprovado disponível, manter essa parte da conferência pendente em vez de publicar dados sintéticos.
|
||||
Continuidade 02/11/2026 — AUD-CHECKLIST-01: consulta administrativa paginada das definições históricas do Balcão implementada localmente, incluindo códigos retirados da configuração, sem republicação ou acesso às respostas dos requerentes. Quatro testes Feature/91 asserções, três frontend, PHPStan, tipos e lint aprovados. Provas e limites no manual; requisito transversal permanece parcial e conferência visual pendente. Reconciliação fiscal: 162 testes/106 aprovados/9 falhas/47 erros; 63 provas históricas conferidas e preservadas, sem declarar a integração global aprovada.
|
||||
Continuidade 02/11/2026 — AUD-CHECKLIST-01: corrigidas perguntas condicionadas por antecessores ocultos e criação de pedidos com campos/opções retirados da definição. Regressões reproduzidas antes da correcção; ramos ocultos conservam respostas, novos pedidos respeitam a própria versão e antigos permanecem intactos. Quatro testes frontend e percursos Feature dirigidos aprovados; detalhes e verificação conjunta no manual. Matriz reconciliada para distinguir implementação local de conferência visual, disponibilização e extensão às restantes checklists ainda pendentes.
|
||||
|
||||
Continuidade 02/11/2026 — AUD-CHECKLIST-01: verificações do Balcão respeitam a definição e as perguntas aplicáveis; deixaram de pedir financiamento inexistente e de bloquear por valores financeiros conservados em ramos ocultos. Recibo distingue respostas conservadas, mantém a versão submetida e preserva a descarga técnica. Publicação confere a disponibilidade da fonte quando a localização se aplica, reutilizando a exigência existente. Passaram 25 testes conjuntos/467 asserções e dois testes específicos/11 asserções; PHPStan/Pint aprovados. Provas no manual; visual, disponibilização e restantes consumidores continuam pendentes, sem alteração no activo.
|
||||
|
||||
Continuidade 02/11/2026 — ACT-20261029-11 / MASTER-471012ab-0124: preferências e correspondência do catálogo público implementadas localmente, reutilizando notificações e pesquisa existentes. Activação voluntária, critérios por sector/província/modalidade, frequência no portal, cancelamento independente de opções retiradas, repetição sem duplicações e preservação das manifestações. Migração/gerador/agendamento incluídos; provas e condições de operação no manual. Não integra ainda concursos/incentivos de outras fontes nem canais externos; visual e disponibilização pendentes. Não declarar ACT-11 integralmente concluído.
|
||||
|
||||
Continuidade 02/11/2026 — pesquisa rápida / MASTER-471012ab-0124: atendido o exemplo «plantio de algodao». Pesquisa comum ao catálogo e às duas APIs combina palavras, acentos, variantes delimitadas de cultivo e localização pública hierárquica. Transformação/fiação não é apresentada como cultivo; quando não há correspondência completa, fichas relacionadas ficam separadas e mantêm os demais filtros. Barra GET com valor efectivo do formulário, incluindo autofill. Três Feature/93 asserções, seis de compatibilidade/112 e cinco frontend aprovados; provas/limites no manual existente. Sem alteração de dados ou instalação no activo. Alertas de concursos em desenvolvimento, sem fecho por esta correcção.
|
||||
|
||||
Continuidade 02/11/2026 — ACT-11 / MASTER-471012ab-0124: integração local de concursos concluída no âmbito demonstrado. Escolha voluntária de catálogos, regime próprio, versões/actos publicados, disponibilidade e histórico reutilizam o módulo existente; rascunhos não geram avisos. Oportunidades continuam quando concursos estão indisponíveis; nenhum novo subscritor foi acrescentado automaticamente. Publicações anteriores, preferências e manifestações preservadas; lote por catálogo sem perda ou bloqueio da outra fonte. Passaram 30 testes distintos/607 asserções e seis frontend, PHPStan/tipos/lint/formatação/compilação. Provas e limitações no manual. Restam incentivos/outros canais, disponibilização/agendador no destino e visual; ACT-11 agregado continua parcial, sem alterações no activo.
|
||||
|
||||
Continuidade 02/11/2026 — AUD-FUND-01: núcleo versionado e interesses privados em desenvolvimento; dois testes sintéticos/dez asserções, PHPStan e Pint aprovados. Catálogo/editor HTTP, filtros, documentos e provas de autorização ainda pendentes; não declarar funcionalidade entregue ou ACT-12 concluído. Detalhes, provas e próxima acção no manual existente. Sem instalação ou publicação no activo.
|
||||
|
||||
Continuidade 02/11/2026 — AUD-FUND-01: primeiro percurso público ligado (/fundos, ficha, critérios, interesse privado e descarga versionada). Dois testes/25 asserções, PHPStan, tipos e lint aprovados; compilação em verificação. Editor, lista privada, navegação, filtros territoriais/sectoriais e provas de documentos/autoridade continuam pendentes. Estado parcial; detalhes no manual, sem publicação no activo.
|
||||
|
||||
Continuidade 02/11/2026 — AUD-FUND-01: lista privada e navegação implementadas localmente; duas provas HTTP/71 asserções e PHPStan aprovados. Referência guardada mantém a versão original e permanece removível após retirada pública; isolamento por sessão conferido. Restam editor, filtros e provas específicas, sem fecho do requisito ou instalação no activo. Detalhes no manual.
|
||||
|
||||
Continuidade 02/11/2026 — AUD-FUND-01: editor ligado a gestão, rascunhos, prévia, publicação e retirada, com autoridade editorial existente e histórico. Quatro testes/91 asserções e tipos aprovados; verificações finais em acompanhamento. Sequência de publicação preserva snapshots quando mudam cadastros. Filtros, documentos, fontes reais e visual continuam pendentes, sem disponibilização no activo; provas e próxima acção no manual.
|
||||
|
||||
Continuidade 02/11/2026 — AUD-FUND-01: filtros sectoriais/territoriais e estados de janela implementados, desconhecidos explícitos e cobertura parcial explicada; snapshots territoriais e sectoriais preservados. Seis testes/204 asserções, análise estática, tipos, lint e compilação aprovados; descarga por versão verifica publicação, direitos, análise e bytes. Provas/limites no manual e recibo funding_filters_increment_20261102. Restam fontes reais, visual, concorrência e disponibilização; integração de incentivos nos alertas pendente. Sem alteração no activo.
|
||||
|
||||
Continuidade 02/11/2026 — AUD-FUND-01: referência FDEL oficial empacotada, prévia e preparação de rascunho pela gestão editorial. Reutiliza ficha existente, preserva edições, auditabilidade e datas; chamada municipal histórica distinta do programa. Sete testes/235 asserções, PHPStan e lint aprovados; tipos/compilação em acompanhamento. Vínculos sectorial/cartográfico, visual, concorrência, alertas e disponibilização pendentes. Fontes e provas no manual, sem publicação ou escrita no activo.
|
||||
|
||||
Continuidade 02/11/2026 — AUD-FUND-01: cinco cenários de concorrência com dois processos e bloqueios efectivamente observados. Reproduzido e corrigido deadlock 40P01 entre retirada e interesse da mesma pessoa, mantendo serialização e auditoria. Cinco cenários finais e sete Feature/235 asserções aprovados, PHPStan/Pint passaram. Bases e logs preservados; provas e limites no manual/recibo funding_concurrency_increment_20261102. Restam visual, vínculos da referência, alertas e disponibilização; nenhum acto no activo.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -43,6 +43,8 @@ As faixas são previsões para o **próximo resultado delimitado**, de confianç
|
||||
|
||||
### Evidência e limites por frente
|
||||
|
||||
Actualizacao local de 02/11/2026 — AUD-CHECKLIST-01: primeiro editor administrativo de formularios do Balcao implementado sobre desk_service_versions, com nova versao sem reescrever pedidos/submissoes anteriores, comparacao de alteracoes, validacao e auditoria. 18 Feature/325 assercoes iniciais passaram; provas finais e limites no manual existente. Sem instalacao ou prova visual; restantes tipos/configuracao integral continuam parciais. A linha historica de 18/10 acima e preservada, sem repetir ou somar a estimativa como trabalho novo.
|
||||
|
||||
#### 1. Documentos públicos
|
||||
|
||||
**IDs:** AUD-LIB-01.
|
||||
@@ -799,3 +801,8 @@ A/B e C37 não entram nestes lotes. O primeiro lote utilizável continua o workf
|
||||
3.401 IDs únicos/linhas, 7.739 ocorrências de origem e 604 fontes inventariadas. Estados do requisito agregado: 3.062 sem evidência suficiente; 316 parciais; 1 com falha; 9 não implementados; 6 dependentes de decisão concreta; 7 demonstrados apenas em QA no âmbito definido. As sete linhas históricas fechadas, A/B, C37, coedição e demais critérios válidos permanecem conservados. A contagem agregada não é uma lista de 3.062 funcionalidades novas nem mede ausência de produto ativo.
|
||||
|
||||
O ZIP é uma cópia de revisão da única matriz/backlog, com relatório executivo, casos, plano, visão por módulo, correspondências cartográficas, manifesto e provas sanitizadas. Exclui bases, credenciais, códigos/segredos MFA, `.env`, originais privados e diagnósticos expostos. C131 permanece correção PHP QA. C132: uma configuração PHP aplicada no ativo e quatro credenciais MFA renovadas por resposta autorizada ao incidente; zero alterações de schema, flags, serviços ou bundle. O backup/restauro de 12/10 11:10:30 UTC é da entrega ativa anterior; C132 tem novo dump/restauro de 19:02:57 UTC (295 tabelas/31 sequências) e recuperação da configuração/cifra em armazenamento restrito. Não reutilizar segredos comprometidos na reversão.
|
||||
Continuidade local 02/11/2026 — Balcão: editor acompanhado de histórico paginado de versões, condições e proveniência, incluindo serviços retirados da configuração para consulta. Não expõe respostas nem permite republicar códigos retirados; pedidos anteriores preservados. AUD-CHECKLIST-01 continua parcial; provas técnicas no manual, conferência visual e instalação pendentes. Línguas nacionais ACT-16 continuam sem prioridades/tradução aprovadas (resposta do proprietário: «ainda nao»).
|
||||
|
||||
Continuidade local 02/11/2026 — ACT-11: alertas no portal para novas disponibilizações do catálogo público, com preferências próprias, confirmação, frequência, cancelamento e correspondência pela pesquisa existente. Notificações de manifestações preservadas. Migração e comando de geração instaláveis, com agenda existente e sem canais externos. Concursos/incentivos de outros catálogos, disponibilização e verificação visual continuam pendentes. Provas/limites em `traceability-and-manual.md`, secção ACT-11 de 02/11/2026; nenhum fecho transversal.
|
||||
|
||||
Continuidade local 02/11/2026 — ACT-11: além de oportunidades, concursos podem ser escolhidos explicitamente para alertas de versões actualmente publicadas. Consulta, regime, estados, versões e direitos reutilizam o catálogo existente; preferências antigas mantêm somente oportunidades. Dados anteriores e notificações de manifestações conservados; 30 testes distintos/607 asserções e seis frontend aprovados. A pendência de concursos do recorte anterior foi implementada em QA; incentivos, canais externos, instalação/agendador e conferência visual continuam pendentes. Detalhes no manual existente, sem fecho transversal.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import InteractiveInvestmentMap, {
|
||||
type InvestmentMapArea,
|
||||
type InvestmentMapFeature,
|
||||
type InvestmentMapKind,
|
||||
} from './interactive-investment-map';
|
||||
import { TerritoryGeometryDetails } from './territory-geometry-preview';
|
||||
import { useTerritoryGeometry } from './use-territory-geometry';
|
||||
const noAreas: InvestmentMapArea[] = [];
|
||||
|
||||
export default function CatalogueInvestmentMap({
|
||||
points,
|
||||
areas = noAreas,
|
||||
territoryId,
|
||||
mapKind,
|
||||
}: {
|
||||
points: InvestmentMapFeature[] | null;
|
||||
areas?: InvestmentMapArea[];
|
||||
territoryId?: string;
|
||||
mapKind?: InvestmentMapKind;
|
||||
}) {
|
||||
const { context, error } = useTerritoryGeometry(territoryId);
|
||||
return (
|
||||
<InteractiveInvestmentMap
|
||||
points={points}
|
||||
areas={areas}
|
||||
administrativeAreas={context?.areas}
|
||||
layerControls
|
||||
mapKind={mapKind}
|
||||
administrativeInfo={
|
||||
territoryId ? (
|
||||
<TerritoryGeometryDetails
|
||||
context={context}
|
||||
error={error}
|
||||
showSources={false}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
notice={
|
||||
territoryId ? (
|
||||
<>
|
||||
{error && (
|
||||
<p role="alert">
|
||||
{error} Os restantes dados do mapa continuam
|
||||
disponíveis.
|
||||
</p>
|
||||
)}
|
||||
{!context && !error && (
|
||||
<p role="status">
|
||||
A carregar limites administrativos…
|
||||
</p>
|
||||
)}
|
||||
{context && !context.selected_geometry_available && (
|
||||
<p role="note">
|
||||
Sem limite administrativo acessível para o
|
||||
território seleccionado.
|
||||
{context.areas.length > 0
|
||||
? ' Os limites de níveis superiores são apenas contexto e não representam a selecção.'
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{context?.truncated && (
|
||||
<p role="alert">
|
||||
Cobertura administrativa incompleta: são
|
||||
apresentados até 100 limites.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -94,9 +94,6 @@ export default function CountrySelect({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{matches.length === 0 && (
|
||||
<p role="status">Nenhum país corresponde à pesquisa.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { portalRequest } from '@/lib/portal-api';
|
||||
import type { Definition, Field } from '@/pages/desk/service-editor';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
version: number;
|
||||
status: string;
|
||||
author_id: number | null;
|
||||
published_by: number | null;
|
||||
published_at: string | null;
|
||||
definition: Definition;
|
||||
source: { identifier?: string; edition?: string; sha256?: string };
|
||||
reason: string | null;
|
||||
};
|
||||
type History = { page: number; pages: number; total: number; data: Version[] };
|
||||
const publishedDate = (value: string | null) => {
|
||||
if (!value) return 'Não indicada';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? 'Não indicada'
|
||||
: date.toLocaleString('pt-MZ');
|
||||
};
|
||||
|
||||
export default function DeskServiceHistory({
|
||||
code,
|
||||
formatField,
|
||||
}: {
|
||||
code: string;
|
||||
formatField: (field: Field) => string;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const key = JSON.stringify([code, page, attempt]);
|
||||
const [result, setResult] = useState<{
|
||||
key: string;
|
||||
history: History | null;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void portalRequest<History>(
|
||||
`/management/balcao/servicos/${encodeURIComponent(code)}/versoes?page=${page}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((history) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (
|
||||
!history ||
|
||||
!Array.isArray(history.data) ||
|
||||
!Number.isSafeInteger(history.page) ||
|
||||
!Number.isSafeInteger(history.pages) ||
|
||||
!Number.isSafeInteger(history.total) ||
|
||||
history.page < 1 ||
|
||||
history.pages < history.page ||
|
||||
history.total < 0 ||
|
||||
history.data.some(
|
||||
(row) =>
|
||||
!row ||
|
||||
typeof row.id !== 'string' ||
|
||||
!Number.isSafeInteger(row.version) ||
|
||||
!row.definition ||
|
||||
typeof row.definition.title !== 'string' ||
|
||||
!row.definition.fields ||
|
||||
typeof row.definition.fields !== 'object' ||
|
||||
Array.isArray(row.definition.fields) ||
|
||||
Object.values(row.definition.fields).some(
|
||||
(field) =>
|
||||
!field ||
|
||||
typeof field.label !== 'string' ||
|
||||
typeof field.type !== 'string',
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid version history');
|
||||
setResult({ key, history });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted)
|
||||
setResult({ key, history: null });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [code, page, attempt, key]);
|
||||
const current = result?.key === key ? result : null;
|
||||
return (
|
||||
<section
|
||||
className="space-y-4 rounded-xl border bg-white p-5 [overflow-wrap:anywhere]"
|
||||
aria-label="Histórico dos formulários"
|
||||
aria-busy={!current}
|
||||
>
|
||||
<h2 className="text-xl font-bold">Versões anteriores e autoria</h2>
|
||||
<p>
|
||||
Consulta das definições guardadas. Esta área não altera versões
|
||||
nem apresenta respostas dos requerentes.
|
||||
</p>
|
||||
{!current ? (
|
||||
<p role="status">A recuperar versões…</p>
|
||||
) : !current.history ? (
|
||||
<div role="alert">
|
||||
<p>Não foi possível recuperar o histórico.</p>
|
||||
<button
|
||||
className="underline"
|
||||
type="button"
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{current.history.total} versões · página{' '}
|
||||
{current.history.page} de {current.history.pages}
|
||||
</p>
|
||||
{current.history.data.length === 0 && (
|
||||
<p>
|
||||
Ainda não existem versões guardadas para este
|
||||
serviço.
|
||||
</p>
|
||||
)}
|
||||
{current.history.data.map((version) => (
|
||||
<details
|
||||
key={version.id}
|
||||
className="rounded border p-3"
|
||||
>
|
||||
<summary>
|
||||
Versão {version.version} —{' '}
|
||||
{version.definition.title} ·{' '}
|
||||
{version.status === 'published'
|
||||
? 'Publicada'
|
||||
: 'Preparada'}
|
||||
</summary>
|
||||
<dl className="mt-3 space-y-2">
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Autor / publicador (identificadores)
|
||||
</dt>
|
||||
<dd>
|
||||
{version.author_id ?? 'Não indicado'} /{' '}
|
||||
{version.published_by ?? 'Não indicado'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Publicação
|
||||
</dt>
|
||||
<dd>
|
||||
{publishedDate(version.published_at)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Motivo registado
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{version.reason ||
|
||||
'Não disponível nesta versão.'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Fonte da definição
|
||||
</dt>
|
||||
<dd>
|
||||
{version.source?.identifier ||
|
||||
'Não indicada'}
|
||||
{version.source?.edition
|
||||
? ` · ${version.source.edition}`
|
||||
: ''}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h3 className="mt-3 font-semibold">
|
||||
Identificação e percurso
|
||||
</h3>
|
||||
<p>
|
||||
{version.definition.theme} ·{' '}
|
||||
{version.definition.profile} ·{' '}
|
||||
{version.definition.entity} · etapa{' '}
|
||||
{version.definition.stage}
|
||||
</p>
|
||||
<p>{version.definition.result}</p>
|
||||
<h3 className="mt-3 font-semibold">
|
||||
Perguntas e regras desta versão
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
{Object.entries(version.definition.fields).map(
|
||||
([id, field]) => (
|
||||
<div key={id}>
|
||||
<dt className="font-semibold">
|
||||
{id}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{formatField(field)}
|
||||
</dd>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</dl>
|
||||
</details>
|
||||
))}
|
||||
<nav
|
||||
className="flex gap-4"
|
||||
aria-label="Páginas do histórico"
|
||||
>
|
||||
<button
|
||||
className="underline disabled:opacity-50"
|
||||
type="button"
|
||||
disabled={current.history.page <= 1}
|
||||
onClick={() => setPage(current.history!.page - 1)}
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
className="underline disabled:opacity-50"
|
||||
type="button"
|
||||
disabled={
|
||||
current.history.page >= current.history.pages
|
||||
}
|
||||
onClick={() => setPage(current.history!.page + 1)}
|
||||
>
|
||||
Seguinte
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -485,7 +485,7 @@ export function DossierPrefill(props: Props) {
|
||||
setReviewed(false);
|
||||
props.onReview?.(view.context.revision, false);
|
||||
setMessage(
|
||||
'Valores selecionados aplicados ao formulário. Os outros valores e os snapshots anteriores foram preservados.',
|
||||
'Dados seleccionados aplicados. Os restantes campos e as versões anteriores foram conservados.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Map as LeafletMap, Marker } from 'leaflet';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import type {
|
||||
Map as LeafletMap,
|
||||
Marker,
|
||||
LayerGroup,
|
||||
LayersControlEvent,
|
||||
} from 'leaflet';
|
||||
import type { GeoJsonObject } from 'geojson';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
@@ -29,24 +34,87 @@ export type InvestmentMapArea = {
|
||||
url: string;
|
||||
};
|
||||
const noAreas: InvestmentMapArea[] = [];
|
||||
export type InvestmentMapKind =
|
||||
| 'investments'
|
||||
| 'administrative'
|
||||
| 'documented_locations'
|
||||
| 'declared_parcel';
|
||||
|
||||
export default function InteractiveInvestmentMap({
|
||||
points,
|
||||
areas = noAreas,
|
||||
contextOnly = false,
|
||||
mapKind = contextOnly ? 'administrative' : 'investments',
|
||||
administrativeAreas = noAreas,
|
||||
layerControls = false,
|
||||
administrativeInfo,
|
||||
notice,
|
||||
}: {
|
||||
points: InvestmentMapFeature[] | null;
|
||||
areas?: InvestmentMapArea[];
|
||||
contextOnly?: boolean;
|
||||
mapKind?: InvestmentMapKind;
|
||||
administrativeAreas?: InvestmentMapArea[];
|
||||
layerControls?: boolean;
|
||||
administrativeInfo?: ReactNode;
|
||||
notice?: ReactNode;
|
||||
}) {
|
||||
const title = {
|
||||
investments: 'Mapa dos investimentos',
|
||||
administrative: 'Contexto administrativo',
|
||||
documented_locations: 'Mapa dos locais documentados',
|
||||
declared_parcel: 'Perímetro declarado pelo Promotor',
|
||||
}[mapKind];
|
||||
const areaDescription = (area: InvestmentMapArea) =>
|
||||
area.purpose === 'administrative_boundary'
|
||||
? 'Limite administrativo da edição indicada; não representa um local de investimento'
|
||||
: area.purpose === 'declared_parcel'
|
||||
? 'Perímetro declarado pelo Promotor; não é limite administrativo nem comprova disponibilidade legal'
|
||||
: mapKind === 'documented_locations'
|
||||
? 'Área documentada na fonte; não confirma implantação de investimento'
|
||||
: 'Polígono cadastrado; comparação com limite administrativo pendente';
|
||||
const allAreas = useMemo(() => {
|
||||
const unique = new Map<string, InvestmentMapArea>();
|
||||
for (const area of [
|
||||
...areas,
|
||||
...administrativeAreas.map((area) => ({
|
||||
...area,
|
||||
purpose: 'administrative_boundary',
|
||||
})),
|
||||
]) {
|
||||
const key = JSON.stringify([
|
||||
area.geometry,
|
||||
area.purpose,
|
||||
area.title,
|
||||
area.source_reference,
|
||||
area.source_date,
|
||||
]);
|
||||
const previous = unique.get(key);
|
||||
unique.set(key, {
|
||||
...area,
|
||||
selected_territory: Boolean(
|
||||
previous?.selected_territory || area.selected_territory,
|
||||
),
|
||||
});
|
||||
}
|
||||
return [...unique.values()];
|
||||
}, [areas, administrativeAreas]);
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<LeafletMap | null>(null);
|
||||
const markers = useRef(new Map<number, Marker>());
|
||||
const pointLayer = useRef<LayerGroup | null>(null);
|
||||
const visibility = useRef({
|
||||
points: true,
|
||||
investments: true,
|
||||
administrative: true,
|
||||
});
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let observer: ResizeObserver | undefined;
|
||||
setError('');
|
||||
setSelected(null);
|
||||
void import('leaflet')
|
||||
.then((L) => {
|
||||
if (cancelled || !element.current) return;
|
||||
@@ -75,8 +143,64 @@ export default function InteractiveInvestmentMap({
|
||||
})
|
||||
.addTo(instance);
|
||||
L.control.scale({ imperial: false }).addTo(instance);
|
||||
const groups = {
|
||||
points: L.layerGroup(),
|
||||
investments: L.layerGroup(),
|
||||
administrative: L.layerGroup(),
|
||||
};
|
||||
pointLayer.current = groups.points;
|
||||
for (const key of Object.keys(
|
||||
groups,
|
||||
) as (keyof typeof groups)[]) {
|
||||
if (!layerControls || visibility.current[key])
|
||||
groups[key].addTo(instance);
|
||||
}
|
||||
if (layerControls) {
|
||||
const overlays: Record<string, LayerGroup> = {};
|
||||
if (points === null || points.length)
|
||||
overlays[
|
||||
mapKind === 'investments'
|
||||
? 'Oportunidades com coordenadas'
|
||||
: 'Locais documentados com coordenadas'
|
||||
] = groups.points;
|
||||
if (
|
||||
allAreas.some(
|
||||
(area) =>
|
||||
area.purpose !== 'administrative_boundary',
|
||||
)
|
||||
)
|
||||
overlays[
|
||||
mapKind === 'investments'
|
||||
? 'Áreas de investimento cadastradas'
|
||||
: 'Áreas documentadas'
|
||||
] = groups.investments;
|
||||
if (
|
||||
allAreas.some(
|
||||
(area) =>
|
||||
area.purpose === 'administrative_boundary',
|
||||
)
|
||||
)
|
||||
overlays['Limites administrativos'] =
|
||||
groups.administrative;
|
||||
L.control
|
||||
.layers(undefined, overlays, { collapsed: false })
|
||||
.addTo(instance);
|
||||
instance.on('overlayadd overlayremove', (event) => {
|
||||
const changed = event as LayersControlEvent;
|
||||
for (const key of Object.keys(
|
||||
groups,
|
||||
) as (keyof typeof groups)[]) {
|
||||
if (changed.layer === groups[key])
|
||||
visibility.current[key] =
|
||||
event.type === 'overlayadd';
|
||||
}
|
||||
});
|
||||
}
|
||||
const bounds: [number, number][] = [];
|
||||
const hasSelectedBoundary = areas.some(
|
||||
const selectedBounds: [number, number][] = [];
|
||||
const opportunityBounds: [number, number][] = [];
|
||||
const contextBounds: [number, number][] = [];
|
||||
const hasSelectedBoundary = allAreas.some(
|
||||
(area) => area.selected_territory,
|
||||
);
|
||||
const administrativeLevel: Record<string, number> = {
|
||||
@@ -85,20 +209,27 @@ export default function InteractiveInvestmentMap({
|
||||
admin_post: 3,
|
||||
locality: 4,
|
||||
};
|
||||
const closestContextLevel = contextOnly
|
||||
? Math.max(
|
||||
0,
|
||||
...areas.map(
|
||||
(area) =>
|
||||
administrativeLevel[
|
||||
area.location_type ?? ''
|
||||
] ?? 0,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
for (const area of areas) {
|
||||
const closestContextLevel =
|
||||
contextOnly || layerControls
|
||||
? Math.max(
|
||||
0,
|
||||
...allAreas
|
||||
.filter(
|
||||
(area) =>
|
||||
area.purpose ===
|
||||
'administrative_boundary',
|
||||
)
|
||||
.map(
|
||||
(area) =>
|
||||
administrativeLevel[
|
||||
area.location_type ?? ''
|
||||
] ?? 0,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
for (const area of allAreas) {
|
||||
const label = document.createElement('p');
|
||||
label.textContent = `${area.title} · ${area.purpose === 'administrative_boundary' ? 'Limite administrativo da edição indicada; não representa um local de investimento' : 'Polígono cadastrado, limites oficiais por verificar'} · Fonte: ${area.source_reference ?? 'Não indicada'} · Data: ${area.source_date ?? 'Não documentada'}`;
|
||||
label.textContent = `${area.title} · ${areaDescription(area)} · Fonte: ${area.source_reference ?? 'Não indicada'} · Data: ${area.source_date ?? 'Não documentada'}`;
|
||||
const layer = L.geoJSON(
|
||||
area.geometry,
|
||||
area.purpose === 'administrative_boundary'
|
||||
@@ -109,10 +240,21 @@ export default function InteractiveInvestmentMap({
|
||||
fillOpacity: 0.06,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
: layerControls
|
||||
? {
|
||||
style: {
|
||||
color: '#b45309',
|
||||
fillOpacity: 0.16,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
)
|
||||
.bindPopup(label)
|
||||
.addTo(instance);
|
||||
.addTo(
|
||||
area.purpose === 'administrative_boundary'
|
||||
? groups.administrative
|
||||
: groups.investments,
|
||||
);
|
||||
const extent = layer.getBounds();
|
||||
const focusBoundary = hasSelectedBoundary
|
||||
? area.selected_territory
|
||||
@@ -124,6 +266,19 @@ export default function InteractiveInvestmentMap({
|
||||
[extent.getSouth(), extent.getWest()],
|
||||
[extent.getNorth(), extent.getEast()],
|
||||
);
|
||||
if (extent.isValid()) {
|
||||
const corners: [number, number][] = [
|
||||
[extent.getSouth(), extent.getWest()],
|
||||
[extent.getNorth(), extent.getEast()],
|
||||
];
|
||||
if (area.purpose !== 'administrative_boundary')
|
||||
opportunityBounds.push(...corners);
|
||||
else {
|
||||
if (area.selected_territory)
|
||||
selectedBounds.push(...corners);
|
||||
if (focusBoundary) contextBounds.push(...corners);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, point] of (points ?? []).entries()) {
|
||||
const [longitude, latitude] = point.geometry.coordinates;
|
||||
@@ -171,12 +326,20 @@ export default function InteractiveInvestmentMap({
|
||||
})
|
||||
.bindPopup(popup, { autoPan: false })
|
||||
.on('click', () => setSelected(index))
|
||||
.addTo(instance);
|
||||
.addTo(groups.points);
|
||||
markers.current.set(index, marker);
|
||||
bounds.push([latitude, longitude]);
|
||||
opportunityBounds.push([latitude, longitude]);
|
||||
}
|
||||
if (bounds.length)
|
||||
instance.fitBounds(bounds, {
|
||||
const focusBounds = layerControls
|
||||
? selectedBounds.length
|
||||
? selectedBounds
|
||||
: opportunityBounds.length
|
||||
? opportunityBounds
|
||||
: contextBounds
|
||||
: bounds;
|
||||
if (focusBounds.length)
|
||||
instance.fitBounds(focusBounds, {
|
||||
padding: [40, 40],
|
||||
maxZoom: 14,
|
||||
animate: false,
|
||||
@@ -196,29 +359,57 @@ export default function InteractiveInvestmentMap({
|
||||
map.current?.remove();
|
||||
map.current = null;
|
||||
markers.current.clear();
|
||||
pointLayer.current = null;
|
||||
};
|
||||
}, [points, areas]);
|
||||
}, [points, allAreas, contextOnly, layerControls, mapKind]);
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded-xl border bg-white p-4 text-slate-900"
|
||||
aria-label={
|
||||
contextOnly
|
||||
? 'Mapa dos limites administrativos'
|
||||
: 'Mapa interactivo dos investimentos'
|
||||
}
|
||||
aria-label={title}
|
||||
>
|
||||
<h2 className="text-xl font-bold">
|
||||
{contextOnly
|
||||
? 'Contexto administrativo'
|
||||
: 'Mapa dos investimentos'}
|
||||
</h2>
|
||||
<h2 className="text-xl font-bold">{title}</h2>
|
||||
<p>
|
||||
Arraste para navegar; use +/− para ampliar. Pode seleccionar um
|
||||
{contextOnly
|
||||
? 'limite cadastrado para consultar a sua edição.'
|
||||
: 'investimento na lista para o localizar.'}
|
||||
Arraste para navegar; use +/− para ampliar.{' '}
|
||||
{mapKind === 'declared_parcel'
|
||||
? 'Esta pré-visualização representa a declaração do Promotor, não o limite administrativo.'
|
||||
: mapKind === 'administrative'
|
||||
? 'Seleccione um limite cadastrado para consultar a sua edição.'
|
||||
: 'Seleccione um local na lista para o localizar.'}
|
||||
</p>
|
||||
{error && <p role="status">{error}</p>}
|
||||
{notice}
|
||||
{layerControls && (
|
||||
<div
|
||||
aria-label="Legenda das camadas"
|
||||
className="flex flex-wrap gap-4 text-sm"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-2 inline-block h-3 w-3 rounded-full bg-emerald-800"
|
||||
/>
|
||||
{mapKind === 'investments'
|
||||
? 'Oportunidade com coordenadas confirmadas'
|
||||
: 'Local documentado com coordenadas'}
|
||||
</span>
|
||||
<span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-2 inline-block h-3 w-4 border-2 border-amber-700 bg-amber-100"
|
||||
/>
|
||||
{mapKind === 'investments'
|
||||
? 'Área de investimento cadastrada'
|
||||
: 'Área documentada'}
|
||||
</span>
|
||||
<span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-2 inline-block h-3 w-4 border-2 border-dashed border-blue-600"
|
||||
/>
|
||||
Limite administrativo — não indica terreno disponível
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={element}
|
||||
className="relative z-0 h-[460px] w-full min-w-0 overflow-hidden rounded"
|
||||
@@ -229,9 +420,11 @@ export default function InteractiveInvestmentMap({
|
||||
<p role="status">A carregar localizações…</p>
|
||||
) : (
|
||||
<p role="status">
|
||||
{contextOnly
|
||||
? `${areas.length} limites administrativos disponíveis.`
|
||||
: `${points.length} locais com coordenadas disponíveis.`}
|
||||
{mapKind === 'declared_parcel'
|
||||
? `${allAreas.length} perímetro declarado em pré-visualização.`
|
||||
: mapKind === 'administrative'
|
||||
? `${areas.length} limites administrativos disponíveis.`
|
||||
: `${points.length} locais com coordenadas disponíveis.`}
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid gap-2 md:grid-cols-2">
|
||||
@@ -251,6 +444,11 @@ export default function InteractiveInvestmentMap({
|
||||
onClick={() => {
|
||||
const marker = markers.current.get(index);
|
||||
if (marker) {
|
||||
visibility.current.points = true;
|
||||
if (pointLayer.current)
|
||||
map.current?.addLayer(
|
||||
pointLayer.current,
|
||||
);
|
||||
map.current?.setView(
|
||||
marker.getLatLng(),
|
||||
14,
|
||||
@@ -278,19 +476,42 @@ export default function InteractiveInvestmentMap({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{areas.map((area, index) => (
|
||||
<p key={index}>
|
||||
<a className="underline" href={area.url}>
|
||||
{area.title}
|
||||
</a>{' '}
|
||||
·{' '}
|
||||
{area.purpose === 'administrative_boundary'
|
||||
? 'Limite administrativo da edição indicada; não representa um local de investimento'
|
||||
: 'Polígono cadastrado; comparação com limite administrativo pendente'}{' '}
|
||||
· Fonte: {area.source_reference ?? 'Não indicada'} · Data:{' '}
|
||||
{area.source_date ?? 'Não documentada'}
|
||||
</p>
|
||||
))}
|
||||
{layerControls && (
|
||||
<details className="rounded border p-3">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
Fontes e contexto do mapa
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
{administrativeInfo}
|
||||
{allAreas.map((area, index) => (
|
||||
<p key={index}>
|
||||
{area.url.startsWith('/') &&
|
||||
!area.url.startsWith('//') ? (
|
||||
<a className="underline" href={area.url}>
|
||||
{area.title}
|
||||
</a>
|
||||
) : (
|
||||
area.title
|
||||
)}{' '}
|
||||
· {areaDescription(area)} · Fonte:{' '}
|
||||
{area.source_reference ?? 'Não indicada'} ·
|
||||
Edição: {area.source_date ?? 'Não indicada'}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{!layerControls &&
|
||||
allAreas.map((area, index) => (
|
||||
<p key={index}>
|
||||
<a className="underline" href={area.url}>
|
||||
{area.title}
|
||||
</a>{' '}
|
||||
· {areaDescription(area)} · Fonte:{' '}
|
||||
{area.source_reference ?? 'Não indicada'} · Data:{' '}
|
||||
{area.source_date ?? 'Não documentada'}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,10 @@ export default function InvestmentMapCoverage({
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption className="text-left">
|
||||
Registos recebidos para os filtros actuais, incluindo
|
||||
camadas ocultas.
|
||||
Registos da pesquisa de investimentos para os filtros
|
||||
actuais, incluindo camadas ocultas. A cobertura dos
|
||||
limites administrativos adicionais é apresentada em
|
||||
«Fontes e contexto do mapa».
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import SearchState from '@/components/search-state';
|
||||
import { useEffect, useState } from 'react';
|
||||
import TerritoryGeometryPreview from './territory-geometry-preview';
|
||||
import TerritorialMap from './catalogue-investment-map';
|
||||
import InvestmentMapCoverage from './investment-map-coverage';
|
||||
import InteractiveInvestmentMap, {
|
||||
import {
|
||||
type InvestmentMapFeature,
|
||||
type InvestmentMapArea,
|
||||
} from './interactive-investment-map';
|
||||
@@ -23,9 +23,9 @@ export default function InvestmentMapPanel({
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [areas, setAreas] = useState<InvestmentMapArea[]>([]);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [showPoints, setShowPoints] = useState(true);
|
||||
const [showAreas, setShowAreas] = useState(true);
|
||||
const [matched, setMatched] = useState(0);
|
||||
const [responseKey, setResponseKey] = useState('');
|
||||
const current = responseKey === key;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const params = new URLSearchParams(JSON.parse(key));
|
||||
@@ -46,6 +46,7 @@ export default function InvestmentMapPanel({
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPoints(data.features);
|
||||
setResponseKey(key);
|
||||
setAreas(data.areas ?? []);
|
||||
setMissing(data.unlocated ?? []);
|
||||
setTruncated(Boolean(data.truncated));
|
||||
@@ -59,8 +60,10 @@ export default function InvestmentMapPanel({
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted)
|
||||
if (!controller.signal.aborted) {
|
||||
setResponseKey(key);
|
||||
setError('Não foi possível concluir a pesquisa.');
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [key, retry]);
|
||||
@@ -69,53 +72,14 @@ export default function InvestmentMapPanel({
|
||||
<SearchState
|
||||
scope="Registos consultados pelo mapa"
|
||||
countLabel="investimentos, incluindo os que não têm coordenadas"
|
||||
loading={points === null && !error}
|
||||
error={error}
|
||||
total={matched}
|
||||
loading={!current || (points === null && !error)}
|
||||
error={current ? error : ''}
|
||||
total={current ? matched : 0}
|
||||
retry={() => setRetry((value) => value + 1)}
|
||||
/>
|
||||
<fieldset className="flex flex-wrap gap-4 rounded border p-3">
|
||||
<legend className="font-semibold">
|
||||
Camadas desta consulta
|
||||
</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPoints}
|
||||
onChange={(event) =>
|
||||
setShowPoints(event.target.checked)
|
||||
}
|
||||
/>{' '}
|
||||
Investimentos com ponto
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAreas}
|
||||
onChange={(event) => setShowAreas(event.target.checked)}
|
||||
/>{' '}
|
||||
Áreas e limites
|
||||
</label>
|
||||
</fieldset>
|
||||
{!showPoints && !showAreas && (
|
||||
<p role="status">
|
||||
Camadas ocultas. Active uma camada para voltar a consultar
|
||||
os registos no mapa.
|
||||
</p>
|
||||
)}
|
||||
<InteractiveInvestmentMap
|
||||
points={points === null ? null : showPoints ? points : []}
|
||||
areas={showAreas ? areas : []}
|
||||
/>
|
||||
{points !== null && (
|
||||
<InvestmentMapCoverage
|
||||
points={points}
|
||||
areas={areas}
|
||||
unlocated={missing.length}
|
||||
truncated={truncated}
|
||||
/>
|
||||
)}
|
||||
<TerritoryGeometryPreview
|
||||
<TerritorialMap
|
||||
points={current ? (error ? [] : points) : null}
|
||||
areas={current ? areas : []}
|
||||
territoryId={
|
||||
territoryId ||
|
||||
String(
|
||||
@@ -123,12 +87,20 @@ export default function InvestmentMapPanel({
|
||||
filters.admin_post_id ||
|
||||
filters.district_id ||
|
||||
filters.province_id ||
|
||||
filters.geo_location_id ||
|
||||
'',
|
||||
)
|
||||
) ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
{missing.length > 0 && (
|
||||
{current && points !== null && (
|
||||
<InvestmentMapCoverage
|
||||
points={points}
|
||||
areas={areas}
|
||||
unlocated={missing.length}
|
||||
truncated={truncated}
|
||||
/>
|
||||
)}
|
||||
{current && missing.length > 0 && (
|
||||
<div>
|
||||
<p>
|
||||
{missing.length} investimentos sem ponto público
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { modalityLabels } from '@/components/opportunity-search-filters';
|
||||
import { button, card, input } from '@/pages/investment/shared';
|
||||
|
||||
export type AlertPreferences = {
|
||||
version: number;
|
||||
enabled: boolean;
|
||||
frequency: string;
|
||||
channel: string;
|
||||
next_run_at: string | null;
|
||||
criteria_available?: boolean;
|
||||
sources?: string[];
|
||||
criteria: {
|
||||
sector?: string;
|
||||
province_id?: string;
|
||||
category?: string;
|
||||
tender_regime?: string;
|
||||
funding_certainty?: string;
|
||||
funding_state?: string;
|
||||
};
|
||||
};
|
||||
export type AlertOptions = {
|
||||
frequencies: Record<string, string>;
|
||||
sectors: { slug: string; name: string }[];
|
||||
provinces: { id: string; name: string }[];
|
||||
categories: string[];
|
||||
tendersAvailable?: boolean;
|
||||
fundingAvailable?: boolean;
|
||||
tenderRegimes?: Record<string, string>;
|
||||
tenderActs?: Record<string, string>;
|
||||
};
|
||||
|
||||
export default function OpportunityAlertPreferences({
|
||||
preferences,
|
||||
options,
|
||||
}: {
|
||||
preferences: AlertPreferences;
|
||||
options: AlertOptions;
|
||||
}) {
|
||||
const form = useForm({
|
||||
version: preferences.version,
|
||||
enabled: true,
|
||||
frequency: preferences.frequency,
|
||||
channel: 'in_app',
|
||||
sources: preferences.sources ?? ['opportunities'],
|
||||
criteria: {
|
||||
sector: preferences.criteria.sector ?? '',
|
||||
province_id: preferences.criteria.province_id ?? '',
|
||||
category: preferences.criteria.category ?? '',
|
||||
tender_regime: preferences.criteria.tender_regime ?? '',
|
||||
funding_certainty:
|
||||
preferences.criteria.funding_certainty ?? 'documented',
|
||||
funding_state: preferences.criteria.funding_state ?? '',
|
||||
},
|
||||
consent: false,
|
||||
});
|
||||
const send = (enabled: boolean) => {
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
enabled,
|
||||
consent: enabled && data.consent,
|
||||
}));
|
||||
form.put('/investidor/alert-preferences', { preserveScroll: true });
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className={card + ' space-y-3'}
|
||||
aria-label="Preferências de alertas"
|
||||
>
|
||||
<h2 className="text-xl font-bold">
|
||||
Novidades dos catálogos públicos
|
||||
</h2>
|
||||
<p role="status">
|
||||
{preferences.enabled
|
||||
? 'Acompanhamento activo.'
|
||||
: 'Acompanhamento desactivado.'}
|
||||
</p>
|
||||
{preferences.enabled &&
|
||||
preferences.criteria_available === false && (
|
||||
<p role="alert">
|
||||
Uma selecção anterior deixou de estar disponível no
|
||||
catálogo. O acompanhamento das selecções indisponíveis
|
||||
fica suspenso; os restantes catálogos seleccionados
|
||||
continuam a ser verificados. Pode escolher outra opção
|
||||
ou cancelar os alertas a qualquer momento.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Escolha os catálogos, critérios e frequência. As novas
|
||||
disponibilizações compatíveis aparecem nesta área. Não são
|
||||
enviados emails ou SMS. O acompanhamento de concursos considera
|
||||
anúncios e actos publicados no catálogo do portal. Fundos e
|
||||
apoios consideram novas versões do catálogo do portal; não há
|
||||
recolha automática de fontes externas.
|
||||
</p>
|
||||
<p>
|
||||
Ao activar, reactivar ou alterar os critérios ou catálogos, os
|
||||
registos já disponíveis estabelecem o ponto de partida e não
|
||||
geram alertas retroactivos. Uma oportunidade ou versão de acto
|
||||
não gera alertas repetidos.
|
||||
</p>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
send(true);
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={form.processing} className="space-y-3">
|
||||
<legend className="font-semibold">
|
||||
O que pretende acompanhar
|
||||
</legend>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="font-semibold">Catálogos</legend>
|
||||
{(['opportunities', 'tenders', 'funding'] as const).map(
|
||||
(source) => (
|
||||
<label
|
||||
key={source}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
<input
|
||||
name={`source-${source}`}
|
||||
type="checkbox"
|
||||
checked={form.data.sources.includes(
|
||||
source,
|
||||
)}
|
||||
disabled={
|
||||
((source === 'tenders' &&
|
||||
!options.tendersAvailable) ||
|
||||
(source === 'funding' &&
|
||||
!options.fundingAvailable)) &&
|
||||
!form.data.sources.includes(source)
|
||||
}
|
||||
onChange={(event) => {
|
||||
form.setData(
|
||||
'sources',
|
||||
event.target.checked
|
||||
? [
|
||||
...form.data.sources,
|
||||
source,
|
||||
]
|
||||
: form.data.sources.filter(
|
||||
(value) =>
|
||||
value !== source,
|
||||
),
|
||||
);
|
||||
form.setData('consent', false);
|
||||
}}
|
||||
/>
|
||||
{source === 'opportunities'
|
||||
? 'Oportunidades de investimento'
|
||||
: source === 'tenders'
|
||||
? 'Concursos e actos publicados'
|
||||
: 'Fundos, programas e incentivos publicados'}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
{!options.tendersAvailable && (
|
||||
<p className="text-sm">
|
||||
O catálogo de concursos não está disponível
|
||||
neste momento.
|
||||
</p>
|
||||
)}
|
||||
{!options.fundingAvailable && (
|
||||
<p className="text-sm">
|
||||
O catálogo de fundos não está disponível neste
|
||||
momento.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<label className="block">
|
||||
Sector
|
||||
<select
|
||||
className={input}
|
||||
value={form.data.criteria.sector}
|
||||
onChange={(event) =>
|
||||
form.setData('criteria', {
|
||||
...form.data.criteria,
|
||||
sector: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Todos os sectores</option>
|
||||
{form.data.criteria.sector &&
|
||||
!options.sectors.some(
|
||||
(sector) =>
|
||||
sector.slug ===
|
||||
form.data.criteria.sector,
|
||||
) && (
|
||||
<option value={form.data.criteria.sector}>
|
||||
Sector anterior indisponível
|
||||
</option>
|
||||
)}
|
||||
{options.sectors.map((sector) => (
|
||||
<option key={sector.slug} value={sector.slug}>
|
||||
{sector.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Província
|
||||
<select
|
||||
className={input}
|
||||
value={form.data.criteria.province_id}
|
||||
onChange={(event) =>
|
||||
form.setData('criteria', {
|
||||
...form.data.criteria,
|
||||
province_id: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Todo o território</option>
|
||||
{form.data.criteria.province_id &&
|
||||
!options.provinces.some(
|
||||
(province) =>
|
||||
province.id ===
|
||||
form.data.criteria.province_id,
|
||||
) && (
|
||||
<option
|
||||
value={form.data.criteria.province_id}
|
||||
>
|
||||
Província anterior indisponível
|
||||
</option>
|
||||
)}
|
||||
{options.provinces.map((province) => (
|
||||
<option key={province.id} value={province.id}>
|
||||
{province.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
A selecção provincial usa o mesmo âmbito territorial do
|
||||
catálogo, incluindo locais dos níveis inferiores.
|
||||
</p>
|
||||
{form.data.sources.includes('opportunities') && (
|
||||
<label className="block">
|
||||
Modalidade
|
||||
<select
|
||||
className={input}
|
||||
value={form.data.criteria.category}
|
||||
onChange={(event) =>
|
||||
form.setData('criteria', {
|
||||
...form.data.criteria,
|
||||
category: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Todas as modalidades</option>
|
||||
{form.data.criteria.category &&
|
||||
!options.categories.includes(
|
||||
form.data.criteria.category,
|
||||
) && (
|
||||
<option
|
||||
value={form.data.criteria.category}
|
||||
>
|
||||
Modalidade anterior indisponível
|
||||
</option>
|
||||
)}
|
||||
{options.categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{modalityLabels[category] ?? category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.data.sources.includes('tenders') && (
|
||||
<label className="block">
|
||||
Regime do concurso
|
||||
<select
|
||||
className={input}
|
||||
value={form.data.criteria.tender_regime}
|
||||
onChange={(event) =>
|
||||
form.setData('criteria', {
|
||||
...form.data.criteria,
|
||||
tender_regime: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Todos os regimes</option>
|
||||
{form.data.criteria.tender_regime &&
|
||||
!Object.hasOwn(
|
||||
options.tenderRegimes ?? {},
|
||||
form.data.criteria.tender_regime,
|
||||
) && (
|
||||
<option
|
||||
value={
|
||||
form.data.criteria.tender_regime
|
||||
}
|
||||
>
|
||||
Regime anterior indisponível
|
||||
</option>
|
||||
)}
|
||||
{Object.entries(
|
||||
options.tenderRegimes ?? {},
|
||||
).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.data.sources.includes('funding') && (
|
||||
<fieldset className="space-y-3">
|
||||
<legend>Condições para fundos e apoios</legend>
|
||||
<label className="block">
|
||||
Âmbitos por confirmar
|
||||
<select
|
||||
className={input}
|
||||
value={form.data.criteria.funding_certainty}
|
||||
onChange={(event) =>
|
||||
form.setData('criteria', {
|
||||
...form.data.criteria,
|
||||
funding_certainty:
|
||||
event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="documented">
|
||||
Só âmbito documentado para o sector e
|
||||
província escolhidos
|
||||
</option>
|
||||
<option value="include_unknown">
|
||||
Incluir e assinalar informação por
|
||||
confirmar
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Estado de uma janela
|
||||
<select
|
||||
className={input}
|
||||
value={form.data.criteria.funding_state}
|
||||
onChange={(event) =>
|
||||
form.setData('criteria', {
|
||||
...form.data.criteria,
|
||||
funding_state: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Qualquer estado</option>
|
||||
<option value="open">
|
||||
Aberta segundo as datas documentadas
|
||||
</option>
|
||||
<option value="upcoming">
|
||||
Abertura futura
|
||||
</option>
|
||||
<option value="closed">Encerrada</option>
|
||||
<option value="unknown">
|
||||
Por confirmar
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
Sector e província referem-se ao programa. Uma
|
||||
chamada pode ter cobertura mais restrita. O
|
||||
aviso acompanha novas versões publicadas; uma
|
||||
mudança de data sem nova publicação não gera
|
||||
outro aviso.
|
||||
</p>
|
||||
</fieldset>
|
||||
)}
|
||||
<label className="block">
|
||||
Frequência
|
||||
<select
|
||||
className={input}
|
||||
value={form.data.frequency}
|
||||
onChange={(event) =>
|
||||
form.setData('frequency', event.target.value)
|
||||
}
|
||||
>
|
||||
{Object.entries(options.frequencies).map(
|
||||
([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<p>
|
||||
Canal: notificações no portal. «Na próxima verificação»
|
||||
depende da execução periódica do acompanhamento.
|
||||
</p>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="alert-consent"
|
||||
checked={form.data.consent}
|
||||
onChange={(event) =>
|
||||
form.setData('consent', event.target.checked)
|
||||
}
|
||||
/>
|
||||
Quero receber estes alertas no portal e confirmo os
|
||||
critérios escolhidos.
|
||||
</label>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p key={key} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button type="submit" className={button}>
|
||||
{preferences.enabled
|
||||
? 'Guardar preferências'
|
||||
: 'Activar alertas'}
|
||||
</button>
|
||||
{preferences.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => send(false)}
|
||||
>
|
||||
Cancelar estes alertas
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
<p className="text-sm">
|
||||
O cancelamento conserva as notificações anteriores e não altera
|
||||
o acompanhamento das manifestações já apresentadas.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -90,10 +90,9 @@ export default function OpportunityContactForm({
|
||||
<details className="rounded border p-3">
|
||||
<summary>Cadastrar contacto do promotor</summary>
|
||||
<p>
|
||||
Reutiliza o cadastro de contactos. Indique dados reais e
|
||||
autorizados do promotor desta oportunidade. O operador do portal
|
||||
não substitui o promotor real. A gravação não publica o
|
||||
contacto.
|
||||
Indique os dados de contacto autorizados pelo promotor desta
|
||||
oportunidade. O contacto fica guardado para reutilização e não é
|
||||
publicado.
|
||||
</p>
|
||||
<fieldset disabled={disabled || busy} className="space-y-3">
|
||||
<legend>Dados do contacto</legend>
|
||||
@@ -142,7 +141,7 @@ export default function OpportunityContactForm({
|
||||
{organization && investors.length > 1 && (
|
||||
<div>
|
||||
<label htmlFor="contact-investor_id">
|
||||
Cadastro mestre do promotor
|
||||
Promotor associado ao contacto
|
||||
</label>
|
||||
<select
|
||||
id="contact-investor_id"
|
||||
|
||||
@@ -204,6 +204,7 @@ export default function OpportunityParcelGeometry({
|
||||
<InteractiveInvestmentMap
|
||||
points={[]}
|
||||
contextOnly
|
||||
mapKind="declared_parcel"
|
||||
areas={[
|
||||
{
|
||||
purpose: 'declared_parcel',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
|
||||
export type RelatedOpportunitySearch = {
|
||||
query: string;
|
||||
subject: string;
|
||||
href: string;
|
||||
items: {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function OpportunityRelatedResults({
|
||||
related,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
}: {
|
||||
related: RelatedOpportunitySearch | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
total: number;
|
||||
}) {
|
||||
if (loading || error || total !== 0 || !related?.items.length) return null;
|
||||
const context = new URL(related.href, 'https://portal.invalid');
|
||||
context.searchParams.set('q', related.query);
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded-xl border border-slate-300 bg-white p-5"
|
||||
aria-label="Fichas relacionadas, sem correspondência completa"
|
||||
>
|
||||
<h3 className="text-xl font-bold">
|
||||
Fichas relacionadas com «{related.subject}»
|
||||
</h3>
|
||||
<p>
|
||||
Não encontrámos fichas que correspondam a todos os termos de «
|
||||
{related.query}». As fichas abaixo são de actividades
|
||||
relacionadas e podem corresponder a outras etapas da cadeia.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{related.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
className="font-semibold underline"
|
||||
href={`/oportunidades/${encodeURIComponent(item.slug)}${context.search}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
{item.summary && <p>{item.summary}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="underline" href={related.href}>
|
||||
Ver a pesquisa relacionada «{related.subject}»
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { Link, router, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ArrowUpRight,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Menu,
|
||||
UserRound,
|
||||
@@ -13,11 +12,8 @@ import {
|
||||
import { Fragment, useEffect, useId, useRef, useState } from 'react';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useNavigationContext } from '@/components/navigation-context';
|
||||
import { tempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
|
||||
import {
|
||||
TempoLanguageSelector,
|
||||
TempoWeather,
|
||||
} from '@/components/tempo-header-tools';
|
||||
import { useTempoUi } from '@/components/tempo-brand';
|
||||
import { TempoLanguageSelector } from '@/components/tempo-header-tools';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -51,6 +47,7 @@ export function PortalGroupedMenu() {
|
||||
businessReviewUrl?: string | null;
|
||||
educationEnabled?: boolean;
|
||||
procurementEnabled?: boolean;
|
||||
fundingEnabled?: boolean;
|
||||
aiEnabled?: boolean;
|
||||
legacyContentEnabled?: boolean;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
@@ -61,7 +58,7 @@ export function PortalGroupedMenu() {
|
||||
opportunities?: { index: string; create?: string | null };
|
||||
};
|
||||
}>();
|
||||
const { t, language, requestCount } = useTempoUi();
|
||||
const { t, language } = useTempoUi();
|
||||
const mobile = useIsMobile();
|
||||
const navigation = useNavigationContext();
|
||||
const groups = portalNavigation({
|
||||
@@ -74,6 +71,7 @@ export function PortalGroupedMenu() {
|
||||
businessReviewUrl: props.businessReviewUrl,
|
||||
education: props.educationEnabled,
|
||||
procurement: props.procurementEnabled,
|
||||
funding: props.fundingEnabled,
|
||||
ai: props.aiEnabled,
|
||||
legacyContent: props.legacyContentEnabled,
|
||||
opportunityManagement: props.opportunityManagement,
|
||||
@@ -137,7 +135,6 @@ export function PortalGroupedMenu() {
|
||||
group.id,
|
||||
),
|
||||
);
|
||||
const panelLabel = `${t('O meu painel')} — ${requestCount} ${t(requestCount === 1 ? 'pedido recebido' : 'pedidos recebidos')}`;
|
||||
const institutionalCurrent = url.split('?')[0] === '/contacto';
|
||||
return (
|
||||
<header
|
||||
@@ -307,36 +304,6 @@ export function PortalGroupedMenu() {
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-slate-500">
|
||||
Revista Tempo
|
||||
</DropdownMenuLabel>
|
||||
{tempoSocialLinks.map(
|
||||
({ name, href, icon: Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={name}
|
||||
asChild
|
||||
className="tempo-menu-item"
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Revista Tempo — ${name}`}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{name}
|
||||
<ArrowUpRight
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -345,31 +312,11 @@ export function PortalGroupedMenu() {
|
||||
<div id={toolsId} className="tempo-header-extras">
|
||||
<div className="tempo-header-utilities">
|
||||
<div className="flex items-center gap-1">
|
||||
<TempoWeather />
|
||||
<TempoLanguageSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tempo-header-actions">
|
||||
<Link
|
||||
href="/investidor/manifestacoes"
|
||||
className="tempo-panel-summary"
|
||||
aria-label={panelLabel}
|
||||
title={panelLabel}
|
||||
>
|
||||
<LayoutDashboard size={19} aria-hidden="true" />
|
||||
<span className="tempo-panel-count" aria-hidden="true">
|
||||
{requestCount}
|
||||
</span>
|
||||
</Link>
|
||||
<span
|
||||
className="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{panelLabel}
|
||||
</span>
|
||||
{props.auth?.user ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger className="tempo-login-button">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { ArrowRight, Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { HomeResponse } from '@/lib/portal-api';
|
||||
|
||||
const slides = [
|
||||
@@ -127,7 +126,6 @@ function HeroAnimatedCopy({
|
||||
type PortalHeroProps = {
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
onSearch: (event: FormEvent<HTMLFormElement>) => void;
|
||||
stats?: HomeResponse['data']['stats'];
|
||||
};
|
||||
|
||||
@@ -135,7 +133,6 @@ type PortalHeroProps = {
|
||||
export default function PortalHero({
|
||||
query,
|
||||
setQuery,
|
||||
onSearch,
|
||||
stats,
|
||||
}: PortalHeroProps) {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
@@ -229,7 +226,20 @@ export default function PortalHero({
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={onSearch}
|
||||
action="/oportunidades"
|
||||
method="get"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const value = new FormData(event.currentTarget).get(
|
||||
'q',
|
||||
);
|
||||
router.get('/oportunidades', {
|
||||
q:
|
||||
typeof value === 'string'
|
||||
? value.trim()
|
||||
: query.trim(),
|
||||
});
|
||||
}}
|
||||
role="search"
|
||||
aria-label="Pesquisar oportunidades"
|
||||
className="mt-7 w-full max-w-4xl"
|
||||
@@ -245,6 +255,8 @@ export default function PortalHero({
|
||||
id="portal-opportunity-search"
|
||||
type="search"
|
||||
name="q"
|
||||
maxLength={120}
|
||||
aria-describedby="portal-opportunity-search-help"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Ex.: agronegócio, Nampula, energia..."
|
||||
@@ -258,6 +270,13 @@ export default function PortalHero({
|
||||
Pesquisar
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="portal-opportunity-search-help"
|
||||
className="mt-2 text-left text-sm text-white/90"
|
||||
>
|
||||
Combine palavras-chave, sector e localização. Ex.:
|
||||
plantio de algodão ou agronegócio Nampula.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { portalRequest } from '@/lib/portal-api';
|
||||
|
||||
type Match = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
published_at?: string | null;
|
||||
};
|
||||
function publicationDate(value?: string | null): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? null
|
||||
: date.toLocaleDateString('pt-MZ');
|
||||
}
|
||||
type Classification = {
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
label: string;
|
||||
purpose?: string;
|
||||
purpose_label?: string;
|
||||
source_reference?: string | null;
|
||||
};
|
||||
const classificationKey = (row: Classification) =>
|
||||
`${row.target_type}:${row.target_id}:${row.purpose ?? ''}`;
|
||||
type Props = {
|
||||
currentId: string;
|
||||
catalogueUrl: string;
|
||||
criteria: string[];
|
||||
classifications?: Classification[];
|
||||
};
|
||||
|
||||
export default function SimilarBusinesses({
|
||||
currentId,
|
||||
catalogueUrl,
|
||||
criteria,
|
||||
classifications = [],
|
||||
}: Props) {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [selection, setSelection] = useState({
|
||||
recordId: currentId,
|
||||
value: '',
|
||||
});
|
||||
const selected =
|
||||
selection.recordId === currentId
|
||||
? classifications.find(
|
||||
(row) => classificationKey(row) === selection.value,
|
||||
)
|
||||
: undefined;
|
||||
const query = new URL(catalogueUrl, 'https://portal.invalid');
|
||||
if (selected) {
|
||||
query.searchParams.set('association_type', selected.target_type);
|
||||
query.searchParams.set('association_target', selected.target_id);
|
||||
if (selected.purpose)
|
||||
query.searchParams.set('association_purpose', selected.purpose);
|
||||
}
|
||||
const effectiveCatalogueUrl = query.pathname + query.search;
|
||||
const key = JSON.stringify([currentId, effectiveCatalogueUrl, attempt]);
|
||||
const [result, setResult] = useState<{
|
||||
key: string;
|
||||
rows: Match[];
|
||||
error: boolean;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const params = new URL(effectiveCatalogueUrl, 'https://portal.invalid')
|
||||
.searchParams;
|
||||
params.set('per_page', '7');
|
||||
params.set('page', '1');
|
||||
void portalRequest<{ data: Match[] }>(
|
||||
`/api/v1/business-opportunities?${params}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((response) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (!Array.isArray(response.data))
|
||||
throw new Error('Invalid catalogue response');
|
||||
const seen = new Set([currentId]);
|
||||
if (
|
||||
response.data.some(
|
||||
(row) =>
|
||||
!row ||
|
||||
typeof row.id !== 'string' ||
|
||||
!row.id.trim() ||
|
||||
typeof row.slug !== 'string' ||
|
||||
!row.slug.trim() ||
|
||||
typeof row.title !== 'string' ||
|
||||
!row.title.trim(),
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid catalogue record');
|
||||
}
|
||||
const rows = response.data
|
||||
.filter((row) => {
|
||||
if (
|
||||
!row ||
|
||||
typeof row.id !== 'string' ||
|
||||
typeof row.slug !== 'string' ||
|
||||
typeof row.title !== 'string' ||
|
||||
seen.has(row.id)
|
||||
)
|
||||
return false;
|
||||
seen.add(row.id);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 6);
|
||||
setResult({ key, rows, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted)
|
||||
setResult({ key, rows: [], error: true });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [currentId, effectiveCatalogueUrl, key]);
|
||||
const current = result?.key === key ? result : null;
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded-xl border bg-white p-5"
|
||||
aria-label="Negócios com critérios semelhantes"
|
||||
aria-busy={!current}
|
||||
>
|
||||
<h2 className="text-xl font-bold">Explorar negócios semelhantes</h2>
|
||||
<p>
|
||||
Critérios:{' '}
|
||||
{[...criteria, ...(selected ? [selected.label] : [])].join(
|
||||
' · ',
|
||||
)}
|
||||
. A consulta compara os dados publicados; não avalia
|
||||
rentabilidade nem proximidade geográfica.
|
||||
</p>
|
||||
{!current ? (
|
||||
<p role="status">A consultar o catálogo…</p>
|
||||
) : current.error ? (
|
||||
<div role="alert">
|
||||
<p>Não foi possível consultar os negócios semelhantes.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
) : current.rows.length ? (
|
||||
<ul className="space-y-2">
|
||||
{current.rows.map((row) => (
|
||||
<li key={row.id}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/negocios/${encodeURIComponent(row.slug)}${new URL(effectiveCatalogueUrl, 'https://portal.invalid').search}`}
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
<p className="text-sm text-slate-600">
|
||||
Fonte: catálogo público do InvesteMoz.{' '}
|
||||
{publicationDate(row.published_at) ? (
|
||||
<>
|
||||
Publicado em{' '}
|
||||
<time
|
||||
dateTime={
|
||||
row.published_at ?? undefined
|
||||
}
|
||||
>
|
||||
{publicationDate(row.published_at)}
|
||||
</time>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
'Data de publicação não indicada.'
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>
|
||||
Não foram encontrados outros negócios públicos com estes
|
||||
critérios. Isto não demonstra ausência de negócios no
|
||||
mercado.
|
||||
</p>
|
||||
)}
|
||||
{classifications.length > 0 && (
|
||||
<label className="block space-y-1">
|
||||
Comparar também por uma classificação divulgada
|
||||
<select
|
||||
className="block w-full rounded border p-2"
|
||||
value={selected ? classificationKey(selected) : ''}
|
||||
onChange={(event) =>
|
||||
setSelection({
|
||||
recordId: currentId,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Sem classificação adicional</option>
|
||||
{classifications
|
||||
.filter(
|
||||
(row, index, all) =>
|
||||
all.findIndex(
|
||||
(other) =>
|
||||
classificationKey(other) ===
|
||||
classificationKey(row),
|
||||
) === index,
|
||||
)
|
||||
.map((row) => (
|
||||
<option
|
||||
key={classificationKey(row)}
|
||||
value={classificationKey(row)}
|
||||
>
|
||||
{row.label}
|
||||
{row.purpose_label
|
||||
? ` — ${row.purpose_label}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{selected && (
|
||||
<p className="text-sm">
|
||||
Fonte da classificação na ficha de origem:{' '}
|
||||
{selected.source_reference || 'não indicada'}. A relação
|
||||
documental não comprova implantação, propriedade ou
|
||||
capacidade.
|
||||
</p>
|
||||
)}
|
||||
<Link href={effectiveCatalogueUrl} className="underline">
|
||||
Consultar o catálogo com estes critérios
|
||||
</Link>
|
||||
<p className="text-sm">
|
||||
São apresentados até seis resultados. Pode ajustar os filtros no
|
||||
catálogo. A data de publicação não indica a data dos dados de
|
||||
origem.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -209,6 +209,8 @@ export default function StructureGeographyPanel({
|
||||
</p>
|
||||
)}
|
||||
<InteractiveInvestmentMap
|
||||
mapKind="documented_locations"
|
||||
layerControls
|
||||
points={points}
|
||||
areas={[
|
||||
...items.flatMap((item) =>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
|
||||
|
||||
const navigation = [
|
||||
['/', 'Início'],
|
||||
['/apoio', 'Contacto'],
|
||||
['/apoio', 'Apoio ao investidor'],
|
||||
];
|
||||
|
||||
export function TempoFooter() {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import InteractiveInvestmentMap, {
|
||||
type InvestmentMapArea,
|
||||
} from './interactive-investment-map';
|
||||
import InteractiveInvestmentMap from './interactive-investment-map';
|
||||
import {
|
||||
useTerritoryGeometry,
|
||||
type TerritoryGeometryContext,
|
||||
} from './use-territory-geometry';
|
||||
|
||||
const noPoints: [] = [];
|
||||
type Context = {
|
||||
selected_geometry_available: boolean;
|
||||
context_geometry_count: number;
|
||||
areas: InvestmentMapArea[];
|
||||
truncated: boolean;
|
||||
};
|
||||
export default function TerritoryGeometryPreview({
|
||||
territoryId,
|
||||
management = false,
|
||||
@@ -21,44 +16,37 @@ export default function TerritoryGeometryPreview({
|
||||
endpoint?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const [context, setContext] = useState<Context | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
setContext(null);
|
||||
setError('');
|
||||
if (!territoryId) return;
|
||||
const controller = new AbortController();
|
||||
const endpoint =
|
||||
suppliedEndpoint ??
|
||||
const { context, error } = useTerritoryGeometry(
|
||||
territoryId,
|
||||
suppliedEndpoint ??
|
||||
(management
|
||||
? '/management/territory-geometries'
|
||||
: '/api/v1/investment/territory-geometries');
|
||||
void fetch(
|
||||
`${endpoint}?${new URLSearchParams({ territory_id: territoryId })}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
)
|
||||
.then(async (response) => {
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
'Não foi possível consultar os limites administrativos.',
|
||||
);
|
||||
return response.json() as Promise<Context>;
|
||||
})
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) setContext(data);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!controller.signal.aborted)
|
||||
setError(
|
||||
e instanceof Error ? e.message : 'Erro de ligação.',
|
||||
);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [territoryId, management, suppliedEndpoint]);
|
||||
: '/api/v1/investment/territory-geometries'),
|
||||
);
|
||||
if (!territoryId) return null;
|
||||
return (
|
||||
<TerritoryGeometryDetails
|
||||
context={context}
|
||||
error={error}
|
||||
label={label}
|
||||
renderMap
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerritoryGeometryDetails({
|
||||
context,
|
||||
error,
|
||||
label = 'Cobertura cartográfica da selecção',
|
||||
renderMap = false,
|
||||
showSources = true,
|
||||
}: {
|
||||
context: TerritoryGeometryContext | null;
|
||||
error: string;
|
||||
label?: string;
|
||||
renderMap?: boolean;
|
||||
showSources?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2 md:col-span-full" aria-label={label}>
|
||||
<p>Território selecionado no cadastro.</p>
|
||||
@@ -91,26 +79,28 @@ export default function TerritoryGeometryPreview({
|
||||
incompleta.
|
||||
</p>
|
||||
)}
|
||||
{context.areas.length > 0 && (
|
||||
{renderMap && context.areas.length > 0 && (
|
||||
<InteractiveInvestmentMap
|
||||
points={noPoints}
|
||||
areas={context.areas}
|
||||
contextOnly
|
||||
/>
|
||||
)}
|
||||
<ul>
|
||||
{context.areas.map((area, index) => (
|
||||
<li key={index}>
|
||||
{area.title} ·{' '}
|
||||
{area.selected_territory
|
||||
? 'Território seleccionado'
|
||||
: 'Contexto de nível superior'}{' '}
|
||||
· Fonte:{' '}
|
||||
{area.source_reference ?? 'Não indicada'} ·
|
||||
Edição: {area.source_date ?? 'Não indicada'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{showSources && (
|
||||
<ul>
|
||||
{context.areas.map((area, index) => (
|
||||
<li key={index}>
|
||||
{area.title} ·{' '}
|
||||
{area.selected_territory
|
||||
? 'Território seleccionado'
|
||||
: 'Contexto de nível superior'}{' '}
|
||||
· Fonte:{' '}
|
||||
{area.source_reference ?? 'Não indicada'} ·
|
||||
Edição: {area.source_date ?? 'Não indicada'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { InvestmentMapArea } from './interactive-investment-map';
|
||||
|
||||
export type TerritoryGeometryContext = {
|
||||
selected_geometry_available: boolean;
|
||||
context_geometry_count: number;
|
||||
areas: InvestmentMapArea[];
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export function useTerritoryGeometry(
|
||||
territoryId?: string,
|
||||
endpoint = '/api/v1/investment/territory-geometries',
|
||||
) {
|
||||
const key = JSON.stringify([territoryId, endpoint]);
|
||||
const [result, setResult] = useState<{
|
||||
key: string;
|
||||
context: TerritoryGeometryContext | null;
|
||||
error: string;
|
||||
}>({ key, context: null, error: '' });
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setResult({ key, context: null, error: '' });
|
||||
if (!territoryId) return () => controller.abort();
|
||||
void fetch(
|
||||
`${endpoint}?${new URLSearchParams({ territory_id: territoryId })}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
)
|
||||
.then(async (response) => {
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
'Não foi possível consultar os limites administrativos.',
|
||||
);
|
||||
return response.json() as Promise<TerritoryGeometryContext>;
|
||||
})
|
||||
.then((context) => {
|
||||
if (!controller.signal.aborted)
|
||||
setResult({ key, context, error: '' });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted)
|
||||
setResult({
|
||||
key,
|
||||
context: null,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erro de ligação.',
|
||||
});
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [key, territoryId, endpoint]);
|
||||
return result.key === key ? result : { key, context: null, error: '' };
|
||||
}
|
||||
@@ -29,8 +29,12 @@ export function useInterestDraft<T>(name: string, initial: T) {
|
||||
setForm,
|
||||
key: draft.key,
|
||||
clear: () => {
|
||||
sessionStorage.removeItem(storage);
|
||||
sessionStorage.removeItem('investemoz-participation-identity');
|
||||
try {
|
||||
sessionStorage.removeItem(storage);
|
||||
sessionStorage.removeItem('investemoz-participation-identity');
|
||||
} catch {
|
||||
/* Storage failure must not turn a confirmed submission into an error. */
|
||||
}
|
||||
},
|
||||
authenticate: () => {
|
||||
const identity = draft.form as {
|
||||
@@ -38,14 +42,18 @@ export function useInterestDraft<T>(name: string, initial: T) {
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
};
|
||||
sessionStorage.setItem(
|
||||
'investemoz-participation-identity',
|
||||
JSON.stringify({
|
||||
name: identity.fullName ?? identity.name ?? '',
|
||||
email: identity.email ?? '',
|
||||
}),
|
||||
);
|
||||
sessionStorage.setItem(storage, JSON.stringify(draft));
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
'investemoz-participation-identity',
|
||||
JSON.stringify({
|
||||
name: identity.fullName ?? identity.name ?? '',
|
||||
email: identity.email ?? '',
|
||||
}),
|
||||
);
|
||||
sessionStorage.setItem(storage, JSON.stringify(draft));
|
||||
} catch {
|
||||
/* Authentication remains available when browser persistence is blocked. */
|
||||
}
|
||||
window.location.assign(
|
||||
'/participar/entrar?return=' +
|
||||
encodeURIComponent(location.pathname + location.search),
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { searchTerritoryHref } from './search-territory.ts';
|
||||
/** Only a query string is carried; the return destination is always local. */
|
||||
export function catalogueDetailHref(path: string, query: string): string {
|
||||
export function catalogueDetailHref(
|
||||
path: string,
|
||||
query: string,
|
||||
pageUrl?: string,
|
||||
): string {
|
||||
if (pageUrl) {
|
||||
const page = new URL(
|
||||
pageUrl,
|
||||
'https://portal.invalid',
|
||||
).searchParams.get('page');
|
||||
if (
|
||||
page &&
|
||||
/^[1-9]\d*$/.test(page) &&
|
||||
Number.isSafeInteger(Number(page))
|
||||
) {
|
||||
const params = new URLSearchParams(query);
|
||||
params.set('page', page);
|
||||
query = params.toString();
|
||||
}
|
||||
}
|
||||
return query
|
||||
? `${path}?catalogue_query=${encodeURIComponent(query)}`
|
||||
: path;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
type ConditionalField = { when?: { field: string; equals: string } };
|
||||
|
||||
export function deskFieldApplies(
|
||||
key: string,
|
||||
fields: Record<string, ConditionalField>,
|
||||
payload: Record<string, unknown>,
|
||||
ancestors: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
const field = fields[key];
|
||||
if (!field || ancestors.has(key)) return false;
|
||||
if (!field.when) return true;
|
||||
const value = payload[field.when.field];
|
||||
if (!['string', 'number', 'boolean'].includes(typeof value)) return false;
|
||||
const answer =
|
||||
typeof value === 'boolean' ? (value ? '1' : '') : String(value);
|
||||
if (answer !== field.when.equals) return false;
|
||||
return deskFieldApplies(
|
||||
field.when.field,
|
||||
fields,
|
||||
payload,
|
||||
new Set([...ancestors, key]),
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,19 @@ import type {
|
||||
PreparationDocument,
|
||||
} from '@/components/private-investment-preparation';
|
||||
function csrfToken(): string {
|
||||
return decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.slice('XSRF-TOKEN='.length) ?? '',
|
||||
);
|
||||
try {
|
||||
return decodeURIComponent(
|
||||
document.cookie
|
||||
.split(';')
|
||||
.map((value) => value.trim())
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.slice('XSRF-TOKEN='.length) ?? '',
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'A sessão não pôde ser validada. Actualize a página e tente novamente.',
|
||||
);
|
||||
}
|
||||
}
|
||||
export type ContextDocument = {
|
||||
resource_id: number;
|
||||
@@ -185,7 +192,7 @@ export async function contextApi<T>(
|
||||
...(serializedBody === undefined ? {} : { body: serializedBody }),
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok)
|
||||
if (!response.ok || data === null)
|
||||
throw new Error(
|
||||
data?.message ||
|
||||
`Não foi possível recuperar ou guardar o contexto (HTTP ${response.status}).`,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Browser persistence is optional; the current selection remains usable without it. */
|
||||
const selectionKey = 'investment-opportunity-selection';
|
||||
export function readOpportunitySelection(): number[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
sessionStorage.getItem(selectionKey) ?? '[]',
|
||||
);
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [
|
||||
...new Set(
|
||||
value.filter(
|
||||
(id): id is number =>
|
||||
typeof id === 'number' &&
|
||||
Number.isSafeInteger(id) &&
|
||||
id > 0,
|
||||
),
|
||||
),
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export function persistOpportunitySelection(ids: number[]): boolean {
|
||||
try {
|
||||
if (ids.length)
|
||||
sessionStorage.setItem(selectionKey, JSON.stringify(ids));
|
||||
else sessionStorage.removeItem(selectionKey);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,9 @@ export class PortalApiError extends Error {
|
||||
? 'O registo solicitado já não está disponível.'
|
||||
: status === 422
|
||||
? 'Verifique os campos assinalados.'
|
||||
: 'Não foi possível comunicar com o portal. Tente novamente.',
|
||||
: status === 419
|
||||
? 'A sessão não pôde ser validada. Actualize a página e tente novamente.'
|
||||
: 'Não foi possível comunicar com o portal. Tente novamente.',
|
||||
);
|
||||
this.status = status;
|
||||
this.errors = errors;
|
||||
@@ -122,10 +124,15 @@ export async function portalRequest<T>(
|
||||
typeof document === 'undefined'
|
||||
? undefined
|
||||
: document.cookie
|
||||
.split('; ')
|
||||
.split(';')
|
||||
.map((value) => value.trim())
|
||||
.find((value) => value.startsWith('XSRF-TOKEN='))
|
||||
?.slice(11);
|
||||
if (token) headers.set('X-XSRF-TOKEN', decodeURIComponent(token));
|
||||
try {
|
||||
if (token) headers.set('X-XSRF-TOKEN', decodeURIComponent(token));
|
||||
} catch {
|
||||
throw new PortalApiError(419);
|
||||
}
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
@@ -136,7 +143,7 @@ export async function portalRequest<T>(
|
||||
if (!response.ok || body === null) {
|
||||
throw new PortalApiError(
|
||||
response.status,
|
||||
response.status === 422 ? body?.errors : undefined,
|
||||
response.status === 422 ? (body?.errors ?? {}) : undefined,
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
|
||||
@@ -14,6 +14,7 @@ export type PortalNavAccess = {
|
||||
};
|
||||
scopedAdministration?: { label: string; href: string }[];
|
||||
procurement?: boolean;
|
||||
funding?: boolean;
|
||||
authenticated: boolean;
|
||||
manager: boolean;
|
||||
businesses?: boolean;
|
||||
@@ -486,11 +487,6 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
'Porquê investir em Moçambique?',
|
||||
'/porque-investir',
|
||||
),
|
||||
link(
|
||||
'idea-entry',
|
||||
'Partir de uma ideia',
|
||||
'/investimentos/iniciar',
|
||||
),
|
||||
link(
|
||||
'opportunities',
|
||||
'Consultar oportunidades',
|
||||
@@ -503,6 +499,24 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
),
|
||||
link('sectors', 'Sectores de investimento', '/sectores'),
|
||||
link('locations', 'Localizações', '/oportunidades?view=map'),
|
||||
...(access.funding
|
||||
? [
|
||||
link(
|
||||
'funding',
|
||||
'Fundos e programas de apoio',
|
||||
'/fundos',
|
||||
),
|
||||
...(access.authenticated
|
||||
? [
|
||||
link(
|
||||
'funding-saved',
|
||||
'Programas que guardei',
|
||||
'/fundos/guardados',
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
...(access.procurement
|
||||
? [
|
||||
link(
|
||||
|
||||
@@ -12,29 +12,51 @@ export async function profileJson(
|
||||
data: object = {},
|
||||
method: 'GET' | 'POST' = 'POST',
|
||||
) {
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='));
|
||||
let token = '';
|
||||
if (method !== 'GET') {
|
||||
const cookie =
|
||||
typeof document === 'undefined'
|
||||
? undefined
|
||||
: document.cookie
|
||||
.split(';')
|
||||
.map((value) => value.trim())
|
||||
.find((value) => value.startsWith('XSRF-TOKEN='));
|
||||
try {
|
||||
token = cookie ? decodeURIComponent(cookie.slice(11)) : '';
|
||||
} catch {
|
||||
throw new ProfileValidationError(
|
||||
'A sessão não pôde ser validada. Actualize a página e tente novamente.',
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': cookie ? decodeURIComponent(cookie.slice(11)) : '',
|
||||
...(method === 'GET' ? {} : { 'X-XSRF-TOKEN': token }),
|
||||
},
|
||||
...(method === 'GET' ? {} : { body: JSON.stringify(data) }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
const result = await response.json().catch(() => null);
|
||||
const reauthenticate =
|
||||
response.headers.get('X-Portal-Reauthentication') === 'required';
|
||||
if (!response.ok || (result === null && response.status !== 204))
|
||||
throw new ProfileValidationError(
|
||||
result.message ?? 'A ação não foi concluída.',
|
||||
typeof result?.message === 'string'
|
||||
? result.message
|
||||
: response.status === 419
|
||||
? 'A sessão expirou. Actualize a página e tente novamente.'
|
||||
: 'A ação não foi concluída. Tente novamente.',
|
||||
Object.fromEntries(
|
||||
Object.entries(result.errors ?? {}).map(([key, value]) => [
|
||||
Object.entries(result?.errors ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.join(' ') : String(value),
|
||||
]),
|
||||
),
|
||||
response.headers.get('X-Portal-Reauthentication') === 'required',
|
||||
reauthenticate,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
publicRecordOfflineHtml,
|
||||
downloadOfflineHtml,
|
||||
offlineValue as display,
|
||||
} from './public-record-offline.ts';
|
||||
export type PublicBusinessOffline = {
|
||||
slug: string;
|
||||
title: string;
|
||||
modality: string;
|
||||
objective?: string | null;
|
||||
stage?: string | null;
|
||||
activity_code?: string | null;
|
||||
amount_min?: string | null;
|
||||
amount_max?: string | null;
|
||||
available_percentage?: string | null;
|
||||
currency?: string | null;
|
||||
province?: string | null;
|
||||
district?: string | null;
|
||||
locality?: string | null;
|
||||
published_at?: string | null;
|
||||
sector?: { name: string } | null;
|
||||
};
|
||||
|
||||
/** Fixed public projection: private or future properties are never serialised. */
|
||||
export function publicBusinessOfflineHtml(
|
||||
record: PublicBusinessOffline,
|
||||
modalityLabel: string,
|
||||
origin: string,
|
||||
savedAt: Date,
|
||||
): string {
|
||||
const rows: [string, string][] = [
|
||||
['Modalidade', display(modalityLabel)],
|
||||
['Sector', display(record.sector?.name)],
|
||||
['Fase', display(record.stage)],
|
||||
['Actividade', display(record.activity_code)],
|
||||
[
|
||||
'Localização divulgada',
|
||||
[record.locality, record.district, record.province]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Não indicada',
|
||||
],
|
||||
[
|
||||
'Montante mínimo indicado',
|
||||
display(record.amount_min) +
|
||||
(record.currency ? ` ${record.currency}` : ''),
|
||||
],
|
||||
[
|
||||
'Montante máximo indicado',
|
||||
display(record.amount_max) +
|
||||
(record.currency ? ` ${record.currency}` : ''),
|
||||
],
|
||||
[
|
||||
'Participação indicada',
|
||||
record.available_percentage
|
||||
? `${record.available_percentage}%`
|
||||
: 'Não indicada',
|
||||
],
|
||||
['Publicação no portal', display(record.published_at)],
|
||||
];
|
||||
return publicRecordOfflineHtml(
|
||||
{
|
||||
title: record.title,
|
||||
kind: 'Ficha p\u00fablica de neg\u00f3cio',
|
||||
sourcePath: `/negocios/${encodeURIComponent(record.slug)}`,
|
||||
rows,
|
||||
sections: [{ title: 'Objectivo', text: display(record.objective) }],
|
||||
notice: 'A publica\u00e7\u00e3o n\u00e3o constitui garantia de titularidade, avalia\u00e7\u00e3o, transmissibilidade ou retorno.',
|
||||
},
|
||||
origin,
|
||||
savedAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadPublicBusinessOffline(
|
||||
record: PublicBusinessOffline,
|
||||
modalityLabel: string,
|
||||
): void {
|
||||
const html = publicBusinessOfflineHtml(
|
||||
record,
|
||||
modalityLabel,
|
||||
window.location.origin,
|
||||
new Date(),
|
||||
);
|
||||
downloadOfflineHtml(html, 'negocio', record.slug || 'ficha');
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
publicRecordOfflineHtml,
|
||||
downloadOfflineHtml,
|
||||
offlineValue as display,
|
||||
} from './public-record-offline.ts';
|
||||
|
||||
type PublicOpportunity = {
|
||||
title: string;
|
||||
slug: string;
|
||||
reference: string;
|
||||
summary?: string | null;
|
||||
description?: string | null;
|
||||
sector?: { name: string } | null;
|
||||
province?: string | null;
|
||||
district?: string | null;
|
||||
maturity_label?: string | null;
|
||||
estimated_investment_amount?: string | null;
|
||||
estimated_investment_currency?: string | null;
|
||||
financing_modality?: string | null;
|
||||
expected_impact?: string | null;
|
||||
last_validated_at?: string | null;
|
||||
};
|
||||
|
||||
export function publicOpportunityOfflineHtml(
|
||||
record: PublicOpportunity,
|
||||
origin: string,
|
||||
savedAt: Date,
|
||||
): string {
|
||||
return publicRecordOfflineHtml(
|
||||
{
|
||||
title: record.title,
|
||||
kind: 'Ficha pública de oportunidade',
|
||||
sourcePath: `/oportunidades/${encodeURIComponent(record.slug)}`,
|
||||
rows: [
|
||||
['Referência', display(record.reference)],
|
||||
['Sector', display(record.sector?.name)],
|
||||
['Preparação', display(record.maturity_label)],
|
||||
[
|
||||
'Contexto territorial indicado na ficha',
|
||||
[record.district, record.province]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Não indicado',
|
||||
],
|
||||
[
|
||||
'Investimento estimado',
|
||||
display(record.estimated_investment_amount) +
|
||||
(record.estimated_investment_currency
|
||||
? ` ${record.estimated_investment_currency}`
|
||||
: ''),
|
||||
],
|
||||
[
|
||||
'Modalidade de financiamento',
|
||||
display(record.financing_modality),
|
||||
],
|
||||
[
|
||||
'Última validação indicada',
|
||||
display(record.last_validated_at),
|
||||
],
|
||||
],
|
||||
sections: [
|
||||
{ title: 'Resumo', text: display(record.summary) },
|
||||
{ title: 'Descrição', text: display(record.description) },
|
||||
{
|
||||
title: 'Impacto esperado',
|
||||
text: display(record.expected_impact),
|
||||
},
|
||||
],
|
||||
notice: 'O contexto territorial não confirma a implantação. A execução depende das condições e autorizações aplicáveis ao projecto. Esta cópia não inclui mapas, anexos ou dados do dossier privado.',
|
||||
},
|
||||
origin,
|
||||
savedAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadPublicOpportunityOffline(
|
||||
record: PublicOpportunity,
|
||||
): void {
|
||||
downloadOfflineHtml(
|
||||
publicOpportunityOfflineHtml(
|
||||
record,
|
||||
window.location.origin,
|
||||
new Date(),
|
||||
),
|
||||
'oportunidade',
|
||||
record.slug || 'ficha',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
type OfflineContent = {
|
||||
title: string;
|
||||
kind: string;
|
||||
sourcePath: string;
|
||||
rows: [string, string][];
|
||||
sections: { title: string; text: string }[];
|
||||
notice: string;
|
||||
};
|
||||
|
||||
export const offlineValue = (value?: string | null) =>
|
||||
value?.trim() || 'Não indicado';
|
||||
const escapeHtml = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character]!,
|
||||
);
|
||||
|
||||
/** All content is plain text; assets and executable content are excluded. */
|
||||
export function publicRecordOfflineHtml(
|
||||
content: OfflineContent,
|
||||
origin: string,
|
||||
savedAt: Date,
|
||||
): string {
|
||||
const base = new URL(origin);
|
||||
if (!['http:', 'https:'].includes(base.protocol))
|
||||
throw new Error('Origem do portal inválida.');
|
||||
const source = new URL(content.sourcePath, base.origin);
|
||||
if (
|
||||
source.origin !== base.origin ||
|
||||
!content.sourcePath.startsWith('/') ||
|
||||
content.sourcePath.startsWith('//')
|
||||
)
|
||||
throw new Error('Referência da ficha inválida.');
|
||||
return `<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>${escapeHtml(content.title)} — consulta offline</title><style>body{font:1rem/1.6 system-ui,sans-serif;margin:2rem auto;padding:0 1rem;max-width:55rem;color:#172b23;background:white}h1{line-height:1.2;overflow-wrap:anywhere}dt{font-weight:700}dd{margin:0 0 1rem;white-space:pre-wrap;overflow-wrap:anywhere}p{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#14623d}footer{border-top:1px solid #ccc;margin-top:2rem;padding-top:1rem}</style></head><body><main><h1>${escapeHtml(content.title)}</h1><p>${escapeHtml(content.kind)} — InvesteMoz</p><p>Cópia guardada em ${escapeHtml(savedAt.toISOString())}. Consulte a ficha online para verificar actualizações.</p><dl>${content.rows.map(([label, value]) => `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`).join('')}</dl>${content.sections.map((section) => `<h2>${escapeHtml(section.title)}</h2><p>${escapeHtml(section.text)}</p>`).join('')}<p>${escapeHtml(content.notice)}</p><p><a href="${escapeHtml(source.href)}" rel="noreferrer">Consultar a ficha de origem online</a></p></main><footer>Fonte: ficha pública no portal InvesteMoz. Esta cópia conserva a informação apresentada no momento em que foi guardada.</footer></body></html>`;
|
||||
}
|
||||
|
||||
export function downloadOfflineHtml(
|
||||
html: string,
|
||||
prefix: string,
|
||||
slug: string,
|
||||
): void {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([html], { type: 'text/html;charset=utf-8' }),
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download =
|
||||
`${prefix}-${slug}`.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 120) +
|
||||
'.html';
|
||||
try {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
}
|
||||
@@ -749,20 +749,20 @@ function InterestForm({
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="País de origem declarado desta solicitação"
|
||||
label="País de origem do pedido (opcional)"
|
||||
error={errors.declared_origin_country_code}
|
||||
>
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
label="País de origem declarado desta solicitação"
|
||||
label="País de origem do pedido (opcional)"
|
||||
value={form.declared_origin_country_code ?? ''}
|
||||
onChange={(value) =>
|
||||
update('declared_origin_country_code', value)
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
Origem opcional; não é deduzida do país acima nem do
|
||||
IP.
|
||||
País de onde apresenta este pedido. Pode ser
|
||||
diferente do país do requerente.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
@@ -971,11 +971,6 @@ export default function PortalInvestidorApp({
|
||||
router.get('/oportunidades');
|
||||
}
|
||||
|
||||
function handleHeroSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
router.get('/oportunidades', { q: heroQuery.trim() });
|
||||
}
|
||||
|
||||
function handleView(opportunity: Opportunity) {
|
||||
router.get('/oportunidades/' + encodeURIComponent(opportunity.slug));
|
||||
}
|
||||
@@ -999,7 +994,6 @@ export default function PortalInvestidorApp({
|
||||
<PortalHero
|
||||
query={heroQuery}
|
||||
setQuery={setHeroQuery}
|
||||
onSearch={handleHeroSearch}
|
||||
stats={home.data?.data.stats}
|
||||
/>
|
||||
<InvestmentContextCards />
|
||||
|
||||
@@ -156,6 +156,16 @@ export default function Install({
|
||||
))}
|
||||
</form>
|
||||
)}
|
||||
{baseDeskInstalled && (
|
||||
<p>
|
||||
<Link
|
||||
href="/management/balcao/servicos"
|
||||
className="underline"
|
||||
>
|
||||
Editar formulários e publicar novas versões
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{baseDeskInstalled && (
|
||||
<form
|
||||
className={card + ' space-y-3'}
|
||||
@@ -165,11 +175,13 @@ export default function Install({
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl font-bold">
|
||||
Publicar serviço de preparação interna
|
||||
Publicar definição inicial de um serviço
|
||||
</h2>
|
||||
<p>
|
||||
Publicação do catálogo é uma acção separada; não submete
|
||||
pedidos nem homologa um formulário oficial.
|
||||
Esta acção usa a definição inicial do código. Para
|
||||
manter alterações feitas pela interface, utilize o
|
||||
editor de formulários. A publicação aplica-se a pedidos
|
||||
futuros.
|
||||
</p>
|
||||
<label>
|
||||
Serviço
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DossierPrefill } from '@/components/dossier-prefill';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { deskFieldApplies } from '@/lib/desk-field-visibility';
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Frame, card, input, button } from '@/pages/investment/shared';
|
||||
import CountrySelect, { type Country } from '@/components/country-select';
|
||||
@@ -309,11 +310,11 @@ export default function RequestPage({
|
||||
}
|
||||
/>
|
||||
<label className="block" htmlFor="declared-origin-country">
|
||||
País de origem declarado desta solicitação
|
||||
País de origem do pedido (opcional)
|
||||
</label>
|
||||
<CountrySelect
|
||||
id="declared-origin-country"
|
||||
label="País de origem declarado desta solicitação"
|
||||
label="País de origem do pedido (opcional)"
|
||||
error={form.errors['payload.declared_origin_country_code']}
|
||||
countries={countries}
|
||||
value={form.data.payload.declared_origin_country_code ?? ''}
|
||||
@@ -333,14 +334,16 @@ export default function RequestPage({
|
||||
}
|
||||
/>
|
||||
<p>
|
||||
A origem não é deduzida do IP nem do país de residência.
|
||||
Pedidos antigos sem declaração mantêm “não informado”.
|
||||
Indique o país de onde apresenta este pedido, se desejar.
|
||||
Este campo é distinto do país do requerente.
|
||||
</p>
|
||||
{Object.entries(definition.fields)
|
||||
.filter(
|
||||
([, f]) =>
|
||||
!f.when ||
|
||||
form.data.payload[f.when.field] === f.when.equals,
|
||||
.filter(([key]) =>
|
||||
deskFieldApplies(
|
||||
key,
|
||||
definition.fields,
|
||||
form.data.payload,
|
||||
),
|
||||
)
|
||||
.map(([key, f]) =>
|
||||
f.type === 'territory' ? (
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import DeskServiceHistory from '@/components/desk-service-history';
|
||||
import { Frame, card, button, input } from '@/pages/investment/shared';
|
||||
|
||||
export type Field = {
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
min?: number;
|
||||
options?: Record<string, string>;
|
||||
when?: { field: string; equals: string };
|
||||
};
|
||||
export type Definition = {
|
||||
title: string;
|
||||
theme: string;
|
||||
profile: string;
|
||||
stage: string;
|
||||
entity: string;
|
||||
result: string;
|
||||
fields: Record<string, Field>;
|
||||
};
|
||||
type Service = {
|
||||
code: string;
|
||||
version: number;
|
||||
definition: Definition;
|
||||
fixedTypes?: Record<string, string>;
|
||||
editable?: boolean;
|
||||
};
|
||||
const types: Record<string, string> = {
|
||||
text: 'Texto curto',
|
||||
textarea: 'Texto longo',
|
||||
number: 'Número',
|
||||
date: 'Data',
|
||||
select: 'Lista de opções',
|
||||
territory: 'Localização do cadastro',
|
||||
email: 'Email',
|
||||
tel: 'Telefone',
|
||||
url: 'Ligação web',
|
||||
};
|
||||
const labels = {
|
||||
title: 'Nome do serviço',
|
||||
theme: 'Tema',
|
||||
profile: 'Destinatários',
|
||||
entity: 'Entidade indicada',
|
||||
result: 'Resultado esperado',
|
||||
};
|
||||
function describeField(field: Field | undefined): string {
|
||||
if (!field) return 'Ausente';
|
||||
return [
|
||||
field.label,
|
||||
types[field.type] ?? field.type,
|
||||
field.required ? 'Obrigatório' : 'Opcional',
|
||||
...(field.min !== undefined ? [`Mínimo: ${field.min}`] : []),
|
||||
...(field.options
|
||||
? [
|
||||
`Opções: ${Object.entries(field.options)
|
||||
.map(([code, label]) => `${label} (${code})`)
|
||||
.join('; ')}`,
|
||||
]
|
||||
: []),
|
||||
field.when
|
||||
? `Aplica-se quando ${field.when.field} = ${field.when.equals}`
|
||||
: 'Sempre aplicável',
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
export default function ServiceEditor({ services }: { services: Service[] }) {
|
||||
const [code, setCode] = useState(services[0]?.code ?? '');
|
||||
const service = services.find((row) => row.code === code);
|
||||
return (
|
||||
<Frame title="Formulários do Balcão — versões">
|
||||
<Link href="/management/balcao-install" className="underline">
|
||||
Voltar à administração do Balcão
|
||||
</Link>
|
||||
<p>
|
||||
Prepare a definição, confira as alterações e publique uma nova
|
||||
versão. Pedidos existentes conservam os campos, respostas e
|
||||
regras da versão em que foram criados.
|
||||
</p>
|
||||
<label className="block">
|
||||
Serviço
|
||||
<select
|
||||
className={input}
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
>
|
||||
{services.map((row) => (
|
||||
<option key={row.code} value={row.code}>
|
||||
{row.definition.title} — versão {row.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{service && service.editable === false && (
|
||||
<p>
|
||||
Este serviço conserva definições históricas fora da
|
||||
configuração actual. Neste editor estão disponíveis para
|
||||
consulta.
|
||||
</p>
|
||||
)}
|
||||
{service && service.editable !== false ? (
|
||||
<Editor
|
||||
key={`${service.code}:${service.version}`}
|
||||
service={service}
|
||||
/>
|
||||
) : !service ? (
|
||||
<p>Não existem serviços disponíveis para edição.</p>
|
||||
) : null}
|
||||
{service && (
|
||||
<DeskServiceHistory
|
||||
key={`history:${service.code}:${service.version}`}
|
||||
code={service.code}
|
||||
formatField={describeField}
|
||||
/>
|
||||
)}
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
function Editor({ service }: { service: Service }) {
|
||||
const form = useForm(
|
||||
`desk-service-editor:${service.code}:${service.version}`,
|
||||
{
|
||||
base_version: service.version,
|
||||
definition: structuredClone(service.definition),
|
||||
reason: '',
|
||||
confirm: false,
|
||||
},
|
||||
);
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const fields = form.data.definition.fields;
|
||||
const setFields = (value: Record<string, Field>) =>
|
||||
form.setData((data) => ({
|
||||
...data,
|
||||
confirm: false,
|
||||
definition: { ...data.definition, fields: value },
|
||||
}));
|
||||
const update = (key: string, patch: Partial<Field>) =>
|
||||
setFields({ ...fields, [key]: { ...fields[key], ...patch } });
|
||||
const changed = [
|
||||
...new Set([
|
||||
...Object.keys(service.definition.fields),
|
||||
...Object.keys(fields),
|
||||
]),
|
||||
].filter(
|
||||
(key) =>
|
||||
JSON.stringify(service.definition.fields[key]) !==
|
||||
JSON.stringify(fields[key]),
|
||||
);
|
||||
const metaChanges = (Object.keys(labels) as (keyof typeof labels)[]).filter(
|
||||
(key) => service.definition[key] !== form.data.definition[key],
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (form.processing) return;
|
||||
form.put(
|
||||
`/management/balcao/servicos/${encodeURIComponent(service.code)}`,
|
||||
{ preserveScroll: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<fieldset
|
||||
disabled={form.processing}
|
||||
className={card + ' space-y-4'}
|
||||
>
|
||||
<legend>Identificação do formulário</legend>
|
||||
{(Object.keys(labels) as (keyof typeof labels)[]).map((key) => (
|
||||
<label key={key} className="block">
|
||||
{labels[key]}
|
||||
<input
|
||||
required
|
||||
maxLength={
|
||||
key === 'result'
|
||||
? 2000
|
||||
: key === 'entity'
|
||||
? 300
|
||||
: key === 'profile' || key === 'title'
|
||||
? 200
|
||||
: 150
|
||||
}
|
||||
className={input}
|
||||
value={form.data.definition[key]}
|
||||
onChange={(event) =>
|
||||
form.setData((data) => ({
|
||||
...data,
|
||||
confirm: false,
|
||||
definition: {
|
||||
...data.definition,
|
||||
[key]: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<p>
|
||||
Etapa do percurso existente: {service.definition.stage}.
|
||||
Este editor não altera a sequência de tramitação.
|
||||
</p>
|
||||
</fieldset>
|
||||
<fieldset disabled={form.processing} className="space-y-4">
|
||||
<legend className="text-xl font-bold">
|
||||
Campos e condições de preenchimento
|
||||
</legend>
|
||||
{Object.entries(fields).map(([key, field]) => (
|
||||
<section
|
||||
key={key}
|
||||
className={card + ' space-y-3'}
|
||||
aria-label={`Campo ${key}`}
|
||||
>
|
||||
<h3 className="font-bold">
|
||||
{field.label}{' '}
|
||||
<span className="font-normal">({key})</span>
|
||||
</h3>
|
||||
<label className="block">
|
||||
Pergunta ou rótulo
|
||||
<input
|
||||
required
|
||||
maxLength={300}
|
||||
className={input}
|
||||
value={field.label}
|
||||
onChange={(event) =>
|
||||
update(key, { label: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Tipo de resposta
|
||||
<select
|
||||
disabled={Boolean(service.fixedTypes?.[key])}
|
||||
className={input}
|
||||
value={field.type}
|
||||
onChange={(event) => {
|
||||
const next = {
|
||||
...field,
|
||||
type: event.target.value,
|
||||
};
|
||||
if (next.type !== 'select')
|
||||
delete next.options;
|
||||
else
|
||||
next.options ??= {
|
||||
opcao_1: 'Primeira opção',
|
||||
};
|
||||
if (next.type !== 'number') delete next.min;
|
||||
setFields({ ...fields, [key]: next });
|
||||
}}
|
||||
>
|
||||
{Object.entries(types).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{service.fixedTypes?.[key] && (
|
||||
<span className="text-sm">
|
||||
O tipo deste campo é usado pelo motor de
|
||||
validação existente. Para outra resposta,
|
||||
acrescente um novo campo.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(event) =>
|
||||
update(key, {
|
||||
required: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Obrigatório ao submeter, quando aplicável
|
||||
</label>
|
||||
{field.type === 'number' && (
|
||||
<label className="block">
|
||||
Valor mínimo
|
||||
<input
|
||||
className={input}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={field.min ?? 0}
|
||||
onChange={(event) =>
|
||||
update(key, {
|
||||
min: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{field.type === 'select' && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend>Opções de resposta</legend>
|
||||
{Object.entries(field.options ?? {}).map(
|
||||
([value, label]) => (
|
||||
<div key={value} className="flex gap-2">
|
||||
<label className="flex-1">
|
||||
Texto da opção ({value})
|
||||
<input
|
||||
required
|
||||
maxLength={300}
|
||||
className={input}
|
||||
value={label}
|
||||
onChange={(event) =>
|
||||
update(key, {
|
||||
options: {
|
||||
...field.options,
|
||||
[value]:
|
||||
event.target
|
||||
.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
const next = {
|
||||
...field.options,
|
||||
};
|
||||
delete next[value];
|
||||
update(key, {
|
||||
options: next,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Remover opção
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
let index = 1;
|
||||
while (
|
||||
`opcao_${index}` in
|
||||
(field.options ?? {})
|
||||
)
|
||||
index++;
|
||||
update(key, {
|
||||
options: {
|
||||
...field.options,
|
||||
[`opcao_${index}`]:
|
||||
'Nova opção',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Acrescentar opção
|
||||
</button>
|
||||
</fieldset>
|
||||
)}
|
||||
<label className="block">
|
||||
Apresentar este campo
|
||||
<select
|
||||
className={input}
|
||||
value={field.when?.field ?? ''}
|
||||
onChange={(event) => {
|
||||
const next = { ...field };
|
||||
if (event.target.value)
|
||||
next.when = {
|
||||
field: event.target.value,
|
||||
equals: '',
|
||||
};
|
||||
else delete next.when;
|
||||
setFields({ ...fields, [key]: next });
|
||||
}}
|
||||
>
|
||||
<option value="">Sempre</option>
|
||||
{Object.entries(fields)
|
||||
.filter(
|
||||
([other, source]) =>
|
||||
other !== key &&
|
||||
source.type !== 'number',
|
||||
)
|
||||
.map(([other, value]) => (
|
||||
<option key={other} value={other}>
|
||||
Quando a resposta a «{value.label}»
|
||||
for…
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{field.when && (
|
||||
<label className="block">
|
||||
Resposta que torna o campo aplicável
|
||||
{fields[field.when.field]?.type === 'select' ? (
|
||||
<select
|
||||
required
|
||||
className={input}
|
||||
value={field.when.equals}
|
||||
onChange={(event) =>
|
||||
update(key, {
|
||||
when: {
|
||||
field: field.when!.field,
|
||||
equals: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar resposta
|
||||
</option>
|
||||
{Object.entries(
|
||||
fields[field.when.field].options ??
|
||||
{},
|
||||
).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
required
|
||||
className={input}
|
||||
value={field.when.equals}
|
||||
onChange={(event) =>
|
||||
update(key, {
|
||||
when: {
|
||||
field: field.when!.field,
|
||||
equals: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
const next = { ...fields };
|
||||
delete next[key];
|
||||
setFields(next);
|
||||
}}
|
||||
>
|
||||
Retirar campo da nova versão
|
||||
</button>
|
||||
</section>
|
||||
))}
|
||||
<div className={card}>
|
||||
<label className="block">
|
||||
Identificador do novo campo
|
||||
<input
|
||||
className={input}
|
||||
pattern="[a-z][a-z0-9_]*"
|
||||
maxLength={64}
|
||||
value={newKey}
|
||||
placeholder="ex.: observacoes_adicionais"
|
||||
onChange={(event) => setNewKey(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
if (
|
||||
!/^[a-z][a-z0-9_]{0,63}$/.test(newKey) ||
|
||||
newKey in fields
|
||||
) {
|
||||
setLocalError(
|
||||
'Indique um identificador novo, com letras minúsculas, números ou sublinhado.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setFields({
|
||||
...fields,
|
||||
[newKey]: {
|
||||
label: 'Nova pergunta',
|
||||
type: 'text',
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
setNewKey('');
|
||||
setLocalError('');
|
||||
}}
|
||||
>
|
||||
Acrescentar campo
|
||||
</button>
|
||||
{localError && <p role="alert">{localError}</p>}
|
||||
</div>
|
||||
</fieldset>
|
||||
<section
|
||||
className={card + ' space-y-3'}
|
||||
aria-label="Conferir efeito da publicação"
|
||||
>
|
||||
<h2 className="text-xl font-bold">
|
||||
Conferir nova versão {service.version + 1}
|
||||
</h2>
|
||||
<p>
|
||||
Alterações de identificação:{' '}
|
||||
{metaChanges.map((key) => labels[key]).join(', ') ||
|
||||
'nenhuma'}
|
||||
. Campos acrescentados, alterados ou retirados:{' '}
|
||||
{changed.join(', ') || 'nenhum'}.
|
||||
</p>
|
||||
{(changed.length > 0 || metaChanges.length > 0) && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption>
|
||||
Comparação com a versão {service.version}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2">Elemento</th>
|
||||
<th className="p-2">Versão anterior</th>
|
||||
<th className="p-2">Nova versão</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metaChanges.map((key) => (
|
||||
<tr key={key}>
|
||||
<th className="p-2">{labels[key]}</th>
|
||||
<td className="p-2">
|
||||
{service.definition[key]}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{form.data.definition[key]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{changed.map((key) => (
|
||||
<tr key={key}>
|
||||
<th className="p-2">{key}</th>
|
||||
<td className="p-2">
|
||||
{describeField(
|
||||
service.definition.fields[key],
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{describeField(fields[key])}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
Aplica-se aos pedidos criados com esta nova versão. Pedidos
|
||||
e submissões anteriores continuam vinculados à sua definição
|
||||
original; as respectivas respostas não são reescritas.
|
||||
</p>
|
||||
<label className="block">
|
||||
Motivo da alteração
|
||||
<textarea
|
||||
required
|
||||
minLength={20}
|
||||
maxLength={2000}
|
||||
className={input}
|
||||
value={form.data.reason}
|
||||
onChange={(event) =>
|
||||
form.setData('reason', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.confirm}
|
||||
onChange={(event) =>
|
||||
form.setData('confirm', event.target.checked)
|
||||
}
|
||||
/>
|
||||
Conferi as alterações e o efeito sobre pedidos futuros.
|
||||
</label>
|
||||
{Object.entries(form.errors).map(([key, value]) => (
|
||||
<p key={key} role="alert">
|
||||
{value}
|
||||
</p>
|
||||
))}
|
||||
<button
|
||||
className={button}
|
||||
disabled={
|
||||
form.processing ||
|
||||
!form.data.confirm ||
|
||||
(service.version > 0 &&
|
||||
changed.length === 0 &&
|
||||
metaChanges.length === 0)
|
||||
}
|
||||
>
|
||||
{form.processing ? 'A publicar…' : 'Publicar nova versão'}
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
import { Link, router, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { portalRequest, PortalApiError } from '@/lib/portal-api';
|
||||
import { Frame, card, button, input } from '@/pages/investment/shared';
|
||||
import type { Criterion } from './shared';
|
||||
|
||||
type Choice = { id: string | number; name?: string; title?: string };
|
||||
type WindowDraft = {
|
||||
code: string;
|
||||
title: string;
|
||||
mode: string;
|
||||
start_on: string;
|
||||
end_on: string;
|
||||
coverage: string;
|
||||
coverage_text: string;
|
||||
territory_ids: string[];
|
||||
source_url: string;
|
||||
source_reference: string;
|
||||
checked_on: string;
|
||||
application_url: string;
|
||||
criteria: Criterion[];
|
||||
};
|
||||
type Draft = {
|
||||
title: string;
|
||||
kind: string;
|
||||
summary: string;
|
||||
manager_name: string;
|
||||
manager_organization_id: string;
|
||||
source_url: string;
|
||||
source_reference: string;
|
||||
source_checked_on: string;
|
||||
source_published_on: string;
|
||||
source_status: string;
|
||||
coverage: string;
|
||||
coverage_text: string;
|
||||
territory_ids: string[];
|
||||
sectors_scope: string;
|
||||
sectors_text: string;
|
||||
sector_ids: number[];
|
||||
resource_ids: number[];
|
||||
criteria: Criterion[];
|
||||
windows: WindowDraft[];
|
||||
};
|
||||
type RecordData = {
|
||||
id: string;
|
||||
code: string;
|
||||
version: number;
|
||||
draft: Draft;
|
||||
published: boolean;
|
||||
};
|
||||
type Plan = { version: number; hash: string; payload: Draft };
|
||||
const empty: Draft = {
|
||||
title: '',
|
||||
kind: 'fund',
|
||||
summary: '',
|
||||
manager_name: '',
|
||||
manager_organization_id: '',
|
||||
source_url: '',
|
||||
source_reference: '',
|
||||
source_checked_on: '',
|
||||
source_published_on: '',
|
||||
source_status: 'unknown',
|
||||
coverage: 'unknown',
|
||||
coverage_text: '',
|
||||
territory_ids: [],
|
||||
sectors_scope: 'unknown',
|
||||
sectors_text: '',
|
||||
sector_ids: [],
|
||||
resource_ids: [],
|
||||
criteria: [],
|
||||
windows: [],
|
||||
};
|
||||
const newCode = () => crypto.randomUUID();
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="my-3 block">
|
||||
<span className="block font-medium">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function Multi({
|
||||
label,
|
||||
options,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: Choice[];
|
||||
values: (string | number)[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<select
|
||||
className={input}
|
||||
multiple
|
||||
value={values.map(String)}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
Array.from(
|
||||
e.target.selectedOptions,
|
||||
(option) => option.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name ?? option.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm">Pode seleccionar mais de uma opção.</span>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
function Criteria({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Criterion[];
|
||||
onChange: (criteria: Criterion[]) => void;
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<Criterion>) =>
|
||||
onChange(
|
||||
value.map((item, i) =>
|
||||
i === index ? { ...item, ...patch } : item,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<section>
|
||||
<h3 className="font-semibold">Critérios documentados</h3>
|
||||
{value.map((item, index) => (
|
||||
<fieldset className={card} key={item.code}>
|
||||
<legend>Critério {index + 1}</legend>
|
||||
<Field label="Pergunta">
|
||||
<input
|
||||
className={input}
|
||||
value={item.question ?? ''}
|
||||
maxLength={500}
|
||||
onChange={(e) =>
|
||||
update(index, { question: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Aplica-se a">
|
||||
<select
|
||||
className={input}
|
||||
value={item.applies_to}
|
||||
onChange={(e) =>
|
||||
update(index, {
|
||||
applies_to: e.target
|
||||
.value as Criterion['applies_to'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="all">Todos os candidatos</option>
|
||||
<option value="individual">
|
||||
Pessoas singulares
|
||||
</option>
|
||||
<option value="entity">Entidades</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Resposta exigida pela fonte">
|
||||
<select
|
||||
className={input}
|
||||
value={item.expected}
|
||||
onChange={(e) =>
|
||||
update(index, {
|
||||
expected: e.target.value as 'yes' | 'no',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="yes">Sim</option>
|
||||
<option value="no">Não</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Explicação da condição">
|
||||
<textarea
|
||||
className={input}
|
||||
value={item.explanation ?? ''}
|
||||
maxLength={2000}
|
||||
onChange={(e) =>
|
||||
update(index, { explanation: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Referência exacta na fonte">
|
||||
<input
|
||||
className={input}
|
||||
value={item.source_reference ?? ''}
|
||||
maxLength={1500}
|
||||
onChange={(e) =>
|
||||
update(index, {
|
||||
source_reference: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
Remover critério
|
||||
</button>
|
||||
</fieldset>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={value.length >= 30}
|
||||
onClick={() =>
|
||||
onChange([
|
||||
...value,
|
||||
{
|
||||
code: newCode(),
|
||||
question: '',
|
||||
applies_to: 'all',
|
||||
expected: 'yes',
|
||||
source_reference: '',
|
||||
explanation: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Acrescentar critério
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function Editor({
|
||||
record,
|
||||
references,
|
||||
kinds,
|
||||
windowModes,
|
||||
history,
|
||||
}: {
|
||||
record: RecordData | null;
|
||||
references: Record<
|
||||
'organizations' | 'sectors' | 'territories' | 'documents',
|
||||
Choice[]
|
||||
>;
|
||||
kinds: Record<string, string>;
|
||||
windowModes: Record<string, string>;
|
||||
history: { version: number; reason: string; published_at: string }[];
|
||||
}) {
|
||||
const form = useForm({
|
||||
version: record?.version ?? 0,
|
||||
code: record?.code ?? newCode(),
|
||||
reason: '',
|
||||
draft: { ...empty, ...record?.draft },
|
||||
});
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const draft = form.data.draft;
|
||||
function change(patch: Partial<Draft>) {
|
||||
form.setData('draft', { ...draft, ...patch });
|
||||
setPlan(null);
|
||||
setConfirmed(false);
|
||||
}
|
||||
function text(
|
||||
key: {
|
||||
[K in keyof Draft]: Draft[K] extends string ? K : never;
|
||||
}[keyof Draft],
|
||||
label: string,
|
||||
type = 'text',
|
||||
) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<input
|
||||
type={type}
|
||||
className={input}
|
||||
value={draft[key] ?? ''}
|
||||
onChange={(e) => change({ [key]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
function windowChange(index: number, patch: Partial<WindowDraft>) {
|
||||
change({
|
||||
windows: draft.windows.map((item, i) =>
|
||||
i === index ? { ...item, ...patch } : item,
|
||||
),
|
||||
});
|
||||
}
|
||||
async function preview() {
|
||||
if (
|
||||
!record ||
|
||||
JSON.stringify(draft) !==
|
||||
JSON.stringify({ ...empty, ...record?.draft })
|
||||
) {
|
||||
setError('Guarde o rascunho antes de conferir a prévia.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setPlan(null);
|
||||
setConfirmed(false);
|
||||
try {
|
||||
setPlan(
|
||||
await portalRequest<Plan>(
|
||||
`/management/fundos/${record.id}/previa`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
);
|
||||
} catch (failure) {
|
||||
setError(
|
||||
failure instanceof PortalApiError
|
||||
? Object.values(failure.errors).flat().join(' ') ||
|
||||
failure.message
|
||||
: 'Não foi possível preparar a prévia.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Frame
|
||||
title={
|
||||
record ? 'Editar fundo ou programa' : 'Novo fundo ou programa'
|
||||
}
|
||||
>
|
||||
<Link className="underline" href="/management/fundos">
|
||||
Voltar à gestão
|
||||
</Link>
|
||||
<p>
|
||||
Guarde as condições efectivamente documentadas. Uma chamada
|
||||
local ou encerrada não altera, por si, a cobertura ou existência
|
||||
do programa.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const url =
|
||||
'/management/fundos' + (record ? `/${record.id}` : '');
|
||||
if (record) form.put(url);
|
||||
else form.post(url);
|
||||
}}
|
||||
>
|
||||
<section className={card}>
|
||||
{text('title', 'Nome do programa')}
|
||||
<Field label="Tipo">
|
||||
<select
|
||||
className={input}
|
||||
value={draft.kind}
|
||||
onChange={(e) => change({ kind: e.target.value })}
|
||||
>
|
||||
{Object.entries(kinds).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Resumo">
|
||||
<textarea
|
||||
className={input}
|
||||
value={draft.summary ?? ''}
|
||||
maxLength={4000}
|
||||
onChange={(e) =>
|
||||
change({ summary: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Entidade gestora do cadastro">
|
||||
<select
|
||||
className={input}
|
||||
value={draft.manager_organization_id ?? ''}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
manager_organization_id: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Identificar pela fonte</option>
|
||||
{references.organizations.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{!draft.manager_organization_id &&
|
||||
text('manager_name', 'Gestor indicado pela fonte')}
|
||||
{text('source_url', 'Endereço da fonte', 'url')}
|
||||
{text('source_reference', 'Documento, edição e referência')}
|
||||
{text(
|
||||
'source_checked_on',
|
||||
'Data de consulta da fonte',
|
||||
'date',
|
||||
)}
|
||||
{text(
|
||||
'source_published_on',
|
||||
'Data de publicação da fonte, se conhecida',
|
||||
'date',
|
||||
)}
|
||||
<Field label="Situação da fonte">
|
||||
<select
|
||||
className={input}
|
||||
value={draft.source_status}
|
||||
onChange={(e) =>
|
||||
change({ source_status: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="unknown">Por identificar</option>
|
||||
<option value="identified">
|
||||
Fonte efectiva identificada
|
||||
</option>
|
||||
<option value="example">
|
||||
Conteúdo explicitamente demonstrativo
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
</section>
|
||||
<section className={card}>
|
||||
<Field label="Cobertura do programa">
|
||||
<select
|
||||
className={input}
|
||||
value={draft.coverage}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
coverage: e.target.value,
|
||||
territory_ids: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="unknown">Por determinar</option>
|
||||
<option value="national">Nacional</option>
|
||||
<option value="territorial">Territorial</option>
|
||||
</select>
|
||||
</Field>
|
||||
{draft.coverage === 'territorial' && (
|
||||
<Multi
|
||||
label="Locais abrangidos"
|
||||
options={references.territories}
|
||||
values={draft.territory_ids}
|
||||
onChange={(territory_ids) =>
|
||||
change({ territory_ids })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{text('coverage_text', 'Explicação da cobertura')}
|
||||
<Field label="Sectores abrangidos">
|
||||
<select
|
||||
className={input}
|
||||
value={draft.sectors_scope}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
sectors_scope: e.target.value,
|
||||
sector_ids: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="unknown">Por determinar</option>
|
||||
<option value="all">Todos, segundo a fonte</option>
|
||||
<option value="selected">
|
||||
Sectores documentados
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
{draft.sectors_scope === 'selected' && (
|
||||
<Multi
|
||||
label="Seleccionar sectores"
|
||||
options={references.sectors}
|
||||
values={draft.sector_ids}
|
||||
onChange={(ids) =>
|
||||
change({ sector_ids: ids.map(Number) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{text('sectors_text', 'Explicação do âmbito sectorial')}
|
||||
<Multi
|
||||
label="Documentos da biblioteca"
|
||||
options={references.documents}
|
||||
values={draft.resource_ids}
|
||||
onChange={(ids) =>
|
||||
change({ resource_ids: ids.map(Number) })
|
||||
}
|
||||
/>
|
||||
<Criteria
|
||||
value={draft.criteria}
|
||||
onChange={(criteria) => change({ criteria })}
|
||||
/>
|
||||
</section>
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Janelas de candidatura
|
||||
</h2>
|
||||
{draft.windows.map((window, index) => (
|
||||
<fieldset key={window.code} className={card}>
|
||||
<legend>Janela {index + 1}</legend>
|
||||
{(
|
||||
[
|
||||
'title',
|
||||
'source_url',
|
||||
'source_reference',
|
||||
'checked_on',
|
||||
'application_url',
|
||||
'coverage_text',
|
||||
] as const
|
||||
).map((key) => (
|
||||
<Field
|
||||
key={key}
|
||||
label={
|
||||
{
|
||||
title: 'Nome da chamada',
|
||||
source_url: 'Endereço da fonte',
|
||||
source_reference:
|
||||
'Referência na fonte',
|
||||
checked_on: 'Data de consulta',
|
||||
application_url:
|
||||
'Canal de candidatura, se documentado',
|
||||
coverage_text:
|
||||
'Explicação da cobertura desta chamada',
|
||||
}[key]
|
||||
}
|
||||
>
|
||||
<input
|
||||
className={input}
|
||||
type={
|
||||
key === 'checked_on'
|
||||
? 'date'
|
||||
: key.endsWith('url')
|
||||
? 'url'
|
||||
: 'text'
|
||||
}
|
||||
value={window[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
windowChange(index, {
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
<Field label="Estado documentado">
|
||||
<select
|
||||
className={input}
|
||||
value={window.mode}
|
||||
onChange={(e) =>
|
||||
windowChange(index, {
|
||||
mode: e.target.value,
|
||||
...(['rolling', 'unknown'].includes(
|
||||
e.target.value,
|
||||
)
|
||||
? { start_on: '', end_on: '' }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.entries(windowModes).map(
|
||||
([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</Field>
|
||||
{['dated', 'closed'].includes(window.mode) && (
|
||||
<>
|
||||
{(['start_on', 'end_on'] as const).map(
|
||||
(key) => (
|
||||
<Field
|
||||
key={key}
|
||||
label={
|
||||
key === 'start_on'
|
||||
? 'Data de abertura'
|
||||
: 'Data-limite'
|
||||
}
|
||||
>
|
||||
<input
|
||||
className={input}
|
||||
type="date"
|
||||
value={window[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
windowChange(index, {
|
||||
[key]: e.target
|
||||
.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Field label="Cobertura da chamada">
|
||||
<select
|
||||
className={input}
|
||||
value={window.coverage}
|
||||
onChange={(e) =>
|
||||
windowChange(index, {
|
||||
coverage: e.target.value,
|
||||
territory_ids: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="programme">
|
||||
A mesma do programa
|
||||
</option>
|
||||
<option value="unknown">
|
||||
Por determinar
|
||||
</option>
|
||||
<option value="national">Nacional</option>
|
||||
<option value="territorial">
|
||||
Territorial
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
{window.coverage === 'territorial' && (
|
||||
<Multi
|
||||
label="Locais da chamada"
|
||||
options={references.territories}
|
||||
values={window.territory_ids ?? []}
|
||||
onChange={(territory_ids) =>
|
||||
windowChange(index, { territory_ids })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Criteria
|
||||
value={window.criteria ?? []}
|
||||
onChange={(criteria) =>
|
||||
windowChange(index, { criteria })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="underline"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
change({
|
||||
windows: draft.windows.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remover janela
|
||||
</button>
|
||||
</fieldset>
|
||||
))}
|
||||
<button
|
||||
className="underline"
|
||||
type="button"
|
||||
disabled={draft.windows.length >= 20}
|
||||
onClick={() =>
|
||||
change({
|
||||
windows: [
|
||||
...draft.windows,
|
||||
{
|
||||
code: newCode(),
|
||||
title: '',
|
||||
mode: 'unknown',
|
||||
start_on: '',
|
||||
end_on: '',
|
||||
coverage: 'programme',
|
||||
coverage_text: '',
|
||||
territory_ids: [],
|
||||
source_url: '',
|
||||
source_reference: '',
|
||||
checked_on: '',
|
||||
application_url: '',
|
||||
criteria: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Acrescentar janela
|
||||
</button>
|
||||
</section>
|
||||
<Field label="Motivo da alteração">
|
||||
<textarea
|
||||
className={input}
|
||||
value={form.data.reason}
|
||||
minLength={20}
|
||||
maxLength={2000}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
{Object.values(form.errors).map((message, index) => (
|
||||
<p role="alert" key={index}>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
<button className={button} disabled={form.processing || busy}>
|
||||
Guardar rascunho
|
||||
</button>
|
||||
</form>
|
||||
{record && (
|
||||
<section className={card}>
|
||||
<h2>Publicação</h2>
|
||||
<p>
|
||||
A publicação cria uma versão pública; não altera
|
||||
interesses já guardados, não abre candidaturas nem
|
||||
activa cálculos fiscais.
|
||||
</p>
|
||||
<button
|
||||
className={button}
|
||||
disabled={busy || form.processing}
|
||||
onClick={() => void preview()}
|
||||
>
|
||||
Conferir prévia de publicação
|
||||
</button>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{plan && (
|
||||
<div>
|
||||
<h3 className="font-semibold">
|
||||
{plan.payload.title}
|
||||
</h3>
|
||||
<p>{plan.payload.summary}</p>
|
||||
<p>
|
||||
Gestor: {plan.payload.manager_name}. Fonte:{' '}
|
||||
{plan.payload.source_reference}.
|
||||
</p>
|
||||
<p>
|
||||
{plan.payload.criteria.length} critérios gerais;{' '}
|
||||
{plan.payload.windows.length} janelas.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
className="underline"
|
||||
href={plan.payload.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Abrir fonte conferida
|
||||
</a>{' '}
|
||||
— consulta em {plan.payload.source_checked_on}.
|
||||
</p>
|
||||
<p>
|
||||
Cobertura: {plan.payload.coverage};{' '}
|
||||
{plan.payload.coverage_text}
|
||||
</p>
|
||||
<p>
|
||||
{references.territories
|
||||
.filter((item) =>
|
||||
plan.payload.territory_ids.includes(
|
||||
String(item.id),
|
||||
),
|
||||
)
|
||||
.map((item) => item.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
<p>
|
||||
Sectores:{' '}
|
||||
{plan.payload.sectors_text ||
|
||||
(plan.payload.sectors_scope === 'all'
|
||||
? 'Todos segundo a fonte'
|
||||
: plan.payload.sectors_scope ===
|
||||
'unknown'
|
||||
? 'Por determinar'
|
||||
: references.sectors
|
||||
.filter((item) =>
|
||||
plan.payload.sector_ids.includes(
|
||||
Number(item.id),
|
||||
),
|
||||
)
|
||||
.map((item) => item.name)
|
||||
.join(', '))}
|
||||
</p>
|
||||
<ul>
|
||||
{plan.payload.criteria.map((item) => (
|
||||
<li key={item.code}>
|
||||
<strong>{item.question}</strong> —{' '}
|
||||
{item.expected === 'yes'
|
||||
? 'Sim'
|
||||
: 'Não'}
|
||||
;{' '}
|
||||
{item.applies_to === 'all'
|
||||
? 'todos os candidatos'
|
||||
: item.applies_to === 'entity'
|
||||
? 'entidades'
|
||||
: 'pessoas singulares'}
|
||||
. {item.explanation} Fonte:{' '}
|
||||
{item.source_reference}.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{plan.payload.windows.map((window) => (
|
||||
<article key={window.code} className={card}>
|
||||
<h4 className="font-semibold">
|
||||
{window.title}
|
||||
</h4>
|
||||
<p>
|
||||
{windowModes[window.mode]};{' '}
|
||||
{window.start_on ||
|
||||
'Abertura sem data indicada'}
|
||||
;{' '}
|
||||
{window.end_on ||
|
||||
'Sem data-limite indicada'}
|
||||
.
|
||||
</p>
|
||||
<p>{window.coverage_text}</p>
|
||||
<p>
|
||||
{references.territories
|
||||
.filter((item) =>
|
||||
(
|
||||
window.territory_ids ?? []
|
||||
).includes(String(item.id)),
|
||||
)
|
||||
.map((item) => item.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
<a
|
||||
className="underline"
|
||||
href={window.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{window.source_reference}
|
||||
</a>
|
||||
<p>Consulta: {window.checked_on}</p>
|
||||
{window.application_url && (
|
||||
<p>
|
||||
Canal indicado:{' '}
|
||||
{window.application_url}
|
||||
</p>
|
||||
)}
|
||||
<ul>
|
||||
{window.criteria.map((item) => (
|
||||
<li key={item.code}>
|
||||
{item.question} —{' '}
|
||||
{item.expected === 'yes'
|
||||
? 'Sim'
|
||||
: 'Não'}
|
||||
; {item.explanation} Fonte:{' '}
|
||||
{item.source_reference}.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
<ul>
|
||||
{references.documents
|
||||
.filter((item) =>
|
||||
plan.payload.resource_ids.includes(
|
||||
Number(item.id),
|
||||
),
|
||||
)
|
||||
.map((item) => (
|
||||
<li key={item.id}>
|
||||
Documento: {item.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) =>
|
||||
setConfirmed(e.target.checked)
|
||||
}
|
||||
/>{' '}
|
||||
Confirmei as condições, fontes e efeito da
|
||||
publicação.
|
||||
</label>
|
||||
<button
|
||||
className={button}
|
||||
disabled={
|
||||
!confirmed ||
|
||||
form.processing ||
|
||||
form.data.reason.length < 20
|
||||
}
|
||||
onClick={() =>
|
||||
router.post(
|
||||
`/management/fundos/${record.id}/publicar`,
|
||||
{
|
||||
version: plan.version,
|
||||
hash: plan.hash,
|
||||
reason: form.data.reason,
|
||||
confirm: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Publicar versão conferida
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{record.published && (
|
||||
<button
|
||||
className="ml-3 underline"
|
||||
disabled={
|
||||
form.data.reason.length < 20 ||
|
||||
JSON.stringify(draft) !==
|
||||
JSON.stringify({
|
||||
...empty,
|
||||
...record?.draft,
|
||||
})
|
||||
}
|
||||
onClick={() =>
|
||||
router.post(
|
||||
`/management/fundos/${record.id}/retirar`,
|
||||
{
|
||||
version: record.version,
|
||||
reason: form.data.reason,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar publicação
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{history.length > 0 && (
|
||||
<section className={card}>
|
||||
<h2>Histórico de publicação</h2>
|
||||
<ul>
|
||||
{history.map((item) => (
|
||||
<li key={item.version}>
|
||||
Versão {item.version} — {item.published_at}:{' '}
|
||||
{item.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Edit(props: Parameters<typeof Editor>[0]) {
|
||||
return (
|
||||
<Editor
|
||||
key={
|
||||
props.record
|
||||
? props.record.id + ':' + props.record.version
|
||||
: 'new'
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { type Programme, windowLabels } from './shared';
|
||||
|
||||
export default function Index({
|
||||
items,
|
||||
filters,
|
||||
kinds,
|
||||
management,
|
||||
options,
|
||||
}: {
|
||||
items: {
|
||||
data: Programme[];
|
||||
links: { url: string | null; label: string; active: boolean }[];
|
||||
};
|
||||
filters: {
|
||||
q?: string;
|
||||
kind?: string;
|
||||
sector?: string;
|
||||
territory?: string;
|
||||
certainty?: string;
|
||||
state?: string;
|
||||
};
|
||||
options: {
|
||||
sectors: { id: number; name: string }[];
|
||||
territories: { id: string; name: string }[];
|
||||
};
|
||||
kinds: Record<string, string>;
|
||||
management: boolean;
|
||||
}) {
|
||||
const form = useForm({
|
||||
q: filters.q ?? '',
|
||||
kind: filters.kind ?? '',
|
||||
sector: filters.sector ?? '',
|
||||
territory: filters.territory ?? '',
|
||||
certainty: filters.certainty ?? 'include_unknown',
|
||||
state: filters.state ?? '',
|
||||
});
|
||||
return (
|
||||
<Frame title="Fundos e programas de apoio">
|
||||
{management && (
|
||||
<Link className={button} href="/management/fundos">
|
||||
Gerir fundos e programas
|
||||
</Link>
|
||||
)}
|
||||
<Link className="underline" href="/fundos/guardados">
|
||||
Programas que guardei
|
||||
</Link>
|
||||
<p>
|
||||
Consulte os programas e as condições documentadas. Cada chamada
|
||||
tem as suas próprias datas e critérios.
|
||||
</p>
|
||||
<form
|
||||
className={`${card} flex flex-wrap gap-4`}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.get('/fundos');
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Palavras-chave
|
||||
<input
|
||||
className="block rounded border p-2"
|
||||
value={form.data.q}
|
||||
maxLength={150}
|
||||
onChange={(e) => form.setData('q', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Tipo
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.kind}
|
||||
onChange={(e) => form.setData('kind', e.target.value)}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(kinds).map(([key, name]) => (
|
||||
<option key={key} value={key}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button className={button} disabled={form.processing}>
|
||||
Pesquisar
|
||||
</button>
|
||||
<label>
|
||||
Sector
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.sector}
|
||||
onChange={(e) => form.setData('sector', e.target.value)}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{options.sectors.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Local de interesse
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.territory}
|
||||
onChange={(e) =>
|
||||
form.setData('territory', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{options.territories.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Informação por confirmar
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.certainty}
|
||||
onChange={(e) =>
|
||||
form.setData('certainty', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="include_unknown">
|
||||
Incluir e assinalar
|
||||
</option>
|
||||
<option value="documented">
|
||||
Apenas âmbito documentado para os filtros escolhidos
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Estado de uma janela
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.state}
|
||||
onChange={(e) => form.setData('state', e.target.value)}
|
||||
>
|
||||
<option value="">Qualquer estado</option>
|
||||
{Object.entries(windowLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
<p className="text-sm">
|
||||
Sector e local filtram o âmbito geral do programa. Cada chamada
|
||||
pode ter uma cobertura mais restrita. O estado indica que existe
|
||||
pelo menos uma janela nesse estado; não confirma elegibilidade
|
||||
nem candidatura aberta no local escolhido.
|
||||
</p>
|
||||
{items.data.length === 0 && (
|
||||
<p>
|
||||
Não foram encontrados programas publicados para esta
|
||||
pesquisa.
|
||||
</p>
|
||||
)}
|
||||
{items.data.map((item) => (
|
||||
<article className={card} key={item.id}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Link className="underline" href={`/fundos/${item.id}`}>
|
||||
{item.payload.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p>{item.payload.summary}</p>
|
||||
<p>Gestor: {item.payload.manager_name}</p>
|
||||
{Object.entries(item.filter_explanation ?? {}).map(
|
||||
([key, message]) => (
|
||||
<p key={key}>{message}</p>
|
||||
),
|
||||
)}
|
||||
{item.payload.windows.length === 0 ? (
|
||||
<p>
|
||||
Não existe uma janela de candidatura confirmada
|
||||
nesta ficha.
|
||||
</p>
|
||||
) : (
|
||||
<ul>
|
||||
{item.payload.windows.map((window) => (
|
||||
<li key={window.code}>
|
||||
{window.title}: {windowLabels[window.state]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
<nav aria-label="Páginas do catálogo" className="flex gap-3">
|
||||
{items.links.map((link, index) =>
|
||||
link.url ? (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.url}
|
||||
aria-current={link.active ? 'page' : undefined}
|
||||
className="underline"
|
||||
>
|
||||
{link.label
|
||||
.replace(/«/g, '«')
|
||||
.replace(/»/g, '»')}
|
||||
</Link>
|
||||
) : null,
|
||||
)}
|
||||
</nav>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
export default function Manage({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
data: {
|
||||
id: string;
|
||||
title: string;
|
||||
version: number;
|
||||
published: boolean;
|
||||
}[];
|
||||
links: { url: string | null; label: string; active: boolean }[];
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<Frame title="Gerir fundos e programas">
|
||||
<Link className={button} href="/management/fundos/novo">
|
||||
Novo programa
|
||||
</Link>
|
||||
<Link className="ml-4 underline" href="/fundos">
|
||||
Catálogo público
|
||||
</Link>
|
||||
<Link
|
||||
className="ml-4 underline"
|
||||
href="/management/fundos/referencias/fdel"
|
||||
>
|
||||
Preparar referência FDEL
|
||||
</Link>
|
||||
{items.data.map((item) => (
|
||||
<article className={card} key={item.id}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/fundos/${item.id}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<p>
|
||||
Versão de trabalho {item.version} —{' '}
|
||||
{item.published
|
||||
? 'com publicação'
|
||||
: 'sem publicação pública'}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<nav aria-label="Páginas de gestão" className="flex gap-3">
|
||||
{items.links.map((link, index) =>
|
||||
link.url ? (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.url}
|
||||
className="underline"
|
||||
aria-current={link.active ? 'page' : undefined}
|
||||
>
|
||||
{link.label
|
||||
.replace(/«/g, '«')
|
||||
.replace(/»/g, '»')}
|
||||
</Link>
|
||||
) : null,
|
||||
)}
|
||||
</nav>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import type { Criterion } from './shared';
|
||||
type Reference = {
|
||||
code: string;
|
||||
hash: string;
|
||||
edition: string;
|
||||
provenance: { url: string; checked_on: string; reference: string }[];
|
||||
draft: {
|
||||
title: string;
|
||||
summary: string;
|
||||
manager_name: string;
|
||||
coverage_text: string;
|
||||
sectors_text: string;
|
||||
criteria: Criterion[];
|
||||
windows: {
|
||||
code: string;
|
||||
title: string;
|
||||
end_on?: string;
|
||||
coverage_text: string;
|
||||
criteria: Criterion[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
function Criteria({ items }: { items: Criterion[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item.code} className="my-3">
|
||||
<strong>{item.question}</strong>
|
||||
<p>{item.explanation}</p>
|
||||
<p>
|
||||
Resposta indicada:{' '}
|
||||
{item.expected === 'yes' ? 'Sim' : 'Não'};{' '}
|
||||
{item.applies_to === 'individual'
|
||||
? 'pessoas singulares'
|
||||
: item.applies_to === 'entity'
|
||||
? 'entidades'
|
||||
: 'todos os candidatos'}
|
||||
. Fonte: {item.source_reference}.
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
export default function ReferencePage({ reference }: { reference: Reference }) {
|
||||
const form = useForm({ hash: reference.hash });
|
||||
return (
|
||||
<Frame title="Preparar referência de fundo">
|
||||
<Link className="underline" href="/management/fundos">
|
||||
Voltar à gestão
|
||||
</Link>
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{reference.draft.title}
|
||||
</h2>
|
||||
<p>{reference.draft.summary}</p>
|
||||
<p>Gestão: {reference.draft.manager_name}</p>
|
||||
<p>{reference.draft.coverage_text}</p>
|
||||
<p>{reference.draft.sectors_text}</p>
|
||||
<Criteria items={reference.draft.criteria} />
|
||||
</section>
|
||||
<section className={card}>
|
||||
<h2>Chamadas documentadas</h2>
|
||||
{reference.draft.windows.map((window) => (
|
||||
<article key={window.code}>
|
||||
<h3 className="font-semibold">{window.title}</h3>
|
||||
{window.end_on && (
|
||||
<p>
|
||||
Prazo indicado na fonte: {window.end_on}.
|
||||
Consulte o estado calculado na ficha; esta data
|
||||
não é renovada automaticamente.
|
||||
</p>
|
||||
)}
|
||||
<p>{window.coverage_text}</p>
|
||||
<Criteria items={window.criteria} />
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className={card}>
|
||||
<h2>Fontes e edições utilizadas</h2>
|
||||
{reference.provenance.map((source) => (
|
||||
<p key={source.url}>
|
||||
<a
|
||||
className="underline"
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{source.reference}
|
||||
</a>{' '}
|
||||
— consultada em {source.checked_on}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post(
|
||||
`/management/fundos/referencias/${reference.code}/preparar`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Prepara um rascunho para edição. A publicação é feita
|
||||
posteriormente no editor. Se a ficha já existir, será aberta
|
||||
sem substituir o seu conteúdo.
|
||||
</p>
|
||||
{Object.values(form.errors).map((error, index) => (
|
||||
<p role="alert" key={index}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
<button className={button} disabled={form.processing}>
|
||||
Preparar ou abrir ficha existente
|
||||
</button>
|
||||
</form>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
|
||||
type SavedProgramme = {
|
||||
id: string;
|
||||
title: string;
|
||||
saved_version: number;
|
||||
saved_at: string;
|
||||
available: boolean;
|
||||
current_version: number | null;
|
||||
};
|
||||
export default function Saved({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
data: SavedProgramme[];
|
||||
links: { url: string | null; label: string; active: boolean }[];
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<Frame title="Programas que guardei">
|
||||
<Link className="underline" href="/fundos">
|
||||
Consultar fundos e programas
|
||||
</Link>
|
||||
<p>
|
||||
Referências privadas aos programas que seleccionou. Guardar
|
||||
interesse não envia candidatura nem reserva financiamento.
|
||||
</p>
|
||||
{items.data.length === 0 && (
|
||||
<p>Ainda não tem programas guardados.</p>
|
||||
)}
|
||||
{items.data.map((item) => (
|
||||
<article key={item.id} className={card}>
|
||||
<h2 className="text-xl font-semibold">{item.title}</h2>
|
||||
<p>Guardado na versão {item.saved_version}.</p>
|
||||
{item.available ? (
|
||||
<>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/fundos/${item.id}`}
|
||||
>
|
||||
Consultar ficha publicada
|
||||
</Link>
|
||||
{item.current_version !== item.saved_version && (
|
||||
<p>
|
||||
Existe uma versão pública diferente:{' '}
|
||||
{item.current_version}. A referência
|
||||
guardada conserva a versão que seleccionou.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
A ficha já não está disponível publicamente. A sua
|
||||
referência guardada foi conservada.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<button
|
||||
className={button}
|
||||
onClick={() =>
|
||||
router.delete(`/fundos/${item.id}/interesse`)
|
||||
}
|
||||
>
|
||||
Remover da minha lista
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<nav
|
||||
aria-label="Páginas de programas guardados"
|
||||
className="flex gap-3"
|
||||
>
|
||||
{items.links.map((link, index) =>
|
||||
link.url ? (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.url}
|
||||
aria-current={link.active ? 'page' : undefined}
|
||||
className="underline"
|
||||
>
|
||||
{link.label
|
||||
.replace(/«/g, '«')
|
||||
.replace(/»/g, '»')}
|
||||
</Link>
|
||||
) : null,
|
||||
)}
|
||||
</nav>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type Criterion = {
|
||||
code: string;
|
||||
question: string;
|
||||
applies_to: 'all' | 'individual' | 'entity';
|
||||
expected: 'yes' | 'no';
|
||||
source_reference: string;
|
||||
explanation: string;
|
||||
};
|
||||
export type FundingWindow = {
|
||||
code: string;
|
||||
title: string;
|
||||
state: string;
|
||||
coverage: string;
|
||||
territory_paths?: { id: string; name: string; type: string }[][];
|
||||
start_on?: string | null;
|
||||
end_on?: string | null;
|
||||
coverage_text?: string | null;
|
||||
source_url: string;
|
||||
source_reference: string;
|
||||
checked_on: string;
|
||||
application_url?: string | null;
|
||||
criteria: Criterion[];
|
||||
};
|
||||
export type Programme = {
|
||||
filter_explanation?: Record<string, string>;
|
||||
id: string;
|
||||
code: string;
|
||||
version_id: string;
|
||||
version: number;
|
||||
published_at: string;
|
||||
payload: {
|
||||
title: string;
|
||||
kind: string;
|
||||
summary: string;
|
||||
manager_name: string;
|
||||
coverage: string;
|
||||
coverage_text?: string | null;
|
||||
territory_paths?: { id: string; name: string; type: string }[][];
|
||||
sector_snapshots?: { id: number; name: string }[];
|
||||
sectors_scope: string;
|
||||
sectors_text?: string | null;
|
||||
source_url: string;
|
||||
source_reference: string;
|
||||
source_checked_on: string;
|
||||
criteria: Criterion[];
|
||||
windows: FundingWindow[];
|
||||
documents: { resource_id: number; title: string; available: boolean }[];
|
||||
};
|
||||
};
|
||||
export const windowLabels: Record<string, string> = {
|
||||
open: 'Janela aberta segundo as datas documentadas',
|
||||
closed: 'Janela encerrada',
|
||||
upcoming: 'Abertura futura',
|
||||
unknown: 'Janela por confirmar',
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { portalRequest } from '@/lib/portal-api';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { type Programme, windowLabels } from './shared';
|
||||
|
||||
type Assessment = {
|
||||
notice: string;
|
||||
criteria: { code: string; status: string }[];
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
matches: 'Corresponde à resposta esperada',
|
||||
does_not_match: 'Não corresponde à resposta esperada',
|
||||
unknown: 'Informação por confirmar',
|
||||
not_applicable: 'Não aplicável ao tipo de candidato indicado',
|
||||
};
|
||||
export default function Show({
|
||||
record,
|
||||
saved,
|
||||
authenticated,
|
||||
}: {
|
||||
record: Programme;
|
||||
saved: { version: number; saved_at: string } | null;
|
||||
authenticated: boolean;
|
||||
}) {
|
||||
const [kind, setKind] = useState('unknown');
|
||||
const [windowCode, setWindowCode] = useState('');
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [assessment, setAssessment] = useState<Assessment | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const payload = record.payload;
|
||||
const window = payload.windows.find((item) => item.code === windowCode);
|
||||
const criteria = [...payload.criteria, ...(window?.criteria ?? [])];
|
||||
async function assess() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setAssessment(null);
|
||||
try {
|
||||
setAssessment(
|
||||
await portalRequest<Assessment>(
|
||||
`/fundos/${record.id}/criterios`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
version_id: record.version_id,
|
||||
applicant_kind: kind,
|
||||
window_code: windowCode || null,
|
||||
answers: Object.fromEntries(
|
||||
criteria.map((c) => [
|
||||
c.code,
|
||||
answers[c.code] ?? 'unknown',
|
||||
]),
|
||||
),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (failure) {
|
||||
setError(
|
||||
failure instanceof Error
|
||||
? failure.message
|
||||
: 'Não foi possível conferir os critérios.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Frame title={payload.title}>
|
||||
<Link className="underline" href="/fundos/guardados">
|
||||
Programas que guardei
|
||||
</Link>
|
||||
<Link className="underline" href="/fundos">
|
||||
Voltar aos fundos e programas
|
||||
</Link>
|
||||
<p>{payload.summary}</p>
|
||||
<p>
|
||||
Gestor: {payload.manager_name}. Versão publicada:{' '}
|
||||
{record.version}.
|
||||
</p>
|
||||
{(payload.territory_paths ?? []).map((path, index) => (
|
||||
<p key={index}>
|
||||
Local abrangido: {path.map((node) => node.name).join(' / ')}
|
||||
</p>
|
||||
))}
|
||||
<p>
|
||||
Sectores:{' '}
|
||||
{payload.sectors_text ||
|
||||
(payload.sectors_scope === 'all'
|
||||
? 'Todos, segundo a fonte'
|
||||
: payload.sectors_scope === 'unknown'
|
||||
? 'Por confirmar'
|
||||
: (payload.sector_snapshots ?? [])
|
||||
.map((sector) => sector.name)
|
||||
.join(', '))}
|
||||
</p>
|
||||
<p>
|
||||
{payload.coverage === 'national'
|
||||
? 'Cobertura nacional'
|
||||
: payload.coverage === 'territorial'
|
||||
? 'Cobertura territorial'
|
||||
: 'Cobertura por confirmar'}
|
||||
{payload.coverage_text ? ` — ${payload.coverage_text}` : ''}
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
className="underline"
|
||||
href={payload.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Consultar fonte: {payload.source_reference}
|
||||
</a>
|
||||
. Consultada em {payload.source_checked_on}.
|
||||
</p>
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Janelas de candidatura
|
||||
</h2>
|
||||
{payload.windows.length === 0 && (
|
||||
<p>
|
||||
Não há uma janela de candidatura confirmada nesta ficha.
|
||||
</p>
|
||||
)}
|
||||
{payload.windows.map((item) => (
|
||||
<article key={item.code} className="my-4">
|
||||
<h3 className="font-semibold">{item.title}</h3>
|
||||
<p>{windowLabels[item.state]}</p>
|
||||
{item.start_on && <p>Abertura: {item.start_on}</p>}
|
||||
{item.end_on && <p>Data-limite: {item.end_on}</p>}
|
||||
{item.coverage_text && <p>{item.coverage_text}</p>}
|
||||
<p>
|
||||
{item.coverage === 'programme'
|
||||
? 'Mesma cobertura do programa'
|
||||
: item.coverage === 'national'
|
||||
? 'Chamada nacional'
|
||||
: item.coverage === 'unknown'
|
||||
? 'Cobertura da chamada por confirmar'
|
||||
: 'Chamada territorial'}
|
||||
</p>
|
||||
{(item.territory_paths ?? []).map((path, index) => (
|
||||
<p key={index}>
|
||||
{path.map((node) => node.name).join(' / ')}
|
||||
</p>
|
||||
))}
|
||||
<a
|
||||
className="underline"
|
||||
href={item.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{item.source_reference}
|
||||
</a>
|
||||
<p>Fonte consultada em {item.checked_on}.</p>
|
||||
{item.application_url && (
|
||||
<a
|
||||
className="underline"
|
||||
href={item.application_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Consultar o canal indicado pela entidade
|
||||
</a>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Conferir critérios documentados
|
||||
</h2>
|
||||
<p>
|
||||
Esta consulta não envia candidatura nem concede
|
||||
financiamento. As respostas não ficam guardadas.
|
||||
</p>
|
||||
<label className="block">
|
||||
Tipo de candidato
|
||||
<select
|
||||
disabled={busy}
|
||||
className="m-2 rounded border p-2"
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value);
|
||||
setAssessment(null);
|
||||
}}
|
||||
>
|
||||
<option value="unknown">Por definir</option>
|
||||
<option value="individual">Pessoa singular</option>
|
||||
<option value="entity">Entidade</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Janela
|
||||
<select
|
||||
disabled={busy}
|
||||
className="m-2 rounded border p-2"
|
||||
value={windowCode}
|
||||
onChange={(e) => {
|
||||
setWindowCode(e.target.value);
|
||||
setAssessment(null);
|
||||
}}
|
||||
>
|
||||
<option value="">Apenas critérios gerais</option>
|
||||
{payload.windows.map((item) => (
|
||||
<option key={item.code} value={item.code}>
|
||||
{item.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{criteria.length === 0 && (
|
||||
<p>
|
||||
Esta ficha não contém critérios suficientes para
|
||||
conferir a correspondência.
|
||||
</p>
|
||||
)}
|
||||
{criteria.map((criterion) => (
|
||||
<div key={criterion.code} className="my-4">
|
||||
<label>
|
||||
{criterion.question}
|
||||
<select
|
||||
disabled={busy}
|
||||
className="m-2 rounded border p-2"
|
||||
value={answers[criterion.code] ?? 'unknown'}
|
||||
onChange={(e) => {
|
||||
setAnswers({
|
||||
...answers,
|
||||
[criterion.code]: e.target.value,
|
||||
});
|
||||
setAssessment(null);
|
||||
}}
|
||||
>
|
||||
<option value="unknown">
|
||||
Não sei / por confirmar
|
||||
</option>
|
||||
<option value="yes">Sim</option>
|
||||
<option value="no">Não</option>
|
||||
</select>
|
||||
</label>
|
||||
<p>{criterion.explanation}</p>
|
||||
<p>Fonte: {criterion.source_reference}</p>
|
||||
{assessment && (
|
||||
<p>
|
||||
{
|
||||
statusLabels[
|
||||
assessment.criteria.find(
|
||||
(c) => c.code === criterion.code,
|
||||
)?.status ?? 'unknown'
|
||||
]
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className={button}
|
||||
disabled={busy || criteria.length === 0}
|
||||
onClick={() => void assess()}
|
||||
>
|
||||
{busy ? 'A conferir…' : 'Conferir respostas'}
|
||||
</button>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{assessment && <p role="status">{assessment.notice}</p>}
|
||||
</section>
|
||||
{payload.documents.length > 0 && (
|
||||
<section className={card}>
|
||||
<h2>Documentos associados</h2>
|
||||
<ul>
|
||||
{payload.documents.map((doc) => (
|
||||
<li key={doc.resource_id}>
|
||||
{doc.available ? (
|
||||
<a
|
||||
className="underline"
|
||||
href={`/fundos/${record.id}/versoes/${record.version_id}/documentos/${doc.resource_id}`}
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
) : (
|
||||
`${doc.title} — versão indisponível para descarga`
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
<section className={card}>
|
||||
<h2>Guardar interesse</h2>
|
||||
<p>
|
||||
Guarda uma referência privada ao programa. Não envia uma
|
||||
candidatura nem contacta a entidade.
|
||||
</p>
|
||||
{saved ? (
|
||||
<>
|
||||
<p>
|
||||
Guardado na versão {saved.version}
|
||||
{saved.version !== record.version
|
||||
? '; existe uma versão pública mais recente'
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
<button
|
||||
className={button}
|
||||
onClick={() =>
|
||||
router.delete(`/fundos/${record.id}/interesse`)
|
||||
}
|
||||
>
|
||||
Remover da minha lista
|
||||
</button>
|
||||
</>
|
||||
) : authenticated ? (
|
||||
<button
|
||||
className={button}
|
||||
onClick={() =>
|
||||
router.put(`/fundos/${record.id}/interesse`, {
|
||||
version_id: record.version_id,
|
||||
consent: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Guardar programa na minha área privada
|
||||
</button>
|
||||
) : (
|
||||
<Link className="underline" href="/login">
|
||||
Entrar para guardar interesse
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { FormEvent } from 'react';
|
||||
import { newSubmissionKey } from '@/lib/portal-api';
|
||||
import { Frame, Field, button, card, input } from './shared';
|
||||
import CountrySelect, { type Country } from '@/components/country-select';
|
||||
import { businessModalities } from './business-types';
|
||||
|
||||
type Opportunity = {
|
||||
id: string;
|
||||
@@ -190,20 +191,20 @@ export default function BusinessInterest({
|
||||
) : (
|
||||
<form className={`${card} space-y-4`} onSubmit={submit}>
|
||||
<div className="space-y-1 text-sm font-medium">
|
||||
<span>País de origem declarado desta solicitação</span>
|
||||
<span>País de origem do pedido (opcional)</span>
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
id={fieldId('declared_origin_country_code')}
|
||||
error={fieldErrors.declared_origin_country_code}
|
||||
value={form.declared_origin_country_code}
|
||||
label="País de origem declarado desta solicitação"
|
||||
label="País de origem do pedido (opcional)"
|
||||
onChange={(value) =>
|
||||
update('declared_origin_country_code', value)
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
Origem opcional e independente do país do
|
||||
investidor.
|
||||
País de onde apresenta este pedido. Pode ser
|
||||
diferente do país do requerente.
|
||||
</p>
|
||||
</div>
|
||||
<Field label="Nome ou denominação">
|
||||
@@ -261,7 +262,9 @@ export default function BusinessInterest({
|
||||
{fieldError('investorType')}
|
||||
</Field>
|
||||
<div className="space-y-1 text-sm font-medium">
|
||||
<span>País</span>
|
||||
<label htmlFor={fieldId('country')}>
|
||||
País do requerente
|
||||
</label>
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
required
|
||||
@@ -296,7 +299,8 @@ export default function BusinessInterest({
|
||||
>
|
||||
{modalities.map((modality) => (
|
||||
<option key={modality} value={modality}>
|
||||
{modality.replaceAll('_', ' ')}
|
||||
{businessModalities[modality] ??
|
||||
modality.replaceAll('_', ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -314,7 +318,7 @@ export default function BusinessInterest({
|
||||
/>
|
||||
{fieldError('investmentRange')}
|
||||
</Field>
|
||||
<Field label="Motivação e pedido">
|
||||
<Field label="Como pretende participar nesta oportunidade?">
|
||||
<textarea
|
||||
className={input}
|
||||
required
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Link } from '@inertiajs/react';
|
||||
import { Frame, button, card } from './shared';
|
||||
import { businessModalities } from './business-types';
|
||||
import PartnershipSourceLinks from '@/components/partnership-source-links';
|
||||
import SimilarBusinesses from '@/components/similar-businesses';
|
||||
import { downloadPublicBusinessOffline } from '@/lib/public-business-offline';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ClassificationSummary,
|
||||
type PublicClassification,
|
||||
@@ -24,17 +27,21 @@ type Business = {
|
||||
district?: string | null;
|
||||
locality?: string | null;
|
||||
sector?: { name: string; slug: string } | null;
|
||||
published_at?: string | null;
|
||||
};
|
||||
|
||||
export default function BusinessOpportunity({
|
||||
record,
|
||||
returnUrl = '/negocios',
|
||||
interestUrl = `/negocios/${record.slug}/manifestar-interesse`,
|
||||
similarCatalogueUrl,
|
||||
}: {
|
||||
record: Business;
|
||||
returnUrl?: string;
|
||||
interestUrl?: string;
|
||||
similarCatalogueUrl?: string;
|
||||
}) {
|
||||
const [offlineMessage, setOfflineMessage] = useState('');
|
||||
return (
|
||||
<Frame title={record.title}>
|
||||
<Link
|
||||
@@ -88,7 +95,52 @@ export default function BusinessOpportunity({
|
||||
<Link className={button} href={interestUrl}>
|
||||
Manifestar interesse
|
||||
</Link>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
try {
|
||||
downloadPublicBusinessOffline(
|
||||
record,
|
||||
businessModalities[record.modality] ??
|
||||
record.modality,
|
||||
);
|
||||
setOfflineMessage(
|
||||
'Ficheiro preparado. Guarde-o e abra-o para consultar esta ficha sem rede.',
|
||||
);
|
||||
} catch {
|
||||
setOfflineMessage(
|
||||
'Não foi possível preparar o ficheiro. Tente novamente.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Guardar ficha para consulta offline (.html)
|
||||
</button>
|
||||
<p role="status" aria-live="polite">
|
||||
{offlineMessage}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{similarCatalogueUrl && (
|
||||
<SimilarBusinesses
|
||||
currentId={record.id}
|
||||
classifications={record.multidimensional}
|
||||
catalogueUrl={similarCatalogueUrl}
|
||||
criteria={[
|
||||
businessModalities[record.modality] ?? record.modality,
|
||||
...(record.sector ? [record.sector.name] : []),
|
||||
...(record.province &&
|
||||
new URL(
|
||||
similarCatalogueUrl,
|
||||
'https://portal.invalid',
|
||||
).searchParams.has('province_id')
|
||||
? [record.province]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
readOpportunitySelection,
|
||||
persistOpportunitySelection,
|
||||
} from '@/lib/opportunity-selection';
|
||||
import CatalogueGuideLinks from '@/components/catalogue-guide-links';
|
||||
import OpportunityCorridorGroups, {
|
||||
type CorridorGroup,
|
||||
@@ -6,10 +10,12 @@ import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import type { EducationalArticle } from '@/components/educational-article-card';
|
||||
import OpportunitySearchFilters from '@/components/opportunity-search-filters';
|
||||
import SearchState from '@/components/search-state';
|
||||
import OpportunityRelatedResults, {
|
||||
type RelatedOpportunitySearch,
|
||||
} from '@/components/opportunity-related-results';
|
||||
import { Link, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Map from '@/components/interactive-investment-map';
|
||||
import TerritoryGeometryPreview from '@/components/territory-geometry-preview';
|
||||
import Map from '@/components/catalogue-investment-map';
|
||||
import type { InvestmentMapArea } from '@/components/interactive-investment-map';
|
||||
import { type TerritoryOption } from '@/components/investment-territory';
|
||||
import { Frame, button, card, money, api } from './shared';
|
||||
@@ -29,7 +35,9 @@ export default function Catalogue({
|
||||
territorySelection,
|
||||
catalogueUrl,
|
||||
corridorGroups = [],
|
||||
relatedSearch = null,
|
||||
}: {
|
||||
relatedSearch?: RelatedOpportunitySearch | null;
|
||||
guides?: EducationalArticle[];
|
||||
corridorGroups?: CorridorGroup[];
|
||||
filters: Record<string, string | string[] | boolean>;
|
||||
@@ -55,27 +63,19 @@ export default function Catalogue({
|
||||
const page = usePage<{ auth: { user: unknown } }>();
|
||||
const { auth } = page.props;
|
||||
const [investmentSelection, setInvestmentSelection] = useState<number[]>(
|
||||
() => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
sessionStorage.getItem(
|
||||
'investment-opportunity-selection',
|
||||
) ?? '[]',
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
readOpportunitySelection,
|
||||
);
|
||||
const [selectionStorageUnavailable, setSelectionStorageUnavailable] =
|
||||
useState(false);
|
||||
function updateInvestmentSelection(next: number[]) {
|
||||
setInvestmentSelection(next);
|
||||
setSelectionStorageUnavailable(!persistOpportunitySelection(next));
|
||||
}
|
||||
function selectInvestment(id: number, checked: boolean) {
|
||||
const next = checked
|
||||
? [...new Set([...investmentSelection, id])]
|
||||
: investmentSelection.filter((x) => x !== id);
|
||||
setInvestmentSelection(next);
|
||||
sessionStorage.setItem(
|
||||
'investment-opportunity-selection',
|
||||
JSON.stringify(next),
|
||||
);
|
||||
updateInvestmentSelection(next);
|
||||
}
|
||||
const form = useForm({
|
||||
perspective: String(filters.perspective ?? ''),
|
||||
@@ -427,6 +427,12 @@ export default function Catalogue({
|
||||
total={results.total}
|
||||
retry={() => runSearch()}
|
||||
/>
|
||||
<OpportunityRelatedResults
|
||||
related={relatedSearch}
|
||||
loading={searchLoading}
|
||||
error={searchError}
|
||||
total={results.total}
|
||||
/>
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
results.total === 0 &&
|
||||
@@ -544,8 +550,8 @@ export default function Catalogue({
|
||||
<div role={error ? 'alert' : 'status'} aria-live="polite">
|
||||
{error ? (
|
||||
<>
|
||||
Não foi possível apresentar o mapa. A lista
|
||||
continua disponível.{' '}
|
||||
Não foi possível carregar as oportunidades no
|
||||
mapa. A lista continua disponível.{' '}
|
||||
<button
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
@@ -562,19 +568,20 @@ export default function Catalogue({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
['map', 'combined'].includes(view) &&
|
||||
!error && <Map points={points} areas={areas} />}
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
['map', 'combined'].includes(view) && (
|
||||
<TerritoryGeometryPreview
|
||||
<Map
|
||||
points={error ? [] : points}
|
||||
areas={areas}
|
||||
territoryId={
|
||||
form.data.locality_id ||
|
||||
form.data.admin_post_id ||
|
||||
form.data.district_id ||
|
||||
form.data.province_id
|
||||
String(
|
||||
filters.locality_id ||
|
||||
filters.admin_post_id ||
|
||||
filters.district_id ||
|
||||
filters.province_id ||
|
||||
'',
|
||||
) || undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -659,10 +666,7 @@ export default function Catalogue({
|
||||
selected={investmentSelection}
|
||||
onSelect={selectInvestment}
|
||||
onClear={() => {
|
||||
setInvestmentSelection([]);
|
||||
sessionStorage.removeItem(
|
||||
'investment-opportunity-selection',
|
||||
);
|
||||
updateInvestmentSelection([]);
|
||||
}}
|
||||
onCompare={(ids) =>
|
||||
void api(
|
||||
@@ -785,10 +789,10 @@ export default function Catalogue({
|
||||
aria-label="Paginação"
|
||||
className={view === 'corridors' ? 'hidden' : 'flex gap-6'}
|
||||
>
|
||||
{results.prev_page_url && (
|
||||
{!searchLoading && !searchError && results.prev_page_url && (
|
||||
<Link href={results.prev_page_url}>Anterior</Link>
|
||||
)}
|
||||
{results.next_page_url && (
|
||||
{!searchLoading && !searchError && results.next_page_url && (
|
||||
<Link href={results.next_page_url}>Seguinte</Link>
|
||||
)}
|
||||
</nav>
|
||||
@@ -807,11 +811,23 @@ export default function Catalogue({
|
||||
Comparar {selected.length} oportunidades
|
||||
</button>
|
||||
)}
|
||||
{selectionStorageUnavailable && (
|
||||
<p
|
||||
role="status"
|
||||
className="my-3 rounded border border-amber-300 p-3 text-sm"
|
||||
>
|
||||
O navegador não permitiu guardar a escolha. A selecção
|
||||
actual continua disponível nesta página; ao sair ou
|
||||
actualizar, pode recuperar uma escolha anterior ou perdê-la.
|
||||
</p>
|
||||
)}
|
||||
{view !== 'corridors' && !!investmentSelection.length && (
|
||||
<section className={card}>
|
||||
<p>
|
||||
{investmentSelection.length} oportunidades escolhidas,
|
||||
conservadas ao mudar de página.
|
||||
{selectionStorageUnavailable
|
||||
? ' disponíveis nesta página.'
|
||||
: ' conservadas ao mudar de página.'}
|
||||
</p>
|
||||
<Link
|
||||
className={button}
|
||||
@@ -827,10 +843,7 @@ export default function Catalogue({
|
||||
<button
|
||||
className="ml-4 underline"
|
||||
onClick={() => {
|
||||
setInvestmentSelection([]);
|
||||
sessionStorage.removeItem(
|
||||
'investment-opportunity-selection',
|
||||
);
|
||||
updateInvestmentSelection([]);
|
||||
}}
|
||||
>
|
||||
Limpar escolha
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { EducationalArticle } from '@/components/educational-article-card';
|
||||
import { ClassificationSummary } from '@/components/opportunity-classification';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { downloadPublicOpportunityOffline } from '@/lib/public-opportunity-offline';
|
||||
import InvestmentMapPanel from '@/components/investment-map-panel';
|
||||
import StructureGeographyPanel, {
|
||||
LocationList,
|
||||
@@ -42,6 +43,7 @@ export default function Detail({
|
||||
page.url.split('?')[1] ?? '',
|
||||
).get('context');
|
||||
const [sharing, setSharing] = useState('');
|
||||
const [offlineMessage, setOfflineMessage] = useState('');
|
||||
return (
|
||||
<Frame title={r.title}>
|
||||
{r.maturity_label === 'Em preparação' && (
|
||||
@@ -114,6 +116,29 @@ export default function Detail({
|
||||
{r.reference} · {r.sector?.name} · {r.province} {r.district}
|
||||
</p>
|
||||
<p className="text-xl">{r.summary}</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
try {
|
||||
downloadPublicOpportunityOffline(r);
|
||||
setOfflineMessage(
|
||||
'Ficheiro preparado. Guarde-o e abra-o para consultar esta ficha sem rede.',
|
||||
);
|
||||
} catch {
|
||||
setOfflineMessage(
|
||||
'Não foi possível preparar o ficheiro. Tente novamente.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Guardar ficha para consulta offline (.html)
|
||||
</button>
|
||||
<p role="status" aria-live="polite">
|
||||
{offlineMessage}
|
||||
</p>
|
||||
</div>
|
||||
<InvestmentComplements items={complements} />
|
||||
<ClassificationSummary rows={c.classification ?? []} />
|
||||
<TerritorialSources sources={c.source_evidence} />
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Frame, card, button } from './shared';
|
||||
import OpportunityAlertPreferences, {
|
||||
type AlertPreferences,
|
||||
type AlertOptions,
|
||||
} from '@/components/opportunity-alert-preferences';
|
||||
export default function Notifications({
|
||||
unread,
|
||||
notifications,
|
||||
alertPreferences = null,
|
||||
alertOptions = null,
|
||||
}: {
|
||||
alertPreferences?: AlertPreferences | null;
|
||||
alertOptions?: AlertOptions | null;
|
||||
unread: number;
|
||||
notifications: {
|
||||
data: {
|
||||
id: string;
|
||||
submission_id: string;
|
||||
submission_id: string | null;
|
||||
opportunity_id?: number | null;
|
||||
tender_version_id?: string | null;
|
||||
funding_version_id?: string | null;
|
||||
kind: string;
|
||||
payload: { status?: string; reference?: string };
|
||||
payload: {
|
||||
status?: string;
|
||||
reference?: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
source?: string;
|
||||
source_url?: string;
|
||||
source_reference?: string;
|
||||
source_checked_on?: string;
|
||||
preference_version?: number;
|
||||
tender_id?: string;
|
||||
programme_id?: string;
|
||||
scope_explanation?: Record<string, string>;
|
||||
window_states?: {
|
||||
title: string;
|
||||
state: string;
|
||||
end_on?: string | null;
|
||||
}[];
|
||||
publication_version?: number;
|
||||
act?: string;
|
||||
published_at?: string | null;
|
||||
};
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}[];
|
||||
@@ -24,38 +56,163 @@ export default function Notifications({
|
||||
contacted: 'Contactado',
|
||||
closed: 'Encerrado',
|
||||
};
|
||||
const fundingStates: Record<string, string> = {
|
||||
open: 'aberta segundo as datas documentadas',
|
||||
closed: 'encerrada',
|
||||
upcoming: 'abertura futura',
|
||||
unknown: 'por confirmar',
|
||||
};
|
||||
return (
|
||||
<Frame title="Notificações e acompanhamento">
|
||||
<Link href="/investidor/manifestacoes" className="underline">
|
||||
Área do investidor
|
||||
</Link>
|
||||
<p role="status">{unread} notificações por ler</p>
|
||||
{alertPreferences && alertOptions && (
|
||||
<OpportunityAlertPreferences
|
||||
key={alertPreferences.version}
|
||||
preferences={alertPreferences}
|
||||
options={alertOptions}
|
||||
/>
|
||||
)}
|
||||
{notifications.data.map((n) => (
|
||||
<article key={n.id} className={card + ' space-y-3'}>
|
||||
<h2 className="text-xl font-bold">
|
||||
{n.kind === 'received'
|
||||
? 'Manifestação recebida'
|
||||
: n.kind === 'status'
|
||||
? 'Estado actualizado'
|
||||
: 'Nova mensagem na conversa'}
|
||||
{n.kind === 'funding_match'
|
||||
? 'Nova publicação de fundo ou apoio'
|
||||
: n.kind === 'tender_match'
|
||||
? 'Novo acto de concurso compatível'
|
||||
: n.kind === 'opportunity_match'
|
||||
? 'Nova oportunidade compatível'
|
||||
: n.kind === 'received'
|
||||
? 'Manifestação recebida'
|
||||
: n.kind === 'status'
|
||||
? 'Estado actualizado'
|
||||
: 'Nova mensagem na conversa'}
|
||||
</h2>
|
||||
<p>
|
||||
{n.payload.reference ??
|
||||
{n.payload.title ??
|
||||
n.payload.reference ??
|
||||
(n.payload.status
|
||||
? (states[n.payload.status] ?? n.payload.status)
|
||||
: 'Consulte o pedido para ler a mensagem.')}
|
||||
</p>
|
||||
{(n.kind === 'funding_match' ||
|
||||
n.kind === 'opportunity_match' ||
|
||||
n.kind === 'tender_match') && (
|
||||
<p className="text-sm">
|
||||
Fonte: {n.payload.source}.{' '}
|
||||
{n.payload.published_at &&
|
||||
!Number.isNaN(
|
||||
new Date(n.payload.published_at).getTime(),
|
||||
) ? (
|
||||
<>
|
||||
Publicação em{' '}
|
||||
<time dateTime={n.payload.published_at}>
|
||||
{new Date(
|
||||
n.payload.published_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</time>
|
||||
.{' '}
|
||||
</>
|
||||
) : (
|
||||
'Data de publicação não indicada. '
|
||||
)}
|
||||
Correspondência com a versão{' '}
|
||||
{n.payload.preference_version} das suas
|
||||
preferências; não representa avaliação de
|
||||
rentabilidade ou elegibilidade.
|
||||
</p>
|
||||
)}
|
||||
{n.kind === 'tender_match' && (
|
||||
<p>
|
||||
{alertOptions?.tenderActs?.[n.payload.act ?? ''] ??
|
||||
n.payload.act ??
|
||||
'Acto publicado'}{' '}
|
||||
· versão {n.payload.publication_version}.
|
||||
Referência: {n.payload.reference ?? 'Não indicada'}.
|
||||
Consulte o concurso para verificar o estado e as
|
||||
condições actuais. Este aviso não apresenta uma
|
||||
proposta.
|
||||
</p>
|
||||
)}
|
||||
{n.kind === 'funding_match' && (
|
||||
<div>
|
||||
{n.payload.source_url && (
|
||||
<p>
|
||||
<a
|
||||
className="underline"
|
||||
href={n.payload.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{n.payload.source_reference ??
|
||||
'Consultar fonte documentada'}
|
||||
</a>
|
||||
{n.payload.source_checked_on
|
||||
? ` — consultada em ${n.payload.source_checked_on}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Versão {n.payload.publication_version}. Consulte
|
||||
a ficha para conferir as condições actuais. Este
|
||||
aviso não confirma uma candidatura aberta nem
|
||||
concessão de financiamento.
|
||||
</p>
|
||||
{Object.entries(
|
||||
n.payload.scope_explanation ?? {},
|
||||
).map(([key, text]) => (
|
||||
<p key={key}>{text}</p>
|
||||
))}
|
||||
<ul>
|
||||
{(n.payload.window_states ?? []).map(
|
||||
(window, index) => (
|
||||
<li key={index}>
|
||||
{window.title}:{' '}
|
||||
{fundingStates[window.state] ??
|
||||
window.state}
|
||||
{window.end_on
|
||||
? `; data-limite ${window.end_on}`
|
||||
: ''}{' '}
|
||||
(estado na data do aviso).
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
Recebida em{' '}
|
||||
{new Date(n.created_at).toLocaleString('pt-MZ')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link
|
||||
href={
|
||||
'/investidor/manifestacoes/' + n.submission_id
|
||||
n.kind === 'funding_match' &&
|
||||
n.payload.programme_id
|
||||
? '/fundos/' +
|
||||
encodeURIComponent(n.payload.programme_id)
|
||||
: n.kind === 'tender_match' &&
|
||||
n.payload.tender_id
|
||||
? '/concursos/' +
|
||||
encodeURIComponent(n.payload.tender_id)
|
||||
: n.kind === 'opportunity_match' &&
|
||||
n.payload.slug
|
||||
? '/oportunidades/' +
|
||||
encodeURIComponent(n.payload.slug)
|
||||
: '/investidor/manifestacoes/' +
|
||||
n.submission_id
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
Abrir manifestação
|
||||
{n.kind === 'funding_match'
|
||||
? 'Consultar fundo ou apoio'
|
||||
: n.kind === 'tender_match'
|
||||
? 'Consultar concurso'
|
||||
: n.kind === 'opportunity_match'
|
||||
? 'Consultar oportunidade'
|
||||
: 'Abrir manifestação'}
|
||||
</Link>
|
||||
{!n.read_at && (
|
||||
<button
|
||||
@@ -77,7 +234,7 @@ export default function Notifications({
|
||||
</article>
|
||||
))}
|
||||
{!notifications.data.length && (
|
||||
<p>Ainda não existem notificações.</p>
|
||||
<p>Não existem notificações disponíveis nesta consulta.</p>
|
||||
)}
|
||||
<nav aria-label="Paginação" className="flex gap-4">
|
||||
{notifications.prev_page_url && (
|
||||
|
||||
@@ -97,7 +97,8 @@ export default function Profile({
|
||||
, versão{' '}
|
||||
{company.countrySource.organization_version}. O
|
||||
valor é uma cópia identificada; não é atualizado
|
||||
automaticamente quando o cadastro mestre muda.
|
||||
automaticamente quando o registo da organização
|
||||
muda.
|
||||
</p>
|
||||
)}
|
||||
<Editor
|
||||
@@ -173,9 +174,7 @@ export default function Profile({
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<h2 className="text-2xl font-bold">
|
||||
Organizações representadas — cadastro mestre
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold">Organizações representadas</h2>
|
||||
<p>
|
||||
O país de constituição é independente da residência e da origem
|
||||
da solicitação. Não se associa uma empresa privada a uma
|
||||
@@ -290,7 +289,7 @@ function OrganizationEditor({
|
||||
className={button}
|
||||
disabled={!organization.canEdit || form.processing}
|
||||
>
|
||||
Guardar país no cadastro mestre
|
||||
Guardar país da organização
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -174,12 +174,21 @@ export function money(r: Opportunity) {
|
||||
: `${new Intl.NumberFormat('pt-MZ', { maximumFractionDigits: 2 }).format(Number(r.estimated_investment_amount))} ${r.estimated_investment_currency}`;
|
||||
}
|
||||
export async function api(url: string, body?: unknown) {
|
||||
const token = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.slice(11) ?? '',
|
||||
);
|
||||
const encodedToken =
|
||||
body === undefined || typeof document === 'undefined'
|
||||
? ''
|
||||
: (document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.slice(11) ?? '');
|
||||
let token = '';
|
||||
try {
|
||||
token = decodeURIComponent(encodedToken);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'A sessão não pôde ser validada. Actualize a página e tente novamente.',
|
||||
);
|
||||
}
|
||||
const formData =
|
||||
typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
const response = await fetch(url, {
|
||||
@@ -194,10 +203,14 @@ export async function api(url: string, body?: unknown) {
|
||||
? {}
|
||||
: { body: formData ? body : JSON.stringify(body) }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || (data === null && response.status !== 204))
|
||||
throw new Error(
|
||||
data.message ?? 'Não foi possível concluir a operação.',
|
||||
typeof data?.message === 'string'
|
||||
? data.message
|
||||
: response.status === 419
|
||||
? 'A sessão expirou. Actualize a página e tente novamente.'
|
||||
: 'Não foi possível concluir a operação. Tente novamente.',
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -606,7 +606,7 @@ function Editor({
|
||||
incorporation_country_code:
|
||||
'País de constituição da entidade',
|
||||
declared_origin_country_code:
|
||||
'País de origem declarado desta solicitação',
|
||||
'País de origem do pedido (opcional)',
|
||||
}[key]
|
||||
}
|
||||
>
|
||||
@@ -620,7 +620,7 @@ function Editor({
|
||||
incorporation_country_code:
|
||||
'País de constituição da entidade',
|
||||
declared_origin_country_code:
|
||||
'País de origem declarado desta solicitação',
|
||||
'País de origem do pedido (opcional)',
|
||||
}[key]
|
||||
}
|
||||
error={
|
||||
@@ -664,9 +664,10 @@ function Editor({
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
A origem da solicitação é uma declaração
|
||||
sua; não é deduzida da residência, da
|
||||
entidade ou do endereço IP.
|
||||
Indique o país de onde apresenta este
|
||||
pedido, se desejar. Pode ser diferente do
|
||||
país de residência ou de constituição da
|
||||
entidade.
|
||||
</p>
|
||||
{option(
|
||||
'profile_id',
|
||||
|
||||
@@ -454,6 +454,7 @@ export default function Zones({
|
||||
href={catalogueDetailHref(
|
||||
`${management ? '/management/investment/zones' : '/zonas'}/${zone.id}`,
|
||||
exportParams.toString(),
|
||||
pageUrl,
|
||||
)}
|
||||
>
|
||||
{zone.name}
|
||||
@@ -464,6 +465,7 @@ export default function Zones({
|
||||
href={catalogueDetailHref(
|
||||
`${management ? '/management/investment/zones' : '/zonas'}/${zone.id}`,
|
||||
exportParams.toString(),
|
||||
pageUrl,
|
||||
)}
|
||||
>
|
||||
Consultar ficha
|
||||
|
||||
@@ -476,7 +476,7 @@ export default function LandForm({
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
País de origem declarado da solicitação (opcional)
|
||||
País de origem do pedido (opcional)
|
||||
</p>
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
@@ -484,7 +484,7 @@ export default function LandForm({
|
||||
onChange={(value) =>
|
||||
update('declared_origin_country_code', value)
|
||||
}
|
||||
label="País de origem declarado da solicitação (opcional)"
|
||||
label="País de origem do pedido (opcional)"
|
||||
error={
|
||||
form.errors[
|
||||
'payload.declared_origin_country_code'
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import TerritoryGeometryPreview from '@/components/territory-geometry-preview';
|
||||
import InteractiveInvestmentMap, {
|
||||
type InvestmentMapFeature,
|
||||
} from '@/components/interactive-investment-map';
|
||||
import TerritorialMap from '@/components/catalogue-investment-map';
|
||||
import type { InvestmentMapFeature } from '@/components/interactive-investment-map';
|
||||
import {
|
||||
Frame,
|
||||
Field,
|
||||
@@ -159,8 +157,9 @@ export default function Catalogue(
|
||||
vários locais dos membros; estes pontos não confirmam a
|
||||
implantação da parceria.
|
||||
</p>
|
||||
<InteractiveInvestmentMap points={p.mapPoints} />
|
||||
<TerritoryGeometryPreview
|
||||
<TerritorialMap
|
||||
points={p.mapPoints}
|
||||
mapKind="documented_locations"
|
||||
territoryId={p.filters.territory_id}
|
||||
/>
|
||||
{!p.mapPoints.length && (
|
||||
|
||||
@@ -78,7 +78,7 @@ function Editor(p: Props) {
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<Field label="Participação">
|
||||
<Field label="Organização que representa (opcional)">
|
||||
<select
|
||||
className={control}
|
||||
value={form.data.organization_id}
|
||||
|
||||
@@ -249,10 +249,10 @@ function RequestForm({
|
||||
);
|
||||
})}
|
||||
<label className="block">
|
||||
País de origem declarado desta solicitação
|
||||
País de origem do pedido (opcional)
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
label="País de origem declarado desta solicitação"
|
||||
label="País de origem do pedido (opcional)"
|
||||
value={form.data.declared_origin_country_code}
|
||||
onChange={(value) =>
|
||||
form.setData('declared_origin_country_code', value)
|
||||
@@ -260,8 +260,8 @@ function RequestForm({
|
||||
/>
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
Origem opcional e independente do país acima. Sem declaração,
|
||||
permanece não informado nas estatísticas.
|
||||
Indique o país de onde apresenta este pedido, se desejar. Pode
|
||||
ser diferente do país do requerente.
|
||||
</p>
|
||||
<label className="block">
|
||||
Descreva o pedido
|
||||
|
||||
@@ -130,6 +130,7 @@ export default function Index({
|
||||
([, value]) => Boolean(value),
|
||||
) as [string, string][],
|
||||
).toString(),
|
||||
url,
|
||||
)}
|
||||
>
|
||||
{r.title}
|
||||
@@ -163,6 +164,7 @@ export default function Index({
|
||||
([, value]) => Boolean(value),
|
||||
) as [string, string][],
|
||||
).toString(),
|
||||
url,
|
||||
)}
|
||||
>
|
||||
Consultar ficha
|
||||
|
||||
@@ -97,11 +97,11 @@ function JourneyCard({
|
||||
allowLegacyValue
|
||||
/>
|
||||
</Field>
|
||||
<Field label="País de origem declarado desta solicitação">
|
||||
<Field label="País de origem do pedido (opcional)">
|
||||
<CountrySelect
|
||||
countries={countries}
|
||||
value={form.data.declared_origin_country_code}
|
||||
label="País de origem declarado desta solicitação"
|
||||
label="País de origem do pedido (opcional)"
|
||||
error={form.errors.declared_origin_country_code}
|
||||
onChange={(value) =>
|
||||
form.setData(
|
||||
@@ -111,7 +111,8 @@ function JourneyCard({
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
Origem opcional, independente do país acima.
|
||||
País de onde apresenta este pedido. Pode ser
|
||||
diferente do país do requerente.
|
||||
</p>
|
||||
</Field>
|
||||
<Field label="Papel pretendido">
|
||||
|
||||
@@ -431,7 +431,7 @@ export default function Edit({
|
||||
</Field>
|
||||
{value('guarantee_status') === 'required' &&
|
||||
input('guarantees')}
|
||||
<Field label="Fuso horário IANA do procedimento">
|
||||
<Field label="Fuso horário do procedimento (ex.: Africa/Maputo)">
|
||||
<input
|
||||
className={control}
|
||||
value={value('timezone')}
|
||||
@@ -440,9 +440,9 @@ export default function Edit({
|
||||
/>
|
||||
</Field>
|
||||
<p>
|
||||
As datas e horas introduzidas pertencem a este fuso.
|
||||
Publicação no InvesteMoz é registada separadamente pelo
|
||||
servidor; não reinicia o prazo.
|
||||
Use o identificador do fuso, como Africa/Maputo. As
|
||||
datas e horas do procedimento seguem esse fuso; a
|
||||
publicação no portal mantém o prazo indicado.
|
||||
</p>
|
||||
<Field label="Prazo mínimo documentado em dias (opcional)">
|
||||
<input
|
||||
|
||||
@@ -75,8 +75,8 @@ function OperatorOrganizationForm() {
|
||||
Organização proprietária e operadora
|
||||
</h2>
|
||||
<p>
|
||||
Reutiliza o cadastro mestre e conserva o histórico. Estes dados
|
||||
não atribuem representação empresarial nem papéis às contas.
|
||||
Seleccione a organização responsável pelo portal. As permissões
|
||||
dos utilizadores são definidas separadamente.
|
||||
</p>
|
||||
<form
|
||||
className="max-w-2xl space-y-3"
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function RequestProfile({
|
||||
</p>
|
||||
)}
|
||||
<label className="block">
|
||||
Explique o seu pedido
|
||||
Para que actividades precisa do perfil de Promotor?
|
||||
<textarea
|
||||
required
|
||||
minLength={20}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@foreach($answers as $key => $value)
|
||||
@php
|
||||
$field = $definition['fields'][$key] ?? [];
|
||||
$empty = $value === null || $value === '' || $value === [];
|
||||
$display = $empty ? 'Não informado' : (is_bool($value) ? ($value ? 'Sim' : 'Não') : (is_scalar($value) ? ($field['options'][(string) $value] ?? (string) $value) : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)));
|
||||
if (!$empty && in_array($key, ['investment_amount', 'own_finance', 'other_finance'], true) && is_numeric($value)) {
|
||||
$display = number_format((float)$value, 2, ',', ' ').' '.(($snapshot['payload']['currency'] ?? '') ?: '(moeda não informada)');
|
||||
}
|
||||
@endphp
|
||||
<tr><th scope="row">{{ $field['label'] ?? \Illuminate\Support\Str::headline($key) }}</th><td>{{ $display }}</td></tr>
|
||||
@endforeach
|
||||
@@ -26,24 +26,23 @@ footer { margin-top:28px; border-top:1px solid #cbd8d3; padding-top:12px; }
|
||||
<h2>Dados submetidos</h2><table><caption>Informação declarada pelo requerente</caption><tbody>
|
||||
@php
|
||||
$opportunityScope = json_decode($snapshot['payload']['context_opportunity_snapshot'] ?? '{}', true);
|
||||
$applicableFields = $applicableFields ?? app(\App\Services\VirtualDesk::class)->applicableFields($snapshot['payload'] ?? [], $definition);
|
||||
$answers = array_diff_key($snapshot['payload'] ?? [], array_flip(['context_opportunity_snapshot', 'context_opportunity', 'context_reuse']));
|
||||
$inactiveAnswers = array_intersect_key($answers, array_diff_key($definition['fields'], $applicableFields));
|
||||
@endphp
|
||||
@if(data_get($opportunityScope, 'choice.opportunity.key'))
|
||||
<tr><th>Oportunidade associada</th><td>{{ data_get($opportunityScope, 'choice.opportunity.title') }} · {{ data_get($opportunityScope, 'choice.opportunity.key') }}</td></tr>
|
||||
<tr><th>Contexto territorial guardado</th><td>{{ data_get($opportunityScope, 'choice.territory.title') ?: 'Por definir' }} · {{ data_get($opportunityScope, 'choice.corridor_name') ?: 'Corredor por definir' }} · {{ data_get($opportunityScope, 'choice.park.title') ?: 'Sem zona/parque seleccionado' }} · Revisão {{ $opportunityScope['revision'] }}</td></tr>
|
||||
@endif
|
||||
@foreach(($snapshot['payload'] ?? []) as $key => $value)
|
||||
@continue(in_array($key, ['context_opportunity_snapshot', 'context_opportunity', 'context_reuse'], true))
|
||||
@php
|
||||
$field = $definition['fields'][$key] ?? [];
|
||||
$empty = $value === null || $value === '' || $value === [];
|
||||
$display = $empty ? 'Não informado' : (is_bool($value) ? ($value ? 'Sim' : 'Não') : (is_scalar($value) ? ($field['options'][(string) $value] ?? (string) $value) : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)));
|
||||
if (!$empty && in_array($key, ['investment_amount', 'own_finance', 'other_finance'], true) && is_numeric($value)) {
|
||||
$display = number_format((float)$value, 2, ',', ' ').' '.(($snapshot['payload']['currency'] ?? '') ?: '(moeda não informada)');
|
||||
}
|
||||
@endphp
|
||||
<tr><th scope="row">{{ $field['label'] ?? \Illuminate\Support\Str::headline($key) }}</th><td>{{ $display }}</td></tr>
|
||||
@endforeach
|
||||
@include('desk.receipt-answers', ['answers' => array_diff_key($answers, $inactiveAnswers)])
|
||||
</tbody></table>
|
||||
@if($inactiveAnswers)
|
||||
<h2>Respostas conservadas de perguntas não aplicáveis</h2>
|
||||
<p>Estas respostas foram conservadas na submissão, mas as condições do formulário não as tornam aplicáveis. Não entram nas verificações de preparação deste pedido.</p>
|
||||
<table><caption>Histórico conservado na submissão</caption><tbody>
|
||||
@include('desk.receipt-answers', ['answers' => $inactiveAnswers])
|
||||
</tbody></table>
|
||||
@endif
|
||||
<h2>Suportes documentais</h2>
|
||||
@forelse(($snapshot['documents'] ?? []) as $document)
|
||||
<p>{{ data_get($document, 'source_reference') ?: 'Referência não informada' }} · Versão {{ data_get($document, 'version_no') ?? 'Não informada' }}</p>
|
||||
|
||||
@@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('investmoz:escalate-overdue-tasks')->everyFifteenMinutes()->withoutOverlapping();
|
||||
Schedule::command('investmoz:dispatch-opportunity-alerts')->everyFiveMinutes()->withoutOverlapping();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\FundingProgrammeController as Funding;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/fundos', [Funding::class, 'index'])->name('funding.index');
|
||||
Route::get('/fundos/guardados', [Funding::class, 'saved'])->middleware(['auth', 'verified'])->name('funding.saved');
|
||||
Route::get('/fundos/{id}', [Funding::class, 'show'])->whereUuid('id')->name('funding.show');
|
||||
Route::post('/fundos/{id}/criterios', [Funding::class, 'assess'])->whereUuid('id')->middleware('throttle:30,1');
|
||||
Route::get('/fundos/{id}/versoes/{version}/documentos/{resource}', [Funding::class, 'download'])->whereUuid('id')->whereUuid('version')->whereNumber('resource');
|
||||
Route::put('/fundos/{id}/interesse', [Funding::class, 'saveInterest'])->whereUuid('id')->middleware(['auth', 'verified', 'throttle:30,1']);
|
||||
Route::delete('/fundos/{id}/interesse', [Funding::class, 'removeInterest'])->whereUuid('id')->middleware(['auth', 'verified', 'throttle:30,1']);
|
||||
|
||||
Route::middleware(['auth', 'verified'])->prefix('management/fundos')->group(function () {
|
||||
Route::get('/', [Funding::class, 'manage']);
|
||||
Route::get('/novo', [Funding::class, 'edit']);
|
||||
Route::get('/referencias/{code}', [Funding::class, 'reference'])->where('code', '[a-z0-9-]+');
|
||||
Route::post('/referencias/{code}/preparar', [Funding::class, 'prepareReference'])->where('code', '[a-z0-9-]+');
|
||||
Route::post('/', [Funding::class, 'save']);
|
||||
Route::get('/{id}', [Funding::class, 'edit'])->whereUuid('id');
|
||||
Route::put('/{id}', [Funding::class, 'save'])->whereUuid('id');
|
||||
Route::post('/{id}/previa', [Funding::class, 'preview'])->whereUuid('id');
|
||||
Route::post('/{id}/publicar', [Funding::class, 'publish'])->whereUuid('id');
|
||||
Route::post('/{id}/retirar', [Funding::class, 'withdraw'])->whereUuid('id');
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InvestmentAlertPreferencesController;
|
||||
use App\Http\Controllers\InvestmentContextController;
|
||||
use App\Http\Controllers\InvestmentDiscoveryController;
|
||||
use App\Http\Controllers\InvestmentSubmissionController;
|
||||
@@ -39,6 +40,7 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class])->group
|
||||
Route::put($prefix.'/organizations/{id}/country', [InvestmentWorkspaceController::class, 'organizationCountry'])->whereUuid('id')->middleware('throttle:20,1');
|
||||
Route::post($prefix.'/companies/{id}/organization-country', [InvestmentWorkspaceController::class, 'reuseOrganizationCountry'])->whereUuid('id')->middleware('throttle:20,1');
|
||||
Route::get($prefix.'/notifications', [InvestmentWorkspaceController::class, 'notifications']);
|
||||
Route::put($prefix.'/alert-preferences', [InvestmentAlertPreferencesController::class, 'save'])->middleware('throttle:20,1');
|
||||
Route::post($prefix.'/notifications/{id}/read', [InvestmentWorkspaceController::class, 'readNotification'])->whereUuid('id');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\DeskServiceEditorController as ServiceEditor;
|
||||
use App\Http\Controllers\InvestorGuidanceController as Guidance;
|
||||
use App\Http\Controllers\VirtualDeskController as Desk;
|
||||
use App\Http\Controllers\VirtualDeskInstallationController as Installation;
|
||||
@@ -45,6 +46,9 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class])->group
|
||||
Route::get('/management/balcao/fontes', [Installation::class, 'sources']);
|
||||
Route::post('/management/balcao-install', [Installation::class, 'apply']);
|
||||
Route::post('/management/balcao/servicos', [Installation::class, 'publishService'])->middleware(VirtualDeskInstalled::class);
|
||||
Route::get('/management/balcao/servicos', [ServiceEditor::class, 'index'])->middleware(VirtualDeskInstalled::class);
|
||||
Route::put('/management/balcao/servicos/{code}', [ServiceEditor::class, 'publish'])->where('code', '[a-z0-9-]+')->middleware(VirtualDeskInstalled::class);
|
||||
Route::get('/management/balcao/servicos/{code}/versoes', [ServiceEditor::class, 'history'])->where('code', '[a-z0-9-]+')->middleware(VirtualDeskInstalled::class);
|
||||
Route::post('/management/balcao/ligacoes/{id}/conferencia', [Installation::class, 'reviewLink'])->whereUuid('id')->middleware(VirtualDeskInstalled::class);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/procurement.php';
|
||||
require __DIR__.'/funding.php';
|
||||
|
||||
require __DIR__.'/existing-business.php';
|
||||
require __DIR__.'/territorial-registry.php';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user