Entrega dashboard e correccoes transversais preservando Ofertas instalado
tests / ci (push) Waiting to run
tests / ci (push) Waiting to run
This commit is contained in:
@@ -20,11 +20,14 @@ final class DemographyController extends Controller
|
||||
{
|
||||
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
|
||||
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
|
||||
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
|
||||
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid', 'investment_context' => 'nullable|boolean']);
|
||||
$start = (int) ($data['start_year'] ?? date('Y'));
|
||||
$end = (int) ($data['end_year'] ?? $start);
|
||||
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
|
||||
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
|
||||
if (! empty($data['investment_context']) && count($data['territories'] ?? []) === 1) {
|
||||
$evidence = $service->investmentContext($data['territories'][0], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
|
||||
}
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($evidence);
|
||||
}
|
||||
|
||||
@@ -150,31 +150,37 @@ final class FundingProgrammeController extends Controller
|
||||
'category' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::CATEGORIES))], 'institution_type' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::TYPES))], 'location_mode' => 'nullable|in:physical,coverage', 'include_historical' => 'nullable|boolean',
|
||||
'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
|
||||
'context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150',
|
||||
'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid',
|
||||
'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 !== '');
|
||||
$dossier = $request->user() && ! empty($filters['context']) ? app(FundingDossierPreparation::class)->context($request->user(), $filters['context'], $filters['opportunity'] ?? null) : null;
|
||||
if ($dossier && empty($filters['territory']) && $dossier['territory']) {
|
||||
$explicitLocation = $request->hasAny(['territory', 'province_id', 'district_id', 'admin_post_id', 'locality_id']);
|
||||
if ($dossier && ! $explicitLocation && empty($filters['territory']) && $dossier['territory']) {
|
||||
$filters['territory'] = $dossier['territory'];
|
||||
}
|
||||
$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])->orWhereRaw("(v.payload->'aliases')::text ilike ?", [$term])->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE l->>'label' ilike ? OR l->>'address' ilike ? OR l->>'location_text' ilike ?)", [$term, $term, $term]));
|
||||
$geography = app(InvestmentGeography::class);
|
||||
$criteriaErrors = [];
|
||||
$territorySelection = [];
|
||||
try {
|
||||
$filters = $geography->interestSelection($filters);
|
||||
$territorySelection = ! empty($filters['territory']) ? $geography->path($filters['territory']) : [];
|
||||
$query = $service->catalogueQuery($filters);
|
||||
$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();
|
||||
} catch (\Illuminate\Validation\ValidationException $error) {
|
||||
if ($request->expectsJson()) throw $error;
|
||||
$criteriaErrors = $error->errors();
|
||||
$items = new \Illuminate\Pagination\LengthAwarePaginator([], 0, 20, 1, ['path' => '/fundos', 'query' => $request->query()]);
|
||||
}
|
||||
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, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
|
||||
'options' => ['territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name']),
|
||||
return tap(Inertia::render('funding/index', ['criteriaErrors' => $criteriaErrors, 'territorySelection' => $territorySelection, 'categoryTypes' => FundingInstitutionClassification::CATEGORY_TYPES, 'items' => $items, 'filters' => $filters, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
|
||||
'options' => ['territories' => $territorySelection,
|
||||
'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'));
|
||||
}
|
||||
@@ -183,7 +189,7 @@ final class FundingProgrammeController extends Controller
|
||||
{
|
||||
$request->validate(['context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150', 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'territory' => 'nullable|uuid', 'category' => 'nullable|string|max:80', 'institution_type' => 'nullable|string|max:80', 'location_mode' => 'nullable|in:physical,coverage']);
|
||||
$dossier = $request->user() && $request->filled('context') ? app(FundingDossierPreparation::class)->context($request->user(), $request->string('context')->toString(), $request->query('opportunity')) : null;
|
||||
if ($dossier && ! $request->filled('territory') && $dossier['territory']) {
|
||||
if ($dossier && ! $request->has('territory') && $dossier['territory']) {
|
||||
$request->merge(['territory' => $dossier['territory']]);
|
||||
}
|
||||
$record = $service->published($id);
|
||||
@@ -204,7 +210,7 @@ final class FundingProgrammeController extends Controller
|
||||
$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, 'dossier' => $dossier, 'selection' => [...$request->only(['need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
|
||||
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['q', 'kind', 'sector', 'certainty', 'state', 'include_historical', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'page', 'need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
|
||||
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentOpportunityAlerts;
|
||||
use App\Services\InvestmentProfileCommands;
|
||||
use App\Services\InvestmentWorkspace;
|
||||
use App\Services\VirtualDesk;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OrganizationCountry;
|
||||
use App\Services\Procurement;
|
||||
@@ -28,6 +29,8 @@ class InvestmentWorkspaceController extends Controller
|
||||
{
|
||||
public function profile(Request $request, InvestmentWorkspace $workspace): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$entry = $request->validate(['dossier' => 'nullable|uuid']);
|
||||
$continuation = ! empty($entry['dossier']) ? app(VirtualDesk::class)->case($request->user(), $entry['dossier']) : null;
|
||||
$data = ['actorId' => $request->user()->id, 'organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
|
||||
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
|
||||
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -36,6 +39,13 @@ class InvestmentWorkspaceController extends Controller
|
||||
return $company;
|
||||
})];
|
||||
|
||||
$data['continuation'] = $continuation ? ['id' => $continuation->id, 'title' => $continuation->title, 'href' => '/investimentos/'.$continuation->id.'#entidade-promotora'] : null;
|
||||
$forms = config('company-comparison.forms');
|
||||
abort_unless(is_array($forms), 503, 'As formas jurídicas estão temporariamente indisponíveis.');
|
||||
$data['legalForms'] = [];
|
||||
foreach ($forms as $code => $form) {
|
||||
$data['legalForms'][] = ['value' => $code, 'label' => $form['title']];
|
||||
}
|
||||
Inertia::encryptHistory();
|
||||
|
||||
$response = $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data)->toResponse($request);
|
||||
@@ -77,7 +87,8 @@ class InvestmentWorkspaceController extends Controller
|
||||
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'operation_key' => ['sometimes', 'required', 'uuid'],
|
||||
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'],
|
||||
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,legal_form,sector,role,description'],
|
||||
'payload.legal_form' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('company-comparison.forms')))],
|
||||
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'],
|
||||
'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestmentComplements;
|
||||
use App\Services\InvestmentCorridorContext;
|
||||
use App\Services\InvestmentGeography;
|
||||
@@ -19,6 +20,27 @@ use Inertia\Response;
|
||||
|
||||
final class InvestorContextController extends Controller
|
||||
{
|
||||
/** Explicit choices only; a title never assigns an activity or amount.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function ideaEntry(Request $request): array
|
||||
{
|
||||
$entry = $request->validate([
|
||||
'project_title' => 'nullable|string|max:200',
|
||||
'sector_id' => 'nullable|integer|exists:sectors,id',
|
||||
'sector' => 'nullable|string|max:100|exists:sectors,slug',
|
||||
'investment_budget_min' => 'nullable|numeric|min:0',
|
||||
'investment_budget_max' => ['nullable', 'numeric', 'min:0', Rule::when($request->filled('investment_budget_min'), 'gte:investment_budget_min')],
|
||||
'currency' => 'nullable|regex:/^[A-Z]{3}$/D',
|
||||
]);
|
||||
if (empty($entry['sector_id']) && ! empty($entry['sector'])) {
|
||||
$entry['sector_id'] = (string) Sector::where('slug', $entry['sector'])->value('id');
|
||||
}
|
||||
unset($entry['sector']);
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
@@ -82,17 +104,21 @@ final class InvestorContextController extends Controller
|
||||
|
||||
public function options(Request $r, InvestorContext $context): JsonResponse
|
||||
{
|
||||
$d = $r->validate(['q' => 'nullable|string|max:120']);
|
||||
$d = $r->validate(['q' => 'nullable|string|max:120', 'sector_id' => 'nullable|integer|exists:sectors,id', 'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']);
|
||||
$filters = array_intersect_key($d, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id']));
|
||||
if (! empty($d['sector_id'])) {
|
||||
$filters['sector'] = Sector::whereKey($d['sector_id'])->firstOrFail()->slug;
|
||||
}
|
||||
|
||||
return response()->json($context->options($d['q'] ?? ''));
|
||||
return response()->json($context->options($d['q'] ?? '', $filters));
|
||||
}
|
||||
|
||||
public function corridorOptions(Request $r): JsonResponse
|
||||
{
|
||||
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
|
||||
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid', 'include_corridors' => 'nullable|boolean']);
|
||||
$service = app(InvestmentCorridorContext::class);
|
||||
|
||||
return response()->json(['corridors' => collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values(),
|
||||
return response()->json(['corridors' => ($data['include_corridors'] ?? true) ? collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values() : [],
|
||||
'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]);
|
||||
}
|
||||
|
||||
@@ -101,6 +127,16 @@ final class InvestorContextController extends Controller
|
||||
return response()->json($context->saveDraft($r->user(), $id, $r->all()));
|
||||
}
|
||||
|
||||
public function supplyAggregate(Request $r, string $id, InvestorContext $context): JsonResponse
|
||||
{
|
||||
return response()->json($context->saveSupplyAggregate($r->user(), $id, $r->all()))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function promoter(Request $r, string $id, InvestorContext $context): JsonResponse
|
||||
{
|
||||
return response()->json($r->isMethod('GET') ? $context->promoter($r->user(), $id) : $context->setPromoter($r->user(), $id, $r->all()))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function edit(Request $r, string $id, InvestorContext $context): Response
|
||||
{
|
||||
$data = $context->view($r->user(), $id);
|
||||
@@ -133,8 +169,8 @@ final class InvestorContextController extends Controller
|
||||
}
|
||||
}
|
||||
$complements = isset($draft['complements']) ? app(InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
|
||||
$entryTerritory = null;
|
||||
if (($source['kind'] ?? null) === 'portfolio') {
|
||||
$entryTerritory = ($source['kind'] ?? '') === 'territory' ? $source : null;
|
||||
if ($r->hasAny(['province_id', 'district_id', 'admin_post_id', 'locality_id'])) {
|
||||
$path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']));
|
||||
if ($path) {
|
||||
$entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
|
||||
@@ -148,7 +184,7 @@ final class InvestorContextController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
|
||||
return Inertia::render('investor-path/context', ['entryIdea' => $this->ideaEntry($r), 'entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
|
||||
}
|
||||
|
||||
public function preview(Request $r, string $id, InvestorContext $context): JsonResponse
|
||||
@@ -175,7 +211,7 @@ final class InvestorContextController extends Controller
|
||||
|
||||
$choices = $this->entryChoices($r, $context);
|
||||
|
||||
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
|
||||
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => [...$this->ideaEntry($r), ...$r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id'])], 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
|
||||
}
|
||||
|
||||
public function installation(Request $r, InvestorContextInstallation $install): JsonResponse|Response
|
||||
|
||||
@@ -37,13 +37,14 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'contact_province', 'contact_district', 'preferred_channel',
|
||||
];
|
||||
$economic = [
|
||||
'preparation_step', 'planning_assumptions_snapshot', 'initial_budget_snapshot', 'fiscal_tools_selection', 'planned_funding', 'requested_funding', 'approved_funding', 'planning_loan',
|
||||
'context_opportunity', 'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', 'monthly_iva_settings', 'monthly_iva_snapshot', 'modelo_a_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
|
||||
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
|
||||
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
|
||||
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
|
||||
'project_duration_years', 'existing_project_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id',
|
||||
'site_known', 'latitude', 'longitude', 'area_required', 'area_unit', 'special_zone_id', 'land_status',
|
||||
'infrastructure_needs', 'currency', 'total_investment', 'own_capital', 'domestic_debt', 'foreign_debt',
|
||||
'infrastructure_needs', 'investment_budget_min', 'investment_budget_max', 'currency', 'total_investment', 'own_capital', 'domestic_debt', 'foreign_debt',
|
||||
'grants', 'secured_amount', 'sought_amount', 'annual_revenue', 'annual_operating_costs', 'expected_profit',
|
||||
'financing_term_months', 'funds_origin_countries', 'direct_jobs', 'indirect_jobs', 'national_jobs',
|
||||
'foreign_jobs', 'skills_needed', 'local_content_percentage', 'exports_value', 'import_substitution_value',
|
||||
@@ -69,6 +70,15 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'economic_snapshot.purpose' => ['required', 'in:simulate_only,own_investment,seek_investor,seek_partner,present_opportunity,request_facilitation'],
|
||||
'economic_snapshot.journey_type' => ['nullable', 'in:personal,small_business,company,investor'],
|
||||
'economic_snapshot.detail_level' => ['nullable', 'in:simple,advanced'],
|
||||
'economic_snapshot.preparation_step' => ['nullable', 'in:purpose,market,investment,financial,operating,cashflow,tax,review,promoter,identity,project,location,impact,opportunity,documents,team,scenarios,procedures'],
|
||||
'economic_snapshot.initial_budget_snapshot' => ['nullable', 'string', 'max:12000'],
|
||||
'economic_snapshot.planning_assumptions_snapshot' => ['nullable', 'string', 'max:12000'],
|
||||
'economic_snapshot.fiscal_tools_selection' => ['nullable', 'array', 'max:80'],
|
||||
'economic_snapshot.fiscal_tools_selection.*' => ['string', 'max:100', 'regex:/^(tax|iva|irpc):[a-z_]+$/'],
|
||||
'economic_snapshot.planned_funding' => ['nullable', 'numeric', 'min:0'],
|
||||
'economic_snapshot.requested_funding' => ['nullable', 'numeric', 'min:0'],
|
||||
'economic_snapshot.approved_funding' => ['nullable', 'numeric', 'min:0'],
|
||||
'economic_snapshot.planning_loan' => ['nullable', 'in:yes,no,unknown'],
|
||||
'economic_snapshot.business_plan_snapshot' => ['nullable', 'string', 'max:60000'],
|
||||
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
|
||||
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
|
||||
@@ -126,6 +136,8 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'economic_snapshot.publication_authorisation' => ['nullable', 'in:yes,no'],
|
||||
'economic_snapshot.public_fields_confirmation' => ['nullable', 'in:yes,no'],
|
||||
'economic_snapshot.validation_acknowledged' => ['nullable', 'in:yes,no'],
|
||||
'economic_snapshot.investment_budget_min' => ['nullable', 'numeric', 'min:0'],
|
||||
'economic_snapshot.investment_budget_max' => ['nullable', 'numeric', 'min:0', \Illuminate\Validation\Rule::when($this->filled('economic_snapshot.investment_budget_min'), 'gte:economic_snapshot.investment_budget_min')],
|
||||
'economic_snapshot.currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/'],
|
||||
'economic_snapshot.opportunity_currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/'],
|
||||
'economic_snapshot.rate' => ['nullable', 'numeric', 'between:0,100'],
|
||||
@@ -288,6 +300,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
|
||||
$allowedLabels = [
|
||||
'economic_snapshot.selection_labels' => ['journey_type', 'detail_level', 'purpose', 'manifestation_type', 'preferred_language', 'project_type', 'maturity_stage', 'sector_id', 'activity_id',
|
||||
'customer_validation_status', 'location_assessment_status', 'cost_estimate_status', 'receivables_timing_status', 'funding_status', 'planning_loan',
|
||||
'province_id', 'district_id', 'admin_post_id', 'locality_id', 'site_known', 'land_status', 'infrastructure_needs',
|
||||
'area_unit', 'required_documents', 'opportunity_currency',
|
||||
'currency', 'funds_origin_countries', 'environmental_category', 'tax_calculation', 'fiscal_scenario_mode', 'offer_or_demand',
|
||||
@@ -303,7 +316,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
$labels = is_array($label) ? $label : [$label];
|
||||
if (! is_string($field) || ! in_array($field, $allowedFields, true) || count($labels) > 100
|
||||
|| collect($labels)->contains(fn ($value) => ! is_string($value) || mb_strlen($value) > 255)) {
|
||||
$validator->errors()->add($path, 'Os rótulos de selecção não são válidos.');
|
||||
$validator->errors()->add($path.'.'.(is_string($field) ? $field : 'draft'), 'Não foi possível guardar a opção apresentada neste campo. Volte a seleccioná-la; os outros dados do rascunho foram preservados.');
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,8 @@ final class CompanyComparison
|
||||
public function view(User $actor, string $id, array $input): array
|
||||
{
|
||||
$case = $this->desk->case($actor, $id);
|
||||
$saved = $this->desk->decode($case->payload)['company_comparison'] ?? null;
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
$saved = $payload['company_comparison'] ?? null;
|
||||
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate();
|
||||
if (! empty($v['revision'])) {
|
||||
if (array_key_exists('answers', $input)) {
|
||||
@@ -160,7 +161,7 @@ final class CompanyComparison
|
||||
return ['id' => $row->id, 'revision' => $snapshot['revision'], 'at' => $snapshot['at'], 'engine_version' => $snapshot['result']['engine_version']];
|
||||
})->all();
|
||||
|
||||
return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
|
||||
return ['entity_kind' => $payload['investor_path']['context']['promoter_entity']['kind'] ?? ($case->company_id ? 'company' : 'undetermined'), 'case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
|
||||
'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'answers' => $answers, 'result' => $result,
|
||||
'saved' => $saved, 'history' => $history, 'historyNext' => $events->count() > 25 ? $events[24]->event_order : null,
|
||||
'historical' => ! empty($v['revision']), 'workingCopy' => array_key_exists('answers', $input)];
|
||||
@@ -171,7 +172,7 @@ final class CompanyComparison
|
||||
public function save(User $actor, string $id, array $input): array
|
||||
{
|
||||
$this->desk->case($actor, $id, true);
|
||||
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate();
|
||||
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid', 'continue_preparation' => 'nullable|boolean'])->validate();
|
||||
$v['version'] = (int) $v['version'];
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $v, $input) {
|
||||
@@ -180,19 +181,23 @@ final class CompanyComparison
|
||||
$data = $this->desk->decode($event->payload);
|
||||
if ($data['operation_key'] === $v['operation_key']) {
|
||||
$answers = $this->normalize($input, $data['snapshot']['result']['questions']);
|
||||
$hash = hash('sha256', json_encode([$v['version'], $answers], JSON_THROW_ON_ERROR));
|
||||
$hash = hash('sha256', json_encode(! empty($v['continue_preparation']) ? [$v['version'], $answers, 'continue_preparation'] : [$v['version'], $answers], JSON_THROW_ON_ERROR));
|
||||
abort_unless(hash_equals($data['request_hash'], $hash), 409, 'Esta operação já guardou respostas diferentes. Reabra a comparação.');
|
||||
|
||||
return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']];
|
||||
}
|
||||
}
|
||||
$answers = $this->answers($input);
|
||||
$hash = hash('sha256', json_encode([$v['version'], $answers], JSON_THROW_ON_ERROR));
|
||||
$hash = hash('sha256', json_encode(! empty($v['continue_preparation']) ? [$v['version'], $answers, 'continue_preparation'] : [$v['version'], $answers], JSON_THROW_ON_ERROR));
|
||||
abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Conserve as respostas e recupere a versão actual antes de guardar.');
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
if (! empty($v['continue_preparation'])) {
|
||||
abort_if(in_array($payload['investor_path']['context']['promoter_entity']['kind'] ?? '', ['company', 'organization'], true), 422, 'A entidade existente pode continuar a viabilidade sem preparar nova constituição.');
|
||||
abort_if(($answers['preferred'] ?? 'unknown') === 'unknown', 422, 'Escolha a alternativa que pretende preparar.');
|
||||
}
|
||||
$result = $this->evaluate($answers);
|
||||
$previous = $payload['company_comparison'] ?? null;
|
||||
if ($previous !== null && $previous['answers'] === $answers && $previous['result'] === $result) {
|
||||
if (empty($v['continue_preparation']) && $previous !== null && $previous['answers'] === $answers && $previous['result'] === $result) {
|
||||
return ['saved' => true, 'replayed' => false, 'unchanged' => true, 'revision' => $previous['revision']];
|
||||
}
|
||||
$snapshot = ['revision' => (int) ($payload['company_comparison']['revision'] ?? 0) + 1, 'answers' => $answers,
|
||||
@@ -201,6 +206,9 @@ final class CompanyComparison
|
||||
$case->version++;
|
||||
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version, 'updated_at' => now()]);
|
||||
$this->desk->event($case, $actor, self::EVENT, ['operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot' => $snapshot]);
|
||||
if (! empty($v['continue_preparation'])) {
|
||||
app(InvestorContext::class)->setPromoter($actor, $id, ['version' => $case->version, 'operation_key' => (string) \Illuminate\Support\Str::uuid(), 'kind' => 'being_formed']);
|
||||
}
|
||||
|
||||
return ['saved' => true, 'replayed' => false, 'revision' => $snapshot['revision']];
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ final class CompanyPreparation
|
||||
return ['version' => self::VERSION, 'forms' => config('company-comparison.forms'),
|
||||
'roles' => ['subscriber' => 'Titular, sócio ou accionista', 'administrator' => 'Administrador ou gerente', 'representative' => 'Representante', 'beneficial_owner' => 'Beneficiário efectivo declarado'],
|
||||
'source' => config('company-comparison.source'),
|
||||
'template_status' => 'source_pending', 'template_notice' => 'Os sete exemplares referidos no complemento ainda não foram fornecidos. Esta ficha prepara dados para revisão; não é uma minuta contratual, assinatura, registo ou licença.'];
|
||||
'template_status' => 'source_pending', 'template_notice' => 'Os modelos contratuais não estão disponíveis nesta preparação. Prepare os dados para revisão; esta preparação não é uma minuta contratual, assinatura, registo ou licença.'];
|
||||
}
|
||||
|
||||
/** Reuse only the already-authorized, frozen dossier values, never a fresh master lookup.
|
||||
@@ -36,8 +36,8 @@ final class CompanyPreparation
|
||||
$sources['case_proponent'] = ['name' => $payload['name'], 'kind' => 'person', 'reference' => $case->id,
|
||||
'version' => $payload['reuse_consent']['date'] ?? 'creation', 'origin' => 'Proponente conservado na criação do dossier'];
|
||||
}
|
||||
if ($case->company_id && ! empty($payload['company']['name'])) {
|
||||
$sources['case_company'] = ['name' => $payload['company']['name'], 'kind' => 'entity', 'reference' => $case->company_id,
|
||||
if (($case->company_id || ! empty($payload['investor_path']['context']['promoter_entity']['id'])) && ! empty($payload['company']['name'])) {
|
||||
$sources['case_company'] = ['name' => $payload['company']['name'], 'kind' => 'entity', 'reference' => $case->company_id ?? $payload['investor_path']['context']['promoter_entity']['id'],
|
||||
'version' => $payload['company_version'] ?? null, 'origin' => 'Declaração de empresa conservada no dossier'];
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ final class CompanyPreparation
|
||||
{
|
||||
$case = $this->desk->case($actor, $id);
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate();
|
||||
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1', 'form' => ['nullable', Rule::in(array_keys(config('company-comparison.forms')))]])->validate();
|
||||
$saved = $payload['company_preparation'] ?? null;
|
||||
if (! empty($v['revision'])) {
|
||||
$event = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first();
|
||||
@@ -228,11 +228,22 @@ final class CompanyPreparation
|
||||
$saved = $this->desk->decode($event->payload)['snapshot'];
|
||||
}
|
||||
$facts = $saved['facts'] ?? $this->facts(['facts' => []]);
|
||||
$entity = $payload['investor_path']['context']['promoter_entity'] ?? null;
|
||||
$context = app(InvestorContext::class)->state($case)['fields'];
|
||||
$existingEntity = in_array($entity['kind'] ?? '', ['company', 'organization'], true);
|
||||
if (! $saved && empty($v['revision'])) {
|
||||
foreach (['currency' => 'currency', 'activities' => 'products_services', 'seat' => 'location'] as $target => $source) {
|
||||
if (empty($facts[$target]) && ! empty($context[$source])) $facts[$target] = $context[$source];
|
||||
}
|
||||
$facts['proposed_name'] = $entity['snapshot']['name'] ?? $facts['proposed_name'];
|
||||
$knownForm = $entity['snapshot']['legal_form'] ?? null;
|
||||
$facts['form'] = $existingEntity ? $knownForm : ($v['form'] ?? $facts['form']);
|
||||
}
|
||||
$events = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)
|
||||
->when(! empty($v['history_before']), fn ($q) => $q->where('event_order', '<', $v['history_before']))
|
||||
->orderByDesc('event_order')->limit(26)->get(['id', 'payload', 'event_order']);
|
||||
|
||||
return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
|
||||
return ['entity' => $entity, 'proposedForm' => ! $existingEntity ? ($v['form'] ?? null) : null, 'case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
|
||||
'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'historical' => ! empty($v['revision']),
|
||||
'definition' => $saved['definition'] ?? $this->definition(), 'facts' => $facts, 'review' => $saved['review'] ?? $this->review($facts), 'saved' => $saved,
|
||||
'sources' => $this->sources($case), 'history' => $events->take(25)->map(function ($e) {
|
||||
@@ -265,6 +276,11 @@ final class CompanyPreparation
|
||||
$hash = hash('sha256', json_encode([(int) $v['version'], $facts], JSON_THROW_ON_ERROR));
|
||||
abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Os dados continuam no formulário; recupere a versão antes de guardar.');
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
$entity = $payload['investor_path']['context']['promoter_entity'] ?? null;
|
||||
$registeredForm = $entity['snapshot']['legal_form'] ?? null;
|
||||
if (in_array($entity['kind'] ?? '', ['company', 'organization'], true) && $registeredForm) {
|
||||
abort_unless($facts['form'] === $registeredForm, 422, 'A forma jurídica da entidade existente não é alterada nesta preparação. Use o percurso próprio de alteração do cadastro.');
|
||||
}
|
||||
$previous = $payload['company_preparation'] ?? null;
|
||||
$resolved = $this->resolve($facts, $this->sources($case), $previous);
|
||||
$definition = $this->definition();
|
||||
|
||||
@@ -34,6 +34,29 @@ final class DemographicEvidence
|
||||
return ['commerce' => 'Comércio e serviços', 'industry' => 'Indústria', 'agriculture' => 'Agricultura e agro-indústria', 'education' => 'Educação', 'health' => 'Saúde', 'general' => 'Contexto geral'];
|
||||
}
|
||||
|
||||
/** Broader administrative evidence stays labelled as context, never local data.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function investmentContext(string $territory, int $start, int $end, string $sector = 'general', ?string $edition = null): array
|
||||
{
|
||||
$path = app(InvestmentGeography::class)->path($territory);
|
||||
$requested = collect($path)->last();
|
||||
$result = $this->analyse([], $start, $end, $sector, $edition);
|
||||
foreach (array_reverse($path) as $node) {
|
||||
if (! in_array($node->location_type, ['district', 'province'], true)) {
|
||||
continue;
|
||||
}
|
||||
$result = $this->analyse([$node->id], $start, $end, $sector, $edition);
|
||||
if ($result['state'] === 'available') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$result['requested_territory'] = $requested ? ['id' => $requested->id, 'name' => $requested->name, 'level' => $requested->location_type] : null;
|
||||
$result['broader_context'] = $requested && count($result['territories']) && $requested->id !== $result['territories'][0]['id'];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param list<string> $territories
|
||||
* @return array<string,mixed> */
|
||||
public function analyse(array $territories, int $start, int $end, string $sector = 'general', ?string $edition = null): array
|
||||
|
||||
@@ -13,6 +13,29 @@ final class FundingInstitutionClassification
|
||||
|
||||
public const TYPES = ['commercial_bank' => 'Banco comercial', 'investment_bank' => 'Banco de investimento', 'bank' => 'Banco (tipo não especificado)', 'microbank' => 'Microbanco', 'cooperative' => 'Cooperativa de crédito', 'savings' => 'Organização de poupança e empréstimo', 'investment_manager' => 'Gestora de fundos de investimento', 'investment_company' => 'Sociedade de investimento', 'pension_manager' => 'Gestora de fundos de pensões', 'insurer' => 'Seguradora', 'reinsurer' => 'Resseguradora', 'microinsurer' => 'Micro-seguradora', 'broker' => 'Corretora de seguros', 'agent' => 'Agente de seguros', 'reinsurance_broker' => 'Corretor de resseguro', 'exchange_house' => 'Casa de câmbio', 'electronic_money' => 'Instituição de moeda electrónica', 'interbank' => 'Serviços interbancários', 'supervisor' => 'Supervisor', 'other' => 'Outro tipo documentado'];
|
||||
|
||||
// Category/type semantics in the supplied, accepted institutional reference.
|
||||
public const CATEGORY_TYPES = ['banking' => ['commercial_bank', 'investment_bank', 'bank'], 'credit' => ['microbank', 'cooperative', 'savings'],
|
||||
'investment' => ['investment_manager', 'investment_company'], 'pensions' => ['pension_manager', 'insurer'], 'insurance' => ['insurer', 'reinsurer', 'microinsurer', 'pension_manager'],
|
||||
'intermediation' => ['broker', 'agent', 'reinsurance_broker'], 'exchange' => ['exchange_house'], 'payments' => ['electronic_money', 'interbank'], 'supervision' => ['supervisor'], 'other' => ['other']];
|
||||
|
||||
/** Structural validity is independent of whether matching records exist.
|
||||
* @param array<string,mixed> $filters
|
||||
*/
|
||||
public function validateFilters(array $filters): void
|
||||
{
|
||||
$v = Validator::make($filters, ['category' => ['nullable', Rule::in(array_keys(self::CATEGORIES))], 'institution_type' => ['nullable', Rule::in(array_keys(self::TYPES))],
|
||||
'kind' => ['nullable', Rule::in(array_keys(FundingProgrammes::KINDS))], 'location_mode' => 'nullable|in:physical,coverage'])->validate();
|
||||
$errors = [];
|
||||
if (! empty($v['category']) && $v['category'] !== 'other' && ! empty($v['institution_type']) && $v['institution_type'] !== 'other' && ! in_array($v['institution_type'], self::CATEGORY_TYPES[$v['category']], true)) {
|
||||
$errors['institution_type'] = 'O tipo de instituição não pertence à categoria escolhida. Reveja esse critério; isto não é uma pesquisa sem resultados.';
|
||||
}
|
||||
if (! empty($v['kind']) && $v['kind'] !== 'institution') {
|
||||
if (! empty($v['category']) || ! empty($v['institution_type'])) $errors['kind'] = 'Categoria e tipo de instituição aplicam-se à consulta de instituições. Um fundo ou programa é um registo distinto.';
|
||||
if (($v['location_mode'] ?? '') === 'physical') $errors['location_mode'] = 'Sede e balcões aplicam-se a instituições. Para fundos e programas, escolha a cobertura do financiamento.';
|
||||
}
|
||||
if ($errors) throw \Illuminate\Validation\ValidationException::withMessages($errors);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input
|
||||
* @return array<string,mixed> */
|
||||
public function normalize(array $input): array
|
||||
@@ -31,6 +54,7 @@ final class FundingInstitutionClassification
|
||||
/** @param array<string,mixed> $filters */
|
||||
public function filter(Builder $query, array $filters): void
|
||||
{
|
||||
$this->validateFilters($filters);
|
||||
foreach (['category' => 'institution_categories', 'institution_type' => 'institution_types'] as $key => $field) {
|
||||
if (! empty($filters[$key])) {
|
||||
$query->whereRaw('v.payload->? @> ?::jsonb', [$field, json_encode([$filters[$key]], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
@@ -113,7 +113,7 @@ final class FundingInstitutionDetails
|
||||
public function match(array $product, array $filters): bool
|
||||
{
|
||||
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
|
||||
if ($product['availability'] === 'closed') {
|
||||
if ($product['availability'] === 'closed' && ($filters['state'] ?? '') !== 'closed') {
|
||||
return false;
|
||||
}
|
||||
if (! $unknown && $product['availability'] === 'unknown') {
|
||||
@@ -122,7 +122,7 @@ final class FundingInstitutionDetails
|
||||
if (! empty($filters['need']) && ! in_array($filters['need'], $product['needs'], true) && ! ($unknown && $product['needs'] === [])) {
|
||||
return false;
|
||||
}
|
||||
if (empty($filters['territory'])) {
|
||||
if (empty($filters['territory']) || ($filters['location_mode'] ?? '') === 'physical') {
|
||||
return true;
|
||||
}
|
||||
if ($product['coverage'] === 'national') {
|
||||
@@ -150,7 +150,7 @@ final class FundingInstitutionDetails
|
||||
return;
|
||||
}
|
||||
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
|
||||
$conditions = ["p->>'availability' <> 'closed'"];
|
||||
$conditions = [($filters['state'] ?? '') === 'closed' ? "p->>'availability' IN ('documented','closed','unknown')" : "p->>'availability' <> 'closed'"];
|
||||
$bindings = [];
|
||||
if (! $unknown) {
|
||||
$conditions[] = "p->>'availability' <> 'unknown'";
|
||||
|
||||
@@ -68,7 +68,17 @@ final class FundingProgrammes
|
||||
/** @param array<string,mixed> $filters */
|
||||
public function catalogueQuery(array $filters): Builder
|
||||
{
|
||||
Validator::make($filters, ['q' => 'nullable|string|max:150', 'state' => 'nullable|in:open,closed,upcoming,unknown', 'certainty' => 'nullable|in:include_unknown,documented',
|
||||
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())], 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'include_historical' => 'nullable|boolean'])->validate();
|
||||
$filters = app(InvestmentGeography::class)->interestSelection($filters);
|
||||
$query = $this->publicQuery();
|
||||
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])->orWhereRaw("(v.payload->'aliases')::text ilike ?", [$term])->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE l->>'label' ilike ? OR l->>'address' ilike ? OR l->>'location_text' ilike ?)", [$term, $term, $term]));
|
||||
}
|
||||
if (! empty($filters['kind'])) {
|
||||
$query->whereRaw("v.payload->>'kind' = ?", [$filters['kind']]);
|
||||
}
|
||||
app(FundingInstitutionDetails::class)->applyFilters($query, $filters);
|
||||
$includeUnknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
|
||||
if (! empty($filters['sector'])) {
|
||||
@@ -94,22 +104,43 @@ final class FundingProgrammes
|
||||
});
|
||||
}
|
||||
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]);
|
||||
}
|
||||
[$windowSql, $windowBindings] = $this->windowFilter($filters);
|
||||
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'windows','[]'::jsonb)) w WHERE ".$windowSql.')', $windowBindings);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** Window dates and geographical scope concern the same documented window.
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{literal-string,list<mixed>}
|
||||
*/
|
||||
private function windowFilter(array $filters): array
|
||||
{
|
||||
$today = today()->toDateString();
|
||||
$state = $filters['state'];
|
||||
$bindings = [];
|
||||
if ($state === 'unknown') $sql = "w->>'mode' = 'unknown'";
|
||||
elseif ($state === 'closed') { $sql = "(w->>'mode' = 'closed' OR (w->>'mode' = 'dated' AND w->>'end_on' < ?))"; $bindings = [$today]; }
|
||||
elseif ($state === 'upcoming') { $sql = "w->>'mode' = 'dated' AND w->>'start_on' > ?"; $bindings = [$today]; }
|
||||
else { $sql = "w->>'mode' IN ('dated','rolling') AND COALESCE(NULLIF(w->>'start_on',''),?) <= ? AND COALESCE(NULLIF(w->>'end_on',''),?) >= ?"; $bindings = [$today, $today, $today, $today]; }
|
||||
if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') {
|
||||
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
|
||||
$window = ["EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(w->'territory_paths','[]'::jsonb)) t WHERE t @> ?::jsonb)"];
|
||||
$scopeBindings = [json_encode([['id' => $filters['territory']]], JSON_THROW_ON_ERROR)];
|
||||
foreach ($path as $id) { $window[] = "w->'territory_ids' @> ?::jsonb"; $scopeBindings[] = json_encode([$id], JSON_THROW_ON_ERROR); }
|
||||
$parent = ["v.payload->>'coverage'='national'", "v.payload->'territory_ancestor_ids' @> ?::jsonb"];
|
||||
$scopeBindings[] = json_encode([$filters['territory']], JSON_THROW_ON_ERROR);
|
||||
foreach ($path as $id) { $parent[] = "v.payload->'territory_ids' @> ?::jsonb"; $scopeBindings[] = json_encode([$id], JSON_THROW_ON_ERROR); }
|
||||
if (($filters['certainty'] ?? 'include_unknown') === 'include_unknown') $parent[] = "v.payload->>'coverage'='unknown'";
|
||||
$scope = "(w->>'coverage'='national' OR (w->>'coverage'='territorial' AND (".implode(' OR ', $window).")) OR (w->>'coverage'='programme' AND (".implode(' OR ', $parent).')))';
|
||||
if (($filters['certainty'] ?? 'include_unknown') === 'include_unknown') $scope = '('.$scope." OR w->>'coverage'='unknown')";
|
||||
$sql = '('.$sql.') AND '.$scope;
|
||||
$bindings = [...$bindings, ...$scopeBindings];
|
||||
}
|
||||
return [$sql, $bindings];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array<string,string> */
|
||||
|
||||
@@ -66,11 +66,16 @@ final class InvestmentCorridorContext
|
||||
*/
|
||||
public function parks(?string $key, ?string $territory): array
|
||||
{
|
||||
if (! $key || ! $territory) {
|
||||
if (! $territory) {
|
||||
return [];
|
||||
}
|
||||
abort_unless($this->compatible($key, $territory), 422, 'A localização não pertence ao corredor escolhido.');
|
||||
if ($key) {
|
||||
abort_unless($this->compatible($key, $territory), 422, 'A localização não pertence ao corredor escolhido.');
|
||||
}
|
||||
$selectedPath = array_column(app(InvestmentGeography::class)->path($territory), 'id');
|
||||
if ($selectedPath === []) {
|
||||
return [];
|
||||
}
|
||||
$candidates = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get();
|
||||
$result = [];
|
||||
foreach ($candidates as $zone) {
|
||||
@@ -83,7 +88,7 @@ final class InvestmentCorridorContext
|
||||
}
|
||||
foreach ($ids as $link) {
|
||||
$id = $link->geo_location_id;
|
||||
if (! $this->compatible($key, $id)) {
|
||||
if ($key && ! $this->compatible($key, $id)) {
|
||||
continue;
|
||||
}
|
||||
$path = array_column(app(InvestmentGeography::class)->path($id), 'id');
|
||||
|
||||
@@ -245,6 +245,30 @@ final class InvestmentGeography
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Canonical selection for a place-of-interest filter, including legacy links.
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function interestSelection(array $filters): array
|
||||
{
|
||||
$location = \Illuminate\Support\Facades\Validator::make($filters, ['territory' => 'nullable|uuid', 'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid'])->validate();
|
||||
if (array_intersect(array_keys(self::FIELDS), array_keys($location))) {
|
||||
$path = $this->selection($location);
|
||||
$territory = $path ? $path[array_key_last($path)]->id : null;
|
||||
if (! empty($location['territory']) && $location['territory'] !== $territory) {
|
||||
throw ValidationException::withMessages(['territory' => 'O local de interesse não corresponde à hierarquia seleccionada.']);
|
||||
}
|
||||
if ($territory) $filters['territory'] = $territory;
|
||||
else unset($filters['territory']);
|
||||
}
|
||||
if (! empty($filters['territory'])) {
|
||||
$path = $this->path($filters['territory']);
|
||||
if (! $path) throw ValidationException::withMessages(['territory' => 'Seleccione um local disponível no cadastro.']);
|
||||
foreach ($path as $node) $filters[$node->location_type.'_id'] = $node->id;
|
||||
}
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function descendants(string $id, bool $includeNonPublic = false): array
|
||||
{
|
||||
|
||||
@@ -74,7 +74,7 @@ final class InvestmentOpportunityChoices
|
||||
$old = collect($previous)->first(fn ($row) => $row['opportunity']['key'] === $ref['key']);
|
||||
$origins = $this->portfolioOrigins($choice['portfolio_origins'] ?? array_column($old['portfolio_origins'] ?? [], 'id'), (int) $record->id);
|
||||
$corridors = app(InvestmentCorridorContext::class);
|
||||
$corridor = $choice['corridor_key'] ?? $old['corridor_key'] ?? $corridors->key($record);
|
||||
$corridor = array_key_exists('corridor_key', $choice) ? $choice['corridor_key'] : ($old['corridor_key'] ?? $corridors->key($record));
|
||||
$provinces = $corridor ? $corridors->provinces($corridor) : [];
|
||||
$documented = $record->primary_territory_id;
|
||||
$territory = array_key_exists('territory', $choice) ? $choice['territory'] : ($documented ? 'territory:'.$documented : null);
|
||||
|
||||
@@ -24,7 +24,7 @@ final class InvestorContext
|
||||
|
||||
public const SOURCE = 'investor-context';
|
||||
|
||||
public const FIELDS = ['project_title', 'project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'currency', 'expected_start_date', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'investor_origin', 'person_type', 'entity_stage', 'tax_residency_country', 'fiscal_residency_country', 'activity_id', 'project_type', 'maturity_stage', 'special_zone_id', 'investment_regime', ...PrivateInvestmentPreparation::FIELDS];
|
||||
public const FIELDS = ['promoter_entity_key', 'legal_name', 'legal_form', 'registration_number', 'products_services', 'investment_budget_min', 'investment_budget_max', 'project_title', 'project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'currency', 'expected_start_date', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'investor_origin', 'person_type', 'entity_stage', 'tax_residency_country', 'fiscal_residency_country', 'activity_id', 'project_type', 'maturity_stage', 'special_zone_id', 'investment_regime', ...PrivateInvestmentPreparation::FIELDS];
|
||||
|
||||
public function __construct(private readonly VirtualDesk $desk) {}
|
||||
|
||||
@@ -51,7 +51,7 @@ final class InvestorContext
|
||||
$entrySources = Validator::make($input, ['entry_sources' => 'sometimes|array|max:200', 'entry_sources.*' => 'string|max:100'])->validate()['entry_sources'] ?? [];
|
||||
unset($input['draft_sequence'],$input['entry_sources']);
|
||||
abort_unless($sequence > 0, 422);
|
||||
$data = $this->input($input);
|
||||
$data = $this->input($input, true);
|
||||
if (isset($data['complements'])) {
|
||||
app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ final class InvestorContext
|
||||
abort_unless($ref['kind'] === 'opportunity', 422);
|
||||
$record = Opportunity::whereKey($ref['id'])->firstOrFail();
|
||||
app(InvestmentOpportunityChoices::class)->portfolioOrigins($choice['portfolio_origins'] ?? [], (int) $record->id);
|
||||
$key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record);
|
||||
$key = array_key_exists('corridor_key', $choice) ? $choice['corridor_key'] : app(InvestmentCorridorContext::class)->key($record);
|
||||
if (! empty($choice['territory'])) {
|
||||
$location = $this->reference($choice['territory']);
|
||||
abort_unless($location['kind'] === 'territory', 422);
|
||||
@@ -114,6 +114,112 @@ final class InvestorContext
|
||||
return Schema::hasTable('investor_navigation_preferences');
|
||||
}
|
||||
|
||||
/** Existing identity records only; these choices never change dossier ownership.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function promoter(User $actor, string $id): array
|
||||
{
|
||||
$case = $this->desk->case($actor, $id);
|
||||
abort_unless((int) $case->user_id === $actor->id, 403);
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
$companies = DB::table('investment_company_declarations')->where('user_id', $actor->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($row) {
|
||||
$f = $this->desk->decode($row->payload);
|
||||
return ['id' => $row->id, 'version' => $row->version, 'name' => $f['name'] ?? 'Empresa declarada', 'legal_form' => $f['legal_form'] ?? null];
|
||||
})->all();
|
||||
return ['version' => $case->version, 'selected' => $payload['investor_path']['context']['promoter_entity'] ?? ($case->company_id ? ['kind' => 'company', 'id' => $case->company_id, 'snapshot' => $payload['company'] ?? []] : ['kind' => 'undetermined']),
|
||||
'companies' => $companies, 'organizations' => $this->desk->representedOrganizations($actor), 'can_write' => $case->organization_id === null && ! $case->closed_at];
|
||||
}
|
||||
|
||||
/** Attach an authorised identity snapshot while retaining the case owner and ACL.
|
||||
* @param array<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function setPromoter(User $actor, string $id, array $input): array
|
||||
{
|
||||
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid', 'kind' => 'required|in:company,organization,being_formed,undetermined', 'id' => 'nullable|uuid', 'source_version' => 'nullable|integer|min:1'])->validate();
|
||||
return DB::transaction(function () use ($actor, $id, $v): array {
|
||||
$case = $this->desk->case($actor, $id, true, true);
|
||||
abort_unless((int) $case->user_id === $actor->id && $case->organization_id === null, 403);
|
||||
$this->desk->assertOpen($case);
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
$hash = hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
|
||||
foreach ($payload['investor_path']['promoter_history'] ?? [] as $entry) {
|
||||
if (($entry['operation_key'] ?? null) === $v['operation_key']) {
|
||||
abort_unless(hash_equals($entry['request_hash'], $hash), 409);
|
||||
return ['saved' => true, 'replayed' => true, 'continue' => '/investimentos/'.$id];
|
||||
}
|
||||
}
|
||||
abort_unless((int) $case->version === (int) $v['version'], 409, 'O dossier mudou; recupere os dados antes de alterar a entidade.');
|
||||
$snapshot = [];
|
||||
$sourceVersion = null;
|
||||
if ($v['kind'] === 'company') {
|
||||
$company = DB::table('investment_company_declarations')->where('id', $v['id'] ?? null)->where('user_id', $actor->id)->lockForUpdate()->first();
|
||||
abort_unless($company !== null, 404);
|
||||
$snapshot = $this->desk->decode($company->payload);
|
||||
$sourceVersion = $company->version;
|
||||
} elseif ($v['kind'] === 'organization') {
|
||||
DB::table('memberships')->where('organization_id', $v['id'] ?? null)->where('user_id', $actor->id)->whereNull('deleted_at')->lockForUpdate()->get();
|
||||
abort_unless($this->desk->represents($actor, $v['id'] ?? '', false), 403);
|
||||
$organization = collect($this->desk->representedOrganizations($actor))->firstWhere('id', $v['id']);
|
||||
abort_unless($organization !== null, 403);
|
||||
$snapshot = ['name' => $organization['name'], 'incorporation_country_code' => $organization['country']];
|
||||
$sourceVersion = $organization['version'];
|
||||
}
|
||||
abort_if(isset($v['source_version']) && (int) $v['source_version'] !== (int) $sourceVersion, 409, 'O cadastro da entidade mudou. Recupere a associação antes de continuar.');
|
||||
$entity = ['kind' => $v['kind'], 'id' => in_array($v['kind'], ['company', 'organization'], true) ? $v['id'] : null, 'source_version' => $sourceVersion, 'snapshot' => $snapshot, 'recorded_at' => now()->toIso8601String()];
|
||||
$stage = in_array($v['kind'], ['company', 'organization'], true) ? 'existing' : ($v['kind'] === 'being_formed' ? 'being_formed' : '');
|
||||
$declarations = ['entity_stage' => $stage, 'promoter_entity_key' => $v['kind'].':'.($entity['id'] ?? '').'@'.($sourceVersion ?? '')];
|
||||
$existingFields = $this->state($case)['fields'];
|
||||
foreach (['legal_name' => 'name', 'legal_form' => 'legal_form', 'registration_number' => 'registration'] as $field => $key) {
|
||||
if (empty($existingFields[$field]) && ! empty($snapshot[$key])) $declarations[$field] = $snapshot[$key];
|
||||
}
|
||||
$body = ['version' => $case->version, 'selection' => [], 'declarations' => $declarations];
|
||||
$plan = $this->preview($actor, $id, $body);
|
||||
$this->commit($actor, $id, [...$body, 'preview_hash' => $plan['preview_hash'], 'confirm' => true, 'operation_key' => $v['operation_key']]);
|
||||
$current = $this->desk->case($actor, $id, true, true);
|
||||
$next = $this->desk->decode($current->payload);
|
||||
$previous = $payload['investor_path']['context']['promoter_entity'] ?? ($case->company_id ? ['kind' => 'company', 'id' => $case->company_id, 'snapshot' => $payload['company'] ?? []] : ['kind' => 'undetermined']);
|
||||
$next['investor_path']['context']['promoter_entity'] = $entity;
|
||||
$next['investor_path']['promoter_history'][] = ['before' => $previous, 'after' => $entity, 'operation_key' => $v['operation_key'], 'request_hash' => $hash];
|
||||
$next['company'] = $snapshot;
|
||||
$next['company_version'] = $sourceVersion;
|
||||
DB::table('desk_cases')->where('id', $id)->update(['company_id' => $v['kind'] === 'company' ? $v['id'] : null, 'payload' => $this->desk->encode($next)]);
|
||||
$this->desk->event($current, $actor, 'investment_promoter_associated', ['before' => $previous, 'after' => $entity, 'ownership_unchanged' => true]);
|
||||
return ['saved' => true, 'replayed' => false, 'continue' => $v['kind'] === 'being_formed' ? '/investimentos/'.$id.'/preparar-empresa' : '/investimentos/'.$id];
|
||||
});
|
||||
}
|
||||
|
||||
/** Explicit consultation saved in the existing encrypted dossier, with optimistic locking.
|
||||
* @param array<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function saveSupplyAggregate(User $actor, string $id, array $input): array
|
||||
{
|
||||
abort_unless(config('investor-path.aggregate_supply_qa_enabled') && \App\Support\IsolatedQaDatabase::allowsSchemaChanges(), 404);
|
||||
$control = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate();
|
||||
return DB::transaction(function () use ($actor, $id, $control): array {
|
||||
$case = $this->desk->case($actor, $id, true, true);
|
||||
abort_unless((int) $case->user_id === $actor->id, 403);
|
||||
$this->desk->assertOpen($case);
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
$history = $payload['investor_path']['supply_aggregates'] ?? [];
|
||||
foreach ($history as $saved) {
|
||||
if ($saved['operation_key'] === $control['operation_key']) return ['version' => $case->version, 'analysis' => $saved, 'replayed' => true];
|
||||
}
|
||||
abort_unless((int) $case->version === (int) $control['version'], 409, 'O dossier mudou. Recupere-o antes de consultar.');
|
||||
$context = $this->state($case);
|
||||
abort_if(count($context['opportunity_choices'] ?? []) > 1, 422, 'Esta consulta agregada de QA utiliza apenas o contexto comum de uma ideia própria.');
|
||||
$f = $context['fields'];
|
||||
$territory = $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null;
|
||||
abort_unless($territory, 422, 'Guarde primeiro a localização conhecida no dossier.');
|
||||
$analysis = [...app(ProactiveSupply::class)->aggregate($actor, $territory), 'context_revision' => $context['revision'], 'recorded_at' => now()->toIso8601String(), 'operation_key' => $control['operation_key']];
|
||||
$payload['investor_path']['supply_aggregates'] = [...$history, $analysis];
|
||||
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]);
|
||||
$this->desk->event($this->desk->case($actor, $id), $actor, 'investment_supply_aggregate_saved', ['context_revision' => $context['revision'], 'territory_id' => $territory, 'aggregate_only' => true]);
|
||||
return ['version' => $case->version + 1, 'analysis' => $analysis, 'replayed' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
@@ -327,17 +433,18 @@ final class InvestorContext
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, mixed>
|
||||
/** @param array<string,mixed> $filters
|
||||
* @return array<array-key,mixed>
|
||||
*/
|
||||
public function options(string $query = ''): array
|
||||
public function options(string $query = '', array $filters = []): array
|
||||
{
|
||||
$query = mb_substr(trim($query), 0, 120);
|
||||
$items = [];
|
||||
foreach (Opportunity::published()->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get(['id', 'title']) as $o) {
|
||||
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title, 'kind' => 'opportunity'];
|
||||
$public = $filters ? app(InvestmentCatalogue::class)->query([...$filters, 'q' => $query]) : Opportunity::published()->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'));
|
||||
foreach ($public->orderBy('title')->limit(51)->get(['opportunities.id', 'opportunities.title']) as $o) {
|
||||
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title, 'kind' => 'opportunity', 'relevance' => $filters ? 'Corresponde aos critérios explicitamente seleccionados.' : 'Correspondência com a pesquisa; confirme a pertinência antes de associar.'];
|
||||
}
|
||||
if (request()->user()) {
|
||||
if (request()->user() && (! $filters || $query !== '')) {
|
||||
$private = app(PortalProfileAccess::class)->scope(Opportunity::query(), request()->user())->where('status', '!=', 'published')->where('status', '!=', 'archived')->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get();
|
||||
foreach ($private as $o) {
|
||||
if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) {
|
||||
@@ -345,13 +452,13 @@ final class InvestorContext
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config('existing-business.enabled')) {
|
||||
if (config('existing-business.enabled') && (! $filters || $query !== '')) {
|
||||
foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) {
|
||||
$items[] = ['key' => 'business:'.$o->id, 'title' => $o->public_title, 'kind' => 'business'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['opportunities' => $items, 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
|
||||
return ['opportunities' => $items, 'countries' => app(CountryCatalogue::class)->all(), 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
|
||||
'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])];
|
||||
}
|
||||
|
||||
@@ -414,7 +521,7 @@ final class InvestorContext
|
||||
* @param array<array-key, mixed> $input
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
private function input(array $input): array
|
||||
private function input(array $input, bool $draft = false): array
|
||||
{
|
||||
abort_if(array_diff(array_keys($input), ['complements', 'opportunity_choices', 'version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']) !== [], 422, 'Campo de controlo desconhecido.');
|
||||
|
||||
@@ -440,8 +547,10 @@ final class InvestorContext
|
||||
'opportunity_choices.*.source' => 'required|string|distinct|max:100', 'opportunity_choices.*.territory' => 'nullable|string|max:100',
|
||||
'opportunity_choices.*.location_reason' => 'nullable|string|max:2000', 'opportunity_choices.*.confirm_location_change' => 'sometimes|boolean',
|
||||
'version' => 'required|integer|min:1', 'selection' => 'present|array:portfolio,opportunity,sector,territory,park',
|
||||
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary,currency,expected_start_date,investor_origin,person_type,entity_stage,tax_residency_country,fiscal_residency_country,project_type,maturity_stage,'.implode(',', PrivateInvestmentPreparation::FIELDS), 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
|
||||
'declarations.currency' => 'nullable|regex:/^[A-Z]{3}$/', 'declarations.expected_start_date' => 'nullable|date_format:Y-m-d', 'declarations.investor_origin' => 'nullable|in:national,foreign,mixed', 'declarations.person_type' => 'nullable|in:singular,collective,sole_trader,consortium', 'declarations.entity_stage' => 'nullable|in:existing,being_formed', 'declarations.tax_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.fiscal_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.project_type' => 'nullable|string|max:100', 'declarations.maturity_stage' => 'nullable|string|max:100',
|
||||
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:promoter_entity_key,legal_name,legal_form,registration_number,products_services,investment_budget_min,investment_budget_max,project_title,project_summary,currency,expected_start_date,investor_origin,person_type,entity_stage,tax_residency_country,fiscal_residency_country,project_type,maturity_stage,'.implode(',', PrivateInvestmentPreparation::FIELDS), 'declarations.promoter_entity_key' => 'nullable|string|max:160', 'declarations.legal_name' => 'nullable|string|max:250', 'declarations.legal_form' => 'nullable|string|max:100', 'declarations.registration_number' => 'nullable|string|max:100',
|
||||
'declarations.products_services' => 'nullable|string|max:2000', 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
|
||||
'declarations.investment_budget_min' => 'nullable|numeric|min:0', 'declarations.investment_budget_max' => ['nullable', 'numeric', 'min:0', ...(! empty($input['declarations']['investment_budget_min']) ? ['gte:declarations.investment_budget_min'] : [])],
|
||||
'declarations.currency' => $draft ? 'nullable|regex:/^[A-Za-z]{0,3}$/D' : 'nullable|regex:/^[A-Z]{3}$/D', 'declarations.expected_start_date' => 'nullable|date_format:Y-m-d', 'declarations.investor_origin' => 'nullable|in:national,foreign,mixed', 'declarations.person_type' => 'nullable|in:singular,collective,sole_trader,consortium', 'declarations.entity_stage' => 'nullable|in:existing,being_formed', 'declarations.tax_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.fiscal_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.project_type' => 'nullable|string|max:100', 'declarations.maturity_stage' => 'nullable|string|max:100',
|
||||
'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
|
||||
}
|
||||
|
||||
@@ -480,7 +589,9 @@ final class InvestorContext
|
||||
$choices = app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $choices);
|
||||
$data['selection']['opportunity'] = $choices[0]['opportunity']['key'] ?? null;
|
||||
// Shared fields concern the first identified opportunity; other sites remain separate.
|
||||
if ($choices !== []) {
|
||||
$data['selection']['territory'] = $choices[0]['territory']['key'] ?? null;
|
||||
}
|
||||
if (array_key_exists('park', $data['opportunity_choices'][0] ?? [])) {
|
||||
$data['selection']['park'] = $choices[0]['park']['key'] ?? null;
|
||||
}
|
||||
@@ -511,7 +622,7 @@ final class InvestorContext
|
||||
}
|
||||
$selection[$slot] = $ref;
|
||||
}
|
||||
$corridor = $choices[0]['corridor_key'] ?? (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity'
|
||||
$corridor = array_key_exists('corridor_key', $choices[0] ?? []) ? $choices[0]['corridor_key'] : (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity'
|
||||
? app(InvestmentCorridorContext::class)->key(Opportunity::whereKey($selection['opportunity']['id'])->firstOrFail()) : null);
|
||||
if ($corridor && isset($selection['territory'])) {
|
||||
abort_unless(app(InvestmentCorridorContext::class)->compatible($corridor, $selection['territory']['id']), 422, 'A localização não pertence ao corredor escolhido.');
|
||||
@@ -579,9 +690,12 @@ final class InvestorContext
|
||||
$fields['special_zone_id'] = $selection['park']['id'] ?? '';
|
||||
$fields['investment_regime'] = $selection['park']['fields']['investment_regime'] ?? 'general';
|
||||
}
|
||||
if (isset($fields['investment_budget_min'], $fields['investment_budget_max']) && $fields['investment_budget_min'] !== '' && $fields['investment_budget_max'] !== '') {
|
||||
Validator::make(['declarations' => $fields], ['declarations.investment_budget_max' => 'numeric|gte:declarations.investment_budget_min'])->validate();
|
||||
}
|
||||
$replaced = [];
|
||||
foreach (array_unique([...array_keys($old['fields']), ...array_keys($fields)]) as $key) {
|
||||
if (($old['fields'][$key] ?? null) !== ($fields[$key] ?? null)) {
|
||||
if (($old['fields'][$key] ?? '') !== ($fields[$key] ?? '')) {
|
||||
$replaced[] = ['field' => $key, 'before' => $old['fields'][$key] ?? null, 'after' => $fields[$key] ?? null];
|
||||
}
|
||||
}
|
||||
@@ -764,7 +878,7 @@ final class InvestorContext
|
||||
$original = $ctx;
|
||||
$ctx = app(InvestmentConsumerScope::class)->context($ctx, $opportunity);
|
||||
$f = $ctx['fields'];
|
||||
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]);
|
||||
$sim = Arr::except(Arr::only($f, self::FIELDS), ['promoter_entity_key', 'sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]);
|
||||
// Entering this tool from an investment dossier selects the existing investment-analysis route.
|
||||
// The reconciliation preserves any purpose deliberately chosen in an existing simulator draft.
|
||||
$sim += ['journey_type' => 'investor', 'journey_type_label' => 'Avaliar o meu investimento', 'purpose' => 'simulate_only', 'purpose_label' => 'Apenas simular'];
|
||||
@@ -790,6 +904,7 @@ final class InvestorContext
|
||||
'version' => 1, 'context_revision' => $ctx['revision'],
|
||||
'consumer_opportunity' => $ctx['consumer_opportunity']['opportunity']['key'] ?? null,
|
||||
'portfolio_origin' => $ctx['selection']['portfolio'] ?? null,
|
||||
'promoter_entity' => $ctx['promoter_entity'] ?? null,
|
||||
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
|
||||
'provenance' => $ctx['provenance'],
|
||||
// Retain dossier options as provenance when no consumer is selected; scoped fields remain empty.
|
||||
|
||||
@@ -162,6 +162,7 @@ final class InvestorPath
|
||||
}
|
||||
|
||||
$preparationTaskKeys = $opportunity !== null && ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->taskKeys($actor, $id, $opportunity) : null;
|
||||
$supplyEnabled = (int) $case->user_id === $actor->id && config('investor-path.aggregate_supply_qa_enabled') && \App\Support\IsolatedQaDatabase::allowsSchemaChanges();
|
||||
|
||||
return ['funding_selections' => ($owner || $manager) ? array_values(array_filter($path['funding_selections'] ?? [], fn ($row) => ($row['opportunity'] ?? null) === $opportunity)) : [], 'preparation_action' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $opportunity, request()->query('swot_action')) : null, 'private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
|
||||
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
|
||||
@@ -171,7 +172,9 @@ final class InvestorPath
|
||||
'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at,
|
||||
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
|
||||
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
|
||||
'can_set_promoter' => (int) $case->user_id === $actor->id && $case->organization_id === null && ! $case->closed_at,
|
||||
'results' => $results, 'requests' => $requests, 'documents' => $documents,
|
||||
'supply_aggregate_enabled' => $supplyEnabled, 'supply_aggregates' => $supplyEnabled ? ($path['supply_aggregates'] ?? []) : [],
|
||||
'fiscal_reports' => (int) $case->user_id === $actor->id && (count($allChoices) < 2 || $opportunity !== null)
|
||||
? app(SimulationFormDrafts::class)->dossierReportSources($actor, $id, $opportunity) : [],
|
||||
'land_report' => $landCase ? ['id' => $landCase->id, 'version' => $landCase->version, 'updated_at' => $landCase->updated_at?->toIso8601String(),
|
||||
@@ -324,6 +327,7 @@ final class InvestorPath
|
||||
'snapshot_sha256' => $draft->snapshot_sha256, 'financial_snapshot' => $snapshot,
|
||||
'context_opportunity' => $opportunity,
|
||||
'currency' => $economic['currency'] ?? null, 'engine' => 'financial-calculators:v1',
|
||||
'preparation_snapshot' => Arr::only($economic, ['total_investment', 'secured_amount', 'planned_funding', 'requested_funding', 'approved_funding', 'planning_loan', 'annual_revenue', 'annual_operating_costs', 'investment_budget_min', 'investment_budget_max', 'initial_budget_snapshot', 'planning_assumptions_snapshot']),
|
||||
'kind' => 'local_calculation_inputs', 'context_state' => 'current', 'context_revision' => $context['revision'], 'context_dependency_hash' => $context['dependency_hash'] ?? app(InvestorContext::class)->dependencyHash([]), 'recorded_at' => now()->toIso8601String()];
|
||||
$payload['investor_path']['results'][] = $result;
|
||||
abort_unless(count($payload['investor_path']['results']) <= 100, 422, 'Limite de evidências financeiras deste dossier atingido.');
|
||||
|
||||
@@ -36,6 +36,39 @@ final class ProactiveSupply
|
||||
});
|
||||
}
|
||||
|
||||
/** No names, contacts, row identifiers or free-text activity descriptions leave this boundary.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function aggregate(User $actor, string $territory): array
|
||||
{
|
||||
abort_unless(config('investor-path.aggregate_supply_qa_enabled') && \App\Support\IsolatedQaDatabase::allowsSchemaChanges(), 404);
|
||||
$path = app(InvestmentGeography::class)->path($territory);
|
||||
abort_if($path === [], 422, 'Confirme primeiro a localização do dossier.');
|
||||
return $this->read($actor, function () use ($path, $territory): array {
|
||||
$labels = app(EconomicReferenceCatalog::class)->activities()->groupBy('code')->map(fn ($rows) => $rows->pluck('label_pt_mz')->unique()->count() === 1 ? $rows->first()->label_pt_mz : null);
|
||||
$scope = collect($path)->last();
|
||||
$query = function (string $id): Builder {
|
||||
return DB::table('proactive_supply_observations as o')->whereIn('o.territory_id', app(InvestmentGeography::class)->descendants($id));
|
||||
};
|
||||
$base = $query($territory);
|
||||
if (! (clone $base)->exists() && in_array($scope->location_type, ['locality', 'admin_post'], true)) {
|
||||
$district = collect($path)->firstWhere('location_type', 'district');
|
||||
if ($district && $query($district->id)->exists()) {
|
||||
$scope = $district;
|
||||
$base = $query($district->id);
|
||||
}
|
||||
}
|
||||
$counts = (clone $base)->selectRaw("count(DISTINCT entity_key) FILTER (WHERE duplicate_of IS NULL AND NOT identity_conflict) AS entities, count(*) FILTER (WHERE duplicate_of IS NOT NULL) AS repeated, count(*) FILTER (WHERE identity_conflict) AS uncertain")->first();
|
||||
$code = "CASE WHEN cae ~ '^[0-9.]{1,10}$' THEN cae ELSE '' END";
|
||||
$groups = (clone $base)->whereNull('duplicate_of')->where('identity_conflict', false)->selectRaw($code." AS code, count(DISTINCT entity_key) AS total, min(substring(source_date from '(19[0-9]{2}|20[0-9]{2})')) AS first_year, max(substring(source_date from '(19[0-9]{2}|20[0-9]{2})')) AS last_year")
|
||||
->groupByRaw($code)->orderByRaw($code)->limit(101)->get()->map(fn ($row) => ['code' => $row->code, 'label' => $row->code ? ($labels->get($row->code) ?? 'CAE '.$row->code.' — descrição por confirmar') : 'Actividade por classificar', 'total' => (int) $row->total, 'first_year' => $row->first_year, 'last_year' => $row->last_year])->all();
|
||||
return ['requested_territory' => ['id' => $territory, 'name' => collect($path)->last()->name], 'scope' => ['id' => $scope->id, 'name' => $scope->name, 'level' => $scope->location_type],
|
||||
'broader_context' => $scope->id !== $territory, 'entities' => (int) $counts->entities, 'repeated' => (int) $counts->repeated, 'uncertain' => (int) $counts->uncertain,
|
||||
'groups' => array_slice($groups, 0, 100), 'truncated' => count($groups) > 100, 'coverage_unknown' => true, 'limitations' => self::LIMITATIONS,
|
||||
'interpretation' => 'Os grupos ajudam a investigar actividades similares e complementares. A relação deve ser fundamentada; não demonstra concorrência efectiva, clientes, capacidade ou receitas. Uma entidade pode constar em vários grupos; não some os grupos.'];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $input
|
||||
* @return array<array-key, mixed>
|
||||
|
||||
@@ -47,6 +47,16 @@ class VirtualDesk
|
||||
return $case->organization_id ? $this->represents($actor, $case->organization_id, $write) : (int) $case->user_id === $actor->id;
|
||||
}
|
||||
|
||||
/** The same effective membership used by dossier access; identity does not depend on country configuration.
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function representedOrganizations(User $actor): array
|
||||
{
|
||||
return array_values(DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $actor->id)
|
||||
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->get(['o.id', 'o.name', 'o.version_no', ...(Schema::hasColumn('organizations', 'incorporation_country_code') ? ['o.incorporation_country_code'] : [])])
|
||||
->unique('id')->map(fn ($row) => ['id' => $row->id, 'name' => $row->name, 'version' => $row->version_no, 'country' => $row->incorporation_country_code ?? null])->values()->all());
|
||||
}
|
||||
|
||||
public function case(User $actor, string $id, bool $write = false, bool $lock = false): \stdClass
|
||||
{
|
||||
$case = DB::table('desk_cases')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first();
|
||||
|
||||
@@ -4,5 +4,7 @@ return [
|
||||
// Explicit opt-in after authenticated installation; never infer activation from schema/ready.
|
||||
'private_preparation' => env('PRIVATE_INVESTMENT_PREPARATION_ENABLED', false),
|
||||
'enabled' => env('INVESTOR_PATH_ENABLED', false),
|
||||
// Owner-authorised aggregate experiment: isolated testing database only.
|
||||
'aggregate_supply_qa_enabled' => env('INVESTOR_AGGREGATE_SUPPLY_QA_ENABLED', false),
|
||||
'definition_id' => '61000919-0000-4000-8000-000000000001',
|
||||
];
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
## 15/11/2026 — entrega delimitada em preparação
|
||||
|
||||
Dashboard existente e correcções transversais: autorizados para homologação. Delta Ofertas: excluído por conferência visual/móvel e interface editorial ainda pendentes. Preservar o módulo instalado em 13497892, sem novas entradas, fotografias L1 ou formulário imobiliário da Etapa B. A entrega é derivada directamente da revisão servida; 142 testes de interface, Wayfinder, build e tipos passaram. As 15 provas transversais aceites são reutilizadas. Backup/restauro actualizado conferiu 219 tabelas e 417 ficheiros. Integração e instalação continuam por executar; ver o mesmo relatório de apresentação/contexto para a delimitação e os recibos posteriores.
|
||||
|
||||
## 14/11/2026 — hierarquia Administração/Dashboard corrigida e instalada em 96f51096
|
||||
|
||||
Revisão servida **`96f510965f70581a4b41dd94463f598b77dd7cf5`**, instalada pelo executor Git como correcção da organização expressamente pedida pela Tempo. **Administração** reúne os grupos Dashboard, Conteúdos e oportunidades, Cadastros e investimento, Pedidos e acompanhamento e Acessos e configuração. Os grupos sem destinos autorizados são omitidos. **Dashboard** contém as funções dos painéis, com `/dashboard` como visão geral; os cadastros administrativos permanecem nos outros grupos. O circuito editorial conserva nome e URL próprios. Operações administrativas foram retiradas de Mais/Área de trabalho sem perder destinos; funções pessoais mantêm o seu âmbito.
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
## Entrega delimitada autorizada — 15/11/2026
|
||||
|
||||
**Estado: preparação concluída; integração/instalação ainda não executadas neste registo.** A autorização permite instalar dashboard e correcções transversais; Ofertas só poderia entrar com demonstração integral. Conferidos os recibos existentes: critérios 5/6 da Etapa B têm prova HTTP, mas a interface editorial e o critério 7 visual/móvel continuam não verificados. Por isso, **o delta de Ofertas foi excluído**, sem transformar quatro critérios anteriormente aceites em aceitação integral.
|
||||
|
||||
Preparada a entrega no ramo `delivery/dashboard-transversal-20261115`, directamente sobre a revisão servida `13497892`, por selecção do conteúdo compatível de `8806312f`, `8c6ed625` e `7810ffc8`; não por integração integral dos seus antecessores de Ofertas. Preservados os ficheiros próprios de Ofertas, recursos/fotografias, contratos, rotas, mapas privados e navegação na versão instalada. O formulário canónico partilhado com esse módulo também fica preservado. O dashboard existente é mantido; não se presume outro incremento de dashboard ausente do delta.
|
||||
|
||||
As dependências partilhadas foram conferidas separadamente. `TerritoryFields` mantém quatro níveis e mapa por defeito; só os consumidores financeiros usam as opções adicionais. Os rótulos de modalidade usados por Ofertas mantêm-se. Fontes/demografia/contexto recebem a correcção comum, sem alterar as permissões ou o contrato privado de Ofertas. O manifesto `outputs/transversal-install-20261115/scope-separation.json` regista as exclusões e os hashes dos ficheiros protegidos, a comparar no destino.
|
||||
|
||||
Provas da separação: **142 testes de interface, zero falhas; geração Wayfinder real, compilação normal e tipos aprovados**. O leitor de testes demográficos foi adaptado para resolver o componente partilhado real, sem simular a sua saída. Reutilizadas as 15 provas autenticadas dos percursos transversais cujo código foi preservado. A consulta empresarial agregada mantém a guarda exclusivamente QA e não alarga acesso a dados internos na homologação.
|
||||
|
||||
Backup actualizado concluído: 219 tabelas e 417 ficheiros. Restauro isolado conferido: contagens/checksums iguais, rede `none`, base separada na porta 55441, ficheiros e chaves/envelopes MFA recuperáveis. Zero migrações previstas; nenhuma alteração de dados, permissões, publicação ou notificações externas. Janela de instalação reservada pelo lock existente; `main` só pode avançar por fast-forward para a revisão separada, após reconferência.
|
||||
|
||||
O histórico QA abaixo conserva o seu âmbito; não demonstra instalação de Ofertas nem autoriza publicação pública.
|
||||
|
||||
---
|
||||
|
||||
# Apresentação e continuidade das consultas — candidata QA, 15/11/2026
|
||||
|
||||
Estado: **candidata QA com execução autenticada do saldo V01–V15; resultados por ecrã/largura abaixo, sujeitos à aceitação da experiência pelo responsável**. Base original `42fba8ba`, ramo `qa/public-context-20261115`. Sem instalação, publicação, alterações de permissões ou escrita em dados operacionais. Os testes posteriores usam exclusivamente a nova base sintética isolada `investor_intent_20261115_test`, em `127.0.0.1:55441`, incluindo a sua estrutura de teste. O trabalho concorrente da pasta principal e a conferência de Ofertas permanecem separados.
|
||||
|
||||
## Condição de entrega — conferência dos consumidores afectados
|
||||
|
||||
Decisão expressa do responsável: a instalação depende de cobertura consolidada dos ecrãs e percursos afectados. Um componente aprovado não aprova automaticamente os seus consumidores. A autorização existente continua limitada a implementação e validação em QA.
|
||||
|
||||
Na mesma matriz, cada consumidor deve conservar o seu estado **alinhado, corrigido, divergente ou não verificado**, com versão, ambiente, evidência e limite da prova. «Corrigido» identifica uma alteração demonstrada; não substitui a aceitação integral do percurso. Sem prova individual suficiente, o estado é «não verificado».
|
||||
|
||||
Conferir intenção; etapas e ajuda; próxima acção; reutilização de entidade/localização/moeda/orçamento; selectores e filtros no cliente e servidor; pertinência dos resultados; coerência dos valores; gravação/recuperação/regresso/recarregamento/retoma autenticada; isolamento de contas/separadores/oportunidades; computador e telemóvel; preservação de dados, funcionalidades, permissões e históricos.
|
||||
|
||||
As tabelas históricas abaixo descrevem provas dirigidas, não uma declaração de alinhamento de todos os ecrãs. A candidata não pode ser disponibilizada como correcção transversal enquanto existirem divergências ou verificações pendentes deste âmbito. Decisões jurídicas, dependências externas e funcionalidades futuras ficam separadas e não ampliam esta conferência.
|
||||
|
||||
Quando a conferência visual autenticada estiver impedida, registar o erro e disponibilizar uma prévia QA à equipa, com os percursos e operações a executar. Renderização sintética, testes automáticos e capturas de componentes não substituem essa prova. Não instalar para obter a conferência sem autorização expressa.
|
||||
|
||||
### Conferência dirigida dos quatro percursos — 15/11/2026
|
||||
|
||||
Esta passagem actualiza as linhas existentes MASTER-600240dd-0001/0018, MASTER-e1169a77-0026 e INE-DEMO-01; não cria outro inventário. «Corrigido» abaixo refere-se apenas ao comportamento e à evidência indicados. As colunas visuais não herdam a aprovação do componente nem da API.
|
||||
|
||||
Tabela de partida, anterior às conferências autenticadas registadas abaixo. **Não usar os seus «não verificado» como pedido para repetir provas entretanto obtidas. O saldo actual é a tabela «Execução do saldo V01–V15»**, que substitui as pendências genéricas desta tabela, sem eliminar o histórico.
|
||||
|
||||
| Percurso / ecrã | Estado funcional no recorte | Evidência disponível | Computador autenticado | Telemóvel autenticado / retoma |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Hotel — entrada e editor de contexto (`/investimentos/iniciar`, `/{id}/contexto/editar`) | Corrigido: ideia própria com sector confirmado, orçamento em intervalo e localização independente de anúncios/corredores | `InvestorIntentContinuityTest`, `preparation-intent.test.mjs`, renderização do editor em `public-information.test.mjs` | Não verificado; prévia sintética anterior em 1280 px | Não verificado; prévia sintética anterior em 360 px, sem gravação |
|
||||
| Hotel — entidade existente no dossier (`/investimentos/{id}#entidade-promotora`) | Corrigido: associação autorizada, versão da fonte, dados recuperados; sem duplicar empresa, alterar dono ou membros | `InvestorPromoterContinuityTest`, incluindo isolamento, fonte alterada e organização com representação revogada | Não verificado | Não verificado |
|
||||
| Hotel — comparação opcional (`/{id}/forma-juridica`) | Corrigido: guardar alternativa e continuar na preparação da mesma entidade; distinção entre entidade existente e por constituir recuperada do payload | `InvestorPromoterContinuityTest`; PHPStan detectou variável não inicializada, corrigida nesta passagem | Não verificado | Não verificado |
|
||||
| Hotel — preparar empresa (`/{id}/preparar-empresa`) | Corrigido no servidor: recupera dados, preserva notas e forma deliberada; não altera a forma da empresa constituída | `InvestorPromoterContinuityTest`; regressões `company-preparation` e `company-comparison` | Não verificado | Não verificado |
|
||||
| Hotel — simulador, percurso assistido | Divergente: a estrutura financeira ainda reúne ferramentas e tratamentos; sete ecrãs assistidos, ajuda por actividade e retoma da etapa ainda não demonstrados | Leitura delimitada de `simulation-form.tsx`: ferramentas e oito blocos fiscais partilham condições `financial`/`tax`. Não classificar essa etapa como alinhada | Não verificado | Não verificado |
|
||||
| Hotel — calculadora e origem do financiamento | Corrigido no recorte: detecta divergências sem arredondar valores nem assumir desconhecido como zero; escolha explícita de origem; CSV e retorno impedidos com divergência; valores demonstrativos retirados dos novos rascunhos | `planning-finance-context.test.mjs`; tipos; histórico guardado conservado | Não verificado | Não verificado |
|
||||
| Hotel — guardar e recuperar cenário / devolver ao dossier | Corrigido no recorte API: cenário explicitamente declarado, intervalo separado, mesma evidência recuperada e retorno repetível sem duplicação | `InvestorIntentContinuityTest`: snapshot financeiro exacto, resultado único, moeda e isolamento da leitura do rascunho | Não verificado; não equivale a clicar em guardar/retomar | Não verificado, incluindo retoma após autenticação |
|
||||
| Hotel — relatório | Não verificado integralmente; testes do gerador preservam intervalo e resultados; não demonstram o relatório completo da sessão autenticada | `investor-dossier-report.test.mjs`; saldos da preparação assistida e períodos fiscais continuam abertos | Não verificado | Não verificado |
|
||||
| Agronegócio — Matola Rio–Sede / Boane / Maputo Província | Corrigido no recorte API: hierarquia sintética identificada, 1–5 milhões MZN, sem oportunidade, corredor ou total inferido | `InvestorIntentContinuityTest`, dois sectores; todos os dados do ensaio são sintéticos | Não verificado | Não verificado |
|
||||
| Agronegócio — consulta empresarial agregada no dossier | Corrigido no recorte QA: totais por actividade/localização, âmbito distrital identificado, repetidos e incertos separados; nomes/contactos/origem reservados não transferidos | `InvestorIntentContinuityTest`; flag privada desactivada por defeito e guarda da base QA; histórico por dossier | Não verificado | Não verificado |
|
||||
| Agronegócio — simulação e retoma | Corrigido no recorte API: valores declarados e snapshot recuperados, resultado devolvido ao mesmo dossier sem duplicação. Percurso assistido completo continua divergente como em Hotel | `InvestorIntentContinuityTest`; teste não substitui utilização do simulador no navegador | Não verificado | Não verificado |
|
||||
| Instituições — lista (`/fundos`) | Corrigido no recorte: Banca/Banco comercial, incompatibilidade estrutural, vazio válido, desconhecido/documentado, presença/cobertura e janela | `FundingCriteriaCoherenceTest`; consulta partilhada e resposta HTML/API testadas | Não verificado | Não verificado |
|
||||
| Instituições — filtros de categoria/tipo e alteração de critérios | Corrigido: opções dependentes; preservação dos critérios válidos; resultados anteriores separados até nova pesquisa; incompatibilidade corrigida só por escolha explícita | `funding-journey-coherence.test.mjs`, executa handlers da página real com transporte simulado | Não verificado | Não verificado |
|
||||
| Instituições — locais de interesse | Corrigido no recorte: distrito sem província não abre consulta nacional; consumidor usa hierarquia comum; servidor rejeita hierarquia contraditória | `FundingCriteriaCoherenceTest`; `territory-search-select.test.mjs`; componente usado no consumidor confirmado, sem presumir prova visual | Não verificado | Não verificado |
|
||||
| Instituições — detalhe e regresso (`/fundos/{id}`) | Corrigido nesta passagem: retorno preserva pesquisa, sector, estado, certeza, tipo, hierarquia e página; limpeza territorial explícita não herda o dossier | `FundingCriteriaCoherenceTest`: selecção recebida pelo detalhe comparada com todos os critérios enviados | Não verificado | Não verificado |
|
||||
| Instituições — guardar interesse / continuar dossier | Não verificado neste delta; testes dos filtros não demonstram esta operação | Reutilizar provas anteriores compatíveis; executar selecção e retoma da candidata antes do fecho | Não verificado | Não verificado |
|
||||
| Demografia — síntese e série (`/localizacoes/demografia`) | Corrigido no recorte: fonte/edição/período separados; originais preservados; grupos incompletos e anos ausentes sem valores inventados | Provas de reconciliação `21ac7eb`, `public-information.test.mjs`; prévia de componentes anterior em computador/360/390 px | Não verificado na página autenticada da candidata | Não verificado integralmente |
|
||||
| Demografia — contexto do editor/dossier/simulador | Corrigido no recorte API: ausência local recupera distrito com âmbito explícito, sem converter população distrital em local; respostas tardias isoladas | `InvestorIntentContinuityTest` e testes dirigidos do componente | Não verificado individualmente nos três consumidores | Não verificado individualmente nos três consumidores |
|
||||
| Demografia — continuar para ideia e recuperar | Corrigido na construção do URL; intenção territorial transportada sem filtros comerciais. Utilização completa não verificada | `public-information.test.mjs`, `preparation-intent.test.mjs`; gravação da ideia testada separadamente, não como prova de clique ponta a ponta | Não verificado | Não verificado |
|
||||
|
||||
**Recibos desta passagem:** `outputs/investor-intent-20261115/backend.log` — 12 testes / 412 asserções; `frontend.log` — 137 testes; `phpstan.log` — aprovado, zero erros. Os testes de API usam transacções revertidas na base sintética identificada acima. As provas são específicas dos comportamentos descritos, não 149 percursos completos.
|
||||
|
||||
**Impedimento visual observado:** inventário do conector devolveu `Browsers: Error: The browser access host is unavailable`. O controlo nativo do Edge funciona e leu o URL da janela separada: `file:///C:/laragon/www/portal-investidor-public-context-qa-20261115/outputs/investor-intent-20261115/editor-preview.html`. Essa prévia declara que não tem autenticação, gravação, consultas nem cartografia real. A sessão do portal com alterações por guardar foi preservada. Não se atribui o impedimento ao portal ou ao navegador; não se repetiram reinícios nem alterações de configuração.
|
||||
|
||||
**Saldo que impede a entrega transversal:** concluir a divergência da preparação financeira assistida e o contrato de retoma da etapa; conferir ajuda e pressupostos por actividade, período/cobertura fiscal e relatório no mesmo percurso; executar os ecrãs acima numa sessão QA da candidata em computador e telemóvel, incluindo regresso, recarregamento e nova autenticação. Os testes de isolamento no servidor não demonstram, por si só, isolamento entre separadores na interface. Nenhuma instalação ou aceitação funcional integral resulta desta passagem.
|
||||
|
||||
## Execução do saldo V01–V15 — resultados na mesma candidata
|
||||
|
||||
Actualização após a interrupção final do controlo Windows. **Instalação suspensa.** Não houve nova tentativa de navegador nesta actualização documental. O erro observado foi: `Computer Use has been stopped for this turn because it could not determine the current browser URL on Windows with enough confidence to enforce policy.` O controlo interrompeu o clique em «Voltar aos fundos e programas»; não foi observada a página de destino. Isto não demonstra erro do portal, perda de filtros ou falha de MFA.
|
||||
|
||||
Candidata local: `C:/laragon/www/portal-investidor-public-context-qa-20261115`, base Git `8c6ed625` **mais o delta QA ainda não consolidado em novo commit**; URL `http://127.0.0.3:18715`. Build servido: `build-qa-opportunities/assisted-mobile`, entrada `assets/app-f0MgDyAw.js`; SHA256 do manifesto `709989dd62b65b72eefb4b0394f776adbf0a1e340a7487a2d013170fe4e6cf03`. O SHA base, sozinho, não identifica esta candidata. Conferir esta identidade antes da execução manual; não instalar nem substituir pela homologação. Usar a conta sintética habitual, sem registar credenciais.
|
||||
|
||||
**Provas preservadas, fora da repetição:** Hotel com empresa existente, orçamento 2 milhões, mobilizado 1 milhão, diferença 1 milhão, receita anual 7,5 milhões, custo anual 2 milhões, gravação/recarga/retorno e HTML descarregado; escolha explícita de modelo/estimativas; bloqueio de cobertura 2027/2026; edição e continuação móvel de Receitas → Tesouraria. Agronegócio com entidade por constituir, actividade/localização/intervalo recuperados, modelo agrícola e recarga/retorno. Banca/Banco comercial com um resultado compatível, referência/produto/versão conservados no mesmo dossier. Demografia distrital identificada no dossier, incluindo leitura móvel, sem atribuição à localidade. A largura dos selectores financeiros foi corrigida e reconferida a 390 × 844. Limpar província desactivou os níveis inferiores e separou resultados anteriores dos critérios por aplicar. Estas provas não encerram as acções diferentes abaixo.
|
||||
|
||||
Cada linha refere-se a requisitos já existentes; **V01–V15 são identificadores de passos de verificação, não novos requisitos ou outra auditoria**. Executar só o saldo, em computador e em 390 × 844 quando a linha indicar ambos. «NÃO VERIFICADO» não equivale a «FALHOU».
|
||||
|
||||
| Passo / requisito existente | Ecrã | Acção verificada | Critério esperado | Resultado executado e evidência |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| V01 · MASTER-e1169a77-0026 | `/fundos` → `/fundos/{id}` → «Voltar aos fundos e programas» | Na pesquisa A, escolher explicitamente critérios válidos (categoria/tipo, tipo de registo, presença/cobertura, território, sector, necessidade, certeza, janela, históricos, palavra-chave e página quando existirem). Abrir o resultado e clicar no retorno da própria página. Repetir o retorno em largura móvel. | Recuperar exactamente os critérios efectivamente escolhidos e a página aplicável; resultado corresponde à pesquisa A. Nenhum critério novo herdado do dossier ou de outra pesquisa. Critérios sem resultados não são alargados silenciosamente. | Critérios e resultado bancário recuperados no retorno real do detalhe. 1440 px: **APROVADO**, 2026-11-15T15:41:27.944Z, evidência `V01-1440-1794757287944`; 390 px: **APROVADO**, 2026-11-15T15:41:54.875Z, evidência `V01-390-1794757314875` |
|
||||
| V02 · MASTER-e1169a77-0026 / MASTER-600240dd-0018 | Duas abas de `/fundos`, detalhe e regresso | Manter A no contexto agrícola e abrir B com categoria/localização diferentes, escolhidas explicitamente. Voltar ao detalhe/lista de A e recarregar A; conferir também B. | Cada URL e formulário conserva os seus critérios; não entram tipos, distritos, sector, janela ou contexto de B em A. A selecção guardada no dossier não altera a pesquisa alheia. | Pesquisas A/B conservaram critérios e contextos separados após retorno e recarga. 1440 px: **APROVADO**, 2026-11-15T16:28:25.029Z, evidência `V02-1440-1794760105052`; 390 px: **APROVADO**, 2026-11-15T16:29:13.443Z, evidência `V02-390-1794760153468` |
|
||||
| V03 · MASTER-e1169a77-0026 | `/fundos`, computador e móvel | Pela interface, tentar uma combinação estruturalmente contraditória e uma combinação válida sem registos; alterar categoria e conferir resolução do tipo incompatível. | Mensagens distintas para combinação inválida e zero resultados válido. Sem banco apresentado como fundo por inferência, sem corretoras como correspondência, sem perda de critérios independentes. | Combinação contraditória distinta do vazio válido; mudança de categoria resolveu apenas o tipo dependente. 1440 px: **APROVADO**, 2026-11-15T15:41:50.001Z, evidência `V03-1440-1794757310001`; 390 px: **APROVADO**, 2026-11-15T15:43:11.560Z, evidência `V03-390-1794757391560` |
|
||||
| V04 · MASTER-600240dd-0018 | `/simulador-fiscal?context={dossier}` e dossier Hotel/Agronegócio | Guardar a etapa de cada dossier; sair e autenticar normalmente a mesma conta sintética; reabrir cada dossier e continuar. | Recuperar etapa, valores, modelo, ordem das escolhas e contexto próprios; sem exemplos, nova pergunta sobre dados já válidos ou duplicação de resultados. | Logout/login normal recuperou Hotel 7,5 milhões e Agronegócio 2 milhões, respectivos modelos e etapa. 1440 px: **APROVADO**, 2026-11-15T15:45:04.572Z, evidência `V04-1440-1794757504572`; 390 px: **APROVADO**, 2026-11-15T16:15:11.676Z, evidência `V04-390-1794759311711` |
|
||||
| V05 · MASTER-600240dd-0018 | Hotel e Agronegócio em duas abas; acesso de segunda conta QA autorizada | Editar um pressuposto sintético de A, manter B aberto, regressar/recarregar; usar uma segunda conta QA já autorizada para tentar os URLs privados directos de A. Não criar permissões. | Sem mistura entre dossiers/abas; divergência de versão tratada sem sobrescrever edição deliberada; segunda conta sem acesso a rascunho, relatório ou documento privado alheio. | Edição concorrente recusada com 412 e entrada local conservada; dossiers separados; segunda conta sem acesso ao dossier ou rascunho privados. 1440 px: **APROVADO**, 2026-11-15T16:24:03.041Z, evidência `V05-1440-1794759843064` |
|
||||
| V06 · MASTER-600240dd-0001 / MASTER-600240dd-0018 | Dossier Hotel sintético adicional → entidade por constituir → `/preparar-empresa` → simulador → relatório | Verificar a variante Hotel com entidade por constituir, usando nova preparação sintética e preservando o Hotel existente com empresa associada. Guardar e retomar dados propostos e continuar até relatório. | Sem cadastro legal fictício, repetição da comparação obrigatória ou transferência do dono; forma escolhida e dados recuperados no mesmo novo dossier; valores do Hotel existente preservados. | Hotel por constituir guardou entidade, pressupostos, recuperação e relatório no mesmo dossier. 1440 px: **APROVADO**, 2026-11-15T16:03:54.766Z, evidência `V06-1440-1794758634766` |
|
||||
| V07 · MASTER-600240dd-0018 | Agronegócio → Receitas/Tesouraria/Revisão → relatórios do mesmo dossier | Em móvel, recuperar o modelo agrícola e os pressupostos já guardados; concluir revisão e gerar/consultar o relatório agrícola. Não redigitar nem adoptar números novos por rotina. | Modelo agrícola, receita 2 milhões e intervalo 1–5 milhões MZN recuperados; total e financiamento desconhecidos não se tornam zero ou valor único. Relatório pertence ao dossier agrícola, sem pressupostos do Hotel. | Relatório agrícola descarregado com receita 2 milhões, intervalo 1–5 milhões e custos desconhecidos preservados. 1440 px: **APROVADO**, 2026-11-15T15:45:15.049Z, evidência `V07-1440-1794757515049`; 390 px: **APROVADO**, 2026-11-15T15:45:59.234Z, evidência `V07-390-1794757559234` |
|
||||
| V08 · MASTER-600240dd-0001 | Associação de entidade, comparação jurídica opcional e preparação de empresa | Conferir estes controlos em móvel. Na comparação, recuperar escolha anterior e usar «continuar preparação»; em empresa existente, não alterar a forma jurídica do cadastro. | Controlos utilizáveis, comparação opcional, dados e edições deliberadas preservados; regresso ao dossier/viabilidade executável. | Comparação opcional e entidade utilizáveis em móvel; forma/nome recuperados; cadastro da empresa existente não substituído. 390 px: **APROVADO**, 2026-11-15T16:00:27.433Z, evidência `V08-390-1794758427433` |
|
||||
| V09 · MASTER-600240dd-0018 | Novo rascunho QA → acção de retorno antes de Financiamento | Verificar a orientação acrescentada «Conferir financiamento», seguir a acção, deixar montantes desconhecidos e guardar/voltar. | Próxima acção executável na etapa correcta, sem exigir inventar montantes; contrato de gravação e versão continua aplicado. | Orientação abriu Financiamento; gravação e retorno concluídos sem inventar montantes desconhecidos. 1440 px: **APROVADO**, 2026-11-15T16:04:46.409Z, evidência `V09-1440-1794758686409` |
|
||||
| V10 · MASTER-600240dd-0001 | `/investimentos/{id}/preparar-empresa` | Reabrir a preparação guardada e conferir a mensagem sobre modelos, em computador/móvel, sem alterar os dados. | Sem referência interna aos «sete exemplares/complemento»; indisponibilidade de modelos e distinção entre preparação e registo continuam claras; dados anteriores conservados. | Aviso histórico apresentado sem referência aos sete exemplares; nome e sede anteriores preservados. 1440 px: **APROVADO**, 2026-11-15T16:05:10.181Z, evidência `V10-1440-1794758710181`; 390 px: **APROVADO**, 2026-11-15T16:06:43.704Z, evidência `V10-390-1794758803704` |
|
||||
| V11 · MASTER-600240dd-0018 | Simulador → Enquadramento fiscal → Revisão | Conferir em móvel pesquisa, adição/retirada de uma operação e preservação dos dados. Na revisão, conferir síntese, diferenças reais e próxima acção. Se demonstrar cálculo, escolher explicitamente período coberto e valores sintéticos; não substituir o cenário 2027 guardado. | Só operações escolhidas montadas; restantes pesquisáveis; dados retirados da análise mantidos no histórico; sem códigos internos, exemplos automáticos ou comparação vazio→vazio. Separar rascunho de cálculo guardado e manter coerência com relatório. | Operação fiscal pesquisada, retirada e reposta sem perder entradas; 2027 recuperado; referência técnica fechada no detalhe da versão. 390 px: **APROVADO**, 2026-11-15T16:24:32.637Z, evidência `V11-390-1794759872659` |
|
||||
| V12 · MASTER-600240dd-0018 | Dossier agrícola → «Consultar e guardar análise agregada» | Abrir/guardar a análise com a localização existente, recarregar e recuperar a mesma evidência; conferir em móvel. | Totais/actividades e âmbito conhecidos ou cobertura desconhecida explícitos; duplicados separados; nenhum nome, contacto ou lista interna exposto; ausência de registos não significa ausência de oferta. | Análise agregada guardada e recuperada com cobertura desconhecida explícita, sem nomes/contactos internos. 1440 px: **APROVADO**, 2026-11-15T15:45:22.425Z, evidência `V12-1440-1794757522425`; 390 px: **APROVADO**, 2026-11-15T15:46:06.294Z, evidência `V12-390-1794757566294` |
|
||||
| V13 · INE-DEMO-01 | `/localizacoes/demografia` e detalhe de originais; consulta no editor e simulador | Na consulta completa, seleccionar explicitamente Boane QA e 2026–2027; abrir evolução e originais. Conferir os consumidores editor/simulador, em computador e móvel, sem repetir a síntese do dossier. | 12.345→12.600, diferença de período 255 (não taxa anual); fonte/edição/unidade/projecção explícitas, originais preservados. Localidade sem observações não recebe população distrital sem aviso. Anos/parcelas ausentes continuam sem dados. | 12.345→12.600 e diferença de período 255; originais conservados; contexto distrital identificado no editor e simulador; rótulos e selector dependente reconferidos. 1440 px: **APROVADO**, 2026-11-15T16:18:31.360Z, evidência `V13-1440-1794759511387`; 390 px: **APROVADO**, 2026-11-15T16:20:49.768Z, evidência `V13-390-1794759649790`; rótulos: V13-labels-1440-1794759923605, V13-labels-390-1794759934158 |
|
||||
| V14 · INE-DEMO-01 / MASTER-600240dd-0001 | Demografia → preparar ideia → contexto/dossier → simulador | Usar a acção territorial da consulta, confirmar sector/intenção e guardar/retomar a preparação sintética nos dois tamanhos. No percurso, conferir os selectores de país/moeda/localização efectivamente usados (lista directa, prefixo de três caracteres, acentos e código curto MZ). | Território explícito transportado sem filtros financeiros/comerciais alheios; sector proposto confirmado, anúncio/zona opcionais; hierarquia pode parar no nível conhecido, códigos canónicos e edições mantidos. Continuação no dossier sem perguntas repetidas. | Origem Boane transportada até ao simulador; país por MZ, moeda por prefixo e nível distrital guardados e recuperados. 1440 px: **APROVADO**, 2026-11-15T16:19:56.796Z, evidência `V14-1440-1794759596823`; 390 px: **APROVADO**, 2026-11-15T16:22:14.914Z, evidência `V14-390-1794759734939` |
|
||||
| V15 · MASTER-600240dd-0001 / MASTER-600240dd-0018 | Página inicial → «Criar investimento» → `/investimentos/iniciar` → editor de contexto | Nos dois tamanhos, entrar pela intenção de criar, escolher sector/localização/intervalo/moeda e guardar uma preparação sintética nova; distinguir da acção «Procurar oportunidades». Conferir regresso e edição do contexto. | Abre preparação da ideia, nunca exige procurar/associar anúncio ou escolher corredor/zona; reutiliza apenas escolhas explícitas e conserva o intervalo. Contexto alterável sem apagar valores deliberados. | Entrada Criar investimento abriu preparação própria sem anúncio ou zona, preservando localização e intervalo orçamental. 1440 px: **APROVADO**, 2026-11-15T15:49:14.009Z, evidência `V15-1440-1794757754009`; 390 px: **APROVADO**, 2026-11-15T15:55:37.546Z, evidência `V15-390-1794758137546` |
|
||||
|
||||
### Registo da execução técnica
|
||||
|
||||
O Codex executa estas linhas através de Playwright local autorizado, sem depender da recuperação do conector. Para cada V01–V15, os recibos conservam **executante, data/hora, identidade da candidata/build, ecrã/largura, acção efectivamente realizada, resultado observado e evidência**, passando a APROVADO, FALHOU ou mantendo NÃO VERIFICADO com impedimento concreto. A equipa mantém a avaliação de negócio e a aceitação da experiência. Uma captura de um componente não encerra os outros consumidores da linha. Não colocar credenciais, códigos MFA ou dados reais no registo.
|
||||
|
||||
Fecho condicionado: não iniciar outra auditoria, refazer provas acima preservadas ou instalar para obter a conferência. Uma falha nova deve indicar causa delimitada e correcção indispensável; verificar apenas os efeitos afectados. **A instalação permanece suspensa até encerrar as divergências de orientação/mensagem e todas as verificações deste saldo**, com aceitação do responsável. Dependências jurídicas ou funcionalidades futuras permanecem fora deste saldo de apresentação/continuidade.
|
||||
|
||||
<!-- EXECUCAO-NAVEGADOR-QA-20261115 -->
|
||||
### Execução técnica do saldo — Playwright local
|
||||
|
||||
Executante: Codex, autenticação normal com contas sintéticas, exclusivamente em `http://127.0.0.3:18715` e na base `investor_intent_20261115_test`. Chromium local, controlador Playwright 1.65.1; contextos novos, sem perfil operacional, permissões novas ou controlos de segurança desactivados. Computador 1440 × 1000; móvel **emulado** 390 × 844, sem ensaio em dispositivo físico.
|
||||
|
||||
A tabela V01–V15 acima contém o resultado executado por largura. Recibos e capturas: `outputs/investor-intent-20261115/browser-acceptance/*-results.json` e respectivos PNG/TXT. As tentativas anteriores permanecem nos recibos; o estado apresentado usa a última execução de cada passo/largura. Não confundir erros de sincronização do ensaio com defeitos comprovados do produto.
|
||||
|
||||
Candidata: ramo `qa/public-context-20261115`, base Git `8c6ed625`. O delta QA e este relatório identificam a revisão preparada para apreciação. Provas iniciais no build `assisted-mobile`; correcções de mensagem em `acceptance-corrections`; build actualmente servido `build-qa-opportunities/acceptance-labels`, manifesto SHA256 `c0c8eb36c0b531613367d4c1c154aa59ab154289b0e459ef57393a3cd3ed8790`. Os recibos posteriores também identificam os assets usados. Nenhuma instalação no activo.
|
||||
|
||||
Correcções delimitadas: apresentação de aviso histórico da preparação da empresa sem modificar a definição guardada; erro de leitura do rascunho distingue limite de pedidos/sessão/servidor de edição concorrente, preservando o bloqueio de escrita; comparação demográfica usa o selector pesquisável existente com distrito dependente da província, preservando quatro locais; origem territorial da demografia recupera a hierarquia no editor sem substituir localização guardada; referência técnica do questionário conservada num detalhe fechado do resumo. Regressão territorial dirigida: 1 teste/33 asserções; selector: 1 teste; aviso empresarial: 7 testes. Tipos, lint, PHPStan dirigido e compilação passaram. As provas técnicas aceites anteriores não foram repetidas por rotina.
|
||||
|
||||
Saldo executado: 15/15 passos aprovados nas larguras previstas. O ensaio adicional de falha de leitura `V04-recovery-error` comprovou mensagem específica, ausência de gravações durante a falha e recuperação dos valores originais. Os testes exercitam a interface real autenticada; a apreciação de negócio e a autorização de instalação continuam separadas.
|
||||
|
||||
**Saldo técnico V01–V15 encerrado nas condições indicadas; instalação não autorizada e continua suspensa.** A avaliação de negócio e a aceitação da experiência cabem ao responsável. Não equivale a aceitação integral do portal nem a teste num dispositivo móvel físico. Limitações jurídicas e futuras funcionalidades continuam fora deste saldo.
|
||||
<!-- /EXECUCAO-NAVEGADOR-QA-20261115 -->
|
||||
|
||||
## Causas e correcções
|
||||
|
||||
### Incremento da preparação assistida — evidência do anexo de 15/11/2026
|
||||
|
||||
O anexo `7a2b72ba-ccbb-4aad-a55b-3f30739e78c7/Pasted text.txt`, identificado pelo responsável como «Texto colado(7).txt», fica associado à divergência já aberta. SHA256 `489473c1e3fdc69ff7cc786dcedb33184ddd74b4a6c9fb97d99f130ae0a2e943`. O texto mostra oito etapas anteriores, empréstimo demonstrativo, tratamentos indiscriminados e códigos internos. Não se atribui esse ecrã a uma revisão exacta sem evidência da origem.
|
||||
|
||||
**Implementação desta passagem, apenas QA:** o percurso simples de investimento passa a oito ecrãs: intenção/entidade/localização; mercado; investimento inicial; financiamento; receitas/custos; tesouraria/cenários; enquadramento fiscal; resultados/próximos passos. Investimento inicial e financiamento ficam separados conforme a sequência do novo anexo; os dois primeiros ecrãs conservam a preparação contextual anterior. O modo avançado e os restantes tipos de percurso conservam as capacidades existentes, sem se presumir a sua aceitação visual por esta alteração.
|
||||
|
||||
- `simulationFieldsForSection` serve a apresentação, validação e revisão do percurso simples. Factos financeiros desconhecidos não impedem continuar uma preparação privada; cálculos continuam sujeitos às suas condições. `preparation_step` é gravado no contrato existente e recuperado com o rascunho. Mudança deliberada de percurso reinicia a navegação, conservando os valores.
|
||||
- O orçamento inicial reutiliza a aritmética decimal existente para instalações, equipamento, preparação e fundo de maneio, sem valores demonstrativos. A soma só substitui o total do cenário por escolha explícita; o intervalo original fica separado. Financiamento previsto, solicitado, aprovado e mobilizado têm campos distintos; os três primeiros não são somados ao montante mobilizado.
|
||||
- A ajuda de receitas/custos exige escolher o modelo. Hotel usa quartos, preço/noite, ocupação e dias anuais; Agronegócio usa unidades vendáveis, preço e ciclos anuais. São pressupostos editáveis, guardados e adoptados apenas pela acção «Usar». Custos incompletos não se tornam zero. A simulação de empréstimo no percurso simples depende da pergunta «Pretende estudar um empréstimo neste cenário?».
|
||||
- A etapa de financiamento deixa de montar os componentes fiscais. O percurso simples apresenta as operações fiscais por escolha explícita; o componente de IVA preserva 32 tratamentos e o de IRPC nove operações complementares através do selector pesquisável comum «Adicionar outra operação». Retirar uma operação da análise não elimina os respectivos dados e históricos. Não se deduz aplicabilidade pelo título ou sector.
|
||||
- O IVA mensal recusa divergência entre ano fiscal e primeiro mês da projecção; mantém a cobertura documentada de 2026. IRPC normal e ISPC trimestral sinalizam divergência entre o ano do enquadramento e o da preparação; a correspondência exige escolha explícita e os validadores de cobertura mantêm-se. A estimativa anual de IRPC não oferece o recorte de 2026 para outro ano. Nenhum ano é alterado automaticamente.
|
||||
- O histórico distingue rascunho guardado de simulação calculada e guardada. O retorno ao dossier identifica «pressupostos financeiros», em consonância com o tipo de evidência existente. O resultado preserva uma selecção dos pressupostos económicos autorizados, orçamento discriminado e modelo de receitas/custos para o relatório privado, sem copiar identidade ou outros campos pessoais para essa selecção. Filtragem por dossier/oportunidade e exportadores Word/impressão existentes são conservados.
|
||||
- Códigos do desenvolvimento retirados dos títulos afectados de importações, apuramento de aquisições e estimativa anual; fontes detalhadas de importação ficam no componente comum de evidência. Não se declara revisto todo o texto de todas as operações avançadas.
|
||||
|
||||
**Estado de aceitação:** implementado e com provas dirigidas; **não verificado integralmente na interface**. Os testes dos controlos reais de orçamento, ajuda de receitas e selector fiscal usam estado/eventos React e transporte simulado; não equivalem a uma sessão de navegador. Os testes de relatório conferem pressupostos, valores desconhecidos, histórico e exclusão de dados de outra oportunidade.
|
||||
|
||||
**Retoma posterior à interrupção por Esc:** provas técnicas preservadas, sem nova execução. A prévia local foi ligada ao build assistido já compilado em `public/build-qa-opportunities/assisted-current`, conservando os assets anteriores; não houve alteração do produto nem instalação no activo nesta retoma. O controlo nativo leu `http://127.0.0.3:18715/login`; a abertura directa do dossier sintético Hotel regressou a esse login. Não foi observada a sessão autenticada que o responsável indicou ter aberto. A enumeração mantém as duas janelas Edge existentes; o Firefox está minimizado e a leitura devolveu `window is minimized; call activate_window, refresh with get_window, then retry get_window_state`. Não foram invocadas APIs não documentadas nem alteradas portas/configurações dos navegadores.
|
||||
|
||||
As tentativas de repor a vista normal não permitiram demonstrar as operações do investidor; essa frente foi interrompida sem reinícios adicionais. Foi pedido apenas o URL do painel aberto para identificar a sessão correcta, sem palavras-passe ou códigos. Não se atribui a causa ao portal, ao navegador ou ao MFA. Hotel, Agronegócio, retorno de filtros financeiros e continuidade demográfica mantêm a conferência autenticada/móvel **NÃO VERIFICADA**; a interrupção não reabre nem invalida as provas técnicas aceites.
|
||||
|
||||
**Retoma seguinte — acesso recuperado e primeiras operações reais:** foi observado o dossier sintético Hotel autenticado em `http://127.0.0.3:18715/investimentos/6bdc2ac6-2a40-497c-831d-31c9c08372ed`. Pela interface foi pesquisada a «Empresa hoteleira sintética QA», seleccionada e guardada a associação; o resumo do dossier passou a mostrar essa empresa. A acção «Continuar a viabilidade» abriu o simulador do mesmo dossier. Foram observados título, actividade, sector, hierarquia Matola Rio–Sede/Boane/Maputo Província e intervalo de 1–5 milhões MZN recuperados, sem anúncio associado. Foram preenchidos uma descrição sintética e o idioma e utilizada a acção Continuar para chegar a Mercado. As capturas nativas disponíveis na conversa demonstram este recorte em computador, não o percurso integral ou a utilização móvel.
|
||||
|
||||
A conferência identificou duas divergências concretas antes do fecho: a navegação focava o cabeçalho geral, afastando as perguntas da etapa; orçamento, parcerias/apoio, contexto e histórico acumulavam-se antes delas. Aplicada correcção localizada em `simulation-form.tsx`: foco no título da etapa com margem para o menu fixo; apresentação progressiva dos blocos acessórios, mantendo os componentes montados, as ligações e os erros de contexto visíveis; ajuda aberta quando uma resposta da etapa é «Não sei». Tipos e lint dirigidos aprovados; reconferência visual desta alteração permanece por executar até observação efectiva. As baterias fiscais e de persistência já aceites não foram repetidas por esta correcção de apresentação.
|
||||
|
||||
**Conferência posterior no mesmo QA — Hotel, computador e emulação móvel:** o acesso autenticado foi recuperado sem alterar a segurança. A navegação passou a focar a etapa efectiva; o recarregamento recuperou Mercado, Financiamento e Receitas e custos. «Por determinar» abriu a ajuda da etapa. Pela interface foram introduzidas instalações 1.000.000, equipamentos 500.000, preparação 200.000 e fundo de maneio 300.000 MZN; a soma de 2.000.000 MZN só foi adoptada por botão explícito. O intervalo original de 1–5 milhões permaneceu separado.
|
||||
|
||||
Foram encontrados e corrigidos três defeitos demonstráveis: (1) a calculadora conservava um campo mobilizado vazio apesar de o investimento indicar 1.000.000, e mostrava erros de ferramentas não utilizadas; agora acompanha a origem comum quando não há edição conhecida divergente, preservando conflitos reais para escolha explícita; (2) receitas/custos anuais estavam ocultos pela regra de campos avançados; agora são editáveis na etapa pertinente; (3) o retorno ao dossier confundia ausência de oportunidade em campo vazio com `null` normalizado pelo servidor. A comparação canónica passou a tratar esses vazios como equivalentes, sem equiparar desconhecido a zero nem aceitar alterações reais de montante ou oportunidade. A pesquisa fiscal inicialmente vazia passou a explicar que é necessário escolher a componente, em vez de aparentar ausência de operações.
|
||||
|
||||
Nova conferência real: investimento 2.000.000, mobilizado 1.000.000 e diferença 1.000.000 MZN coerentes após recarregar; sem erros dos grupos ocultos ou empréstimo demonstrativo. Hotel escolhido explicitamente; 10 quartos × 300 dias × 50% × 5.000 MZN gerou 7.500.000 MZN anuais, adoptados por escolha explícita. Recarregar recuperou os quatro pressupostos, fundamento e total anual. Em emulação Edge **390 × 844**, a interface autenticada apresentou uma coluna; foi editado o custo anual para 2.000.000 e usado Continuar até Tesouraria. Não é prova num aparelho físico nem aprovação móvel de todos os ecrãs. Em 2027, seleccionar IVA à taxa geral apresentou o limite de cobertura de 2026, sem mudar o ano ou calcular silenciosamente.
|
||||
|
||||
O botão de retorno, após a correcção do vazio, guardou os pressupostos no **mesmo dossier Hotel**, agora versão 6. A interface preparou e descarregou o HTML consolidado; o ficheiro efectivamente descarregado conserva descrição, empresa/contexto, intervalo, orçamento inicial, receita 7.500.000, custo 2.000.000, mobilizado 1.000.000, diferença 1.000.000 MZN e pressupostos/fundamento. Evidência local: `outputs/investor-intent-20261115/hotel-browser-report.html`. Não foi apresentado imposto calculado fora da cobertura nem aprovada a viabilidade comercial. Recibos dirigidos adicionais: `financial-source-ui.log` (5/5), `visual-followup-tests.log` (8/8), `return-fingerprint-tests.log` (8/8, baterias sobrepostas), tipos e builds incrementais. Capturas nativas disponíveis na conversa; não inventar caminhos de screenshots.
|
||||
|
||||
**Saldo desta conferência:** Hotel tem prova autenticada de associação de empresa existente, preparação/estimativa explícita, gravação, recuperação, retorno e geração do relatório, com o recorte móvel descrito. Permanecem NÃO VERIFICADOS o percurso integral com entidade por constituir, Agronegócio na interface, retorno dos filtros financeiros, continuidade demográfica autenticada e retoma após nova autenticação/isolamento entre separadores. Os restantes consumidores da tabela não ficam aprovados por partilharem componentes. A candidata e as correcções continuam exclusivamente em QA, sem instalação autorizada.
|
||||
|
||||
**Agronegócio e consultas — continuação da mesma conferência:** no dossier `bd830c95-a3bc-4342-84e2-df8e1dccb614`, seleccionada entidade por constituir; a preparação recuperou sede e actividade e guardou uma denominação sintética, continuando para o simulador. Mantiveram-se sector, hierarquia e intervalo de 1–5 milhões MZN, sem anúncio/zona obrigatórios. O modelo agrícola, escolhido explicitamente, conservou 20.000 unidades × 50 MZN × 2 ciclos = 2.000.000 anuais após recarregar, sem os valores do Hotel. Foram guardados pressupostos no mesmo dossier após conferir Financiamento, deixando total/mobilizado desconhecidos: o intervalo não foi convertido num montante único. A tentativa de retorno antes de percorrer Financiamento expôs a mensagem pouco orientadora «Guarde primeiro as entradas da ferramenta financeira»; permanece divergência de orientação, não uma perda de dados ou autorização para dispensar o contrato do servidor.
|
||||
|
||||
Para provas positivas de consulta, acrescentados apenas à base isolada três registos financeiros e duas observações distritais **claramente sintéticos**, com recibo `outputs/investor-intent-20261115/consultation-fixtures.json`; nenhuma conta recebeu permissões persistentes, nenhuma instituição operacional foi publicada ou alterada. A consulta autenticada Banca → Banco comercial, presença física Boane e âmbito documentado apresentou apenas o banco, excluindo corretora/microsseguradora sintéticas. A ligação ao detalhe conserva os filtros na URL de retorno. Pela interface foram escolhidos necessidade Investimento/equipamento e produto, confirmada a conservação privada e aberto o mesmo dossier: instituição, versão consultada 1, produto, categoria e tipo recuperados. O clique efectivo de retorno à lista, combinações vazias/inválidas e a conferência móvel financeira ainda não ficam aprovados por esta prova.
|
||||
|
||||
No dossier agrícola foi aberta «Demografia na análise de viabilidade» → «Consultar indicadores do ano actual». Apresentados 12.345 habitantes sintéticos, fonte/2026/projecção e **Boane QA — Distrito**, com aviso explícito de contexto administrativo mais amplo, que não representa a população da localidade. Estrutura etária/rural ausente continuou «Sem dados»; não foram inventados clientes ou rentabilidade. A consulta completa, evolução temporal, continuidade por nova ideia e versão móvel autenticada continuam abertas. Também foi identificada linguagem interna sobre «sete exemplares/complemento» na preparação da empresa, ainda por corrigir no âmbito de apresentação. Estas constatações não encerram a cobertura transversal.
|
||||
|
||||
**Prévia autenticada preparada:** servidor de desenvolvimento local em `http://127.0.0.3:18715`, exclusivamente com a base `investor_intent_20261115_test`, guarda de identidade da base e conta/dossiers sintéticos sem novas permissões. O host e cookie próprios separam a sessão das outras prévias locais. Não é instalação na homologação. Não desactivar autenticação, MFA ou avisos do navegador para continuar.
|
||||
|
||||
Para a conferência pendente, usar os dossiers sintéticos do Hotel e Agronegócio preparados nessa base: associar empresa existente ou preparar entidade por constituir; confirmar contexto; preencher só pressupostos conhecidos; adoptar orçamento e estimativa; guardar na etapa de receitas; recarregar e retomar a mesma etapa; escolher operações fiscais e testar 2027 sem substituição por 2026; guardar pressupostos no dossier e gerar relatório; repetir em largura móvel. Conferir também lista/detalhe/retorno de fundos e demografia/âmbito/continuar ideia. As quatro linhas da matriz mantêm a aceitação integral aberta até essas provas, sem apagar as provas anteriores ou transformar a prévia em instalação autorizada.
|
||||
|
||||
- Proveniência apresentada como conteúdo principal: componente partilhado `EvidenceDetails` para consulta progressiva, mantendo referências, exemplares e hashes no detalhe. Limitações de utilização e períodos aplicáveis permanecem junto do conteúdo afectado.
|
||||
- Retorno ao catálogo aceitava qualquer parâmetro e podia transportar critérios de outra família: lista explícita por destino, preservação da página válida, múltiplas facetas e alterações deliberadas no destino. Mudar/limpar um nível territorial não recupera descendentes incompatíveis. Não usa armazenamento global nem transfere contexto privado em `catalogue_query`. Menus de consulta não transportam o identificador de dossier para biblioteca, legislação ou outras famílias sem aplicação.
|
||||
- A reutilização do componente de interesse podia escrever o formulário anterior na chave de outra oportunidade: estado associado à chave do recurso, preservando os formatos anteriores, as edições deliberadas, a autenticação normal e o funcionamento com armazenamento bloqueado.
|
||||
- O resumo de dossier podia adoptar a escolha efectuada noutro separador: pista por conta em `sessionStorage`, sempre confrontada com a lista autorizada recebida do servidor. Um URL explícito prevalece. Um novo separador sem escolha continua a usar a preferência existente. Uma mudança deliberada recupera a versão actual do servidor antes da escrita normal com controlo de concorrência. Não altera a autoridade do servidor nem os dados do dossier.
|
||||
- A consulta demográfica podia mostrar a localização anterior e aceitar uma resposta tardia: estado isolado pela localização, cancelamento da consulta e rejeição da resposta após desmontagem. A página de comparação identifica a consulta pelos filtros recebidos do servidor.
|
||||
|
||||
## Cobertura demonstrada e limites por família
|
||||
|
||||
| Família / consumidores | Alteração verificável | Evidência / excepções |
|
||||
| --- | --- | --- |
|
||||
| Catálogo, pesquisa, carteira e zonas | Retorno por família; filtros explícitos, página, facetas, contexto territorial; explicação do estado sem anúncios | Testes de URLs e navegação. Estado vazio distingue anúncios de empresas, recursos e potencial. Não altera o motor de pesquisa nem incorpora os deltas concorrentes de `FederatedSearch` da pasta principal. |
|
||||
| Localização e demografia, incluindo consulta no catálogo/dossier | Síntese dos totais mais recentes **dentro do período consultado e de cada fonte**; ano, unidade e tipo junto do valor; dimensões, original, ficheiro e hash recuperáveis no detalhe | Renderização real dos componentes e teste de resposta tardia. Não soma faixas etárias, não combina fontes ou tipos estatísticos, não preenche anos ausentes nem infere procura. A síntese conserva advertências e unidades ausentes. **Comparação com `21ac7eb` pendente**, pois o objecto e o bundle não estão disponíveis localmente; não houve cherry-pick dessa candidata. |
|
||||
| Sectores, Mineração/Energia, âncoras e clusters | Estado sem anúncios explicado; fontes territoriais em detalhe; campos vazios da análise de competitividade omitidos da apresentação | Renderização e testes de filtros de recursos, localização e continuidade no dossier, incluindo Energia e Mineração. Não reescreve descrições editoriais existentes nem altera geometrias, contratos ou relações. |
|
||||
| Biblioteca e legislação | Identificação/fonte em detalhe; sem bloco de entidade desconhecida; período de aplicação e aviso de utilização visíveis; acções documentais conservadas | Renderização do consumidor comum e regressão dos filtros de biblioteca/legislação. Não altera decisões jurídicas, disponibilização ou acesso aos ficheiros. |
|
||||
| Dossier, manifestações de interesse e simulador | Escolha por separador/conta, rascunho por oportunidade; fonte técnica do Modelo A em detalhe; cálculos e reutilização preservados | Integração do provider real com transporte simulado, testes de recuperação e autenticação, herança e regressões de Modelo A. Não representa conferência autenticada integral num navegador nem entrega real de email. |
|
||||
|
||||
Estas provas cobrem componentes e consumidores identificados, não todos os ecrãs ou todas as combinações de módulos. A distinção entre oferta publicada, empresas existentes, indicadores e hipóteses é preservada; não se utiliza ausência de anúncios como prova de ausência de oferta empresarial ou viabilidade.
|
||||
|
||||
## Verificação
|
||||
|
||||
Recibos em `outputs/public-context-20261115/`:
|
||||
|
||||
- `frontend.xml`: 82 testes, zero falhas, erros ou omissões; inclui componentes renderizados, pesquisa/navegação, biblioteca, recursos sectoriais, interesses, contexto e Modelo A.
|
||||
- `navigation-context.xml`: 1 teste de integração do provider, com contexto por separador, recarregamento, URL explícito e versão concorrente do servidor. Dados exclusivamente sintéticos; transporte simulado.
|
||||
- `types.log`: `tsc --noEmit`, aprovado.
|
||||
- `lint.log`: lint dirigido a todos os ficheiros de produto/teste alterados, aprovado sem avisos.
|
||||
- `build.log`: compilação QA aprovada. Aviso do plugin de fontes sobre a dependência opcional `fontaine`, com configuração já existente; não foi instalada nem alterada qualquer dependência para o eliminar.
|
||||
|
||||
A skill de controlo Windows foi tentada sem reiniciar aplicações. A enumeração devolveu **`Computer Use native pipe is unavailable: failed to connect native pipe: O sistema não conseguiu localizar o ficheiro especificado. (os error 2)`**. Não existem screenshots aceites. Apresentação móvel, foco real, ausência de sobreposição e utilização autenticada destes deltas estão **NÃO VERIFICADOS**. Classes responsivas e testes de renderização não substituem essa conferência.
|
||||
|
||||
Conferência manual quando houver prévia da candidata: catálogo → detalhe → voltar, incluindo alteração explícita de província; demografia com duas localizações/períodos e retorno; sector/âncora/cluster com e sem anúncios; biblioteca/legislação com aviso/período; dois dossiers em separadores distintos e recuperação de um interesse. Repetir em computador e janela de 390 × 844, verificando teclado, detalhes, tabelas e botões. Não utilizar a prévia das Ofertas como prova desta candidata: conserva o seu código anterior.
|
||||
|
||||
## Compatibilidade e entrega
|
||||
|
||||
Pacote Git com delta exclusivo sobre `42fba8ba`, testes e documentação. O manifesto indica SHA, pré-requisito e intersecção de caminhos com as alterações concorrentes detectadas na pasta principal. **Não aplicar uma substituição de pastas nem fazer push para `main`**. Integrar o delta revisto, preservando o trabalho concorrente e as actualizações da matriz/manual. O pacote não inclui credenciais, sessões, dados operacionais, dependências ou uma nova importação demográfica.
|
||||
|
||||
As linhas existentes RF-MN-010, RF-GEO-009, INE-DEMO-01, MASTER-471012ab-0017 e M2-d0c06a75-0055 conservam o estado e o saldo anteriores; acrescenta-se apenas esta cobertura verificada. A candidata `21ac7eb`, os deltas concorrentes da pesquisa e a aceitação visual não são declarados integrados ou concluídos.
|
||||
|
||||
O saldo acima corresponde à entrega `8806312f`. A actualização abaixo regista a reconciliação posterior; os recibos anteriores conservam-se como histórico. Nenhuma instalação está autorizada por esta entrega.
|
||||
|
||||
## Reconciliação do bundle `21ac7eb` com `8806312f`
|
||||
|
||||
O exemplar `\\Mac\Home\Downloads\demografia-leitura-21ac7eb.bundle` foi efectivamente recebido e verificado pelo Git. Contém `21ac7eb4fb9c0fd7dfbbf2db7ff8d6ab56249238`, com pré-requisito `13497892`. Não se efectuou cherry-pick integral. O hash do transporte e os recibos desta passagem ficam em `outputs/demography-reconciliation-20261115/`.
|
||||
|
||||
| Parte da candidata recebida | Decisão e resultado |
|
||||
| --- | --- |
|
||||
| Síntese demográfica | A apresentação progressiva e os indicadores originais de `8806312f` foram preservados. Acrescentou-se uma síntese de população por fonte/edição, sem repetir o total nos cartões dos restantes indicadores. |
|
||||
| Evolução temporal | Incorporado o helper de apresentação: extremos exactos do período escolhido, mesma unidade e tipo estatístico; diferença absoluta e percentual **do período**, nunca taxa anual. Anos intermédios ausentes permanecem «Sem dados». |
|
||||
| Distribuição etária e rural | Incorporados os grupos indicados apenas quando existem todas as parcelas únicas e compatíveis. Percentagens rural/urbana exigem reconciliação com o total. Unidades desconhecidas, duplicações, tipos diferentes e parcelas ausentes não geram percentagens inventadas. São derivações para leitura, sem escrita sobre os valores originais ou cálculos fiscais. |
|
||||
| Interpretação | Incorporadas orientações ligadas aos valores apresentados: verificar acessos/custos/serviços, capacidade instalada e rendimento; a população em idade activa não demonstra disponibilidade de trabalhadores. Não se deduz procura, rentabilidade ou lacuna de mercado. |
|
||||
| Fontes, decimais e versões | Conservado o componente comum `EvidenceDetails`, tabela dos originais, hash, unidades, advertências e múltiplas fontes separadas. Não foram alterados lotes, dados, documentos ou geometrias. |
|
||||
| Catálogo | Aproveitada a separação explícita entre estudo territorial e filtros comerciais, com os destinos existentes. Não se duplicou o aviso de ausência de anúncios já tratado pela solução comum. |
|
||||
| Continuidade da análise | Incorporada a acção de preparar ideia a partir de cada território, transportando apenas `source=territory:ID`; sem importar filtros comerciais. Conservado o isolamento por URL e separador de `8806312f`. |
|
||||
| Documentação e testes | Não se substituíram o manual nem as matrizes pelos exemplares antigos do bundle. Acrescenta-se esta actualização às referências existentes. Reutilizaram-se os testes dirigidos recebidos, adaptando apenas os imports aos componentes comuns e o carregamento ao mecanismo `compileFunction` já usado no projecto. |
|
||||
|
||||
### Prova dirigida adicional e conferência visual
|
||||
|
||||
Os testes adicionais verificam conservação dos decimais, grupos incompletos, zero, dimensões duplicadas, unidades/tipos incompatíveis, distribuições incoerentes, anos ausentes, extremos seleccionados, separação de edições, interpretação e ligação territorial. O exemplar de Báruè do bundle confirma os resultados de apresentação sem modificar os originais. Não se repetiram importações ou as baterias técnicas globais aceites.
|
||||
|
||||
A captura nativa do Edge recuperou nesta passagem; o conector de abas continua a indicar `The browser access host is unavailable`. A janela existente no portal activo, com alterações por guardar, foi preservada. Abriu-se uma janela separada para `visual-components.html`: componentes reais renderizados com CSS compilado da candidata e dados explicitamente sintéticos, sem autenticação, API operacional ou publicação.
|
||||
|
||||
Foram observados em computador e em emulação móvel (390 px e 360 × 844): cartões em uma coluna, leitura da variação temporal, abertura da série anual, ano ausente como «Sem dados» e recuperação do detalhe de fontes/originais. A primeira captura móvel detectou palavras comprimidas na tabela original; foi aplicada largura mínima com deslocamento interno, seguida de nova conferência. As capturas são evidência da **prévia de componentes**, não de aceitação autenticada integral ou de utilização num telefone físico.
|
||||
|
||||
Continuam pendentes a conferência autenticada dos percursos completos e a aceitação do responsável. Sem instalação no activo, sem alteração do esquema ou de permissões e sem envio externo. A pendência de obter/comparar o bundle fica encerrada por esta reconciliação selectiva.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"base": "13497892",
|
||||
"source_candidate": "7810ffc8",
|
||||
"excluded_candidate_files": [
|
||||
"docs/investmoz/unification/traceability-and-manual.md",
|
||||
"docs/investmoz/v2/auditoria-portal/apresentacao-contexto-qa-20261115.md",
|
||||
"docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
|
||||
"outputs/demography-reconciliation-20261115/build.log",
|
||||
"outputs/demography-reconciliation-20261115/frontend.xml",
|
||||
"outputs/demography-reconciliation-20261115/lint.log",
|
||||
"outputs/demography-reconciliation-20261115/qa-result.json",
|
||||
"outputs/demography-reconciliation-20261115/types.log",
|
||||
"outputs/demography-reconciliation-20261115/visual-components.html",
|
||||
"outputs/public-context-20261115/build.log",
|
||||
"outputs/public-context-20261115/final-consumers.xml",
|
||||
"outputs/public-context-20261115/final-lint.log",
|
||||
"outputs/public-context-20261115/frontend.xml",
|
||||
"outputs/public-context-20261115/lint.log",
|
||||
"outputs/public-context-20261115/navigation-context.xml",
|
||||
"outputs/public-context-20261115/qa-result.json",
|
||||
"outputs/public-context-20261115/types.log",
|
||||
"resources/js/components/offer-particulars.tsx",
|
||||
"resources/js/components/opportunity-canonical-fields.tsx",
|
||||
"resources/js/pages/offers/preparation.tsx",
|
||||
"tests/frontend/offer-rental-flow.test.mjs",
|
||||
"tests/frontend/opportunity-screen-presentation.test.mjs",
|
||||
"tests/frontend/portal-navigation.test.mjs"
|
||||
],
|
||||
"offers_dependency_files": 60,
|
||||
"changed_shared_dependencies": [
|
||||
"resources/js/components/demographic-context.tsx",
|
||||
"resources/js/components/demographic-evidence.tsx",
|
||||
"resources/js/components/dossier-navigation-context.tsx",
|
||||
"resources/js/components/investment-territory.tsx",
|
||||
"resources/js/components/opportunity-search-filters.tsx",
|
||||
"resources/js/components/territorial-source-evidence.tsx",
|
||||
"resources/js/lib/investor-context.ts"
|
||||
],
|
||||
"selected_product_files": [
|
||||
"app/Http/Controllers/DemographyController.php",
|
||||
"app/Http/Controllers/FundingProgrammeController.php",
|
||||
"app/Http/Controllers/InvestmentWorkspaceController.php",
|
||||
"app/Http/Controllers/InvestorContextController.php",
|
||||
"app/Http/Requests/StoreSimulationFormDraftRequest.php",
|
||||
"app/Services/CompanyComparison.php",
|
||||
"app/Services/CompanyPreparation.php",
|
||||
"app/Services/DemographicEvidence.php",
|
||||
"app/Services/FundingInstitutionClassification.php",
|
||||
"app/Services/FundingInstitutionDetails.php",
|
||||
"app/Services/FundingProgrammes.php",
|
||||
"app/Services/InvestmentCorridorContext.php",
|
||||
"app/Services/InvestmentGeography.php",
|
||||
"app/Services/InvestmentOpportunityChoices.php",
|
||||
"app/Services/InvestorContext.php",
|
||||
"app/Services/InvestorPath.php",
|
||||
"app/Services/ProactiveSupply.php",
|
||||
"app/Services/VirtualDesk.php",
|
||||
"config/investor-path.php",
|
||||
"resources/js/components/acquisition-closure.tsx",
|
||||
"resources/js/components/catalogue-choice-select.tsx",
|
||||
"resources/js/components/catalogue-navigation.tsx",
|
||||
"resources/js/components/cluster-competitiveness-public.tsx",
|
||||
"resources/js/components/country-select.tsx",
|
||||
"resources/js/components/currency-select.tsx",
|
||||
"resources/js/components/demographic-context.tsx",
|
||||
"resources/js/components/demographic-evidence.tsx",
|
||||
"resources/js/components/demographic-reading-summary.tsx",
|
||||
"resources/js/components/dossier-navigation-context.tsx",
|
||||
"resources/js/components/dossier-prefill.tsx",
|
||||
"resources/js/components/evidence-details.tsx",
|
||||
"resources/js/components/fiscal-l3-preparation.tsx",
|
||||
"resources/js/components/fiscal-operation-picker.tsx",
|
||||
"resources/js/components/investment-budget-range.tsx",
|
||||
"resources/js/components/investment-initial-budget.tsx",
|
||||
"resources/js/components/investment-operating-assistance.tsx",
|
||||
"resources/js/components/investment-opportunity-choices.tsx",
|
||||
"resources/js/components/investment-profile-editor.tsx",
|
||||
"resources/js/components/investment-promoter.tsx",
|
||||
"resources/js/components/investment-supply-aggregate.tsx",
|
||||
"resources/js/components/investment-territory.tsx",
|
||||
"resources/js/components/opportunity-search-filters.tsx",
|
||||
"resources/js/components/portal-hero.tsx",
|
||||
"resources/js/components/public-document-content.tsx",
|
||||
"resources/js/components/published-opportunities-empty.tsx",
|
||||
"resources/js/components/sector-content.tsx",
|
||||
"resources/js/components/simulation-business-plan.tsx",
|
||||
"resources/js/components/simulation-cashflow-planner.tsx",
|
||||
"resources/js/components/simulation-financial-tools.tsx",
|
||||
"resources/js/components/simulation-form.tsx",
|
||||
"resources/js/components/simulation-irpc-annual.tsx",
|
||||
"resources/js/components/simulation-irpc-normal.tsx",
|
||||
"resources/js/components/simulation-ispc-quarter.tsx",
|
||||
"resources/js/components/simulation-miners.tsx",
|
||||
"resources/js/components/simulation-modelo-a.tsx",
|
||||
"resources/js/components/simulation-monthly-iva.tsx",
|
||||
"resources/js/components/simulation-remission-imports.tsx",
|
||||
"resources/js/components/simulation-returnees.tsx",
|
||||
"resources/js/components/territorial-source-evidence.tsx",
|
||||
"resources/js/hooks/use-interest-draft.ts",
|
||||
"resources/js/lib/catalogue-choice.ts",
|
||||
"resources/js/lib/catalogue-context.ts",
|
||||
"resources/js/lib/company-preparation.ts",
|
||||
"resources/js/lib/demographic-reading.ts",
|
||||
"resources/js/lib/financial-calculators.ts",
|
||||
"resources/js/lib/investment-operating-assistance.ts",
|
||||
"resources/js/lib/investor-context.ts",
|
||||
"resources/js/lib/investor-dossier-report.ts",
|
||||
"resources/js/lib/investor-path.ts",
|
||||
"resources/js/lib/monthly-cashflow.ts",
|
||||
"resources/js/lib/monthly-iva.ts",
|
||||
"resources/js/lib/navigation-focus.ts",
|
||||
"resources/js/lib/planning-finance-context.ts",
|
||||
"resources/js/lib/preparation-intent.ts",
|
||||
"resources/js/lib/project-dossier-report.ts",
|
||||
"resources/js/lib/simulation-api.ts",
|
||||
"resources/js/lib/simulation-form.ts",
|
||||
"resources/js/pages/funding/index.tsx",
|
||||
"resources/js/pages/investment/catalogue.tsx",
|
||||
"resources/js/pages/investment/company-comparison.tsx",
|
||||
"resources/js/pages/investment/company-preparation.tsx",
|
||||
"resources/js/pages/investment/demography.tsx",
|
||||
"resources/js/pages/investment/profile.tsx",
|
||||
"resources/js/pages/investor-path/context-start.tsx",
|
||||
"resources/js/pages/investor-path/context.tsx",
|
||||
"resources/js/pages/investor-path/show.tsx",
|
||||
"resources/js/pages/portal/search.tsx",
|
||||
"resources/js/pages/portfolio/show.tsx",
|
||||
"resources/js/pages/structures/editor.tsx",
|
||||
"routes/investor-path.php",
|
||||
"tests/Feature/FundingCriteriaCoherenceTest.php",
|
||||
"tests/Feature/InvestorIntentContinuityTest.php",
|
||||
"tests/Feature/InvestorPromoterContinuityTest.php",
|
||||
"tests/Fixtures/demography/barue-2026.json",
|
||||
"tests/frontend/catalogue-context.test.mjs",
|
||||
"tests/frontend/company-preparation.test.mjs",
|
||||
"tests/frontend/demographic-reading.test.mjs",
|
||||
"tests/frontend/financial-calculators.test.mjs",
|
||||
"tests/frontend/financial-tools-source.test.mjs",
|
||||
"tests/frontend/fiscal-operation-picker.test.mjs",
|
||||
"tests/frontend/funding-journey-coherence.test.mjs",
|
||||
"tests/frontend/helpers/investment-profile-editor.mjs",
|
||||
"tests/frontend/interest-draft-storage.test.mjs",
|
||||
"tests/frontend/investment-assistance-controls.test.mjs",
|
||||
"tests/frontend/investment-assisted-journey.test.mjs",
|
||||
"tests/frontend/investment-profile-editor.test.mjs",
|
||||
"tests/frontend/investor-dossier-report.test.mjs",
|
||||
"tests/frontend/navigation-context-integration.test.mjs",
|
||||
"tests/frontend/navigation-focus.test.mjs",
|
||||
"tests/frontend/planning-finance-context.test.mjs",
|
||||
"tests/frontend/preparation-intent.test.mjs",
|
||||
"tests/frontend/public-information.test.mjs",
|
||||
"tests/frontend/simulation-form.test.mjs"
|
||||
],
|
||||
"migration_changes": [],
|
||||
"protected_offers_files": {
|
||||
"app/Http/Controllers/OfferPublicCatalogueController.php": "04034126a52660e10a6e64207a3aa4fb09a9b03aee045383b240d434477f03ca",
|
||||
"app/Http/Controllers/PrivateOfferOperationsController.php": "15da904c466be9bb38fa9a673beff55816026caf72569bc4289d399c77f10cce",
|
||||
"app/Http/Controllers/PrivateOfferPreparationController.php": "32415ac61a948a0bd53f440c728b5e99522017d765910101eb1a7df1d8b6d0d7",
|
||||
"app/Services/OfferAvailability.php": "72f975c8c01c7a3ccd3cc00d372210b3546dc6bbae1c34d91514b341a7968adf",
|
||||
"app/Services/OfferPublicCatalogue.php": "7eee7ab9864cc29c2de90b071ff2c3bf3cac093d3adde9e1937ded324b2223c6",
|
||||
"app/Services/OpportunityResources.php": "e8bcd6f4f5b994b466005aace3601fcfc642550d6ebda227c6de7a498f95f1b5",
|
||||
"app/Services/PrivateOfferConfidentiality.php": "17e02925c3cfe26a4853db272b6d3c2acd1095efac826371a517a603eaa429ca",
|
||||
"app/Services/PrivateOfferDetails.php": "1253c5d3cb9e3a2ae422f813afc4eb5ee8ed3859b07e15a3b32b678d3a97a015",
|
||||
"app/Services/PrivateOfferOperations.php": "1b6032e96712f9d19befe76029a47464c3bb52e4f018d858dcc6f8e605cf0cdc",
|
||||
"app/Services/PrivateOfferPreparation.php": "442d7aa1093001630bdfc581fde3b9f16e4b5b76a4e8d028ba4b3da41b1cfe4d",
|
||||
"resources/js/components/interactive-investment-map.tsx": "3a59970e926736a2dfc11f962130f38cc9602a07d994a49fa2fc301a918d8c7d",
|
||||
"resources/js/components/offer-confidential-access.tsx": "5723e5e8eda7500fe24670e7676749a4f36ad294bd284224cf7b6919675bddc1",
|
||||
"resources/js/components/offer-editorial-review.tsx": "c519fb77848d8760ea88556184201fbaef27bbf7f9b00adefcff04de191ba81e",
|
||||
"resources/js/components/offer-navigation.tsx": "4676f4859f7d378ebca988bc5436629f97c0004abe32af6db8a6b40789b48874",
|
||||
"resources/js/components/offer-operations.tsx": "ce4a32b422c7a8e33dade2002b9565d53b4294c44f051ab06022fa6fcfc5ee4e",
|
||||
"resources/js/components/offer-particulars.tsx": "ef78a2a493705b6f4828addd0227fc7e91f511a48b2c7b12fbbe35c610fe5de0",
|
||||
"resources/js/components/offer-private-resources.tsx": "cb2070aeb4fd8ebe4eac28b41501223d2a87a2b1213538ab66c9520ab244d9c9",
|
||||
"resources/js/components/opportunity-canonical-fields.tsx": "70bba90799e6057024f09fc32b85282c46002b6c8274b970fc80a48a79a20bf5",
|
||||
"resources/js/lib/offer-editorial.ts": "dc70169aaa746b9ae8063b0312a6601d0347d2927a7c0f4bfc58cfc1f31aae99",
|
||||
"resources/js/lib/offer-navigation.ts": "94ec55b7bbbc800f57fd10a9b51a49398c6cdd23fa0df635ca20e949426aef13",
|
||||
"resources/js/lib/portal-navigation.ts": "4fb7cbf0a508cf0964e7ea0dd2023bc00202236bf048f228822d7de6e1f44534",
|
||||
"resources/js/pages/offers/catalogue.tsx": "a17dd461a9a0e15bf1d8d182f5b0ef5687b9b0c8237ad2d4082701b08ad3bb47",
|
||||
"resources/js/pages/offers/confidentiality.tsx": "b31d2cd72b55eaa9d04a3e7ff03fcb0d4a18696c403f89f5b791f9dc050c4d68",
|
||||
"resources/js/pages/offers/preparation-index.tsx": "1cdfe52e4ed8311f52e24caab32d83dc7611213e5b9d3cc477b61c6e6c996276",
|
||||
"resources/js/pages/offers/preparation.tsx": "7503946ba28e71ff48e6fd955b1ed587494678df433b6a13492dd026b74ce1de",
|
||||
"resources/js/pages/offers/public-detail.tsx": "387c8a9a0013920bf74c85853362671d76131849508a9b86fc2391622cb0c647",
|
||||
"resources/js/pages/offers/report.tsx": "91f9e32e88c82b1af984226ae81ede188fddaff395743905b8ab8ce483a8f859",
|
||||
"routes/web.php": "50dbec32d17dbbd334298eb0bf77585b15d98781c9368fcb3f398642b83b2168"
|
||||
},
|
||||
"protected_offers_identical": true,
|
||||
"offers_excluded_reason": "Etapa B: criterios 5/6 comprovados HTTP; criterio 7 visual/mobile ainda NAO VERIFICADO. Aceitacao anterior 4/7 preservada.",
|
||||
"shared_dependencies_review": {
|
||||
"investment-territory": "Defaults unchanged: four levels and map enabled; optional funding-only props.",
|
||||
"opportunity-search-filters": "Ofertas imports unchanged modalityLabels only.",
|
||||
"demographic-context/evidence,territorial-source-evidence,dossier-navigation-context,investor-context": "Shared transversal presentation/context; no private Ofertas contract or permission changed."
|
||||
},
|
||||
"verification": {
|
||||
"frontend_tests": 142,
|
||||
"frontend_failures": 0,
|
||||
"wayfinder": "passed",
|
||||
"build": "passed",
|
||||
"types": "passed",
|
||||
"accepted_transversal_steps": 15
|
||||
},
|
||||
"migrations": 0,
|
||||
"permissions_changed": false,
|
||||
"operational_data_changed": false
|
||||
}
|
||||
@@ -71,12 +71,14 @@ export function AcquisitionClosure({
|
||||
);
|
||||
return (
|
||||
<details className="rounded border p-3">
|
||||
<summary>L3-A/B — crédito,fecho e pagamentos por período</summary>
|
||||
<summary>
|
||||
IVA — crédito, apuramento e valores documentados por período
|
||||
</summary>
|
||||
<p>
|
||||
Reutiliza resultados congelados de L2. Crédito anterior,saldo
|
||||
calculado,pagamento registado e transporte são distintos.
|
||||
Identifique o âmbito fiscal da entidade,sem consolidar
|
||||
oportunidades implicitamente.
|
||||
Reutiliza os resultados guardados das aquisições. Crédito
|
||||
anterior, saldo calculado,pagamento registado e transporte são
|
||||
distintos. Identifique o âmbito fiscal da entidade,sem
|
||||
consolidar oportunidades implicitamente.
|
||||
</p>
|
||||
<p>
|
||||
Primeira declaração em prazo,2026/MZN e reporte integral ou
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useId, useState } from 'react';
|
||||
import TerritorySearchSelect from './territory-search-select';
|
||||
import { matchesChoice } from '@/lib/catalogue-choice';
|
||||
|
||||
export type CatalogueChoice = {
|
||||
value: string;
|
||||
label: string;
|
||||
keywords?: string[];
|
||||
};
|
||||
export default function CatalogueChoiceSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
id,
|
||||
disabled = false,
|
||||
required = false,
|
||||
error,
|
||||
className,
|
||||
}: {
|
||||
options: CatalogueChoice[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const generated = useId();
|
||||
const [search, setSearch] = useState('');
|
||||
const selected = options.find((o) => o.value === value);
|
||||
return (
|
||||
<TerritorySearchSelect
|
||||
id={id ?? generated}
|
||||
label={label}
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
selected={
|
||||
value
|
||||
? {
|
||||
id: value,
|
||||
name:
|
||||
selected?.label ??
|
||||
`${value} — valor anterior; confirme a selecção`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
options={options
|
||||
.filter((o) =>
|
||||
matchesChoice(search, [
|
||||
o.value,
|
||||
o.label,
|
||||
...(o.keywords ?? []),
|
||||
]),
|
||||
)
|
||||
.map((o) => ({ id: o.value, name: o.label }))}
|
||||
onChange={(o) => onChange(o?.id ?? '')}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
error={error}
|
||||
className={
|
||||
className ??
|
||||
'bg-background text-foreground w-full rounded border p-2'
|
||||
}
|
||||
placeholder={`Seleccionar ou pesquisar ${label.toLowerCase()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export default function CatalogueNavigation({
|
||||
dimensionUrl
|
||||
)
|
||||
return dimensionUrl(u.searchParams.get('dimension')!);
|
||||
if (context) u.searchParams.set('context', context);
|
||||
if (context && ['/oportunidades', '/educacao'].includes(u.pathname))
|
||||
u.searchParams.set('context', context);
|
||||
return searchTerritoryHref(u.pathname + u.search, url);
|
||||
};
|
||||
const sections = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { forceLabels, forces } from '@/lib/cluster-competitiveness';
|
||||
import type { CompetitivenessDraft } from '@/lib/cluster-competitiveness';
|
||||
import EvidenceDetails from './evidence-details';
|
||||
|
||||
export type ApprovedCompetitiveness = {
|
||||
draft: CompetitivenessDraft;
|
||||
@@ -27,9 +28,13 @@ export default function ClusterCompetitivenessPublic({
|
||||
Competitividade — cinco forças de Porter
|
||||
</h2>
|
||||
<p>
|
||||
{analysis.draft.sector} · {analysis.draft.region} · Versão
|
||||
aprovada {analysis.version} · {analysis.at}
|
||||
{analysis.draft.sector} · {analysis.draft.region}
|
||||
</p>
|
||||
<EvidenceDetails title="Versão e data da análise">
|
||||
<p>
|
||||
Versão aprovada {analysis.version} · {analysis.at}
|
||||
</p>
|
||||
</EvidenceDetails>
|
||||
<p>
|
||||
Apreciação qualitativa fundamentada nas fontes e períodos
|
||||
indicados. Não representa garantia de procura ou viabilidade.
|
||||
@@ -55,14 +60,18 @@ export default function ClusterCompetitivenessPublic({
|
||||
'Oportunidade e fundamento',
|
||||
],
|
||||
] as const
|
||||
).map(([field, label]) => (
|
||||
<div key={field}>
|
||||
<dt className="font-semibold">{label}</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{row[field]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
.filter(([field]) => row[field]?.trim())
|
||||
.map(([field, label]) => (
|
||||
<div key={field}>
|
||||
<dt className="font-semibold">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{row[field]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import CatalogueChoiceSelect from './catalogue-choice-select';
|
||||
export type Country = { code: string; name: string; name_en: string };
|
||||
|
||||
export default function CountrySelect({
|
||||
countries,
|
||||
value,
|
||||
@@ -10,7 +8,6 @@ export default function CountrySelect({
|
||||
disabled = false,
|
||||
label = 'País',
|
||||
required = false,
|
||||
allowLegacyValue = false,
|
||||
error,
|
||||
}: {
|
||||
countries: Country[];
|
||||
@@ -23,77 +20,20 @@ export default function CountrySelect({
|
||||
allowLegacyValue?: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
const generatedId = useId();
|
||||
const [search, setSearch] = useState('');
|
||||
const matches = countries.filter(
|
||||
(country) =>
|
||||
country.code === value ||
|
||||
`${country.name} ${country.name_en} ${country.code}`
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
search
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
disabled={disabled}
|
||||
className="w-full rounded border p-2"
|
||||
aria-label={`Pesquisar ${label.toLowerCase()}`}
|
||||
maxLength={100}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Pesquisar nome ou código do país"
|
||||
/>
|
||||
{search.trim() && (
|
||||
<p role="status" aria-live="polite">
|
||||
Países:{' '}
|
||||
{matches.length
|
||||
? `${matches.length} opções disponíveis`
|
||||
: 'Nenhuma opção encontrada'}
|
||||
</p>
|
||||
)}
|
||||
<select
|
||||
disabled={disabled}
|
||||
id={id ?? generatedId}
|
||||
aria-label={label}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={
|
||||
error ? (id ?? generatedId) + '-error' : undefined
|
||||
}
|
||||
required={required}
|
||||
className="w-full rounded border p-2"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">Não informado</option>
|
||||
{allowLegacyValue &&
|
||||
value &&
|
||||
!countries.some((country) => country.code === value) && (
|
||||
<option value={value}>
|
||||
{value} (valor anterior conservado)
|
||||
</option>
|
||||
)}
|
||||
{matches.map((country) => (
|
||||
<option key={country.code} value={country.code}>
|
||||
{country.name} ({country.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p
|
||||
id={(id ?? generatedId) + '-error'}
|
||||
role="alert"
|
||||
className="text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<CatalogueChoiceSelect
|
||||
options={countries.map((c) => ({
|
||||
value: c.code,
|
||||
label: `${c.name} — ${c.code}`,
|
||||
keywords: [c.name_en, c.code],
|
||||
}))}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
id={id}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import CatalogueChoiceSelect from './catalogue-choice-select';
|
||||
import { currencyChoices } from '@/lib/catalogue-choice';
|
||||
export default function CurrencySelect({
|
||||
codes,
|
||||
...props
|
||||
}: {
|
||||
codes?: readonly string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<CatalogueChoiceSelect
|
||||
{...props}
|
||||
label={props.label ?? 'Moeda'}
|
||||
options={currencyChoices(codes)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import DemographicEvidence, {
|
||||
type DemographicAnalysis,
|
||||
} from './demographic-evidence';
|
||||
@@ -8,10 +8,17 @@ export default function DemographicContext({
|
||||
}: {
|
||||
territoryId?: string | null;
|
||||
}) {
|
||||
if (!territoryId) return null;
|
||||
// A change of territory resets this consultation, never the investor's dossier.
|
||||
return <TerritoryDemography key={territoryId} territoryId={territoryId} />;
|
||||
}
|
||||
|
||||
function TerritoryDemography({ territoryId }: { territoryId: string }) {
|
||||
const [evidence, setEvidence] = useState<DemographicAnalysis | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!territoryId) return null;
|
||||
const pending = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => pending.current?.abort(), []);
|
||||
return (
|
||||
<details className="rounded border p-4">
|
||||
<summary>Demografia na análise de viabilidade</summary>
|
||||
@@ -26,20 +33,35 @@ export default function DemographicContext({
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
setEvidence(null);
|
||||
pending.current?.abort();
|
||||
const controller = new AbortController();
|
||||
pending.current = controller;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/investment/demography?territories[]=${encodeURIComponent(territoryId)}`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
`/api/v1/investment/demography?investment_context=1&territories[]=${encodeURIComponent(territoryId)}`,
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error('Consulta indisponível.');
|
||||
setEvidence(await response.json());
|
||||
const result = await response.json();
|
||||
if (!controller.signal.aborted) {
|
||||
setEvidence(result);
|
||||
if (result.broader_context)
|
||||
setMessage(
|
||||
'Contexto administrativo mais amplo. Estes indicadores não representam a população da localidade escolhida.',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
setMessage(
|
||||
'Não foi possível consultar a evidência demográfica.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (!controller.signal.aborted) setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import EvidenceDetails from './evidence-details';
|
||||
import DemographicReadingSummary from './demographic-reading-summary';
|
||||
|
||||
export type DemographicAnalysis = {
|
||||
sector?: string;
|
||||
state: string;
|
||||
start_year: number;
|
||||
end_year: number;
|
||||
@@ -59,6 +63,24 @@ const indicators: Record<string, string> = {
|
||||
fertility_rate: 'Fecundidade',
|
||||
population_growth_rate: 'Crescimento populacional',
|
||||
};
|
||||
type Value =
|
||||
DemographicAnalysis['territories'][number]['series'][number]['values'][number];
|
||||
export function demographicHighlights(values: Value[]): Value[] {
|
||||
const totals = values.filter(
|
||||
(v) => v.age_group === 'all' && v.residence === 'all',
|
||||
);
|
||||
// Never add age groups, choose between sources or mix observed/projected values.
|
||||
return totals.filter(
|
||||
(v) =>
|
||||
!totals.some(
|
||||
(other) =>
|
||||
other.indicator === v.indicator &&
|
||||
other.unit === v.unit &&
|
||||
other.data_kind === v.data_kind &&
|
||||
other.year > v.year,
|
||||
),
|
||||
);
|
||||
}
|
||||
export default function DemographicEvidence({
|
||||
evidence,
|
||||
}: {
|
||||
@@ -66,8 +88,17 @@ export default function DemographicEvidence({
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4" aria-label="Evidência demográfica">
|
||||
<p className="text-sm">
|
||||
Os indicadores demográficos não demonstram procura ou
|
||||
rentabilidade. Cruze-os com oferta existente, acessos, custos e
|
||||
procura documentada.
|
||||
</p>
|
||||
{evidence.state !== 'available' && (
|
||||
<p>Sem séries demográficas aprovadas para esta selecção.</p>
|
||||
<p>
|
||||
Sem indicadores disponíveis para esta selecção. Isto não
|
||||
significa população inexistente nem ausência de potencial
|
||||
económico.
|
||||
</p>
|
||||
)}
|
||||
{evidence.territories.map((t) => (
|
||||
<article
|
||||
@@ -85,101 +116,175 @@ export default function DemographicEvidence({
|
||||
<p>Sem dados no período escolhido.</p>
|
||||
)}
|
||||
{t.series.map((s) => (
|
||||
<details key={s.import_id} open={t.series.length === 1}>
|
||||
<summary>
|
||||
{s.source} · {s.version} · {s.file_name}
|
||||
</summary>
|
||||
<p className="text-xs break-all">
|
||||
SHA256: {s.sha256}
|
||||
</p>
|
||||
<section key={s.import_id} className="space-y-3">
|
||||
<h3 className="font-semibold">{s.source}</h3>
|
||||
<DemographicReadingSummary
|
||||
series={s}
|
||||
evidence={evidence}
|
||||
/>
|
||||
{demographicHighlights(s.values).some(
|
||||
(v) => v.indicator !== 'population',
|
||||
) && (
|
||||
<>
|
||||
<h4 className="font-semibold">
|
||||
Outros indicadores mais recentes do
|
||||
período
|
||||
</h4>
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
{demographicHighlights(s.values)
|
||||
.filter(
|
||||
(v) =>
|
||||
v.indicator !==
|
||||
'population',
|
||||
)
|
||||
.map((v) => (
|
||||
<div
|
||||
className="rounded border p-3"
|
||||
key={`${v.sheet}:${v.cell}`}
|
||||
>
|
||||
<dt>
|
||||
{indicators[
|
||||
v.indicator
|
||||
] ?? v.indicator}{' '}
|
||||
· {v.year}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold">
|
||||
{v.original_value}{' '}
|
||||
{v.unit ===
|
||||
'unspecified'
|
||||
? '(unidade não indicada)'
|
||||
: v.unit}
|
||||
</dd>
|
||||
<dd className="text-sm">
|
||||
{v.data_kind ===
|
||||
'projected'
|
||||
? 'Projecção'
|
||||
: 'Tipo estatístico não especificado'}
|
||||
</dd>
|
||||
{v.validation_status !==
|
||||
'valid' && (
|
||||
<dd className="text-sm">
|
||||
Valor com
|
||||
advertência na
|
||||
fonte; consulte o
|
||||
detalhe antes de o
|
||||
utilizar.
|
||||
</dd>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
{s.missing_years.length > 0 && (
|
||||
<p>
|
||||
Anos sem dados: {s.missing_years.join(', ')}
|
||||
. Não foram preenchidos por estimativa.
|
||||
</p>
|
||||
)}
|
||||
{Object.keys(s.issue_counts).length > 0 && (
|
||||
<details>
|
||||
<summary>Advertências da série</summary>
|
||||
<pre className="overflow-auto text-xs">
|
||||
{JSON.stringify(
|
||||
s.issue_counts,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
{(Object.keys(s.issue_counts).length > 0 ||
|
||||
s.values.some(
|
||||
(v) => v.validation_status !== 'valid',
|
||||
)) && (
|
||||
<p>
|
||||
Esta série contém advertências. Consulte-as
|
||||
antes de comparar ou utilizar os valores.
|
||||
</p>
|
||||
)}
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption className="text-left">
|
||||
Valores originais por ano e dimensão —
|
||||
versões apresentadas separadamente
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'Ano',
|
||||
'Indicador',
|
||||
'Idade',
|
||||
'Residência',
|
||||
'Valor',
|
||||
'Unidade',
|
||||
'Tipo',
|
||||
'Referência',
|
||||
].map((h) => (
|
||||
<th className="p-2" key={h}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.values.map((v) => (
|
||||
<tr
|
||||
key={`${v.sheet}:${v.cell}`}
|
||||
className="border-t"
|
||||
>
|
||||
<td className="p-2">
|
||||
{v.year}
|
||||
</td>
|
||||
<td>
|
||||
{indicators[v.indicator] ??
|
||||
v.indicator}
|
||||
</td>
|
||||
<td>
|
||||
{v.age_group === 'all'
|
||||
? 'Todas'
|
||||
: v.age_group}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
{
|
||||
all: 'Total',
|
||||
urban: 'Urbana',
|
||||
rural: 'Rural',
|
||||
}[v.residence]
|
||||
}
|
||||
</td>
|
||||
<td>{v.original_value}</td>
|
||||
<td>
|
||||
{v.unit === 'unspecified'
|
||||
? 'Não indicada na fonte'
|
||||
: v.unit}
|
||||
</td>
|
||||
<td>
|
||||
{v.data_kind === 'projected'
|
||||
? 'Projecção'
|
||||
: 'Não especificado'}
|
||||
</td>
|
||||
<td>
|
||||
{v.sheet}!{v.cell}
|
||||
</td>
|
||||
<EvidenceDetails title="Valores por dimensão, fonte e advertências">
|
||||
<p>
|
||||
{s.version} · {s.file_name}
|
||||
</p>
|
||||
<p className="text-xs break-all">
|
||||
SHA256: {s.sha256}
|
||||
</p>
|
||||
{Object.keys(s.issue_counts).length > 0 && (
|
||||
<details>
|
||||
<summary>Advertências da série</summary>
|
||||
<pre className="overflow-auto text-xs">
|
||||
{JSON.stringify(
|
||||
s.issue_counts,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full min-w-[44rem] text-left text-sm whitespace-nowrap">
|
||||
<caption className="text-left">
|
||||
Valores originais por ano e dimensão
|
||||
— versões apresentadas separadamente
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'Ano',
|
||||
'Indicador',
|
||||
'Idade',
|
||||
'Residência',
|
||||
'Valor',
|
||||
'Unidade',
|
||||
'Tipo',
|
||||
'Referência',
|
||||
].map((h) => (
|
||||
<th className="p-2" key={h}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.values.map((v) => (
|
||||
<tr
|
||||
key={`${v.sheet}:${v.cell}`}
|
||||
className="border-t"
|
||||
>
|
||||
<td className="p-2">
|
||||
{v.year}
|
||||
</td>
|
||||
<td>
|
||||
{indicators[
|
||||
v.indicator
|
||||
] ?? v.indicator}
|
||||
</td>
|
||||
<td>
|
||||
{v.age_group === 'all'
|
||||
? 'Todas'
|
||||
: v.age_group}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
{
|
||||
all: 'Total',
|
||||
urban: 'Urbana',
|
||||
rural: 'Rural',
|
||||
}[v.residence]
|
||||
}
|
||||
</td>
|
||||
<td>{v.original_value}</td>
|
||||
<td>
|
||||
{v.unit ===
|
||||
'unspecified'
|
||||
? 'Não indicada na fonte'
|
||||
: v.unit}
|
||||
</td>
|
||||
<td>
|
||||
{v.data_kind ===
|
||||
'projected'
|
||||
? 'Projecção'
|
||||
: 'Não especificado'}
|
||||
</td>
|
||||
<td>
|
||||
{v.sheet}!{v.cell}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</EvidenceDetails>
|
||||
</section>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { DemographicAnalysis } from './demographic-evidence';
|
||||
import {
|
||||
demographicReading,
|
||||
populationLabel,
|
||||
percentageLabel,
|
||||
} from '@/lib/demographic-reading';
|
||||
export default function DemographicReadingSummary({
|
||||
series,
|
||||
evidence,
|
||||
}: {
|
||||
series: DemographicAnalysis['territories'][number]['series'][number];
|
||||
evidence: DemographicAnalysis;
|
||||
}) {
|
||||
const reading = demographicReading(
|
||||
series,
|
||||
evidence.start_year,
|
||||
evidence.end_year,
|
||||
);
|
||||
const current = reading.last;
|
||||
const sector = evidence.sector ?? 'general';
|
||||
return (
|
||||
<section
|
||||
className="space-y-4 rounded-lg bg-slate-50 p-4"
|
||||
aria-label="Leitura para o investimento"
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
Retrato da população · {evidence.end_year}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{series.source} ·{' '}
|
||||
{current.kind === 'projected'
|
||||
? 'Projecção'
|
||||
: 'Tipo de dado não especificado'}
|
||||
. Valores de apresentação arredondados; os originais permanecem
|
||||
disponíveis abaixo.
|
||||
</p>
|
||||
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
[
|
||||
'População total',
|
||||
populationLabel(current.total),
|
||||
'pessoas',
|
||||
],
|
||||
[
|
||||
'População rural',
|
||||
populationLabel(current.rural),
|
||||
percentageLabel(current.ruralShare) + ' do total',
|
||||
],
|
||||
[
|
||||
'Menores de 15 anos',
|
||||
populationLabel(current.under15),
|
||||
percentageLabel(current.under15Share) + ' do total',
|
||||
],
|
||||
[
|
||||
'População de 15 a 64 anos',
|
||||
populationLabel(current.age15to64),
|
||||
percentageLabel(current.age15to64Share) + ' do total',
|
||||
],
|
||||
].map(([label, value, note]) => (
|
||||
<div key={label} className="rounded border bg-white p-3">
|
||||
<dt className="text-sm">{label}</dt>
|
||||
<dd className="text-2xl font-semibold">{value}</dd>
|
||||
<dd className="text-sm text-slate-600">{note}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{evidence.start_year === evidence.end_year ? (
|
||||
<p>
|
||||
Está a consultar um único ano. Escolha um ano final
|
||||
posterior para conhecer a evolução projectada.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<h4 className="font-semibold">
|
||||
Evolução entre {evidence.start_year} e{' '}
|
||||
{evidence.end_year}
|
||||
</h4>
|
||||
{reading.delta === null ? (
|
||||
<p>
|
||||
Faltam totais comparáveis nos anos extremos; não foi
|
||||
calculado crescimento.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{populationLabel(reading.first.total)} →{' '}
|
||||
{populationLabel(reading.last.total)} pessoas.
|
||||
Variação no período:{' '}
|
||||
{populationLabel(reading.delta)} pessoas (
|
||||
{percentageLabel(reading.growth)}). Não é uma taxa
|
||||
anual.
|
||||
</p>
|
||||
)}
|
||||
<details>
|
||||
<summary>Série anual de população</summary>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ano</th>
|
||||
<th>Total</th>
|
||||
<th>Urbana</th>
|
||||
<th>Rural</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reading.years.map((y) => (
|
||||
<tr key={y.year}>
|
||||
<td>{y.year}</td>
|
||||
<td>{populationLabel(y.total)}</td>
|
||||
<td>{populationLabel(y.urban)}</td>
|
||||
<td>{populationLabel(y.rural)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">
|
||||
Como usar estes dados na ideia de investimento
|
||||
</h4>
|
||||
{current.ruralShare !== null && (
|
||||
<p>
|
||||
{percentageLabel(current.ruralShare)} da população está
|
||||
em áreas rurais. Para avaliar distribuição, comércio ou
|
||||
serviços, confronte essa dispersão com acessos,
|
||||
distâncias, custos de entrega e oferta existente.
|
||||
</p>
|
||||
)}
|
||||
{current.under15Share !== null &&
|
||||
['general', 'education', 'health'].includes(sector) && (
|
||||
<p>
|
||||
Os menores de 15 anos representam{' '}
|
||||
{percentageLabel(current.under15Share)} do total. É
|
||||
uma base para estudar serviços educativos e de
|
||||
saúde, cruzando população-alvo, capacidade
|
||||
instalada, acessibilidade e rendimento das famílias.
|
||||
</p>
|
||||
)}
|
||||
{current.age15to64 !== null && (
|
||||
<p>
|
||||
As {populationLabel(current.age15to64)} pessoas entre 15
|
||||
e 64 anos ajudam a dimensionar a população nessa faixa
|
||||
etária. Para planear contratações, faltam qualificações,
|
||||
participação no mercado de trabalho e disponibilidade.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Próximo passo: comparar estes indicadores com empresas e
|
||||
serviços existentes, infra-estruturas, preços e procura
|
||||
documentada. Só essa comparação permite fundamentar uma
|
||||
lacuna de mercado.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { contextApi, type DossierContext } from '@/lib/investor-context';
|
||||
import {
|
||||
readNavigationFocus,
|
||||
rememberNavigationFocus,
|
||||
} from '@/lib/navigation-focus';
|
||||
export type NavigationView = {
|
||||
case: { id: string; title: string; version: number };
|
||||
phases: Record<string, string>;
|
||||
@@ -129,7 +133,15 @@ export function NavigationContextProvider({
|
||||
new URL(url, 'https://context.invalid').searchParams.get(
|
||||
'context',
|
||||
);
|
||||
let focus = pref.focus;
|
||||
const localFocus = readNavigationFocus(
|
||||
actor,
|
||||
listing.contexts.map((c) => c.id),
|
||||
);
|
||||
// The server preference seeds a new tab, but another tab cannot
|
||||
// replace this tab's deliberate dossier choice on navigation/reload.
|
||||
let focus = localFocus
|
||||
? { ...localFocus, version: pref.focus?.version ?? 0 }
|
||||
: pref.focus;
|
||||
const code = /^#S(0[1-9]|1[0-9])$/.test(window.location.hash)
|
||||
? window.location.hash.slice(1)
|
||||
: null;
|
||||
@@ -157,6 +169,7 @@ export function NavigationContextProvider({
|
||||
)
|
||||
: null;
|
||||
if (!stopped) {
|
||||
rememberNavigationFocus(actor, focus);
|
||||
setLoaded({ actor, cases: listing.contexts, focus, view });
|
||||
if (pref.message) setError(pref.message);
|
||||
}
|
||||
@@ -182,16 +195,22 @@ export function NavigationContextProvider({
|
||||
if (!actor || !current?.cases.some((c) => c.id === id)) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
void contextApi(
|
||||
void contextApi<{ focus: Focus | null }>(
|
||||
'/investimentos/contexto-atual',
|
||||
{
|
||||
case_id: id,
|
||||
step_code: step,
|
||||
version: current.focus?.version ?? 0,
|
||||
},
|
||||
'PUT',
|
||||
)
|
||||
.then(() => {
|
||||
.then((pref) =>
|
||||
contextApi<{ focus: Focus }>(
|
||||
'/investimentos/contexto-atual',
|
||||
{
|
||||
case_id: id,
|
||||
step_code: step,
|
||||
version: pref.focus?.version ?? 0,
|
||||
},
|
||||
'PUT',
|
||||
),
|
||||
)
|
||||
.then(({ focus }) => {
|
||||
rememberNavigationFocus(actor, focus);
|
||||
const target = `/investimentos/${id}${step ? '#' + step : ''}`;
|
||||
if (url !== target) router.visit(target);
|
||||
else setReload((x) => x + 1);
|
||||
|
||||
@@ -32,7 +32,7 @@ const empty = (v: unknown) =>
|
||||
v === '' ||
|
||||
(Array.isArray(v) && v.length === 0);
|
||||
const text = (v: unknown) =>
|
||||
typeof v === 'string' ? v : JSON.stringify(v ?? '');
|
||||
empty(v) ? '' : typeof v === 'string' ? v : JSON.stringify(v);
|
||||
function savedOpportunity(values: Record<string, unknown>): string {
|
||||
if (values.context_opportunity)
|
||||
return inputText(values.context_opportunity);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Keep provenance available without making it the investor's primary content. */
|
||||
export default function EvidenceDetails({
|
||||
children,
|
||||
title = 'Fontes e detalhes da informação',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<details className="min-w-0 rounded-lg border border-slate-200 p-3 text-sm [overflow-wrap:anywhere]">
|
||||
<summary className="cursor-pointer font-semibold focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
{title}
|
||||
</summary>
|
||||
<div className="mt-3 min-w-0 space-y-3">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function FiscalL3Preparation({
|
||||
if (!forms.length) return null;
|
||||
return (
|
||||
<details className="rounded border p-3">
|
||||
<summary>Preparação dos quadros L3 e formalidades</summary>
|
||||
<summary>Correspondência documental e formalidades</summary>
|
||||
<p>
|
||||
Reutiliza entradas e resultados congelados. Os campos com
|
||||
correspondência identificada ficam preparados para
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import CatalogueChoiceSelect from '@/components/catalogue-choice-select';
|
||||
|
||||
export default function FiscalOperationPicker({
|
||||
family,
|
||||
selected,
|
||||
operations,
|
||||
onChange,
|
||||
}: {
|
||||
family: string;
|
||||
selected: unknown;
|
||||
operations: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
render: () => ReactNode;
|
||||
}[];
|
||||
onChange: (selected: string[]) => void;
|
||||
}) {
|
||||
const saved = Array.isArray(selected)
|
||||
? selected.filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
const chosen = operations.filter((operation) =>
|
||||
saved.includes(`${family}:${operation.id}`),
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="space-y-4 rounded border p-4"
|
||||
aria-label="Operações fiscais adicionais"
|
||||
>
|
||||
<h4 className="font-semibold">Operações que pretende analisar</h4>
|
||||
<p>
|
||||
Acrescente apenas as operações que prevê realizar. Se não sabe
|
||||
se uma operação se aplica, consulte a orientação e conserve-a
|
||||
por confirmar; o sector, por si só, não determina o
|
||||
enquadramento.
|
||||
</p>
|
||||
<CatalogueChoiceSelect
|
||||
label="Adicionar outra operação"
|
||||
value=""
|
||||
options={operations
|
||||
.filter(
|
||||
(operation) =>
|
||||
!saved.includes(`${family}:${operation.id}`),
|
||||
)
|
||||
.map((operation) => ({
|
||||
value: operation.id,
|
||||
label: operation.label,
|
||||
keywords: [operation.description],
|
||||
}))}
|
||||
onChange={(id) => {
|
||||
if (operations.some((operation) => operation.id === id))
|
||||
onChange([...saved, `${family}:${id}`]);
|
||||
}}
|
||||
/>
|
||||
{!chosen.length && (
|
||||
<p>
|
||||
Nenhuma operação adicional escolhida. As preparações e os
|
||||
resultados anteriormente guardados permanecem no rascunho e
|
||||
no histórico.
|
||||
</p>
|
||||
)}
|
||||
{chosen.map((operation) => (
|
||||
<section key={operation.id} className="space-y-3 border-t pt-3">
|
||||
<h5 className="font-semibold">{operation.label}</h5>
|
||||
<p>{operation.description}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
onChange(
|
||||
saved.filter(
|
||||
(id) => id !== `${family}:${operation.id}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar da análise actual, conservando os dados
|
||||
guardados
|
||||
</button>
|
||||
{operation.render()}
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import CurrencySelect from './currency-select';
|
||||
|
||||
/** A planning interval is not the amount used in a financial calculation. */
|
||||
export default function InvestmentBudgetRange({
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
values: Record<string, string>;
|
||||
onChange?: (field: string, value: string) => void;
|
||||
}) {
|
||||
if (
|
||||
!onChange &&
|
||||
!values.investment_budget_min &&
|
||||
!values.investment_budget_max
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<section className="grid gap-3 rounded border p-4">
|
||||
<h3 className="font-semibold">Orçamento previsto</h3>
|
||||
{onChange ? (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
'investment_budget_min',
|
||||
'investment_budget_max',
|
||||
] as const
|
||||
).map((field, index) => (
|
||||
<label key={field} className="grid gap-1">
|
||||
{index === 0 ? 'Desde' : 'Até'}
|
||||
<input
|
||||
className="min-w-0 rounded border p-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={values[field] ?? ''}
|
||||
onChange={(event) =>
|
||||
onChange(field, event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<CurrencySelect
|
||||
label="Moeda do orçamento"
|
||||
value={values.currency ?? ''}
|
||||
onChange={(value) => onChange('currency', value)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{values.investment_budget_min || 'Mínimo por indicar'} –{' '}
|
||||
{values.investment_budget_max || 'Máximo por indicar'}{' '}
|
||||
{values.currency}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Este intervalo orienta a preparação. O valor de cada cenário de
|
||||
investimento será indicado por si no simulador.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { sumFinancialAmounts } from '@/lib/financial-calculators';
|
||||
type Row = { label: string; amount: string };
|
||||
const initialRows = (): Row[] =>
|
||||
[
|
||||
'Instalações',
|
||||
'Equipamentos',
|
||||
'Preparação e arranque',
|
||||
'Fundo de maneio inicial',
|
||||
].map((label) => ({ label, amount: '' }));
|
||||
export function restoreInitialBudget(snapshot: string): Row[] {
|
||||
try {
|
||||
const parsed = JSON.parse(snapshot);
|
||||
if (
|
||||
parsed.version === 1 &&
|
||||
Array.isArray(parsed.rows) &&
|
||||
parsed.rows.length <= 30 &&
|
||||
parsed.rows.every(
|
||||
(row: Row) =>
|
||||
typeof row?.label === 'string' &&
|
||||
row.label.length <= 120 &&
|
||||
typeof row.amount === 'string' &&
|
||||
row.amount.length <= 40,
|
||||
)
|
||||
)
|
||||
return parsed.rows;
|
||||
} catch {
|
||||
/* Preserve the saved original; no invented amounts. */
|
||||
}
|
||||
return initialRows();
|
||||
}
|
||||
export default function InvestmentInitialBudget({
|
||||
snapshot,
|
||||
currency,
|
||||
onChange,
|
||||
}: {
|
||||
snapshot: string;
|
||||
currency: string;
|
||||
onChange: (key: string, value: string) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState(() => restoreInitialBudget(snapshot));
|
||||
const [message, setMessage] = useState('');
|
||||
const save = (next: Row[]) => {
|
||||
setRows(next);
|
||||
setMessage('');
|
||||
onChange(
|
||||
'initial_budget_snapshot',
|
||||
JSON.stringify({ version: 1, rows: next }),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded border p-4"
|
||||
aria-label="Construir orçamento inicial"
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
Ajuda para construir o investimento inicial
|
||||
</h3>
|
||||
<p>
|
||||
Liste apenas os custos iniciais aplicáveis. O fundo de maneio
|
||||
cobre o arranque; não repita aqui custos já incluídos noutra
|
||||
parcela. Um valor desconhecido fica em branco, não é zero.
|
||||
</p>
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} className="grid gap-2 sm:grid-cols-2">
|
||||
<label>
|
||||
Parcela
|
||||
<input
|
||||
className="block w-full rounded border p-2"
|
||||
maxLength={120}
|
||||
value={row.label}
|
||||
onChange={(e) =>
|
||||
save(
|
||||
rows.map((item, i) =>
|
||||
i === index
|
||||
? { ...item, label: e.target.value }
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Montante {currency}
|
||||
<input
|
||||
className="block w-full rounded border p-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={row.amount}
|
||||
onChange={(e) =>
|
||||
save(
|
||||
rows.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
amount: e.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left underline"
|
||||
onClick={() => save(rows.filter((_, i) => i !== index))}
|
||||
>
|
||||
Retirar parcela não aplicável
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="mr-3 rounded border px-3 py-2"
|
||||
disabled={rows.length >= 30}
|
||||
onClick={() => save([...rows, { label: '', amount: '' }])}
|
||||
>
|
||||
Acrescentar parcela
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-3 py-2"
|
||||
onClick={() => {
|
||||
try {
|
||||
if (
|
||||
!currency ||
|
||||
!rows.length ||
|
||||
rows.some(
|
||||
(row) =>
|
||||
!row.label.trim() || !row.amount.trim(),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Indique a moeda e as parcelas conhecidas; complete os montantes em falta antes de usar a soma.',
|
||||
);
|
||||
const total = sumFinancialAmounts(
|
||||
rows.map((row) => row.amount),
|
||||
);
|
||||
onChange('total_investment', total);
|
||||
setMessage(
|
||||
`Soma adoptada por sua escolha: ${total} ${currency}. O intervalo orçamental original permanece separado.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Confira as parcelas.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Usar a soma como investimento deste cenário
|
||||
</button>
|
||||
{message && <p role="status">{message}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
estimateOperatingCosts,
|
||||
estimateOperatingRevenue,
|
||||
restoreOperatingAssumptions,
|
||||
} from '@/lib/investment-operating-assistance';
|
||||
|
||||
export default function InvestmentOperatingAssistance({
|
||||
snapshot,
|
||||
currency,
|
||||
onChange,
|
||||
}: {
|
||||
snapshot: string;
|
||||
currency: string;
|
||||
onChange: (key: string, value: string) => void;
|
||||
}) {
|
||||
const [data, setData] = useState(() =>
|
||||
restoreOperatingAssumptions(snapshot),
|
||||
);
|
||||
const [message, setMessage] = useState('');
|
||||
const save = (next: typeof data) => {
|
||||
setData(next);
|
||||
setMessage('');
|
||||
onChange(
|
||||
'planning_assumptions_snapshot',
|
||||
JSON.stringify({ version: 1, data: next }),
|
||||
);
|
||||
};
|
||||
const field = (
|
||||
key: 'capacity' | 'price' | 'occupancy' | 'frequency',
|
||||
label: string,
|
||||
) => (
|
||||
<label className="block">
|
||||
{label}
|
||||
<input
|
||||
className="block w-full rounded border p-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={data[key]}
|
||||
onChange={(e) => save({ ...data, [key]: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const adopt = (kind: 'revenue' | 'costs') => {
|
||||
try {
|
||||
if (!currency)
|
||||
throw new Error(
|
||||
'Seleccione a moeda do investimento antes de utilizar a estimativa.',
|
||||
);
|
||||
const value =
|
||||
kind === 'revenue'
|
||||
? estimateOperatingRevenue(data)
|
||||
: estimateOperatingCosts(data);
|
||||
onChange(
|
||||
kind === 'revenue'
|
||||
? 'annual_revenue'
|
||||
: 'annual_operating_costs',
|
||||
value,
|
||||
);
|
||||
setMessage(
|
||||
`Estimativa anual de ${kind === 'revenue' ? 'receitas' : 'custos'} adoptada por sua escolha: ${value} ${currency}. Pode alterá-la nos campos acima.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Confira os pressupostos.',
|
||||
);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className="space-y-4 rounded border p-4"
|
||||
aria-label="Ajuda para estimar receitas e custos"
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
Não sei estimar receitas ou custos
|
||||
</h3>
|
||||
<p>
|
||||
Escolha a actividade e introduza os pressupostos que conhece.
|
||||
Não são previsões de procura nem valores recomendados. Nenhuma
|
||||
estimativa substitui os seus valores até clicar em Usar.
|
||||
</p>
|
||||
<label className="block">
|
||||
Modelo para esta estimativa
|
||||
<select
|
||||
className="block w-full rounded border p-2"
|
||||
value={data.model}
|
||||
onChange={(e) =>
|
||||
save({
|
||||
...data,
|
||||
model: e.target.value as typeof data.model,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Escolher</option>
|
||||
<option value="hotel">Hotel — quartos e ocupação</option>
|
||||
<option value="agriculture">
|
||||
Agronegócio — produção e ciclos
|
||||
</option>
|
||||
<option value="other">
|
||||
Outra actividade — unidades e frequência
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{data.model && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{field(
|
||||
'capacity',
|
||||
data.model === 'hotel'
|
||||
? 'Quartos disponíveis'
|
||||
: 'Unidades vendáveis por ciclo ou período',
|
||||
)}
|
||||
{field(
|
||||
'price',
|
||||
data.model === 'hotel'
|
||||
? 'Preço por quarto/noite'
|
||||
: 'Preço por unidade',
|
||||
)}
|
||||
{data.model === 'hotel' &&
|
||||
field('occupancy', 'Ocupação prevista (%)')}
|
||||
{field(
|
||||
'frequency',
|
||||
data.model === 'hotel'
|
||||
? 'Dias de funcionamento previstos no ano'
|
||||
: 'Ciclos ou períodos no ano',
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{data.model === 'hotel'
|
||||
? 'Receita anual = quartos × dias de funcionamento × ocupação ÷ 100 × preço. Considere sazonalidade ao escolher a ocupação e os dias.'
|
||||
: 'Receita anual = unidades vendáveis × preço × ciclos. Considere perdas e sazonalidade nas unidades e ciclos declarados.'}{' '}
|
||||
A facturação não equivale a recebimentos; a próxima
|
||||
etapa trata a tesouraria.
|
||||
</p>
|
||||
<label className="block">
|
||||
Fundamento ou informação ainda a confirmar
|
||||
<textarea
|
||||
className="block w-full rounded border p-2"
|
||||
maxLength={1000}
|
||||
value={data.source}
|
||||
onChange={(e) =>
|
||||
save({ ...data, source: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-3 py-2"
|
||||
onClick={() => adopt('revenue')}
|
||||
>
|
||||
Usar esta estimativa de receitas
|
||||
</button>
|
||||
<details className="rounded border p-3">
|
||||
<summary>Categorias de custos anuais</summary>
|
||||
<p>
|
||||
{data.model === 'hotel'
|
||||
? 'Considere pessoal, limpeza/lavandaria, energia, água, manutenção, consumíveis e distribuição.'
|
||||
: data.model === 'agriculture'
|
||||
? 'Considere insumos, pessoal, água/energia, armazenagem, transformação, transporte e perdas.'
|
||||
: 'Considere pessoal, matérias-primas, serviços, instalações, energia, manutenção e distribuição.'}{' '}
|
||||
Acrescente apenas categorias aplicáveis; a soma não
|
||||
demonstra que todos os custos foram identificados.
|
||||
</p>
|
||||
{data.costs.map((row, index) => (
|
||||
<div
|
||||
className="my-3 grid gap-2 sm:grid-cols-2"
|
||||
key={index}
|
||||
>
|
||||
<label>
|
||||
Categoria
|
||||
<input
|
||||
className="block w-full rounded border p-2"
|
||||
maxLength={120}
|
||||
value={row.label}
|
||||
onChange={(e) =>
|
||||
save({
|
||||
...data,
|
||||
costs: data.costs.map(
|
||||
(item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
label: e
|
||||
.target
|
||||
.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Estimativa anual
|
||||
<input
|
||||
className="block w-full rounded border p-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={row.annual}
|
||||
onChange={(e) =>
|
||||
save({
|
||||
...data,
|
||||
costs: data.costs.map(
|
||||
(item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
annual: e
|
||||
.target
|
||||
.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left underline"
|
||||
onClick={() =>
|
||||
save({
|
||||
...data,
|
||||
costs: data.costs.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Retirar esta categoria
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="mr-3 rounded border px-3 py-2"
|
||||
disabled={data.costs.length >= 30}
|
||||
onClick={() =>
|
||||
save({
|
||||
...data,
|
||||
costs: [
|
||||
...data.costs,
|
||||
{ label: '', annual: '' },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Acrescentar categoria
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-3 py-2"
|
||||
onClick={() => adopt('costs')}
|
||||
>
|
||||
Usar esta estimativa de custos
|
||||
</button>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -36,10 +36,16 @@ function ChoiceCard({
|
||||
const [parkError, setParkError] = useState('');
|
||||
const [parkLoading, setParkLoading] = useState(false);
|
||||
const corridor =
|
||||
corridors.find((r) => r.key === c.corridor_key) ||
|
||||
corridors.find((r) => r.name === c.coverage);
|
||||
c.corridor_key !== undefined
|
||||
? corridors.find((r) => r.key === c.corridor_key)
|
||||
: corridors.find((r) => r.name === c.coverage);
|
||||
const path = c.territory_path ?? [];
|
||||
function location(next: TerritoryOption[], key = corridor?.key) {
|
||||
function location(
|
||||
next: TerritoryOption[],
|
||||
key: string | null | undefined = c.corridor_key !== undefined
|
||||
? c.corridor_key
|
||||
: corridor?.key,
|
||||
) {
|
||||
update({
|
||||
corridor_key: key,
|
||||
territory_path: next,
|
||||
@@ -64,16 +70,8 @@ function ChoiceCard({
|
||||
!corridor.provinces.some((p) => p.id === path[0].id)
|
||||
) {
|
||||
setNotice(
|
||||
'A localização anterior foi retirada porque não pertence ao corredor escolhido. Escolha uma localização compatível.',
|
||||
'Localizacao guardada conservada. A associacao ao corredor nao e compativel; altere-a ou retire-a antes de confirmar.',
|
||||
);
|
||||
location(corridor.provinces.length === 1 ? corridor.provinces : []);
|
||||
} else if (!path.length && corridor.provinces.length === 1) {
|
||||
setNotice(
|
||||
'Província seleccionada automaticamente: ' +
|
||||
corridor.provinces[0].name +
|
||||
'.',
|
||||
);
|
||||
location(corridor.provinces);
|
||||
}
|
||||
}, [
|
||||
corridor?.key,
|
||||
@@ -140,33 +138,18 @@ function ChoiceCard({
|
||||
const next = corridors.find(
|
||||
(r) => r.key === e.target.value,
|
||||
);
|
||||
if (!next) return;
|
||||
const compatible =
|
||||
!path.length ||
|
||||
next.provinces.some((p) => p.id === path[0].id);
|
||||
const kept = compatible ? path : [];
|
||||
location(
|
||||
kept.length
|
||||
? kept
|
||||
: next.provinces.length === 1
|
||||
? next.provinces
|
||||
: [],
|
||||
next.key,
|
||||
);
|
||||
update({
|
||||
corridor_key: next?.key ?? null,
|
||||
corridor_name: next?.name ?? null,
|
||||
});
|
||||
setNotice(
|
||||
!path.length
|
||||
? next.provinces.length === 1
|
||||
? 'Província seleccionada automaticamente: ' +
|
||||
next.provinces[0].name +
|
||||
'.'
|
||||
: 'Escolha uma das províncias deste corredor.'
|
||||
: compatible
|
||||
? 'A localização compatível foi conservada.'
|
||||
: 'A província e os níveis territoriais incompatíveis foram retirados.',
|
||||
'A localizacao foi conservada; a associacao e opcional e exige compatibilidade documentada.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccione um corredor</option>
|
||||
<option value="">
|
||||
Sem corredor associado (opcional)
|
||||
</option>
|
||||
{corridors.map((r) => (
|
||||
<option key={r.key} value={r.key}>
|
||||
{r.name}
|
||||
@@ -381,7 +364,7 @@ export default function OpportunityChoices({
|
||||
</h2>
|
||||
<p>
|
||||
{onChange
|
||||
? 'Escolha o corredor e confirme onde pretende realizar o investimento.'
|
||||
? 'Confirme a localização conhecida. Corredores e zonas são associações opcionais, quando documentadas.'
|
||||
: 'Escolhas guardadas no dossier; cada oportunidade conserva a sua localização e associações.'}
|
||||
</p>
|
||||
{choices.map((c, i) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId } from 'react';
|
||||
import CountrySelect, { type Country } from './country-select';
|
||||
import CatalogueChoiceSelect from './catalogue-choice-select';
|
||||
import OrganizationCountryReuse from './organization-country-reuse';
|
||||
import { Field, input, button } from '@/pages/investment/shared';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ export default function InvestmentProfileEditor({
|
||||
reportDirty,
|
||||
labels,
|
||||
organizations = [],
|
||||
legalForms = [],
|
||||
}: {
|
||||
record: ProfileRecord;
|
||||
fields: string[];
|
||||
@@ -28,6 +30,7 @@ export default function InvestmentProfileEditor({
|
||||
reportDirty: (id: string, dirty: boolean) => void;
|
||||
labels: Record<string, string>;
|
||||
organizations?: OrganizationRecord[];
|
||||
legalForms?: { value: string; label: string }[];
|
||||
}) {
|
||||
const isCompany = company || !!record.id;
|
||||
const editor = useInvestmentProfileEditor(
|
||||
@@ -113,6 +116,18 @@ export default function InvestmentProfileEditor({
|
||||
editor.change(key, value)
|
||||
}
|
||||
/>
|
||||
) : key === 'legal_form' ? (
|
||||
<CatalogueChoiceSelect
|
||||
id={fieldId}
|
||||
label={labels[key]}
|
||||
options={legalForms}
|
||||
value={editor.draft[key] ?? ''}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
onChange={(value) =>
|
||||
editor.change(key, value)
|
||||
}
|
||||
/>
|
||||
) : long ? (
|
||||
<textarea
|
||||
id={fieldId}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import CatalogueChoiceSelect from './catalogue-choice-select';
|
||||
import { contextApi, type PromoterEntity } from '@/lib/investor-context';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
|
||||
type Options = {
|
||||
version: number;
|
||||
selected: PromoterEntity;
|
||||
can_write: boolean;
|
||||
companies: { id: string; name: string; version: number }[];
|
||||
organizations: { id: string; name: string; version: number }[];
|
||||
};
|
||||
const valueOf = (entity?: PromoterEntity) =>
|
||||
entity && ['company', 'organization'].includes(entity.kind)
|
||||
? entity.kind + ':' + entity.id
|
||||
: (entity?.kind ?? 'undetermined');
|
||||
export default function InvestmentPromoter({
|
||||
caseId,
|
||||
selected,
|
||||
canEdit,
|
||||
}: {
|
||||
caseId: string;
|
||||
selected?: PromoterEntity;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [options, setOptions] = useState<Options | null>(null);
|
||||
const [choice, setChoice] = useState(valueOf(selected));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [uncertain, setUncertain] = useState(false);
|
||||
const command = useRef<Record<string, unknown> | null>(null);
|
||||
const current = options?.selected ?? selected;
|
||||
const url = `/investimentos/${caseId}/contexto/entidade`;
|
||||
const recover = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
setOptions(await contextApi<Options>(url));
|
||||
command.current = null;
|
||||
setUncertain(false);
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível consultar a associação.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section
|
||||
id="entidade-promotora"
|
||||
className="space-y-3 rounded border p-4"
|
||||
>
|
||||
<h2 className="font-semibold">Entidade promotora</h2>
|
||||
<p>
|
||||
{current?.snapshot?.name ||
|
||||
(current?.kind === 'being_formed'
|
||||
? 'Entidade por constituir'
|
||||
: 'Entidade por definir')}
|
||||
</p>
|
||||
<p>
|
||||
{current?.kind === 'company' || current?.kind === 'organization'
|
||||
? 'Cadastro associado no portal. A associação não comprova constituição legal nem altera a propriedade ou o acesso ao dossier.'
|
||||
: 'Pode continuar a ideia e a viabilidade. Os actos que dependam da entidade serão identificados na respectiva etapa.'}
|
||||
</p>
|
||||
<nav className="flex flex-wrap gap-3">
|
||||
{canEdit && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/simulador-fiscal?context=${caseId}`}
|
||||
>
|
||||
Continuar a viabilidade
|
||||
</Link>
|
||||
)}
|
||||
{current?.kind === 'being_formed' && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/investimentos/${caseId}/preparar-empresa`}
|
||||
>
|
||||
Continuar a preparação da empresa
|
||||
</Link>
|
||||
)}
|
||||
{current?.kind !== 'company' &&
|
||||
current?.kind !== 'organization' && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/investimentos/${caseId}/forma-juridica`}
|
||||
>
|
||||
Comparar formas jurídicas, se precisar
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
{canEdit && (
|
||||
<details
|
||||
onToggle={(event) => {
|
||||
if (event.currentTarget.open && !options && !busy)
|
||||
void recover();
|
||||
}}
|
||||
>
|
||||
<summary>Associar ou alterar a entidade promotora</summary>
|
||||
{options && (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<CatalogueChoiceSelect
|
||||
label="Entidade para este investimento"
|
||||
value={choice}
|
||||
disabled={
|
||||
busy || uncertain || !options.can_write
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: 'undetermined',
|
||||
label: 'Continuar com entidade por definir',
|
||||
},
|
||||
{
|
||||
value: 'being_formed',
|
||||
label: 'Preparar uma entidade por constituir',
|
||||
},
|
||||
...options.companies.map((company) => ({
|
||||
value: 'company:' + company.id,
|
||||
label:
|
||||
company.name +
|
||||
' — empresa declarada',
|
||||
})),
|
||||
...options.organizations.map(
|
||||
(organization) => ({
|
||||
value:
|
||||
'organization:' +
|
||||
organization.id,
|
||||
label:
|
||||
organization.name +
|
||||
' — organização com acesso autorizado',
|
||||
}),
|
||||
),
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setChoice(value || 'undetermined');
|
||||
command.current = null;
|
||||
}}
|
||||
/>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/investidor/profile?dossier=${caseId}`}
|
||||
>
|
||||
Registar uma empresa existente no cadastro
|
||||
habitual e regressar
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 rounded border p-2"
|
||||
disabled={
|
||||
busy || uncertain || !options.can_write
|
||||
}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
const [kind, id] = choice.split(':');
|
||||
const source = (
|
||||
kind === 'company'
|
||||
? options.companies
|
||||
: options.organizations
|
||||
).find((source) => source.id === id);
|
||||
command.current ??= {
|
||||
version: options.version,
|
||||
operation_key: createOperationKey(),
|
||||
kind,
|
||||
...(id
|
||||
? {
|
||||
id,
|
||||
source_version:
|
||||
source?.version,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
try {
|
||||
const result = await contextApi<{
|
||||
continue: string;
|
||||
}>(url, command.current);
|
||||
command.current = null;
|
||||
router.visit(result.continue);
|
||||
} catch (error) {
|
||||
setUncertain(true);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível confirmar a associação.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Guardar associação e continuar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{(error || uncertain) && (
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={busy}
|
||||
onClick={() => void recover()}
|
||||
>
|
||||
Recuperar associação guardada sem perder a escolha
|
||||
</button>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { contextApi, type SupplyAggregate } from '@/lib/investor-context';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
|
||||
export default function InvestmentSupplyAggregate({
|
||||
caseId,
|
||||
version,
|
||||
revision,
|
||||
history,
|
||||
disabled = false,
|
||||
}: {
|
||||
caseId: string;
|
||||
version: number;
|
||||
revision: number;
|
||||
history: SupplyAggregate[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const command = useRef<{ version: number; operation_key: string } | null>(
|
||||
null,
|
||||
);
|
||||
const latest = history.at(-1);
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded border p-4"
|
||||
aria-label="Oferta empresarial agregada"
|
||||
>
|
||||
<h2 className="font-semibold">
|
||||
Actividades existentes na localização
|
||||
</h2>
|
||||
<p>
|
||||
Consulte uma análise agregada no seu dossier. As listas, nomes e
|
||||
contactos internos permanecem reservados.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 rounded border px-3 py-2 underline"
|
||||
disabled={disabled || busy}
|
||||
onClick={async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
command.current ??= {
|
||||
version,
|
||||
operation_key: createOperationKey(),
|
||||
};
|
||||
try {
|
||||
await contextApi(
|
||||
`/investimentos/${caseId}/contexto/oferta-agregada`,
|
||||
command.current,
|
||||
);
|
||||
command.current = null;
|
||||
setMessage('Análise agregada guardada no dossier.');
|
||||
router.reload({
|
||||
only: [
|
||||
'case',
|
||||
'supply_aggregates',
|
||||
'supply_aggregate_enabled',
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível confirmar a consulta. A repetição conserva o mesmo comando.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy
|
||||
? 'A consultar…'
|
||||
: command.current
|
||||
? 'Repetir a mesma consulta'
|
||||
: 'Consultar e guardar análise agregada'}
|
||||
</button>
|
||||
{message && <p role="status">{message}</p>}
|
||||
{latest && (
|
||||
<>
|
||||
<h3 className="font-semibold">
|
||||
{latest.scope.level === 'district'
|
||||
? 'Contexto distrital'
|
||||
: latest.scope.level === 'province'
|
||||
? 'Contexto provincial'
|
||||
: 'Contexto local'}{' '}
|
||||
— {latest.scope.name}
|
||||
</h3>
|
||||
{latest.broader_context && (
|
||||
<p>
|
||||
Local escolhido: {latest.requested_territory.name}.
|
||||
Os totais referem-se ao território mais amplo
|
||||
indicado acima.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{latest.entities} entidades identificadas nos registos
|
||||
administrativos; {latest.repeated} observações repetidas
|
||||
e {latest.uncertain} identificações incertas, separadas
|
||||
dos totais.
|
||||
</p>
|
||||
{latest.context_revision !== revision && (
|
||||
<p role="status">
|
||||
O contexto mudou depois desta análise. Reveja-a; a
|
||||
consulta anterior mantém-se no histórico.
|
||||
</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<caption className="text-left">
|
||||
Grupos para investigar actividades similares e
|
||||
complementares
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 text-left">
|
||||
Actividade
|
||||
</th>
|
||||
<th className="p-2">Entidades</th>
|
||||
<th className="p-2">Período conhecido</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{latest.groups.map((group) => (
|
||||
<tr key={group.code}>
|
||||
<td className="p-2">{group.label}</td>
|
||||
<td className="p-2 text-center">
|
||||
{group.total}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{group.first_year ??
|
||||
'Por confirmar'}
|
||||
{group.last_year &&
|
||||
group.last_year !== group.first_year
|
||||
? '–' + group.last_year
|
||||
: ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!latest.groups.length && (
|
||||
<p>
|
||||
Não existem registos aplicáveis nesta consulta. A
|
||||
oferta empresarial permanece por determinar; não
|
||||
significa ausência de empresas.
|
||||
</p>
|
||||
)}
|
||||
{latest.truncated && (
|
||||
<p>
|
||||
Apresentação limitada aos primeiros 100 grupos; não
|
||||
representa cobertura completa.
|
||||
</p>
|
||||
)}
|
||||
<p>{latest.interpretation}</p>
|
||||
<p>{latest.limitations}</p>
|
||||
<details>
|
||||
<summary>Consulta e histórico</summary>
|
||||
<p>
|
||||
{history.length} análises preservadas. Última
|
||||
consulta: {latest.recorded_at}. Cobertura
|
||||
territorial e actividade actual por confirmar.
|
||||
</p>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -185,12 +185,16 @@ export function TerritoryFields({
|
||||
endpoint = '/api/v1/investment/territory-options',
|
||||
disabled = false,
|
||||
corridor,
|
||||
showMap = true,
|
||||
levelCount = 4,
|
||||
}: {
|
||||
initial?: TerritoryOption[];
|
||||
onChange?: (path: TerritoryOption[]) => void;
|
||||
endpoint?: string;
|
||||
disabled?: boolean;
|
||||
corridor?: string | null;
|
||||
showMap?: boolean;
|
||||
levelCount?: 2 | 4;
|
||||
}) {
|
||||
const [path, setPath] = useState(initial);
|
||||
useEffect(() => setPath(initial), [initial]);
|
||||
@@ -203,41 +207,45 @@ export function TerritoryFields({
|
||||
'admin_post_id',
|
||||
'locality_id',
|
||||
] as const
|
||||
).map((field, index) => (
|
||||
<div key={`${field}-${path[index - 1]?.id ?? ''}`}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={field}
|
||||
value={path[index]?.id ?? ''}
|
||||
/>
|
||||
<TerritoryLevel
|
||||
disabled={disabled}
|
||||
index={index}
|
||||
parent={path[index - 1]?.id ?? ''}
|
||||
selected={path[index]}
|
||||
endpoint={endpoint}
|
||||
corridor={corridor}
|
||||
onChange={(value) => {
|
||||
const next = replaceTerritoryPath(
|
||||
path,
|
||||
index,
|
||||
value,
|
||||
);
|
||||
setPath(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={path.at(-1)?.id}
|
||||
management={endpoint.startsWith('/management/')}
|
||||
endpoint={
|
||||
endpoint.startsWith('/minhas-oportunidades/')
|
||||
? '/minhas-oportunidades/territory-geometries'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
.slice(0, levelCount)
|
||||
.map((field, index) => (
|
||||
<div key={`${field}-${path[index - 1]?.id ?? ''}`}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={field}
|
||||
value={path[index]?.id ?? ''}
|
||||
/>
|
||||
<TerritoryLevel
|
||||
disabled={disabled}
|
||||
index={index}
|
||||
parent={path[index - 1]?.id ?? ''}
|
||||
selected={path[index]}
|
||||
endpoint={endpoint}
|
||||
corridor={corridor}
|
||||
onChange={(value) => {
|
||||
const next = replaceTerritoryPath(
|
||||
path,
|
||||
index,
|
||||
value,
|
||||
);
|
||||
setPath(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{showMap && (
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={path.at(-1)?.id}
|
||||
management={endpoint.startsWith('/management/')}
|
||||
endpoint={
|
||||
endpoint.startsWith('/minhas-oportunidades/')
|
||||
? '/minhas-oportunidades/territory-geometries'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Field, input, button, groupLabels } from '@/pages/investment/shared';
|
||||
import type { References } from '@/pages/investment/shared';
|
||||
import { TerritoryLevel, type TerritoryOption } from './investment-territory';
|
||||
import CatalogueFilterGroup from './catalogue-filter-group';
|
||||
import { createInvestmentHref } from '@/lib/preparation-intent';
|
||||
import CurrencySelect from './currency-select';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, Search, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
@@ -96,56 +98,69 @@ export default function OpportunitySearchFilters({
|
||||
key: string,
|
||||
choices: { value: string; label: string }[],
|
||||
hint?: string,
|
||||
) => (
|
||||
<Field key={key} label={labels[key]}>
|
||||
<select
|
||||
className={input}
|
||||
aria-label={labels[key]}
|
||||
aria-describedby={
|
||||
specialisedEmpty[key] ? `filter-help-${key}` : undefined
|
||||
}
|
||||
disabled={
|
||||
Boolean(specialisedEmpty[key]) &&
|
||||
choices.length === 0 &&
|
||||
!data[key]
|
||||
}
|
||||
) =>
|
||||
key === 'currency' ? (
|
||||
<CurrencySelect
|
||||
key={key}
|
||||
label={labels[key]}
|
||||
codes={choices.map((choice) => choice.value).filter(Boolean)}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
[key]: e.target.value,
|
||||
...(key === 'sector'
|
||||
? { subsector_id: '', chain_id: '' }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{data[key] &&
|
||||
!choices.some((o) => o.value === String(data[key])) && (
|
||||
<option value={String(data[key])}>
|
||||
Selecção indisponível neste contexto — remova o
|
||||
filtro
|
||||
onChange={(value) => change({ [key]: value })}
|
||||
error={errors[key]}
|
||||
/>
|
||||
) : (
|
||||
<Field key={key} label={labels[key]}>
|
||||
<select
|
||||
className={input}
|
||||
aria-label={labels[key]}
|
||||
aria-describedby={
|
||||
specialisedEmpty[key] ? `filter-help-${key}` : undefined
|
||||
}
|
||||
disabled={
|
||||
Boolean(specialisedEmpty[key]) &&
|
||||
choices.length === 0 &&
|
||||
!data[key]
|
||||
}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
[key]: e.target.value,
|
||||
...(key === 'sector'
|
||||
? { subsector_id: '', chain_id: '' }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{data[key] &&
|
||||
!choices.some((o) => o.value === String(data[key])) && (
|
||||
<option value={String(data[key])}>
|
||||
Selecção indisponível neste contexto — remova o
|
||||
filtro
|
||||
</option>
|
||||
)}
|
||||
{choices.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
)}
|
||||
{choices.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{specialisedEmpty[key] && (
|
||||
<p id={`filter-help-${key}`} className="text-sm text-slate-600">
|
||||
{choices.length === 0
|
||||
? specialisedEmpty[key]
|
||||
: `${choices.length} opções públicas disponíveis, mesmo sem oportunidades associadas.`}
|
||||
{key === 'subsector_id' &&
|
||||
choices.length > 0 &&
|
||||
' Classificação de actividades; não confirma a existência de investimentos nesta localização.'}
|
||||
</p>
|
||||
)}
|
||||
{hint && <p className="text-sm text-slate-600">{hint}</p>}
|
||||
</Field>
|
||||
);
|
||||
))}
|
||||
</select>
|
||||
{specialisedEmpty[key] && (
|
||||
<p
|
||||
id={`filter-help-${key}`}
|
||||
className="text-sm text-slate-600"
|
||||
>
|
||||
{choices.length === 0
|
||||
? specialisedEmpty[key]
|
||||
: `${choices.length} opções públicas disponíveis, mesmo sem oportunidades associadas.`}
|
||||
{key === 'subsector_id' &&
|
||||
choices.length > 0 &&
|
||||
' Classificação de actividades; não confirma a existência de investimentos nesta localização.'}
|
||||
</p>
|
||||
)}
|
||||
{hint && <p className="text-sm text-slate-600">{hint}</p>}
|
||||
</Field>
|
||||
);
|
||||
const zoneChoices = (category: string) =>
|
||||
(references.zones ?? [])
|
||||
.filter((z) => z.category === category)
|
||||
@@ -727,6 +742,15 @@ export default function OpportunitySearchFilters({
|
||||
</p>
|
||||
))}
|
||||
<div className="catalogue-filter-submit grid gap-3">
|
||||
{mode === 'opportunities' && (
|
||||
<a
|
||||
className="rounded border p-3 underline"
|
||||
href={createInvestmentHref(data, path)}
|
||||
>
|
||||
Criar investimento com estas escolhas, sem anúncio
|
||||
associado
|
||||
</a>
|
||||
)}
|
||||
{select('sort', [
|
||||
{ value: 'recent', label: 'Mais recentes' },
|
||||
{ value: 'title', label: 'Título' },
|
||||
|
||||
@@ -290,13 +290,13 @@ export default function PortalHero({
|
||||
href="/investimentos/iniciar"
|
||||
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
||||
>
|
||||
Partir de uma ideia
|
||||
Criar investimento
|
||||
</Link>
|
||||
<Link
|
||||
href="/oportunidades"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-sm font-semibold text-[#101820] transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
||||
>
|
||||
Consultar oportunidades
|
||||
Procurar oportunidades
|
||||
<ArrowRight size={16} aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import EvidenceDetails from './evidence-details';
|
||||
|
||||
export type PublicDocument = {
|
||||
id: number;
|
||||
@@ -36,50 +37,51 @@ export function PublicDocumentContent({
|
||||
{r.description && (
|
||||
<p className="whitespace-pre-line">{r.description}</p>
|
||||
)}
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-semibold">Entidade emissora / autor</dt>
|
||||
<dd>{r.issuer ?? 'Não indicada'}</dd>
|
||||
</div>
|
||||
{r.document_number && (
|
||||
<div>
|
||||
<dt className="font-semibold">Número</dt>
|
||||
<dd>{r.document_number}</dd>
|
||||
</div>
|
||||
)}
|
||||
{r.document_date && (
|
||||
<div>
|
||||
<dt className="font-semibold">Data do documento</dt>
|
||||
<dd>{r.document_date}</dd>
|
||||
</div>
|
||||
)}
|
||||
{r.applicable_from && (
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Data de início indicada no registo
|
||||
</dt>
|
||||
<dd>{r.applicable_from}</dd>
|
||||
</div>
|
||||
)}
|
||||
{r.applicable_until && (
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Data final indicada no registo
|
||||
</dt>
|
||||
<dd>{r.applicable_until}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="font-semibold">Fonte</dt>
|
||||
<dd>
|
||||
{r.source_confirmation === 'official_confirmed'
|
||||
? 'Fonte oficial'
|
||||
: r.source_url
|
||||
? 'Fonte identificada'
|
||||
: 'Não indicada'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<EvidenceDetails title="Identificação e fonte do documento">
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
{r.issuer && (
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Entidade emissora / autor
|
||||
</dt>
|
||||
<dd>{r.issuer}</dd>
|
||||
</div>
|
||||
)}
|
||||
{r.document_number && (
|
||||
<div>
|
||||
<dt className="font-semibold">Número</dt>
|
||||
<dd>{r.document_number}</dd>
|
||||
</div>
|
||||
)}
|
||||
{r.document_date && (
|
||||
<div>
|
||||
<dt className="font-semibold">Data do documento</dt>
|
||||
<dd>{r.document_date}</dd>
|
||||
</div>
|
||||
)}
|
||||
{(r.source_url ||
|
||||
r.source_confirmation === 'official_confirmed') && (
|
||||
<div>
|
||||
<dt className="font-semibold">Fonte</dt>
|
||||
<dd>
|
||||
{r.source_confirmation === 'official_confirmed'
|
||||
? 'Fonte oficial'
|
||||
: r.source_url
|
||||
? 'Fonte identificada'
|
||||
: 'Não indicada'}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</EvidenceDetails>
|
||||
{(r.applicable_from || r.applicable_until) && (
|
||||
<p className="text-sm">
|
||||
Período indicado:{' '}
|
||||
{r.applicable_from ?? 'início não indicado'}
|
||||
{' — '}
|
||||
{r.applicable_until ?? 'fim não indicado'}.
|
||||
</p>
|
||||
)}
|
||||
{r.usage_notice && (
|
||||
<p
|
||||
role="note"
|
||||
@@ -94,7 +96,7 @@ export function PublicDocumentContent({
|
||||
className="underline"
|
||||
href={`/documentos/${r.id}?${new URLSearchParams({ back }).toString()}`}
|
||||
>
|
||||
Consultar ficha
|
||||
Consultar documento
|
||||
</Link>
|
||||
)}
|
||||
{r.view_url && (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
|
||||
export default function PublishedOpportunitiesEmpty({
|
||||
href = '/oportunidades',
|
||||
label = 'Rever a pesquisa de oportunidades',
|
||||
}: {
|
||||
href?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="space-y-2 rounded-lg border border-slate-200 p-4"
|
||||
role="status"
|
||||
>
|
||||
<p className="font-semibold">
|
||||
Sem anúncios publicados nesta selecção.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Este resultado não indica ausência de empresas, recursos ou
|
||||
potencial de investimento. Consulte também o contexto
|
||||
territorial e sectorial; a viabilidade exige análise própria.
|
||||
</p>
|
||||
<Link className="inline-block py-2 underline" href={href}>
|
||||
{label}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import SectorEntry, { type SectorEntryData } from '@/components/sector-entry';
|
||||
import PublishedOpportunitiesEmpty from './published-opportunities-empty';
|
||||
|
||||
export type SectorDetailData = {
|
||||
sector: {
|
||||
@@ -90,7 +91,9 @@ export default function SectorContent({
|
||||
</article>
|
||||
))}
|
||||
{!opportunities?.length && (
|
||||
<p>Sem oportunidades publicadas neste sector.</p>
|
||||
<PublishedOpportunitiesEmpty
|
||||
href={`/oportunidades?sector=${sector.slug}`}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
className={button}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CurrencySelect from '@/components/currency-select';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
compareBusinessScenarios,
|
||||
@@ -23,48 +24,48 @@ const emptyPlan: BusinessFinancialPlanInput = {
|
||||
fxRate: '',
|
||||
fxDate: '',
|
||||
fxSource: '',
|
||||
openingCash: '0',
|
||||
openingCash: '',
|
||||
products: [
|
||||
{
|
||||
label: 'Produto ou serviço',
|
||||
monthlyUnits: '100',
|
||||
unitPrice: '1000',
|
||||
unitVariableCost: '400',
|
||||
monthlyUnits: '',
|
||||
unitPrice: '',
|
||||
unitVariableCost: '',
|
||||
},
|
||||
],
|
||||
monthlyCollections: '100000',
|
||||
monthlyCollections: '',
|
||||
salesCollectionLagMonths: null,
|
||||
salesCollectionPercent: '100',
|
||||
salesCollectionPercent: '',
|
||||
salesRecoverySchedule: [],
|
||||
openingReceivablesCollectionMonth: null,
|
||||
openingReceivablesCollectionPercent: '100',
|
||||
openingReceivablesCollectionPercent: '',
|
||||
openingReceivablesInstallments: [],
|
||||
openingPayablesInstallments: [],
|
||||
monthlyVariableCostPayments: '40000',
|
||||
monthlyVariableCostPayments: '',
|
||||
monthlyVariableCostPaymentsByPeriod: null,
|
||||
monthlyInventoryPurchases: '40000',
|
||||
monthlyInventoryPurchases: '',
|
||||
inventoryPurchaseLeadMonths: 0,
|
||||
inventoryPurchaseSchedule: null,
|
||||
useInventoryPurchaseSchedule: false,
|
||||
useProductInventorySchedule: false,
|
||||
useSupplierPaymentTerms: false,
|
||||
monthlyOperatingExpenses: '20000',
|
||||
monthlyOperatingPayments: '20000',
|
||||
monthlyPayrollExpense: '15000',
|
||||
monthlyPayrollPayments: '15000',
|
||||
assetInvestment: '100000',
|
||||
inventory: '20000',
|
||||
receivables: '10000',
|
||||
payables: '5000',
|
||||
equity: '80000',
|
||||
shareholderLoans: '0',
|
||||
bankLoanProceeds: '50000',
|
||||
grants: '0',
|
||||
assetDepreciation: '100000',
|
||||
monthlyOperatingExpenses: '',
|
||||
monthlyOperatingPayments: '',
|
||||
monthlyPayrollExpense: '',
|
||||
monthlyPayrollPayments: '',
|
||||
assetInvestment: '',
|
||||
inventory: '',
|
||||
receivables: '',
|
||||
payables: '',
|
||||
equity: '',
|
||||
shareholderLoans: '',
|
||||
bankLoanProceeds: '',
|
||||
grants: '',
|
||||
assetDepreciation: '',
|
||||
depreciationLifeMonths: 60,
|
||||
monthlyInterestExpense: '0',
|
||||
monthlyInterestPayment: '0',
|
||||
monthlyPrincipalRepayment: '0',
|
||||
monthlyInterestExpense: '',
|
||||
monthlyInterestPayment: '',
|
||||
monthlyPrincipalRepayment: '',
|
||||
periods: 12,
|
||||
};
|
||||
|
||||
@@ -913,30 +914,23 @@ export function SimulationBusinessPlan({
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label className="block text-sm font-medium">
|
||||
Moeda (código ISO)
|
||||
<input
|
||||
list="plan-currency-options"
|
||||
maxLength={3}
|
||||
pattern="[A-Z]{3}"
|
||||
Moeda
|
||||
<CurrencySelect
|
||||
label="Moeda"
|
||||
codes={simulationCurrencyOptions}
|
||||
value={currency}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value.toUpperCase();
|
||||
onChange={(value) => {
|
||||
onCurrencyChange(value);
|
||||
savePlan({ ...plan, currency: value });
|
||||
}}
|
||||
className="mt-1 w-full rounded border p-2"
|
||||
aria-invalid={Boolean(
|
||||
currency && !isSimulationCurrency(currency),
|
||||
)}
|
||||
error={
|
||||
currency && !isSimulationCurrency(currency)
|
||||
? 'Confirme a moeda seleccionada.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<datalist id="plan-currency-options">
|
||||
{simulationCurrencyOptions.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<p className="text-sm text-slate-700">
|
||||
O plano financeiro aceita moedas ISO suportadas com duas
|
||||
casas decimais. Se um investidor indicar uma moeda com
|
||||
@@ -952,31 +946,22 @@ export function SimulationBusinessPlan({
|
||||
</p>
|
||||
<label className="block text-sm font-medium">
|
||||
Moeda de apresentação (opcional)
|
||||
<input
|
||||
list="plan-presentation-currency-options"
|
||||
maxLength={3}
|
||||
pattern="[A-Z]{3}"
|
||||
<CurrencySelect
|
||||
label="Moeda de apresentação"
|
||||
codes={simulationCurrencyOptions}
|
||||
value={plan.presentationCurrency ?? ''}
|
||||
onChange={(event) =>
|
||||
update(
|
||||
'presentationCurrency',
|
||||
event.target.value.toUpperCase(),
|
||||
)
|
||||
onChange={(value) =>
|
||||
update('presentationCurrency', value)
|
||||
}
|
||||
className="mt-1 w-full rounded border p-2"
|
||||
aria-invalid={Boolean(
|
||||
error={
|
||||
plan.presentationCurrency &&
|
||||
!isSimulationCurrency(plan.presentationCurrency),
|
||||
)}
|
||||
!isSimulationCurrency(plan.presentationCurrency)
|
||||
? 'Confirme a moeda seleccionada.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<datalist id="plan-presentation-currency-options">
|
||||
{simulationCurrencyOptions.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{plan.presentationCurrency &&
|
||||
plan.presentationCurrency !== currency && (
|
||||
<>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CurrencySelect from '@/components/currency-select';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
exportMonthlyCashflowCsv,
|
||||
@@ -90,30 +91,20 @@ export function SimulationCashflowPlanner({
|
||||
aria-labelledby="cashflow-title"
|
||||
>
|
||||
<label className="block max-w-xs text-sm font-medium">
|
||||
Moeda do projecto (código ISO)
|
||||
<input
|
||||
list="cashflow-currency-options"
|
||||
inputMode="text"
|
||||
maxLength={3}
|
||||
pattern="[A-Z]{3}"
|
||||
autoCapitalize="characters"
|
||||
Moeda do projecto
|
||||
<CurrencySelect
|
||||
label="Moeda do projecto"
|
||||
codes={simulationCurrencyOptions}
|
||||
value={currency}
|
||||
onChange={(event) =>
|
||||
onCurrencyChange(event.target.value.toUpperCase())
|
||||
}
|
||||
className="mt-1 w-full rounded border p-2"
|
||||
aria-invalid={
|
||||
currency !== '' && !isSimulationCurrency(currency)
|
||||
onChange={onCurrencyChange}
|
||||
error={
|
||||
currency && !isSimulationCurrency(currency)
|
||||
? 'Confirme a moeda seleccionada.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<datalist id="cashflow-currency-options">
|
||||
{simulationCurrencyOptions.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<h3 id="cashflow-title" className="text-lg font-semibold">
|
||||
Projecção de tesouraria — 12 meses
|
||||
</h3>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import type { AdvanceFiscalPeriod } from '@/lib/irpc-ppc-obligation';
|
||||
import {SimulationIrpcPecObligation} from './simulation-irpc-pec-obligation';
|
||||
import { SimulationIrpcPecObligation } from './simulation-irpc-pec-obligation';
|
||||
import { SimulationIrpcPpcObligation } from './simulation-irpc-ppc-obligation';
|
||||
import { SimulationIrpcInvestment } from './simulation-irpc-investment';
|
||||
import { SimulationIrpcEconomic } from './simulation-irpc-economic';
|
||||
@@ -38,6 +38,19 @@ type Props = {
|
||||
export function SimulationIrpcAnnual({ draft, onChange }: Props) {
|
||||
const [error, setError] = useState('');
|
||||
if (draft.fiscal_component !== 'irpc') return null;
|
||||
if (
|
||||
typeof draft.fiscal_period === 'string' &&
|
||||
draft.fiscal_period &&
|
||||
draft.fiscal_period !== '2026'
|
||||
)
|
||||
return (
|
||||
<p role="alert">
|
||||
Esta estimativa anual tem cobertura documentada para 2026. O
|
||||
período escolhido, {draft.fiscal_period}, permanece inalterado;
|
||||
os dados e resultados anteriores ficam preservados, sem aplicar
|
||||
este cálculo a outro ano.
|
||||
</p>
|
||||
);
|
||||
try {
|
||||
const b = annualBook(draft),
|
||||
save = (next: AnnualBook) => {
|
||||
@@ -51,10 +64,10 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
|
||||
if (!b)
|
||||
return (
|
||||
<section
|
||||
aria-label="IRPC — L8 fecho anual"
|
||||
aria-label="IRPC — estimativa anual"
|
||||
className="rounded border p-4"
|
||||
>
|
||||
<h4>L8 — da colecta ao saldo anual simulado</h4>
|
||||
<h4>Do imposto estimado ao saldo anual simulado</h4>
|
||||
<p>
|
||||
Reutiliza a colecta válida, retenções efectivamente
|
||||
sofridas por conta e PPC comprovados do próprio
|
||||
@@ -77,14 +90,14 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Preparar saldo anual L8
|
||||
Preparar estimativa anual
|
||||
</button>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
const patch = (p: Partial<AnnualBook>) => save({ ...b, ...p }),
|
||||
a = annualAssessment(draft),
|
||||
advancePeriod = annualAdvancePeriod(draft,b),
|
||||
advancePeriod = annualAdvancePeriod(draft, b),
|
||||
options = annualWithholdingOptions(draft);
|
||||
const field = (
|
||||
key: 'subjectNuit' | 'year' | 'reference' | 'assessmentReference',
|
||||
@@ -143,10 +156,10 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
|
||||
);
|
||||
return (
|
||||
<section
|
||||
aria-label="IRPC — L8 fecho anual"
|
||||
aria-label="IRPC — estimativa anual"
|
||||
className="space-y-3 rounded border p-4"
|
||||
>
|
||||
<h4>L8 — colecta, abatimentos e saldo anual simulado</h4>
|
||||
<h4>Imposto estimado, abatimentos e saldo anual simulado</h4>
|
||||
<p>
|
||||
A colecta acima é anterior aos abatimentos. Este saldo tem
|
||||
cálculo e histórico próprios; retenção entregue pela empresa
|
||||
@@ -350,11 +363,33 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2 rounded border p-3">
|
||||
<legend>Estimativas PPC e PEC para a viabilidade</legend>
|
||||
<p>Simulação fiscal para avaliar a viabilidade do negócio e as necessidades de tesouraria. Os valores e meses previstos são estimativas; o portal não executa pagamentos nem submete declarações fiscais.</p>
|
||||
<SimulationIrpcAdvancePeriodDeclaration draft={draft} book={b} onChange={declaration=>patch({advancePeriodDeclaration:declaration})}/>
|
||||
<SimulationIrpcPecObligation book={b} period={advancePeriod} onChange={pecObligation=>patch({pecObligation})}/>
|
||||
<SimulationIrpcPpcObligation book={b} period={advancePeriod} onChange={ppcObligation=>patch({ppcObligation})}/>
|
||||
<SimulationIrpcAdvanceCashflow book={b} period={advancePeriod}/>
|
||||
<p>
|
||||
Simulação fiscal para avaliar a viabilidade do negócio e
|
||||
as necessidades de tesouraria. Os valores e meses
|
||||
previstos são estimativas; o portal não executa
|
||||
pagamentos nem submete declarações fiscais.
|
||||
</p>
|
||||
<SimulationIrpcAdvancePeriodDeclaration
|
||||
draft={draft}
|
||||
book={b}
|
||||
onChange={(declaration) =>
|
||||
patch({ advancePeriodDeclaration: declaration })
|
||||
}
|
||||
/>
|
||||
<SimulationIrpcPecObligation
|
||||
book={b}
|
||||
period={advancePeriod}
|
||||
onChange={(pecObligation) => patch({ pecObligation })}
|
||||
/>
|
||||
<SimulationIrpcPpcObligation
|
||||
book={b}
|
||||
period={advancePeriod}
|
||||
onChange={(ppcObligation) => patch({ ppcObligation })}
|
||||
/>
|
||||
<SimulationIrpcAdvanceCashflow
|
||||
book={b}
|
||||
period={advancePeriod}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2 rounded border p-3">
|
||||
<legend>PPC documentados como pagos</legend>
|
||||
@@ -627,33 +662,255 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function SimulationIrpcAdvanceCashflow({book,period}: {book: AnnualBook;period?: AdvanceFiscalPeriod}) {
|
||||
export function SimulationIrpcAdvanceCashflow({
|
||||
book,
|
||||
period,
|
||||
}: {
|
||||
book: AnnualBook;
|
||||
period?: AdvanceFiscalPeriod;
|
||||
}) {
|
||||
if (!book.ppcObligation && !book.pecObligation) return null;
|
||||
const m = annualAdvanceCashflow(book,period);
|
||||
const m = annualAdvanceCashflow(book, period);
|
||||
const show = advanceCashflowAmount;
|
||||
return <section className="space-y-3 rounded border p-3" aria-label="Projecção conjunta PPC e PEC">
|
||||
<h5 className="font-semibold">Saídas previstas PPC/PEC por mês</h5>
|
||||
{period && <p>Período preparado em L6: {period.from || 'por confirmar'} — {period.to || 'por confirmar'}.</p>}
|
||||
<p>Reúne as estimativas PPC e a hipótese de tesouraria PEC. Os comprovativos pagos e o fecho anual permanecem separados; esta vista não acrescenta outra saída ao fluxo financeiro do negócio.</p>
|
||||
<p role="status">{m.total ? 'Total da projecção: '+show(m.total) : 'Projecção parcial — total por determinar.'}</p>
|
||||
{!!m.pending.length && <ul className="list-disc pl-5">{m.pending.map(p=><li key={p}>{p}</li>)}</ul>}
|
||||
{!!m.rows.length && <div className="overflow-x-auto"><table className="w-full text-left text-sm">
|
||||
<caption className="sr-only">Projecção mensal das estimativas PPC e PEC</caption>
|
||||
<thead><tr><th className="p-2">Mês</th><th className="p-2">PPC</th><th className="p-2">PEC</th><th className="p-2">{m.total ? 'Total previsto' : 'Subtotal conhecido'}</th></tr></thead>
|
||||
<tbody>{m.rows.map(r=><tr key={r.month} className="border-t"><th scope="row" className="p-2">{r.month}</th><td className="p-2">{m.ppcKnown ? show(r.ppc) : 'Por determinar'}</td><td className="p-2">{m.pecKnown ? show(r.pec) : 'Por determinar'}</td><td className="p-2">{show(r.knownSubtotal)}</td></tr>)}</tbody>
|
||||
</table></div>}
|
||||
{!m.rows.length && m.total && <p>Sem saída PPC/PEC estimada neste recorte.</p>}
|
||||
<p>Meses e montantes dependem das fontes e opções preparadas acima. A opção PEC concentrada conserva o mês escolhido; esta apresentação não certifica o prazo legal.</p>
|
||||
</section>;
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded border p-3"
|
||||
aria-label="Projecção conjunta PPC e PEC"
|
||||
>
|
||||
<h5 className="font-semibold">Saídas previstas PPC/PEC por mês</h5>
|
||||
{period && (
|
||||
<p>
|
||||
Período preparado em L6: {period.from || 'por confirmar'} —{' '}
|
||||
{period.to || 'por confirmar'}.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Reúne as estimativas PPC e a hipótese de tesouraria PEC. Os
|
||||
comprovativos pagos e o fecho anual permanecem separados; esta
|
||||
vista não acrescenta outra saída ao fluxo financeiro do negócio.
|
||||
</p>
|
||||
<p role="status">
|
||||
{m.total
|
||||
? 'Total da projecção: ' + show(m.total)
|
||||
: 'Projecção parcial — total por determinar.'}
|
||||
</p>
|
||||
{!!m.pending.length && (
|
||||
<ul className="list-disc pl-5">
|
||||
{m.pending.map((p) => (
|
||||
<li key={p}>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!!m.rows.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption className="sr-only">
|
||||
Projecção mensal das estimativas PPC e PEC
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2">Mês</th>
|
||||
<th className="p-2">PPC</th>
|
||||
<th className="p-2">PEC</th>
|
||||
<th className="p-2">
|
||||
{m.total
|
||||
? 'Total previsto'
|
||||
: 'Subtotal conhecido'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.rows.map((r) => (
|
||||
<tr key={r.month} className="border-t">
|
||||
<th scope="row" className="p-2">
|
||||
{r.month}
|
||||
</th>
|
||||
<td className="p-2">
|
||||
{m.ppcKnown
|
||||
? show(r.ppc)
|
||||
: 'Por determinar'}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{m.pecKnown
|
||||
? show(r.pec)
|
||||
: 'Por determinar'}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{show(r.knownSubtotal)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!m.rows.length && m.total && (
|
||||
<p>Sem saída PPC/PEC estimada neste recorte.</p>
|
||||
)}
|
||||
<p>
|
||||
Meses e montantes dependem das fontes e opções preparadas acima.
|
||||
A opção PEC concentrada conserva o mês escolhido; esta
|
||||
apresentação não certifica o prazo legal.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function SimulationIrpcAdvancePeriodDeclaration({draft,book,onChange}:{draft:Record<string,unknown>;book:AnnualBook;onChange:(value:NonNullable<AnnualBook['advancePeriodDeclaration']>)=>void}){
|
||||
const period=annualAdvancePeriod(draft,book),calendar=advanceCalendar(period,book.year,book.subjectNuit),d=book.advancePeriodDeclaration;
|
||||
if(!d&&!calendar.candidate)return null;
|
||||
if(!d)return <section className="rounded border p-3"><h5>Período anual não civil</h5><p>Confronte o fundamento do período L6 e os doze meses do período anterior. As datas sugeridas não substituem o suporte documental.</p><button type="button" className="rounded border p-2" onClick={()=>onChange(newAdvancePeriodDeclaration(book.subjectNuit,book.year,period))}>Preparar correspondência dos períodos</button></section>;
|
||||
const patch=(values:Partial<typeof d>)=>onChange({...d,...values,confirmed:''});
|
||||
const labels={subjectNuit:'NUIT dos períodos',year:'Exercício de termo',currentFrom:'Início do período L6 declarado',currentTo:'Termo do período L6 declarado',currentReference:'Fundamento documentado do período anual não civil',previousFrom:'Início do período anterior',previousTo:'Termo do período anterior',previousReference:'Fonte e correspondência do período anterior'};
|
||||
return <section className="space-y-2 rounded border p-3" aria-label="Correspondência dos períodos anuais"><h5>Correspondência dos períodos anuais</h5><p role="status">{calendar.mode==='annual_non_civil'?'Correspondência documental conferida;previsões nos meses do período L6.':calendar.mode==='civil'?'L6 usa o ano civil;referência anterior conservada sem aplicação.':calendar.pending.join(' ')}</p><p>PPC: imposto anterior {book.ppcObligation?.previousTax||'por confirmar'} MZN;retenções {book.ppcObligation?.previousWithholdings||'por confirmar'} MZN. PEC: volume anterior {book.pecObligation?.previousTurnover||'por confirmar'} MZN;PPC anteriores {book.pecObligation?.previousPpcPaid||'por confirmar'} MZN.</p>{d.confirmed==='yes'&&(d.ppcBasisKey!==advancePeriodBasis(book,'ppc')||d.pecBasisKey!==advancePeriodBasis(book,'pec'))&&<button type="button" className="rounded border p-2" onClick={()=>onChange({...d,ppcBasisKey:advancePeriodBasis(book,'ppc'),pecBasisKey:advancePeriodBasis(book,'pec')})}>Reconferi os montantes e as fontes neste período</button>}<details open={d.confirmed!=='yes'||calendar.pending.length>0}><summary>Conferir ou alterar períodos</summary><label className="block">Aplicação<select aria-label="Aplicação do período não civil" value={d.mode} onChange={e=>patch({mode:e.target.value as typeof d.mode})}><option value="annual_non_civil">Período anual não civil</option><option value="">Conservar sem aplicação</option></select></label>{(Object.keys(labels) as Array<keyof typeof labels>).map(key=><label className="block" key={key}>{labels[key]}<input className="mt-1 block w-full rounded border px-3 py-2" aria-label={labels[key]} value={d[key]} onChange={e=>patch({[key]:e.target.value})}/></label>)}<p>No PPC, indique que o período anterior não é um ano civil completo. Os doze meses e a continuidade são conferidos pelas datas e fontes aqui indicadas.</p><label className="block"><input type="checkbox" checked={d.confirmed==='yes'} onChange={e=>onChange({...d,confirmed:e.target.checked?'yes':'',ppcBasisKey:advancePeriodBasis(book,'ppc'),pecBasisKey:advancePeriodBasis(book,'pec')})}/>Conferi no suporte os dois períodos, o fundamento do enquadramento e a correspondência dos montantes e fontes PPC/PEC</label></details><p>Guardar conserva estas escolhas. Uma alteração nas datas L6 exige reconferência; o portal não autoriza uma mudança do período fiscal.</p></section>;
|
||||
export function SimulationIrpcAdvancePeriodDeclaration({
|
||||
draft,
|
||||
book,
|
||||
onChange,
|
||||
}: {
|
||||
draft: Record<string, unknown>;
|
||||
book: AnnualBook;
|
||||
onChange: (
|
||||
value: NonNullable<AnnualBook['advancePeriodDeclaration']>,
|
||||
) => void;
|
||||
}) {
|
||||
const period = annualAdvancePeriod(draft, book),
|
||||
calendar = advanceCalendar(period, book.year, book.subjectNuit),
|
||||
d = book.advancePeriodDeclaration;
|
||||
if (!d && !calendar.candidate) return null;
|
||||
if (!d)
|
||||
return (
|
||||
<section className="rounded border p-3">
|
||||
<h5>Período anual não civil</h5>
|
||||
<p>
|
||||
Confronte o fundamento do período L6 e os doze meses do
|
||||
período anterior. As datas sugeridas não substituem o
|
||||
suporte documental.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border p-2"
|
||||
onClick={() =>
|
||||
onChange(
|
||||
newAdvancePeriodDeclaration(
|
||||
book.subjectNuit,
|
||||
book.year,
|
||||
period,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Preparar correspondência dos períodos
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
const patch = (values: Partial<typeof d>) =>
|
||||
onChange({ ...d, ...values, confirmed: '' });
|
||||
const labels = {
|
||||
subjectNuit: 'NUIT dos períodos',
|
||||
year: 'Exercício de termo',
|
||||
currentFrom: 'Início do período L6 declarado',
|
||||
currentTo: 'Termo do período L6 declarado',
|
||||
currentReference: 'Fundamento documentado do período anual não civil',
|
||||
previousFrom: 'Início do período anterior',
|
||||
previousTo: 'Termo do período anterior',
|
||||
previousReference: 'Fonte e correspondência do período anterior',
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className="space-y-2 rounded border p-3"
|
||||
aria-label="Correspondência dos períodos anuais"
|
||||
>
|
||||
<h5>Correspondência dos períodos anuais</h5>
|
||||
<p role="status">
|
||||
{calendar.mode === 'annual_non_civil'
|
||||
? 'Correspondência documental conferida;previsões nos meses do período L6.'
|
||||
: calendar.mode === 'civil'
|
||||
? 'L6 usa o ano civil;referência anterior conservada sem aplicação.'
|
||||
: calendar.pending.join(' ')}
|
||||
</p>
|
||||
<p>
|
||||
PPC: imposto anterior{' '}
|
||||
{book.ppcObligation?.previousTax || 'por confirmar'}{' '}
|
||||
MZN;retenções{' '}
|
||||
{book.ppcObligation?.previousWithholdings || 'por confirmar'}{' '}
|
||||
MZN. PEC: volume anterior{' '}
|
||||
{book.pecObligation?.previousTurnover || 'por confirmar'}{' '}
|
||||
MZN;PPC anteriores{' '}
|
||||
{book.pecObligation?.previousPpcPaid || 'por confirmar'} MZN.
|
||||
</p>
|
||||
{d.confirmed === 'yes' &&
|
||||
(d.ppcBasisKey !== advancePeriodBasis(book, 'ppc') ||
|
||||
d.pecBasisKey !== advancePeriodBasis(book, 'pec')) && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border p-2"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...d,
|
||||
ppcBasisKey: advancePeriodBasis(book, 'ppc'),
|
||||
pecBasisKey: advancePeriodBasis(book, 'pec'),
|
||||
})
|
||||
}
|
||||
>
|
||||
Reconferi os montantes e as fontes neste período
|
||||
</button>
|
||||
)}
|
||||
<details
|
||||
open={d.confirmed !== 'yes' || calendar.pending.length > 0}
|
||||
>
|
||||
<summary>Conferir ou alterar períodos</summary>
|
||||
<label className="block">
|
||||
Aplicação
|
||||
<select
|
||||
aria-label="Aplicação do período não civil"
|
||||
value={d.mode}
|
||||
onChange={(e) =>
|
||||
patch({ mode: e.target.value as typeof d.mode })
|
||||
}
|
||||
>
|
||||
<option value="annual_non_civil">
|
||||
Período anual não civil
|
||||
</option>
|
||||
<option value="">Conservar sem aplicação</option>
|
||||
</select>
|
||||
</label>
|
||||
{(Object.keys(labels) as Array<keyof typeof labels>).map(
|
||||
(key) => (
|
||||
<label className="block" key={key}>
|
||||
{labels[key]}
|
||||
<input
|
||||
className="mt-1 block w-full rounded border px-3 py-2"
|
||||
aria-label={labels[key]}
|
||||
value={d[key]}
|
||||
onChange={(e) =>
|
||||
patch({ [key]: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
<p>
|
||||
No PPC, indique que o período anterior não é um ano civil
|
||||
completo. Os doze meses e a continuidade são conferidos
|
||||
pelas datas e fontes aqui indicadas.
|
||||
</p>
|
||||
<label className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={d.confirmed === 'yes'}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...d,
|
||||
confirmed: e.target.checked ? 'yes' : '',
|
||||
ppcBasisKey: advancePeriodBasis(book, 'ppc'),
|
||||
pecBasisKey: advancePeriodBasis(book, 'pec'),
|
||||
})
|
||||
}
|
||||
/>
|
||||
Conferi no suporte os dois períodos, o fundamento do
|
||||
enquadramento e a correspondência dos montantes e fontes
|
||||
PPC/PEC
|
||||
</label>
|
||||
</details>
|
||||
<p>
|
||||
Guardar conserva estas escolhas. Uma alteração nas datas L6
|
||||
exige reconferência; o portal não autoriza uma mudança do
|
||||
período fiscal.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SimulationIrpcSpecial } from '@/components/simulation-irpc-special';
|
||||
import FiscalOperationPicker from '@/components/fiscal-operation-picker';
|
||||
import { inputText } from '@/lib/input-text';
|
||||
import { SimulationIrpcFormPreparation } from '@/components/simulation-irpc-form-preparation';
|
||||
import { SimulationIrpcPeriodFx } from '@/components/simulation-irpc-period-fx';
|
||||
import { SimulationIrpcDepreciation } from '@/components/simulation-irpc-depreciation';
|
||||
@@ -31,7 +33,7 @@ export function SimulationIrpcNormal({
|
||||
onChange,
|
||||
}: {
|
||||
draft: Record<string, unknown>;
|
||||
onChange: (field: string, value: string) => void;
|
||||
onChange: (field: string, value: string | string[]) => void;
|
||||
}) {
|
||||
const [error, setError] = useState('');
|
||||
if (draft.fiscal_component !== 'irpc') return null;
|
||||
@@ -46,6 +48,26 @@ export function SimulationIrpcNormal({
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
const selectedPeriod = inputText(draft.fiscal_period ?? '');
|
||||
if (selectedPeriod && selectedPeriod !== b.year)
|
||||
return (
|
||||
<aside className="rounded border p-3" role="alert">
|
||||
<p>
|
||||
O enquadramento escolheu {selectedPeriod}, mas esta
|
||||
preparação de IRPC está em {b.year}. Confira o período
|
||||
antes de calcular; os dados anteriores ficam preservados
|
||||
e nenhum ano foi alterado.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
disabled={!/^\d{4}$/.test(selectedPeriod)}
|
||||
onClick={() => save({ ...b, year: selectedPeriod })}
|
||||
>
|
||||
Preparar o ano escolhido, sujeito à cobertura disponível
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
const patch = (id: string, p: Partial<NormalAdjustment>) =>
|
||||
save({
|
||||
...b,
|
||||
@@ -74,16 +96,16 @@ export function SimulationIrpcNormal({
|
||||
aria-label="IRPC normal — ajustamentos"
|
||||
className="space-y-3 rounded border p-4"
|
||||
>
|
||||
<h4>L6 — IRPC normal: ajustamentos ao resultado financeiro</h4>
|
||||
<h4>IRPC — ajustamentos ao resultado financeiro</h4>
|
||||
<p>
|
||||
Acréscimos de multas sem origem contratual e juros
|
||||
compensatórios incluídos nos custos do plano, artigo 36(1)c.
|
||||
A conferência anual pode determinar o lucro tributável
|
||||
simulado no recorte ordinário documentado. A matéria
|
||||
colectável exige conferência específica de deduções e
|
||||
perdas; a colecta tem cálculo próprio e o saldo final a
|
||||
pagar ou recuperar pertence aL8. Não reutiliza
|
||||
automaticamente juros simulados de L5.
|
||||
perdas; o imposto estimado e a estimativa anual têm cálculos
|
||||
próprios. Os juros da simulação de empréstimo não são
|
||||
adoptados automaticamente como custo fiscal.
|
||||
</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{source ? (
|
||||
@@ -437,31 +459,126 @@ export function SimulationIrpcNormal({
|
||||
})}
|
||||
</fieldset>
|
||||
)}
|
||||
<SimulationIrpcPeriodFx draft={draft} book={b} save={save} />
|
||||
<SimulationIrpcSchedules book={b} save={save} />
|
||||
<SimulationIrpcEconomicInclusion
|
||||
key={JSON.stringify([
|
||||
draft.context_opportunity,
|
||||
b.base?.subjectNuit,
|
||||
b.year,
|
||||
])}
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
<SimulationIrpcDepreciation
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
<SimulationIrpcCosts draft={draft} book={b} save={save} />
|
||||
<SimulationIrpcTaxableBase draft={draft} book={b} save={save} />
|
||||
<SimulationIrpcAssessment book={b} save={save} />
|
||||
<SimulationIrpcSpecial draft={draft} book={b} save={save} />
|
||||
<SimulationIrpcFormPreparation
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
<FiscalOperationPicker
|
||||
family="irpc"
|
||||
selected={draft.fiscal_tools_selection}
|
||||
onChange={(selected) =>
|
||||
onChange('fiscal_tools_selection', selected)
|
||||
}
|
||||
operations={[
|
||||
{
|
||||
id: 'period',
|
||||
label: 'Período e moeda do plano',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcPeriodFx
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
label: 'Mapas complementares',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcSchedules book={b} save={save} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'economic',
|
||||
label: 'Inclusões e correcções económicas',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcEconomicInclusion
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'depreciation',
|
||||
label: 'Amortizações e depreciações',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcDepreciation
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'costs',
|
||||
label: 'Tratamento dos custos',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcCosts
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'taxable',
|
||||
label: 'Matéria colectável',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcTaxableBase
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'assessment',
|
||||
label: 'Estimativa do imposto',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcAssessment
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'special',
|
||||
label: 'Situações fiscais específicas',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcSpecial
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'forms',
|
||||
label: 'Preparação documental fiscal',
|
||||
description:
|
||||
'Acrescente quando necessário ao seu cenário e confira os pressupostos e fontes da operação.',
|
||||
render: () => (
|
||||
<SimulationIrpcFormPreparation
|
||||
draft={draft}
|
||||
book={b}
|
||||
save={save}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -49,6 +49,29 @@ export function SimulationIspcQuarter({
|
||||
},
|
||||
change = (patch: Partial<IspcQuarterInput>) =>
|
||||
save({ ...book, input: { ...i, ...patch } });
|
||||
if (
|
||||
typeof draft.fiscal_period === 'string' &&
|
||||
draft.fiscal_period &&
|
||||
draft.fiscal_period !== i.year
|
||||
)
|
||||
return (
|
||||
<aside className="rounded border p-3" role="alert">
|
||||
<p>
|
||||
O ano desta preparação difere do enquadramento fiscal
|
||||
escolhido. Confira a correspondência antes de calcular;
|
||||
nenhum período foi alterado automaticamente.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() =>
|
||||
change({ year: draft.fiscal_period as string })
|
||||
}
|
||||
>
|
||||
Preparar o ano escolhido, sujeito à cobertura disponível
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
let service = false,
|
||||
ready = false;
|
||||
try {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function SimulationMiners({
|
||||
return (
|
||||
<details className="rounded border p-3">
|
||||
<summary>
|
||||
Importações dos mineiros — regime da África do Sul (P059)
|
||||
Importações dos mineiros — regime da África do Sul
|
||||
</summary>
|
||||
<p>
|
||||
Lei n.º2/2011,artigos1–4:página1 doPDF;artigo5:página2/p110-(8).
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
modeloAPurchaseReferences,
|
||||
modeloAReference,
|
||||
} from '@/lib/iva-modelo-a';
|
||||
import EvidenceDetails from './evidence-details';
|
||||
|
||||
export function SimulationModeloA({
|
||||
draft,
|
||||
@@ -100,14 +101,15 @@ export function SimulationModeloA({
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
<details>
|
||||
<summary>
|
||||
Ver campos, cálculos, fundamentos e cobertura do Modelo A
|
||||
</summary>
|
||||
<p>
|
||||
{modeloAReference.file}. {modeloAReference.edition} SHA256:{' '}
|
||||
{modeloAReference.sha256}.
|
||||
</p>
|
||||
<EvidenceDetails title="Campos e fundamentos da estimativa — Modelo A">
|
||||
<EvidenceDetails title="Exemplar e integridade da fonte">
|
||||
<p>
|
||||
{modeloAReference.file}. {modeloAReference.edition}
|
||||
</p>
|
||||
<p className="break-all">
|
||||
SHA256: {modeloAReference.sha256}
|
||||
</p>
|
||||
</EvidenceDetails>
|
||||
<p>
|
||||
O exemplar contém divergências de numeração/remissões,
|
||||
registadas no confronto documental. Não foram corrigidas no
|
||||
@@ -159,7 +161,7 @@ export function SimulationModeloA({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</EvidenceDetails>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import FiscalOperationPicker from './fiscal-operation-picker';
|
||||
import { inputText } from '../lib/input-text.ts';
|
||||
import { SimulationRemissionImports } from './simulation-remission-imports';
|
||||
import { SimulationObjectiveImports } from './simulation-objective-imports';
|
||||
@@ -58,7 +59,7 @@ export function SimulationMonthlyIva({
|
||||
onCalculate: (
|
||||
input: MonthlyIvaInput,
|
||||
) => Promise<ApiSimulationExecutionV1 | null | undefined>;
|
||||
onChange: (field: string, value: string) => void;
|
||||
onChange: (field: string, value: string | string[]) => void;
|
||||
}) {
|
||||
const settings = readMonthlyIvaSettings(
|
||||
inputText(draft.monthly_iva_settings ?? ''),
|
||||
@@ -416,38 +417,394 @@ export function SimulationMonthlyIva({
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
<SimulationOperations draft={draft} onChange={onChange} />
|
||||
<SimulationAcquisitions draft={draft} onChange={onChange} />
|
||||
<SimulationImportExemptions draft={draft} onChange={onChange} />
|
||||
<SimulationObjectiveImports draft={draft} onChange={onChange} />
|
||||
<SimulationMiners draft={draft} onChange={onChange} />
|
||||
<SimulationRemissionImports draft={draft} onChange={onChange} />
|
||||
<SimulationReturnees draft={draft} onChange={onChange} />
|
||||
<SimulationPublicAuthorityBase draft={draft} onChange={onChange} />
|
||||
<SimulationCommissionBase draft={draft} onChange={onChange} />
|
||||
<SimulationActSaleBase draft={draft} onChange={onChange} />
|
||||
<SimulationFuelBase draft={draft} onChange={onChange} />
|
||||
<SimulationNoncashBase draft={draft} onChange={onChange} />
|
||||
<SimulationCompositionBase draft={draft} onChange={onChange} />
|
||||
<SimulationExclusionBase draft={draft} onChange={onChange} />
|
||||
<SimulationValueReview draft={draft} onChange={onChange} />
|
||||
<SimulationServiceNormal draft={draft} onChange={onChange} />
|
||||
<SimulationGoodsLocation draft={draft} onChange={onChange} />
|
||||
<SimulationServiceLocation draft={draft} onChange={onChange} />
|
||||
<SimulationListedServices draft={draft} onChange={onChange} />
|
||||
<SimulationReducedBases draft={draft} onChange={onChange} />
|
||||
<SimulationOwnName draft={draft} onChange={onChange} />
|
||||
<SimulationGoodsAllocation draft={draft} onChange={onChange} />
|
||||
<SimulationForeignOperations draft={draft} onChange={onChange} />
|
||||
<SimulationDigital draft={draft} onChange={onChange} />
|
||||
<SimulationTravel draft={draft} onChange={onChange} />
|
||||
<SimulationTravelEvents draft={draft} onChange={onChange} />
|
||||
<SimulationCurrencyEvents draft={draft} onChange={onChange} />
|
||||
<SimulationPropertyTransfers draft={draft} onChange={onChange} />
|
||||
<SimulationGoodsPresumptions draft={draft} onChange={onChange} />
|
||||
<SimulationSecondhand draft={draft} onChange={onChange} />
|
||||
<SimulationOfferPreparations draft={draft} onChange={onChange} />
|
||||
<SimulationModeloA draft={draft} />
|
||||
<FiscalOperationPicker
|
||||
family="iva"
|
||||
selected={draft.fiscal_tools_selection}
|
||||
onChange={(selected) =>
|
||||
onChange('fiscal_tools_selection', selected)
|
||||
}
|
||||
operations={[
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operações de venda e prestação de serviços',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationOperations
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'acquisitions',
|
||||
label: 'Aquisições e dedutibilidade',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationAcquisitions
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'import_exemptions',
|
||||
label: 'Importações com isenção',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationImportExemptions
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'objective_imports',
|
||||
label: 'Condições específicas de importação',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationObjectiveImports
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'miners',
|
||||
label: 'Importações no regime de trabalhadores mineiros',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationMiners
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'remission_imports',
|
||||
label: 'Importações com isenção por remissão legal',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationRemissionImports
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'returnees',
|
||||
label: 'Regresso definitivo e bagagem',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationReturnees
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'public_authority_base',
|
||||
label: 'Operações de entidades públicas',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationPublicAuthorityBase
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'commission_base',
|
||||
label: 'Comissões',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationCommissionBase
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'act_sale_base',
|
||||
label: 'Venda por acto ou processo específico',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationActSaleBase
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'fuel_base',
|
||||
label: 'Revenda de combustíveis',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationFuelBase
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'noncash_base',
|
||||
label: 'Operações sem contrapartida em dinheiro',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationNoncashBase
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'composition_base',
|
||||
label: 'Composição da base tributável',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationCompositionBase
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'exclusion_base',
|
||||
label: 'Parcelas excluídas da base',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationExclusionBase
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'value_review',
|
||||
label: 'Revisão do valor tributável',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationValueReview
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'service_normal',
|
||||
label: 'Valor de serviços',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationServiceNormal
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'goods_location',
|
||||
label: 'Localização das operações sobre bens',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationGoodsLocation
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'service_location',
|
||||
label: 'Localização das prestações de serviços',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationServiceLocation
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'listed_services',
|
||||
label: 'Serviços com enquadramento específico',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationListedServices
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'reduced_bases',
|
||||
label: 'Bases reduzidas',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationReducedBases
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'own_name',
|
||||
label: 'Operações em nome próprio',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationOwnName
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'goods_allocation',
|
||||
label: 'Afectação de bens',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationGoodsAllocation
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'foreign_operations',
|
||||
label: 'Operações com o exterior',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationForeignOperations
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'digital',
|
||||
label: 'Serviços digitais',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationDigital
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'travel',
|
||||
label: 'Viagens',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationTravel
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'travel_events',
|
||||
label: 'Factos específicos de viagens',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationTravelEvents
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'currency_events',
|
||||
label: 'Operações em moeda estrangeira',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationCurrencyEvents
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'property_transfers',
|
||||
label: 'Transmissões de imóveis',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationPropertyTransfers
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'goods_presumptions',
|
||||
label: 'Presunções relativas a bens',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationGoodsPresumptions
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'secondhand',
|
||||
label: 'Bens em segunda mão',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationSecondhand
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'offer_preparations',
|
||||
label: 'Preparação de ofertas',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => (
|
||||
<SimulationOfferPreparations
|
||||
draft={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'modelo_a',
|
||||
label: 'Conferência do Modelo A',
|
||||
description:
|
||||
'Escolha quando esta operação fizer parte do cenário; confira as condições, fontes e período no cálculo correspondente.',
|
||||
render: () => <SimulationModeloA draft={draft} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inputText } from '../lib/input-text.ts';
|
||||
import { useState } from 'react';
|
||||
import EvidenceDetails from '@/components/evidence-details';
|
||||
import {
|
||||
remissionBook,
|
||||
saveRemissionBook,
|
||||
@@ -52,9 +53,7 @@ export function SimulationRemissionImports({
|
||||
}
|
||||
return (
|
||||
<details className="rounded border p-3">
|
||||
<summary>
|
||||
Importações — quatro famílias de isenção por remissão (P047)
|
||||
</summary>
|
||||
<summary>Importações — isenções por remissão legal</summary>
|
||||
<p>
|
||||
Confirme o fundamento e o âmbito da isenção de direitos. Taxa
|
||||
pautal de 0%, redução e pedido apresentado são situações
|
||||
@@ -500,7 +499,9 @@ export function SimulationRemissionImports({
|
||||
)}
|
||||
<details>
|
||||
<summary>Fontes e limites de cobertura</summary>
|
||||
<p>{remissionSource}</p>
|
||||
<EvidenceDetails title="Fontes e âmbito deste cálculo">
|
||||
<p>{remissionSource.replace(/^P\d+\s*[—-]\s*/, '')}</p>
|
||||
</EvidenceDetails>
|
||||
<p>
|
||||
Avalia o IVA por remissão quando a isenção de direitos está
|
||||
demonstrada. Não substitui a classificação pautal nem
|
||||
|
||||
@@ -45,8 +45,7 @@ export function SimulationReturnees({
|
||||
return (
|
||||
<details className="rounded border p-3">
|
||||
<summary>
|
||||
Regresso definitivo — veículos e bagagem (P058, cobertura
|
||||
parcial)
|
||||
Regresso definitivo — veículos e bagagem (cobertura parcial)
|
||||
</summary>
|
||||
<p>
|
||||
Prepare os bens e escolha o percurso próprio:veículos no
|
||||
@@ -54,7 +53,7 @@ export function SimulationReturnees({
|
||||
artigos57.º/61.º do Decreto37/2023. Isenção, redução de80% e
|
||||
redução excepcional de50% têm condições distintas. Estas
|
||||
percentagens são benefícios sobre as imposições; não são taxas
|
||||
deIVA. Não se aplicam aqui as condições dos mineirosP059.
|
||||
de IVA. Não se aplicam aqui as condições próprias dos mineiros.
|
||||
</p>
|
||||
<p>
|
||||
Bagagem de funcionários e estudantes tem percurso
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import EvidenceDetails from './evidence-details';
|
||||
|
||||
export type TerritorialSourceEvidence = {
|
||||
association: string;
|
||||
role: string;
|
||||
@@ -15,16 +17,7 @@ export default function TerritorialSources({
|
||||
}) {
|
||||
if (!sources?.length) return null;
|
||||
return (
|
||||
<section className="min-w-0 space-y-3 rounded border p-4 [overflow-wrap:anywhere]">
|
||||
<h2 className="text-xl font-bold">
|
||||
Fundamento documental das associações
|
||||
</h2>
|
||||
<p>
|
||||
Fontes oficiais identificadas são aceites; recuperar uma
|
||||
referência não retira a aceitação existente. O fundamento
|
||||
aplica-se à informação indicada, sem deduzir implantação,
|
||||
disponibilidade ou capacidade.
|
||||
</p>
|
||||
<EvidenceDetails title="Fontes das relações territoriais">
|
||||
{sources.map((source, i) => (
|
||||
<article className="border-t pt-3" key={i}>
|
||||
<h3 className="font-semibold">
|
||||
@@ -36,17 +29,19 @@ export default function TerritorialSources({
|
||||
'ligação ao exemplar por recuperar'}
|
||||
</p>
|
||||
<p>{source.source_quote ?? source.supports}</p>
|
||||
<p>
|
||||
{source.official_source_accepted
|
||||
? 'Fonte oficial identificada e aceite.'
|
||||
: 'Aceitação existente preservada; ponte ao exemplar oficial por recuperar. Esta referência não recebe outra classificação de proveniência.'}{' '}
|
||||
Rastreabilidade: {source.traceability}. Estado
|
||||
registado:{' '}
|
||||
{source.existing_validation ?? 'sem campo específico'};
|
||||
preservado.
|
||||
</p>
|
||||
<EvidenceDetails title="Proveniência e versão da referência">
|
||||
<p>
|
||||
{source.official_source_accepted
|
||||
? 'Fonte oficial identificada.'
|
||||
: 'Ligação ao exemplar oficial por recuperar.'}
|
||||
</p>
|
||||
<p>{source.traceability}</p>
|
||||
{source.existing_validation && (
|
||||
<p>{source.existing_validation}</p>
|
||||
)}
|
||||
</EvidenceDetails>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</EvidenceDetails>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,26 @@ import { useEffect, useState } from 'react';
|
||||
import { newSubmissionKey } from '@/lib/portal-api';
|
||||
export function useInterestDraft<T>(name: string, initial: T) {
|
||||
const storage = 'investemoz-interest:' + name;
|
||||
const [draft, setDraft] = useState<{ form: T; key: string }>(() => {
|
||||
const read = () => {
|
||||
try {
|
||||
const value = JSON.parse(sessionStorage.getItem(storage) ?? 'null');
|
||||
if (value?.form && typeof value.key === 'string') return value;
|
||||
if (value?.form && typeof value.key === 'string')
|
||||
return { ...value, storage } as {
|
||||
form: T;
|
||||
key: string;
|
||||
storage: string;
|
||||
};
|
||||
} catch {
|
||||
/* unavailable storage leaves the current form usable */
|
||||
}
|
||||
return { form: initial, key: newSubmissionKey() };
|
||||
});
|
||||
return { form: initial, key: newSubmissionKey(), storage };
|
||||
};
|
||||
const [draft, setDraft] = useState(read);
|
||||
// Inertia can reuse this component for a different opportunity. Never write
|
||||
// the previous opportunity's answers into the new opportunity's storage key.
|
||||
if (draft.storage !== storage) setDraft(read());
|
||||
useEffect(() => {
|
||||
if (draft.storage !== storage) return;
|
||||
try {
|
||||
sessionStorage.setItem(storage, JSON.stringify(draft));
|
||||
} catch {}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const normaliseChoice = (value: string) =>
|
||||
value
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLocaleLowerCase('pt')
|
||||
.trim();
|
||||
export function matchesChoice(query: string, labels: string[]): boolean {
|
||||
const needle = normaliseChoice(query);
|
||||
return (
|
||||
!needle ||
|
||||
labels.some((label) => normaliseChoice(label).includes(needle))
|
||||
);
|
||||
}
|
||||
/** The same ISO currency source already used by the simulator, with readable labels. */
|
||||
export function currencyChoices(codes?: readonly string[]) {
|
||||
const intl = Intl as unknown as {
|
||||
supportedValuesOf?: (key: string) => string[];
|
||||
};
|
||||
const names = new Intl.DisplayNames(['pt-MZ'], { type: 'currency' });
|
||||
return [
|
||||
...new Set(
|
||||
codes ??
|
||||
intl.supportedValuesOf?.('currency') ?? [
|
||||
'MZN',
|
||||
'USD',
|
||||
'EUR',
|
||||
'ZAR',
|
||||
],
|
||||
),
|
||||
].map((code) => ({
|
||||
value: code,
|
||||
label: `${names.of(code) ?? code} — ${code}`,
|
||||
keywords: [code, names.of(code) ?? code],
|
||||
}));
|
||||
}
|
||||
@@ -1,10 +1,94 @@
|
||||
import { searchTerritoryHref } from './search-territory.ts';
|
||||
const geography = [
|
||||
'province_id',
|
||||
'district_id',
|
||||
'admin_post_id',
|
||||
'locality_id',
|
||||
];
|
||||
const catalogueKeys: Record<string, string[]> = {
|
||||
'/carteira': ['q', 'kind', 'sector', 'geo_location_id'],
|
||||
'/zonas': [
|
||||
'q',
|
||||
'category',
|
||||
'physical_category',
|
||||
'legal_regime',
|
||||
'development_status',
|
||||
'availability_status',
|
||||
'province',
|
||||
],
|
||||
'/oportunidades': [
|
||||
'q',
|
||||
'query',
|
||||
'sector',
|
||||
'province',
|
||||
'district',
|
||||
'geo_location_id',
|
||||
'include_national',
|
||||
'cluster_id',
|
||||
'zee_id',
|
||||
'zfi_id',
|
||||
'park_id',
|
||||
'corridor_id',
|
||||
'project_id',
|
||||
'education',
|
||||
'min_amount',
|
||||
'max_amount',
|
||||
'currency',
|
||||
'maturity_level',
|
||||
'preparation',
|
||||
'before',
|
||||
'sort',
|
||||
'facets[]',
|
||||
'view',
|
||||
'dimension',
|
||||
'association_type',
|
||||
'association_target',
|
||||
'association_state',
|
||||
'association_purpose',
|
||||
'chain_id',
|
||||
'subsector_id',
|
||||
],
|
||||
};
|
||||
function family(path: string): string {
|
||||
if (path.startsWith('/management/portfolio')) return '/carteira';
|
||||
if (path.startsWith('/management/investment/zones')) return '/zonas';
|
||||
if (path === '/educacao' || path.startsWith('/educacao/'))
|
||||
return '/oportunidades';
|
||||
return (
|
||||
Object.keys(catalogueKeys).find(
|
||||
(route) => path === route || path.startsWith(route + '/'),
|
||||
) ?? ''
|
||||
);
|
||||
}
|
||||
/** Explicit URL criteria only. No ambient storage, dossier IDs or nested return URLs. */
|
||||
export function catalogueQuery(path: string, query: string): URLSearchParams {
|
||||
const group = family(path);
|
||||
const allowed = new Set(
|
||||
group
|
||||
? [...geography, ...catalogueKeys[group], 'page', 'per_page']
|
||||
: [],
|
||||
);
|
||||
const result = new URLSearchParams();
|
||||
for (const [key, value] of new URLSearchParams(query)) {
|
||||
if (!allowed.has(key) || !value || value.length > 2000) continue;
|
||||
if (
|
||||
['page', 'per_page'].includes(key) &&
|
||||
(!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value)))
|
||||
)
|
||||
continue;
|
||||
if (!key.endsWith('[]')) result.delete(key);
|
||||
result.append(key, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** Only a query string is carried; the return destination is always local. */
|
||||
export function catalogueDetailHref(
|
||||
path: string,
|
||||
query: string,
|
||||
pageUrl?: string,
|
||||
): string {
|
||||
const target = new URL(path, 'https://portal.invalid');
|
||||
if (target.origin !== 'https://portal.invalid') return path;
|
||||
if (pageUrl) {
|
||||
const page = new URL(
|
||||
pageUrl,
|
||||
@@ -20,16 +104,35 @@ export function catalogueDetailHref(
|
||||
query = params.toString();
|
||||
}
|
||||
}
|
||||
return query
|
||||
? `${path}?catalogue_query=${encodeURIComponent(query)}`
|
||||
: path;
|
||||
const selected = catalogueQuery(target.pathname, query).toString();
|
||||
if (selected) target.searchParams.set('catalogue_query', selected);
|
||||
return target.pathname + target.search + target.hash;
|
||||
}
|
||||
export function catalogueReturnHref(base: string, pageUrl: string): string {
|
||||
const query = new URL(pageUrl, 'https://portal.invalid').searchParams.get(
|
||||
'catalogue_query',
|
||||
);
|
||||
const source = new URL(pageUrl, 'https://portal.invalid');
|
||||
const target = new URL(base, 'https://portal.invalid');
|
||||
if (target.origin !== 'https://portal.invalid') return base;
|
||||
const query = source.searchParams.get('catalogue_query');
|
||||
const selected = catalogueQuery(target.pathname, query ?? '');
|
||||
let changedAncestor = false;
|
||||
for (const key of geography) {
|
||||
if (target.searchParams.has(key))
|
||||
changedAncestor ||=
|
||||
target.searchParams.get(key) !== selected.get(key);
|
||||
else if (changedAncestor) selected.delete(key);
|
||||
}
|
||||
const explicit = new Set(target.searchParams.keys());
|
||||
for (const [key, value] of selected) {
|
||||
// Text, kind, price and page belong to their original catalogue only.
|
||||
if (
|
||||
family(source.pathname) !== family(target.pathname) &&
|
||||
!geography.includes(key)
|
||||
)
|
||||
continue;
|
||||
if (!explicit.has(key)) target.searchParams.append(key, value);
|
||||
}
|
||||
return searchTerritoryHref(
|
||||
base + (query ? `?${new URLSearchParams(query).toString()}` : ''),
|
||||
target.pathname + target.search + target.hash,
|
||||
pageUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/** Historical definitions remain stored verbatim; internal delivery wording is not investor guidance. */
|
||||
export function companyPreparationNotice(notice: string): string {
|
||||
return notice.replace(
|
||||
'Os sete exemplares referidos no complemento ainda não foram fornecidos.',
|
||||
'Os modelos contratuais não estão disponíveis nesta preparação.',
|
||||
);
|
||||
}
|
||||
|
||||
export type Party = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { DemographicAnalysis } from '../components/demographic-evidence';
|
||||
|
||||
type Series = DemographicAnalysis['territories'][number]['series'][number];
|
||||
type Row = Series['values'][number];
|
||||
const children = ['0-4', '5-9', '10-14'];
|
||||
const adults = [
|
||||
'15-19',
|
||||
'20-24',
|
||||
'25-29',
|
||||
'30-34',
|
||||
'35-39',
|
||||
'40-44',
|
||||
'45-49',
|
||||
'50-54',
|
||||
'55-59',
|
||||
'60-64',
|
||||
];
|
||||
|
||||
// Display-only derivation. Original decimals, persisted evidence and fiscal calculations are untouched.
|
||||
function numeric(row: Row | undefined): number | null {
|
||||
if (
|
||||
!row ||
|
||||
!['pessoas', 'persons'].includes(row.unit) ||
|
||||
!/^\d+(\.\d+)?$/.test(row.value)
|
||||
)
|
||||
return null;
|
||||
const n = Number(row.value);
|
||||
return Number.isFinite(n) && n <= Number.MAX_SAFE_INTEGER ? n : null;
|
||||
}
|
||||
function one(rows: Row[], age: string, residence = 'all'): Row | undefined {
|
||||
const found = rows.filter(
|
||||
(r) =>
|
||||
r.indicator === 'population' &&
|
||||
r.age_group === age &&
|
||||
r.residence === residence,
|
||||
);
|
||||
return found.length === 1 ? found[0] : undefined;
|
||||
}
|
||||
export const populationLabel = (n: number | null) =>
|
||||
n === null
|
||||
? 'Sem dados'
|
||||
: new Intl.NumberFormat('pt-MZ', { maximumFractionDigits: 0 }).format(
|
||||
n,
|
||||
);
|
||||
export const percentageLabel = (n: number | null) =>
|
||||
n === null
|
||||
? 'Sem dados'
|
||||
: new Intl.NumberFormat('pt-MZ', {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 1,
|
||||
}).format(n) + '%';
|
||||
|
||||
export function demographicReading(series: Series, start: number, end: number) {
|
||||
function year(year: number) {
|
||||
const rows = series.values.filter((r) => r.year === year);
|
||||
const totalRow = one(rows, 'all');
|
||||
const total = numeric(totalRow);
|
||||
const compatible = (r: Row | undefined) =>
|
||||
r &&
|
||||
totalRow &&
|
||||
r.unit === totalRow.unit &&
|
||||
r.data_kind === totalRow.data_kind;
|
||||
const part = (age: string, residence = 'all') => {
|
||||
const r = one(rows, age, residence);
|
||||
return compatible(r) ? numeric(r) : null;
|
||||
};
|
||||
const sum = (ages: string[]) => {
|
||||
const values = ages.map((age) => part(age));
|
||||
return values.every((v) => v !== null)
|
||||
? values.reduce<number>((a, v) => a + (v ?? 0), 0)
|
||||
: null;
|
||||
};
|
||||
const share = (n: number | null) =>
|
||||
total !== null && total > 0 && n !== null && n <= total
|
||||
? (n / total) * 100
|
||||
: null;
|
||||
const urban = part('all', 'urban'),
|
||||
rural = part('all', 'rural');
|
||||
const residenceReconciled =
|
||||
total !== null &&
|
||||
urban !== null &&
|
||||
rural !== null &&
|
||||
Math.abs(urban + rural - total) <=
|
||||
Math.max(0.000001, total * 1e-10);
|
||||
const under15 = sum(children),
|
||||
age15to64 = sum(adults);
|
||||
return {
|
||||
year,
|
||||
total,
|
||||
kind: totalRow?.data_kind,
|
||||
unit: totalRow?.unit,
|
||||
urban,
|
||||
rural,
|
||||
urbanShare: residenceReconciled ? share(urban) : null,
|
||||
ruralShare: residenceReconciled ? share(rural) : null,
|
||||
under15,
|
||||
under15Share: share(under15),
|
||||
age15to64,
|
||||
age15to64Share: share(age15to64),
|
||||
};
|
||||
}
|
||||
const first = year(start),
|
||||
last = year(end);
|
||||
const comparable =
|
||||
end > start &&
|
||||
first.total !== null &&
|
||||
last.total !== null &&
|
||||
first.kind === last.kind &&
|
||||
first.unit === last.unit;
|
||||
const delta = comparable ? last.total! - first.total! : null;
|
||||
const growth =
|
||||
delta !== null && first.total! > 0
|
||||
? (delta / first.total!) * 100
|
||||
: null;
|
||||
return {
|
||||
first,
|
||||
last,
|
||||
delta,
|
||||
growth,
|
||||
years: Array.from({ length: end - start + 1 }, (_, i) =>
|
||||
year(start + i),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -329,19 +329,24 @@ export function buildLoanSchedule(input: LoanScheduleInput) {
|
||||
}
|
||||
|
||||
/** A simple one-period budget allows an operating deficit; it is not forced to zero. */
|
||||
function sumMonetaryInputs(values: string[], label: string): bigint {
|
||||
if (!values.length)
|
||||
throw new Error('Indique pelo menos uma parcela conhecida.');
|
||||
return values.reduce(
|
||||
(sum, value) => sum + decimalToInteger(value, 2, label),
|
||||
0n,
|
||||
);
|
||||
}
|
||||
export function sumFinancialAmounts(values: string[]): string {
|
||||
return money(sumMonetaryInputs(values, 'Parcela'));
|
||||
}
|
||||
export function calculateBudget(incomes: string[], expenses: string[]) {
|
||||
if (incomes.length === 0 || expenses.length === 0)
|
||||
throw new Error(
|
||||
'Introduza pelo menos uma linha de receitas e uma linha de despesas; use zero apenas quando esse valor for conhecido.',
|
||||
);
|
||||
const incomeTotal = incomes.reduce(
|
||||
(sum, value) => sum + decimalToInteger(value, 2, 'Receita'),
|
||||
0n,
|
||||
);
|
||||
const expenseTotal = expenses.reduce(
|
||||
(sum, value) => sum + decimalToInteger(value, 2, 'Despesa'),
|
||||
0n,
|
||||
);
|
||||
const incomeTotal = sumMonetaryInputs(incomes, 'Receita');
|
||||
const expenseTotal = sumMonetaryInputs(expenses, 'Despesa');
|
||||
return {
|
||||
incomeTotal: money(incomeTotal),
|
||||
expenseTotal: money(expenseTotal),
|
||||
@@ -444,7 +449,7 @@ export function exportFinancialToolsCsv(
|
||||
['Estado', 'Estimativa com pressupostos introduzidos pelo utilizador'],
|
||||
[
|
||||
'Fonte dos pressupostos',
|
||||
'Valores editaveis desta ferramenta; os valores iniciais sao exemplos demonstrativos',
|
||||
'Pressupostos editáveis introduzidos ou recuperados pelo utilizador; conferir antes de utilizar os resultados.',
|
||||
],
|
||||
['Período do orçamento e dos custos fixos', input.budgetPeriod],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export type OperatingAssumptions = {
|
||||
model: '' | 'hotel' | 'agriculture' | 'other';
|
||||
capacity: string;
|
||||
price: string;
|
||||
occupancy: string;
|
||||
frequency: string;
|
||||
source: string;
|
||||
costs: { label: string; annual: string }[];
|
||||
};
|
||||
export const emptyOperatingAssumptions = (): OperatingAssumptions => ({
|
||||
model: '',
|
||||
capacity: '',
|
||||
price: '',
|
||||
occupancy: '',
|
||||
frequency: '',
|
||||
source: '',
|
||||
costs: [],
|
||||
});
|
||||
export function restoreOperatingAssumptions(
|
||||
snapshot: string,
|
||||
): OperatingAssumptions {
|
||||
try {
|
||||
const parsed = JSON.parse(snapshot);
|
||||
const data = parsed?.data;
|
||||
if (
|
||||
parsed.version !== 1 ||
|
||||
!data ||
|
||||
!['', 'hotel', 'agriculture', 'other'].includes(data.model)
|
||||
)
|
||||
return emptyOperatingAssumptions();
|
||||
if (
|
||||
!['capacity', 'price', 'occupancy', 'frequency', 'source'].every(
|
||||
(key) =>
|
||||
typeof data[key] === 'string' && data[key].length <= 1000,
|
||||
)
|
||||
)
|
||||
return emptyOperatingAssumptions();
|
||||
if (
|
||||
!Array.isArray(data.costs) ||
|
||||
data.costs.length > 30 ||
|
||||
!data.costs.every(
|
||||
(row: { label: unknown; annual: unknown }) =>
|
||||
typeof row?.label === 'string' &&
|
||||
typeof row?.annual === 'string' &&
|
||||
row.label.length <= 120 &&
|
||||
row.annual.length <= 40,
|
||||
)
|
||||
)
|
||||
return emptyOperatingAssumptions();
|
||||
return data;
|
||||
} catch {
|
||||
return emptyOperatingAssumptions();
|
||||
}
|
||||
}
|
||||
function hundredths(value: string): bigint {
|
||||
const match = /^(\d{1,15})(?:\.(\d{1,2}))?$/.exec(value.trim());
|
||||
if (!match)
|
||||
throw new Error(
|
||||
'Indique cada pressuposto em falta, com até duas casas decimais.',
|
||||
);
|
||||
return BigInt(match[1]) * 100n + BigInt((match[2] ?? '').padEnd(2, '0'));
|
||||
}
|
||||
const money = (cents: bigint) =>
|
||||
`${cents / 100n}.${String(cents % 100n).padStart(2, '0')}`;
|
||||
export function estimateOperatingRevenue(data: OperatingAssumptions): string {
|
||||
if (!data.model)
|
||||
throw new Error('Escolha o modelo de actividade que pretende estimar.');
|
||||
const capacity = hundredths(data.capacity),
|
||||
price = hundredths(data.price),
|
||||
frequency = hundredths(data.frequency);
|
||||
let numerator = capacity * price * frequency;
|
||||
let divisor = 10000n;
|
||||
if (data.model === 'hotel') {
|
||||
const occupancy = hundredths(data.occupancy);
|
||||
if (occupancy > 10000n)
|
||||
throw new Error('A ocupação não pode exceder 100%.');
|
||||
numerator *= occupancy;
|
||||
divisor *= 10000n;
|
||||
}
|
||||
return money((numerator + divisor / 2n) / divisor);
|
||||
}
|
||||
export function estimateOperatingCosts(data: OperatingAssumptions): string {
|
||||
if (!data.costs.length)
|
||||
throw new Error(
|
||||
'Acrescente as categorias de custos que conhece. Valores em falta não são zero.',
|
||||
);
|
||||
return money(
|
||||
data.costs.reduce((total, row) => {
|
||||
if (!row.label.trim())
|
||||
throw new Error('Identifique a categoria de cada custo.');
|
||||
return total + hundredths(row.annual);
|
||||
}, 0n),
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,27 @@ export type ContextDocument = {
|
||||
opportunity_id: number;
|
||||
download_url: string;
|
||||
};
|
||||
export type SupplyAggregate = {
|
||||
context_revision: number;
|
||||
recorded_at: string;
|
||||
requested_territory: { id: string; name: string };
|
||||
scope: { id: string; name: string; level: string };
|
||||
broader_context: boolean;
|
||||
entities: number;
|
||||
repeated: number;
|
||||
uncertain: number;
|
||||
groups: {
|
||||
code: string;
|
||||
label: string;
|
||||
total: number;
|
||||
first_year: string | null;
|
||||
last_year: string | null;
|
||||
}[];
|
||||
truncated: boolean;
|
||||
coverage_unknown: boolean;
|
||||
limitations: string;
|
||||
interpretation: string;
|
||||
};
|
||||
export type ContextReference = {
|
||||
key: string;
|
||||
kind: string;
|
||||
@@ -92,6 +113,7 @@ export type OpportunityChoice = {
|
||||
source_sha256?: string | null;
|
||||
};
|
||||
export type DossierContext = {
|
||||
promoter_entity?: PromoterEntity;
|
||||
complements?: import('@/components/investment-complements').Complement[];
|
||||
requirements_assessment?: RequirementsAssessment;
|
||||
opportunity_choices?: OpportunityChoice[];
|
||||
@@ -115,6 +137,12 @@ export type DossierContext = {
|
||||
updated_at?: string;
|
||||
dependency_hash?: string;
|
||||
};
|
||||
export type PromoterEntity = {
|
||||
kind: 'company' | 'organization' | 'being_formed' | 'undetermined';
|
||||
id?: string | null;
|
||||
source_version?: number | null;
|
||||
snapshot?: Record<string, string | null>;
|
||||
};
|
||||
export type ContextView = {
|
||||
private_investment?: Preparation | null;
|
||||
preparation_documents?: PreparationDocument[];
|
||||
@@ -203,6 +231,9 @@ export async function contextApi<T>(
|
||||
}
|
||||
}
|
||||
export const contextFieldNames: Record<string, string> = {
|
||||
products_services: 'Actividade concreta e produto ou serviço',
|
||||
investment_budget_min: 'Orçamento previsto — mínimo',
|
||||
investment_budget_max: 'Orçamento previsto — máximo',
|
||||
investment_form: 'Tipo do investimento',
|
||||
investment_preparation: 'Preparação do pedido',
|
||||
investment_activity_scope: 'Natureza da actividade',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { pathStates, type PathResult } from './investor-path.ts';
|
||||
import type { SupplyAggregate } from './investor-context';
|
||||
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
|
||||
import { restoreOperatingAssumptions } from './investment-operating-assistance.ts';
|
||||
import { buildProjectDossierRows } from './project-dossier.ts';
|
||||
import type { SwotDraft } from './investment-preparation.ts';
|
||||
import type {
|
||||
@@ -34,6 +36,7 @@ export type PreparationReport = {
|
||||
swot_review_required: boolean;
|
||||
};
|
||||
export type InvestorDossierReportInput = {
|
||||
supply_aggregates?: SupplyAggregate[];
|
||||
case: { id: string; title: string; version: number };
|
||||
context_opportunity: string | null;
|
||||
opportunity_options: unknown[];
|
||||
@@ -322,6 +325,7 @@ export function createInvestorDossierReport(
|
||||
generatedAt = new Date().toISOString(),
|
||||
): DossierReportModel {
|
||||
const index = investorDossierReportIndex(input);
|
||||
const supply = input.supply_aggregates?.at(-1);
|
||||
const row = (
|
||||
field: string,
|
||||
value: string,
|
||||
@@ -339,11 +343,101 @@ export function createInvestorDossierReport(
|
||||
result.context_state !== 'review_required'
|
||||
? 'Estimativa guardada'
|
||||
: 'Histórico — rever antes de utilizar';
|
||||
const preparation = result.preparation_snapshot ?? {};
|
||||
for (const [key, label] of Object.entries({
|
||||
total_investment: 'Investimento declarado do cenário',
|
||||
secured_amount: 'Financiamento efectivamente mobilizado',
|
||||
planned_funding: 'Financiamento previsto — não mobilizado',
|
||||
requested_funding: 'Financiamento solicitado — não mobilizado',
|
||||
approved_funding: 'Financiamento aprovado — não mobilizado',
|
||||
annual_revenue: 'Receitas anuais estimadas',
|
||||
annual_operating_costs: 'Custos anuais estimados',
|
||||
investment_budget_min: 'Orçamento previsto — mínimo do intervalo',
|
||||
investment_budget_max: 'Orçamento previsto — máximo do intervalo',
|
||||
})) {
|
||||
if (typeof preparation[key] === 'string' && preparation[key].trim())
|
||||
finance.push(
|
||||
row(
|
||||
label,
|
||||
`${preparation[key]} ${result.currency ?? ''}`.trim(),
|
||||
reference,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
const assumptions = restoreOperatingAssumptions(
|
||||
preparation.planning_assumptions_snapshot ?? '',
|
||||
);
|
||||
if (assumptions.model) {
|
||||
const assumptionLabels = {
|
||||
model: 'Modelo escolhido para estimar receitas',
|
||||
capacity:
|
||||
assumptions.model === 'hotel'
|
||||
? 'Quartos disponíveis'
|
||||
: 'Unidades vendáveis por ciclo',
|
||||
price: 'Preço declarado',
|
||||
occupancy: 'Ocupação prevista (%)',
|
||||
frequency:
|
||||
assumptions.model === 'hotel'
|
||||
? 'Dias de funcionamento no ano'
|
||||
: 'Ciclos ou períodos no ano',
|
||||
source: 'Fundamento dos pressupostos',
|
||||
};
|
||||
for (const [key, label] of Object.entries(assumptionLabels)) {
|
||||
const value =
|
||||
key === 'model'
|
||||
? {
|
||||
hotel: 'Hotel',
|
||||
agriculture: 'Agronegócio',
|
||||
other: 'Outra actividade',
|
||||
}[assumptions.model]
|
||||
: assumptions[key as keyof typeof assumptionLabels];
|
||||
finance.push(
|
||||
row(
|
||||
label,
|
||||
value || 'Por confirmar',
|
||||
reference,
|
||||
'Pressuposto declarado, não comprova procura ou rentabilidade',
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const cost of assumptions.costs)
|
||||
finance.push(
|
||||
row(
|
||||
`Custo anual — ${cost.label}`,
|
||||
cost.annual || 'Por confirmar',
|
||||
reference,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (
|
||||
result.engine !== 'financial-calculators:v1' ||
|
||||
!restoreFinancialToolsSnapshot(result.financial_snapshot)
|
||||
)
|
||||
const budget = JSON.parse(
|
||||
preparation.initial_budget_snapshot ?? '{}',
|
||||
);
|
||||
if (budget.version === 1 && Array.isArray(budget.rows))
|
||||
for (const part of budget.rows) {
|
||||
if (
|
||||
typeof part?.label === 'string' &&
|
||||
typeof part?.amount === 'string'
|
||||
)
|
||||
finance.push(
|
||||
row(
|
||||
`Investimento inicial — ${part.label}`,
|
||||
part.amount || 'Por confirmar',
|
||||
reference,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* The original remains stored; malformed optional details do not become invented amounts. */
|
||||
}
|
||||
try {
|
||||
const tools = restoreFinancialToolsSnapshot(
|
||||
result.financial_snapshot,
|
||||
);
|
||||
if (result.engine !== 'financial-calculators:v1' || !tools)
|
||||
throw new Error('Versão não recuperável');
|
||||
const currency = result.currency ?? '';
|
||||
const baseline = new Set(
|
||||
@@ -358,6 +452,25 @@ export function createInvestorDossierReport(
|
||||
'Financiamento',
|
||||
'Orçamento',
|
||||
]);
|
||||
if (
|
||||
preparation.planning_loan === 'no' ||
|
||||
[
|
||||
tools.loan.principal,
|
||||
tools.loan.rate,
|
||||
tools.loan.months,
|
||||
].every((value) => !value.trim())
|
||||
)
|
||||
sections.delete('Empréstimo educativo');
|
||||
if (Object.values(tools.breakEven).every((value) => !value.trim()))
|
||||
sections.delete('Ponto de equilíbrio');
|
||||
if (
|
||||
[...tools.incomes, ...tools.expenses].every(
|
||||
(value) => !value.amount.trim(),
|
||||
)
|
||||
)
|
||||
sections.delete('Orçamento');
|
||||
if (Object.values(tools.funding).every((value) => !value.trim()))
|
||||
sections.delete('Financiamento');
|
||||
const rows = buildProjectDossierRows({
|
||||
currency,
|
||||
financial_tools_snapshot: result.financial_snapshot,
|
||||
@@ -400,6 +513,50 @@ export function createInvestorDossierReport(
|
||||
'O índice referencia os originais; não incorpora automaticamente os seus anexos nem envia dados a entidades externas.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'supply',
|
||||
title: 'Actividades existentes — análise agregada',
|
||||
private: true,
|
||||
paragraphs: supply
|
||||
? [
|
||||
`Consulta de ${supply.recorded_at}; contexto v${supply.context_revision}.`,
|
||||
`Âmbito: ${supply.scope.name} (${supply.scope.level}). Local escolhido: ${supply.requested_territory.name}.`,
|
||||
supply.broader_context
|
||||
? 'Contexto administrativo mais amplo; não representa apenas a localidade escolhida.'
|
||||
: 'Âmbito da localização escolhida.',
|
||||
supply.context_revision !==
|
||||
input.investment_context?.revision
|
||||
? 'Contexto alterado; análise anterior preservada e por rever.'
|
||||
: 'Análise guardada para esta revisão do contexto.',
|
||||
supply.interpretation,
|
||||
supply.limitations,
|
||||
]
|
||||
: ['Análise agregada ainda não guardada neste dossier.'],
|
||||
rows: supply
|
||||
? [
|
||||
row(
|
||||
'Entidades identificadas administrativamente',
|
||||
String(supply.entities),
|
||||
),
|
||||
row(
|
||||
'Observações repetidas, separadas do total',
|
||||
String(supply.repeated),
|
||||
),
|
||||
row(
|
||||
'Identificações incertas, separadas do total',
|
||||
String(supply.uncertain),
|
||||
),
|
||||
...supply.groups.map((group) =>
|
||||
row(
|
||||
group.label,
|
||||
String(group.total),
|
||||
`${group.first_year ?? 'Período por confirmar'}–${group.last_year ?? ''}; ${supply.scope.name}`,
|
||||
'Actividade actual e relação económica por confirmar',
|
||||
),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
title: 'Investimento e localização em preparação',
|
||||
@@ -410,11 +567,16 @@ export function createInvestorDossierReport(
|
||||
rows: Object.entries({
|
||||
project_title: 'Projecto',
|
||||
project_summary: 'Descrição',
|
||||
products_services: 'Actividade e produtos ou serviços',
|
||||
sector: 'Sector',
|
||||
province: 'Província',
|
||||
district: 'Distrito',
|
||||
location: 'Localização',
|
||||
total_investment: 'Investimento previsto',
|
||||
investment_budget_min:
|
||||
'Orçamento previsto — mínimo do intervalo',
|
||||
investment_budget_max:
|
||||
'Orçamento previsto — máximo do intervalo',
|
||||
currency: 'Moeda',
|
||||
expected_start_date: 'Início previsto',
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ export type PathResult = {
|
||||
context_opportunity?: string | null;
|
||||
id: string;
|
||||
financial_snapshot: string;
|
||||
preparation_snapshot?: Record<string, string>;
|
||||
currency: string | null;
|
||||
draft_id: string;
|
||||
draft_version: number;
|
||||
|
||||
@@ -2,16 +2,16 @@ import { csvCell } from './csv.js';
|
||||
|
||||
export function defaultCashflowDraftInputs() {
|
||||
return {
|
||||
openingCash: '0',
|
||||
monthlyRevenue: '200000',
|
||||
monthlyOperatingCosts: '150000',
|
||||
monthlyDebtService: '0',
|
||||
openingCash: '',
|
||||
monthlyRevenue: '',
|
||||
monthlyOperatingCosts: '',
|
||||
monthlyDebtService: '',
|
||||
monthlyTaxAssessed: '',
|
||||
monthlyTaxSchedule: Array.from({ length: 12 }, () => ''),
|
||||
vatCreditEstimated: '0',
|
||||
vatRefundRequested: '0',
|
||||
vatRefundRecognized: '0',
|
||||
vatRefundExpectedReceipt: '0',
|
||||
vatCreditEstimated: '',
|
||||
vatRefundRequested: '',
|
||||
vatRefundRecognized: '',
|
||||
vatRefundExpectedReceipt: '',
|
||||
vatRefundExpectedMonth: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,10 +91,17 @@ export function monthlyIvaInput(
|
||||
draft: Record<string, unknown>,
|
||||
settings: MonthlyIvaSettings,
|
||||
): MonthlyIvaInput {
|
||||
if (
|
||||
draft.fiscal_period &&
|
||||
inputText(draft.fiscal_period) !== settings.start.slice(0, 4)
|
||||
)
|
||||
throw new Error(
|
||||
'O ano escolhido no enquadramento fiscal difere do primeiro mês da projecção. Confira o período; nenhum ano foi alterado automaticamente.',
|
||||
);
|
||||
const sales = monthlySalesFromDraft(draft);
|
||||
if (!/^2026-(0[1-9]|1[0-2])$/.test(settings.start))
|
||||
throw new Error(
|
||||
'Indique o primeiro mês real da projecção. Este lote abrange apenas 2026.',
|
||||
'Indique o primeiro mês real da projecção. Este cálculo dispõe de cobertura apenas para 2026.',
|
||||
);
|
||||
const start = Number(settings.start.slice(5));
|
||||
if (sales.amounts.length > 12 || start + sales.amounts.length > 13)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type NavigationFocus = {
|
||||
case_id: string;
|
||||
step_code: string | null;
|
||||
version: number;
|
||||
};
|
||||
const key = (actor: number) => `investmoz-navigation-focus:${actor}`;
|
||||
/** A per-tab hint, never authority to access a dossier. Recheck the server listing. */
|
||||
export function readNavigationFocus(
|
||||
actor: number,
|
||||
allowed: string[],
|
||||
): NavigationFocus | null {
|
||||
try {
|
||||
const value = JSON.parse(sessionStorage.getItem(key(actor)) ?? 'null');
|
||||
if (
|
||||
!value ||
|
||||
!allowed.includes(value.case_id) ||
|
||||
!Number.isSafeInteger(value.version) ||
|
||||
value.version < 0
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
value.step_code !== null &&
|
||||
!/^S(0[1-9]|1[0-9])$/.test(value.step_code)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
case_id: value.case_id,
|
||||
step_code: value.step_code,
|
||||
version: value.version,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function rememberNavigationFocus(
|
||||
actor: number,
|
||||
focus: NavigationFocus | null,
|
||||
): void {
|
||||
try {
|
||||
if (focus) sessionStorage.setItem(key(actor), JSON.stringify(focus));
|
||||
else sessionStorage.removeItem(key(actor));
|
||||
} catch {
|
||||
// The current consultation remains usable when browser storage is blocked.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
|
||||
|
||||
const amount = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const match = /^(\d+)(?:\.(\d+))?$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return (
|
||||
match[1].replace(/^0+(?=\d)/, '') +
|
||||
'.' +
|
||||
(match[2] ?? '').replace(/0+$/, '')
|
||||
);
|
||||
};
|
||||
export const samePlanningAmount = (a: string, b: string) =>
|
||||
(!a && !b) || (amount(a) !== null && amount(a) === amount(b));
|
||||
|
||||
/** Compare named origins without adopting either one or turning unknown into zero. */
|
||||
export function fundingDifferences(
|
||||
source: { total: string; secured: string },
|
||||
calculator: { total: string; secured: string },
|
||||
) {
|
||||
return (['total', 'secured'] as const).filter(
|
||||
(key) => !samePlanningAmount(source[key], calculator[key]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Unknown calculator inputs may reuse the canonical source; a known divergent value requires a decision. */
|
||||
export function canLinkFundingSource(
|
||||
source: { total: string; secured: string } | undefined,
|
||||
stored: { total: string; secured: string } | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
source &&
|
||||
(!stored ||
|
||||
(['total', 'secured'] as const).every(
|
||||
(key) =>
|
||||
!stored[key].trim() ||
|
||||
samePlanningAmount(source[key], stored[key]),
|
||||
)),
|
||||
);
|
||||
}
|
||||
export function draftFundingDifferences(draft: Record<string, unknown>) {
|
||||
const snapshot = restoreFinancialToolsSnapshot(
|
||||
typeof draft.financial_tools_snapshot === 'string'
|
||||
? draft.financial_tools_snapshot
|
||||
: undefined,
|
||||
);
|
||||
if (!snapshot) return [];
|
||||
return fundingDifferences(
|
||||
{
|
||||
total:
|
||||
typeof draft.total_investment === 'string'
|
||||
? draft.total_investment
|
||||
: '',
|
||||
secured:
|
||||
typeof draft.secured_amount === 'string'
|
||||
? draft.secured_amount
|
||||
: '',
|
||||
},
|
||||
snapshot.funding,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Only confirmed context becomes a criterion; titles never assign a sector. */
|
||||
export function preparationSearch(
|
||||
query: string,
|
||||
sector: string,
|
||||
path: { id: string; location_type: string }[],
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.trim()) params.set('q', query.trim());
|
||||
if (/^sector:[1-9]\d*$/.test(sector))
|
||||
params.set('sector_id', sector.slice(7));
|
||||
for (const place of path)
|
||||
if (
|
||||
['province', 'district', 'admin_post', 'locality'].includes(
|
||||
place.location_type,
|
||||
)
|
||||
)
|
||||
params.set(place.location_type + '_id', place.id);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/** Carry only deliberate preparation criteria, never catalogue relationships. */
|
||||
export function createInvestmentHref(
|
||||
values: Record<string, unknown>,
|
||||
path: { id: string; location_type: string }[] = [],
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ['sector_id', 'project_title', 'currency']) {
|
||||
if (typeof values[key] === 'string' && values[key])
|
||||
query.set(key, values[key]);
|
||||
}
|
||||
if (typeof values.sector === 'string' && values.sector)
|
||||
query.set('sector', values.sector);
|
||||
for (const [from, to] of [
|
||||
['min_amount', 'investment_budget_min'],
|
||||
['max_amount', 'investment_budget_max'],
|
||||
] as const) {
|
||||
if (typeof values[from] === 'string' && values[from] !== '')
|
||||
query.set(to, values[from]);
|
||||
}
|
||||
for (const place of path)
|
||||
if (
|
||||
['province', 'district', 'admin_post', 'locality'].includes(
|
||||
place.location_type,
|
||||
)
|
||||
)
|
||||
query.set(place.location_type + '_id', place.id);
|
||||
return (
|
||||
'/investimentos/iniciar' + (query.size ? '?' + query.toString() : '')
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,8 @@ export const dossierReportSections = [
|
||||
] as const;
|
||||
|
||||
export type DossierReportSectionId =
|
||||
(typeof dossierReportSections)[number]['id'];
|
||||
| (typeof dossierReportSections)[number]['id']
|
||||
| 'supply';
|
||||
export type DossierReference = {
|
||||
version: number;
|
||||
date: string;
|
||||
|
||||
@@ -38,6 +38,7 @@ export function simulationApiFieldErrors(
|
||||
for (const [path, messages] of Object.entries(value.errors ?? {})) {
|
||||
const key = path
|
||||
.replace(/^(economic_snapshot|personal_snapshot)\./, '')
|
||||
.replace(/^selection_labels\./, '')
|
||||
.split('.')[0];
|
||||
if (/^[a-z][a-z0-9_]*$/.test(key) && messages.length)
|
||||
fields[key] = messages.join(' ');
|
||||
|
||||
@@ -8,6 +8,114 @@ export type SimulationDraft = Record<string, string | string[]>;
|
||||
export const projectQuestionnaireVersion =
|
||||
'investmoz-project-dossier-2026-10-05.1';
|
||||
|
||||
export function isAssistedInvestmentJourney(draft: SimulationDraft): boolean {
|
||||
return (
|
||||
draft.journey_type === 'investor' &&
|
||||
['simulate_only', 'own_investment'].includes(String(draft.purpose)) &&
|
||||
draft.detail_level !== 'advanced'
|
||||
);
|
||||
}
|
||||
|
||||
export const assistedInvestmentSections = [
|
||||
{ id: 'purpose', title: 'Intenção, entidade e localização' },
|
||||
{ id: 'market', title: 'Mercado e dimensão do negócio' },
|
||||
{ id: 'investment', title: 'Investimento inicial' },
|
||||
{ id: 'financial', title: 'Financiamento' },
|
||||
{ id: 'operating', title: 'Receitas e custos' },
|
||||
{ id: 'cashflow', title: 'Tesouraria e cenários' },
|
||||
{ id: 'tax', title: 'Enquadramento fiscal' },
|
||||
{ id: 'review', title: 'Resultados e próximos passos' },
|
||||
];
|
||||
|
||||
/** One field contract for rendering, validation and the final review. */
|
||||
export function simulationFieldsForSection(
|
||||
draft: SimulationDraft,
|
||||
section: string,
|
||||
) {
|
||||
if (!isAssistedInvestmentJourney(draft))
|
||||
return simulationFieldGroups[section] ?? [];
|
||||
const unique = (fields: (typeof simulationFieldGroups)[string]) =>
|
||||
fields.filter(
|
||||
(field, index) =>
|
||||
fields.findIndex((other) => other.key === field.key) === index,
|
||||
);
|
||||
if (section === 'purpose')
|
||||
return unique([
|
||||
...simulationFieldGroups.purpose.filter((field) =>
|
||||
[
|
||||
'journey_type',
|
||||
'detail_level',
|
||||
'purpose',
|
||||
'preferred_language',
|
||||
].includes(field.key),
|
||||
),
|
||||
...simulationFieldGroups.project.filter((field) =>
|
||||
[
|
||||
'project_title',
|
||||
'project_summary',
|
||||
'products_services',
|
||||
'sector_id',
|
||||
'activity_id',
|
||||
].includes(field.key),
|
||||
),
|
||||
...simulationFieldGroups.location.filter((field) =>
|
||||
[
|
||||
'province_id',
|
||||
'district_id',
|
||||
'admin_post_id',
|
||||
'locality_id',
|
||||
].includes(field.key),
|
||||
),
|
||||
]);
|
||||
if (section === 'market')
|
||||
return unique([
|
||||
...simulationFieldGroups.project.filter(
|
||||
(field) =>
|
||||
![
|
||||
'project_title',
|
||||
'project_summary',
|
||||
'products_services',
|
||||
'sector_id',
|
||||
'activity_id',
|
||||
].includes(field.key),
|
||||
),
|
||||
...simulationFieldGroups.market,
|
||||
]);
|
||||
if (section === 'investment')
|
||||
return simulationFieldGroups.financial.filter((field) =>
|
||||
['cost_estimate_status', 'currency', 'total_investment'].includes(
|
||||
field.key,
|
||||
),
|
||||
);
|
||||
if (section === 'financial')
|
||||
return simulationFieldGroups.financial.filter(
|
||||
(field) =>
|
||||
![
|
||||
'cost_estimate_status',
|
||||
'currency',
|
||||
'total_investment',
|
||||
'annual_revenue',
|
||||
'annual_operating_costs',
|
||||
'expected_profit',
|
||||
'receivables_timing_status',
|
||||
'planning_loan',
|
||||
].includes(field.key),
|
||||
);
|
||||
if (section === 'operating')
|
||||
return simulationFieldGroups.financial.filter((field) =>
|
||||
[
|
||||
'annual_revenue',
|
||||
'annual_operating_costs',
|
||||
'expected_profit',
|
||||
].includes(field.key),
|
||||
);
|
||||
if (section === 'cashflow')
|
||||
return simulationFieldGroups.financial.filter((field) =>
|
||||
['receivables_timing_status', 'planning_loan'].includes(field.key),
|
||||
);
|
||||
return simulationFieldGroups[section] ?? [];
|
||||
}
|
||||
|
||||
function isProjectGuidedJourney(draft: SimulationDraft): boolean {
|
||||
return (
|
||||
draft.purpose === 'simulate_only' &&
|
||||
@@ -80,6 +188,7 @@ export const simulationSections = [
|
||||
|
||||
/** Keep the anonymous arithmetic journey short; formal submissions retain the full questionnaire. */
|
||||
export function getSimulationSections(draft: SimulationDraft) {
|
||||
if (isAssistedInvestmentJourney(draft)) return assistedInvestmentSections;
|
||||
if (isProjectGuidedJourney(draft)) {
|
||||
if (draft.journey_type === 'small_business') {
|
||||
return [
|
||||
@@ -192,7 +301,28 @@ export const advancedSimulationFields = new Set([
|
||||
'required_documents',
|
||||
]);
|
||||
|
||||
export function simulationFieldAvailableAtDetailLevel(
|
||||
draft: SimulationDraft,
|
||||
section: string,
|
||||
key: string,
|
||||
): boolean {
|
||||
return (
|
||||
draft.detail_level === 'advanced' ||
|
||||
(isAssistedInvestmentJourney(draft) &&
|
||||
[
|
||||
'secured_amount',
|
||||
'sought_amount',
|
||||
'annual_revenue',
|
||||
'annual_operating_costs',
|
||||
'expected_profit',
|
||||
].includes(key)) ||
|
||||
(section === 'procedures' && key === 'required_documents') ||
|
||||
!advancedSimulationFields.has(key)
|
||||
);
|
||||
}
|
||||
|
||||
export const simulationOptions: Record<string, string[]> = {
|
||||
planning_loan: ['yes', 'no', 'unknown'],
|
||||
journey_type: ['personal', 'small_business', 'company', 'investor'],
|
||||
detail_level: ['simple', 'advanced'],
|
||||
purpose: [
|
||||
@@ -784,6 +914,30 @@ export const simulationFieldGroups: Record<
|
||||
},
|
||||
],
|
||||
financial: [
|
||||
{
|
||||
key: 'planning_loan',
|
||||
label: 'Pretende estudar um empréstimo neste cenário?',
|
||||
options: 'planning_loan',
|
||||
showWhen: isAssistedInvestmentJourney,
|
||||
},
|
||||
{
|
||||
key: 'planned_funding',
|
||||
label: 'Financiamento previsto (ainda não mobilizado)',
|
||||
type: 'number',
|
||||
showWhen: isAssistedInvestmentJourney,
|
||||
},
|
||||
{
|
||||
key: 'requested_funding',
|
||||
label: 'Financiamento solicitado (ainda não mobilizado)',
|
||||
type: 'number',
|
||||
showWhen: isAssistedInvestmentJourney,
|
||||
},
|
||||
{
|
||||
key: 'approved_funding',
|
||||
label: 'Financiamento aprovado (ainda não mobilizado)',
|
||||
type: 'number',
|
||||
showWhen: isAssistedInvestmentJourney,
|
||||
},
|
||||
{
|
||||
key: 'cost_estimate_status',
|
||||
label: 'Já identificou os principais custos e respectiva periodicidade?',
|
||||
@@ -1540,6 +1694,9 @@ export function restoreSimulationDraft(
|
||||
|
||||
export function simulationDraftFingerprint(draft: SimulationDraft): string {
|
||||
const sort = (value: unknown): unknown => {
|
||||
// Nullable empty form inputs are normalised to null by the HTTP
|
||||
// middleware. They remain unknown, never numeric zero.
|
||||
if (value === null) return '';
|
||||
if (Array.isArray(value)) return value.map(sort);
|
||||
if (value && typeof value === 'object')
|
||||
return Object.fromEntries(
|
||||
@@ -1561,7 +1718,7 @@ export function validateSimulationDraft(
|
||||
if (sectionId === 'tax' && draft.fiscal_scenario_mode === 'custom_planning')
|
||||
return {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const field of simulationFieldGroups[sectionId] ?? []) {
|
||||
for (const field of simulationFieldsForSection(draft, sectionId)) {
|
||||
if (field.showWhen && !field.showWhen(draft)) continue;
|
||||
if (
|
||||
sectionId === 'tax' &&
|
||||
@@ -1581,7 +1738,12 @@ export function validateSimulationDraft(
|
||||
if (
|
||||
required &&
|
||||
empty &&
|
||||
!(sectionId === 'tax' && isInvestorProjectJourney(draft))
|
||||
!(isAssistedInvestmentJourney(draft) && sectionId !== 'purpose') &&
|
||||
!(
|
||||
sectionId === 'tax' &&
|
||||
(isInvestorProjectJourney(draft) ||
|
||||
isAssistedInvestmentJourney(draft))
|
||||
)
|
||||
) {
|
||||
errors[field.key] = 'Este campo é obrigatório.';
|
||||
}
|
||||
|
||||
@@ -9,14 +9,25 @@ import {
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { type Programme, windowLabels } from './shared';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
TerritoryFields,
|
||||
type TerritoryOption,
|
||||
} from '@/components/investment-territory';
|
||||
|
||||
export default function Index({
|
||||
export default function FundingIndex(props: Parameters<typeof Index>[0]) {
|
||||
return <Index key={JSON.stringify(props.filters)} {...props} />;
|
||||
}
|
||||
function Index({
|
||||
items,
|
||||
filters,
|
||||
kinds,
|
||||
management,
|
||||
options,
|
||||
needs = {},
|
||||
categoryTypes = {},
|
||||
territorySelection = [],
|
||||
criteriaErrors = {},
|
||||
}: {
|
||||
items: {
|
||||
data: Programme[];
|
||||
@@ -36,6 +47,10 @@ export default function Index({
|
||||
institution_type?: string;
|
||||
location_mode?: string;
|
||||
include_historical?: string;
|
||||
province_id?: string;
|
||||
district_id?: string;
|
||||
admin_post_id?: string;
|
||||
locality_id?: string;
|
||||
};
|
||||
options: {
|
||||
sectors: { id: number; name: string }[];
|
||||
@@ -44,7 +59,11 @@ export default function Index({
|
||||
kinds: Record<string, string>;
|
||||
needs?: Record<string, string>;
|
||||
management: boolean;
|
||||
categoryTypes?: Record<string, string[]>;
|
||||
territorySelection?: TerritoryOption[];
|
||||
criteriaErrors?: Record<string, string[]>;
|
||||
}) {
|
||||
const [notice, setNotice] = useState('');
|
||||
const form = useForm({
|
||||
category: filters.category ?? '',
|
||||
institution_type: filters.institution_type ?? '',
|
||||
@@ -57,9 +76,21 @@ export default function Index({
|
||||
kind: filters.kind ?? '',
|
||||
sector: filters.sector ?? '',
|
||||
territory: filters.territory ?? '',
|
||||
province_id: filters.province_id ?? '',
|
||||
district_id: filters.district_id ?? '',
|
||||
admin_post_id: filters.admin_post_id ?? '',
|
||||
locality_id: filters.locality_id ?? '',
|
||||
certainty: filters.certainty ?? 'include_unknown',
|
||||
state: filters.state ?? '',
|
||||
});
|
||||
const applied = useRef(JSON.stringify(form.data));
|
||||
const criteriaChanged = JSON.stringify(form.data) !== applied.current;
|
||||
const typeAllowed = (category: string, type: string) =>
|
||||
!category ||
|
||||
category === 'other' ||
|
||||
!type ||
|
||||
type === 'other' ||
|
||||
(categoryTypes[category] ?? []).includes(type);
|
||||
return (
|
||||
<Frame title="Instituições financeiras, fundos e programas de apoio">
|
||||
{management && (
|
||||
@@ -76,20 +107,96 @@ export default function Index({
|
||||
investimento.
|
||||
</p>
|
||||
<form
|
||||
className={`${card} flex flex-wrap gap-4`}
|
||||
className={`${card} flex flex-wrap gap-4 [&_select]:w-full [&_select]:max-w-full [&_select]:min-w-0 [&>label]:w-full [&>label]:max-w-full [&>label]:min-w-0 sm:[&>label]:w-auto`}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.get('/fundos');
|
||||
}}
|
||||
>
|
||||
{Object.keys(criteriaErrors).length > 0 && (
|
||||
<aside
|
||||
className="basis-full rounded border border-amber-600 p-3"
|
||||
role="alert"
|
||||
>
|
||||
<h2 className="font-semibold">
|
||||
Reveja esta combinação de critérios
|
||||
</h2>
|
||||
{Object.entries(criteriaErrors).map(
|
||||
([key, messages]) => (
|
||||
<p key={key}>{messages.join(' ')}</p>
|
||||
),
|
||||
)}
|
||||
<p>
|
||||
A pesquisa não foi executada. Corrija os critérios
|
||||
indicados e volte a pesquisar.
|
||||
</p>
|
||||
</aside>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="basis-full" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p className="basis-full" role="alert" key={key}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
{form.data.kind &&
|
||||
form.data.kind !== 'institution' &&
|
||||
(form.data.category ||
|
||||
form.data.institution_type ||
|
||||
form.data.location_mode === 'physical') && (
|
||||
<aside className="basis-full rounded border p-3">
|
||||
<p>
|
||||
Categoria, tipo de instituição e presença física
|
||||
não identificam um fundo ou programa. Reveja o
|
||||
tipo de registo ou retire apenas estes critérios
|
||||
incompatíveis.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
form.setData({
|
||||
...form.data,
|
||||
category: '',
|
||||
institution_type: '',
|
||||
location_mode: 'coverage',
|
||||
});
|
||||
setNotice(
|
||||
'Critérios de instituição retirados por sua escolha. Local, sector, necessidade e janela foram preservados.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
Manter o fundo/programa e rever os critérios de
|
||||
instituição
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
<label>
|
||||
Categoria
|
||||
Categoria da instituição
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.category}
|
||||
onChange={(e) =>
|
||||
form.setData('category', e.target.value)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const category = e.target.value;
|
||||
const valid = typeAllowed(
|
||||
category,
|
||||
form.data.institution_type,
|
||||
);
|
||||
if (!valid)
|
||||
setNotice(
|
||||
'O tipo anterior não pertence à nova categoria e foi retirado. Escolha um dos tipos aplicáveis.',
|
||||
);
|
||||
form.setData({
|
||||
...form.data,
|
||||
category,
|
||||
institution_type: valid
|
||||
? form.data.institution_type
|
||||
: '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{Object.entries(financialCategories).map(
|
||||
@@ -102,7 +209,7 @@ export default function Index({
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Tipo
|
||||
Tipo de instituição
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.institution_type}
|
||||
@@ -111,11 +218,19 @@ export default function Index({
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(financialTypes).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
{Object.entries(financialTypes)
|
||||
.filter(
|
||||
([id]) =>
|
||||
typeAllowed(form.data.category, id) ||
|
||||
id === form.data.institution_type,
|
||||
)
|
||||
.map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{typeAllowed(form.data.category, id)
|
||||
? label
|
||||
: 'Incompatível: ' + label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -174,7 +289,7 @@ export default function Index({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Tipo
|
||||
Tipo de registo a consultar
|
||||
<select
|
||||
className="block rounded border p-2"
|
||||
value={form.data.kind}
|
||||
@@ -206,23 +321,41 @@ export default function Index({
|
||||
))}
|
||||
</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)
|
||||
<fieldset className="min-w-0 basis-full">
|
||||
<legend>
|
||||
Local de interesse — seleccione até ao nível conhecido
|
||||
</legend>
|
||||
<TerritoryFields
|
||||
initial={territorySelection}
|
||||
showMap={false}
|
||||
onChange={(path) =>
|
||||
form.setData({
|
||||
...form.data,
|
||||
territory: path.at(-1)?.id ?? '',
|
||||
province_id:
|
||||
path.find(
|
||||
(node) =>
|
||||
node.location_type === 'province',
|
||||
)?.id ?? '',
|
||||
district_id:
|
||||
path.find(
|
||||
(node) =>
|
||||
node.location_type === 'district',
|
||||
)?.id ?? '',
|
||||
admin_post_id:
|
||||
path.find(
|
||||
(node) =>
|
||||
node.location_type === 'admin_post',
|
||||
)?.id ?? '',
|
||||
locality_id:
|
||||
path.find(
|
||||
(node) =>
|
||||
node.location_type === 'locality',
|
||||
)?.id ?? '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{options.territories.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
/>
|
||||
</fieldset>
|
||||
<label>
|
||||
Informação por confirmar
|
||||
<select
|
||||
@@ -256,82 +389,108 @@ export default function Index({
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
<p className="text-sm">
|
||||
Explore as instituições e soluções adequadas ao seu
|
||||
investimento. Use os filtros para encontrar a informação de que
|
||||
precisa.
|
||||
</p>
|
||||
{items.data.length === 0 && (
|
||||
<p>
|
||||
Não foram encontrados programas publicados para esta
|
||||
pesquisa.
|
||||
{criteriaChanged && (
|
||||
<p role="status">
|
||||
Alterou os critérios. Clique em Pesquisar para os aplicar.
|
||||
Os resultados anteriores ficam separados abaixo.
|
||||
</p>
|
||||
)}
|
||||
{items.data.map((item) => (
|
||||
<article className={card} key={item.id}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/fundos/${item.id}?${new URLSearchParams(Object.entries(filters).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))}`}
|
||||
>
|
||||
{item.payload.title}
|
||||
</Link>
|
||||
</h2>
|
||||
{item.payload.kind === 'institution' ? (
|
||||
<InstitutionView
|
||||
compact
|
||||
value={
|
||||
item.payload as InstitutionDetails & {
|
||||
title: string;
|
||||
summary: string;
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p>{item.payload.summary}</p>
|
||||
<p>Entidade gestora: {item.payload.manager_name}</p>
|
||||
{Object.entries(item.filter_explanation ?? {}).map(
|
||||
([key, message]) => (
|
||||
<p key={key}>{message}</p>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{item.payload.kind === 'institution' &&
|
||||
item.payload.windows.length === 0 ? null : item.payload
|
||||
.windows.length === 0 ? (
|
||||
<details
|
||||
open={
|
||||
!criteriaChanged && Object.keys(criteriaErrors).length === 0
|
||||
}
|
||||
className="space-y-3"
|
||||
>
|
||||
<summary>
|
||||
{Object.keys(criteriaErrors).length
|
||||
? 'Pesquisa não executada — critérios inválidos'
|
||||
: criteriaChanged
|
||||
? 'Resultados da pesquisa anterior — não correspondem aos critérios por aplicar'
|
||||
: 'Resultados correspondentes à pesquisa'}
|
||||
</summary>
|
||||
<p className="text-sm">
|
||||
Explore as instituições e soluções adequadas ao seu
|
||||
investimento. Use os filtros para encontrar a informação de
|
||||
que precisa.
|
||||
</p>
|
||||
{items.data.length === 0 &&
|
||||
Object.keys(criteriaErrors).length === 0 && (
|
||||
<p>
|
||||
Não existe uma janela de candidatura confirmada para
|
||||
este programa.
|
||||
Não foram encontradas instituições, fundos ou
|
||||
programas publicados que correspondam a todos os
|
||||
critérios desta pesquisa.
|
||||
</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>
|
||||
{items.data.map((item) => (
|
||||
<article className={card} key={item.id}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/fundos/${item.id}?${new URLSearchParams(Object.entries(filters).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))}`}
|
||||
>
|
||||
{item.payload.title}
|
||||
</Link>
|
||||
</h2>
|
||||
{item.payload.kind === 'institution' ? (
|
||||
<InstitutionView
|
||||
compact
|
||||
value={
|
||||
item.payload as InstitutionDetails & {
|
||||
title: string;
|
||||
summary: string;
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p>{item.payload.summary}</p>
|
||||
<p>
|
||||
Entidade gestora:{' '}
|
||||
{item.payload.manager_name}
|
||||
</p>
|
||||
{Object.entries(
|
||||
item.filter_explanation ?? {},
|
||||
).map(([key, message]) => (
|
||||
<p key={key}>{message}</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.payload.kind === 'institution' &&
|
||||
item.payload.windows.length === 0 ? null : item.payload
|
||||
.windows.length === 0 ? (
|
||||
<p>
|
||||
Não existe uma janela de candidatura confirmada
|
||||
para este programa.
|
||||
</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>
|
||||
</details>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
persistOpportunitySelection,
|
||||
} from '@/lib/opportunity-selection';
|
||||
import CatalogueGuideLinks from '@/components/catalogue-guide-links';
|
||||
import PublishedOpportunitiesEmpty from '@/components/published-opportunities-empty';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import OpportunityCorridorGroups, {
|
||||
type CorridorGroup,
|
||||
@@ -409,6 +410,10 @@ export default function Catalogue({
|
||||
: 'Catálogo de investimento'
|
||||
}
|
||||
>
|
||||
<section className="my-4 space-y-3 rounded border p-4" aria-label="Estudar a localização">
|
||||
<h2 className="font-semibold">Estudar a localização e preparar uma ideia</h2>
|
||||
<p>A análise territorial é independente dos anúncios publicados. Os filtros comerciais abaixo aplicam-se à pesquisa de oportunidades.</p>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Link
|
||||
className="underline"
|
||||
href={
|
||||
@@ -435,6 +440,8 @@ export default function Catalogue({
|
||||
Iniciar ou continuar ideia nesta localização
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<p className="catalogue-intro">
|
||||
Combine os filtros para encontrar oportunidades publicadas.
|
||||
Nenhum critério é alargado automaticamente.
|
||||
@@ -506,6 +513,15 @@ export default function Catalogue({
|
||||
error={searchError}
|
||||
total={results.total}
|
||||
/>
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
results.total === 0 &&
|
||||
selectedStructures.length === 0 && (
|
||||
<PublishedOpportunitiesEmpty
|
||||
href="/localizacoes/demografia"
|
||||
label="Consultar indicadores territoriais"
|
||||
/>
|
||||
)}
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
results.total === 0 &&
|
||||
|
||||
@@ -36,6 +36,7 @@ type Result = {
|
||||
}[];
|
||||
};
|
||||
type Props = {
|
||||
entity_kind?: string;
|
||||
definition: { version: string; questions: Record<string, Question> };
|
||||
case: { id: string; title: string; version: number } | null;
|
||||
canWrite: boolean;
|
||||
@@ -98,7 +99,7 @@ function Comparator(props: Props) {
|
||||
}),
|
||||
);
|
||||
};
|
||||
async function save() {
|
||||
async function save(next?: string) {
|
||||
if (!dossier || busy) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
@@ -107,8 +108,9 @@ function Comparator(props: Props) {
|
||||
version: dossier.version,
|
||||
operation_key: operation.current,
|
||||
answers: currentValues(),
|
||||
...(next ? { continue_preparation: true } : {}),
|
||||
});
|
||||
router.visit(base, { preserveState: false });
|
||||
router.visit(next ?? base, { preserveState: false });
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
@@ -222,7 +224,7 @@ function Comparator(props: Props) {
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy}
|
||||
onClick={save}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{busy ? 'A guardar…' : 'Guardar no dossier'}
|
||||
</button>
|
||||
@@ -321,9 +323,36 @@ function Comparator(props: Props) {
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="mt-4 font-semibold">
|
||||
Preparar a próxima conversa
|
||||
</h3>
|
||||
<h3 className="mt-4 font-semibold">Continuar a preparação</h3>
|
||||
{dossier &&
|
||||
!['company', 'organization'].includes(
|
||||
props.entity_kind ?? '',
|
||||
) &&
|
||||
!readOnly &&
|
||||
props.canWrite &&
|
||||
answers.preferred &&
|
||||
answers.preferred !== 'unknown' && (
|
||||
<button
|
||||
className={button}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void save(
|
||||
`/investimentos/${dossier.id}/preparar-empresa?form=${encodeURIComponent(answers.preferred)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Usar esta opção e continuar a preparação da empresa
|
||||
</button>
|
||||
)}
|
||||
{dossier && (
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`/simulador-fiscal?context=${dossier.id}`}
|
||||
>
|
||||
Continuar a viabilidade sem repetir esta comparação
|
||||
</Link>
|
||||
)}
|
||||
<ul className="list-disc pl-5">
|
||||
{result.preparation.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import CurrencySelect from '@/components/currency-select';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Frame, card, input, button } from './shared';
|
||||
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import {
|
||||
companyPreparationNotice,
|
||||
preparationValues,
|
||||
type CompanyFacts,
|
||||
type Party,
|
||||
@@ -34,12 +36,17 @@ type Props = {
|
||||
>;
|
||||
history: { id: string; revision: number; at: string }[];
|
||||
historyNext: number | null;
|
||||
entity?: {
|
||||
kind: string;
|
||||
snapshot?: { name?: string; legal_form?: string };
|
||||
} | null;
|
||||
proposedForm?: string | null;
|
||||
};
|
||||
|
||||
export default function CompanyPreparation(props: Props) {
|
||||
return (
|
||||
<Preparation
|
||||
key={`${props.case.id}:${props.saved?.revision}:${props.historical}`}
|
||||
key={`${props.case.id}:${props.saved?.revision}:${props.historical}:${props.proposedForm ?? ''}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -86,19 +93,19 @@ function Preparation(props: Props) {
|
||||
onChange={(e) => update(key, e.target.value)}
|
||||
maxLength={2000}
|
||||
/>
|
||||
) : key === 'currency' ? (
|
||||
<CurrencySelect
|
||||
label="Moeda"
|
||||
value={facts[key] ?? ''}
|
||||
onChange={(value) => update(key, value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={input}
|
||||
name={key}
|
||||
value={facts[key] ?? ''}
|
||||
onChange={(e) => update(key, e.target.value)}
|
||||
maxLength={
|
||||
key === 'proposed_name'
|
||||
? 250
|
||||
: key === 'currency'
|
||||
? 3
|
||||
: 25
|
||||
}
|
||||
maxLength={key === 'proposed_name' ? 250 : 25}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
@@ -138,7 +145,7 @@ function Preparation(props: Props) {
|
||||
</label>
|
||||
);
|
||||
}
|
||||
async function save() {
|
||||
async function save(next?: string) {
|
||||
if (busy || readOnly) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
@@ -151,7 +158,7 @@ function Preparation(props: Props) {
|
||||
operation_key: operation.current,
|
||||
facts: values,
|
||||
});
|
||||
router.visit(base, { preserveState: false });
|
||||
router.visit(next ?? base, { preserveState: false });
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
@@ -178,7 +185,41 @@ function Preparation(props: Props) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Frame title="Preparar a constituição da empresa">
|
||||
<Frame
|
||||
title={
|
||||
['company', 'organization'].includes(props.entity?.kind ?? '')
|
||||
? 'Preparação da empresa existente'
|
||||
: 'Preparar a constituição da empresa'
|
||||
}
|
||||
>
|
||||
{props.entity?.snapshot?.name && (
|
||||
<p>
|
||||
Entidade associada: {props.entity.snapshot.name}. Os dados
|
||||
recuperados não alteram o cadastro nem comprovam um acto
|
||||
legal.
|
||||
</p>
|
||||
)}
|
||||
{props.proposedForm &&
|
||||
facts.form !== props.proposedForm &&
|
||||
!readOnly && (
|
||||
<aside className={card}>
|
||||
<p>
|
||||
A alternativa escolhida não substituiu a forma já
|
||||
preparada.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
update('form', props.proposedForm!);
|
||||
setDirty(true);
|
||||
operation.current = createOperationKey();
|
||||
}}
|
||||
>
|
||||
Usar a alternativa escolhida nesta preparação
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
<p>
|
||||
<Link
|
||||
className="underline"
|
||||
@@ -194,7 +235,7 @@ function Preparation(props: Props) {
|
||||
Comparar formas jurídicas
|
||||
</Link>
|
||||
</p>
|
||||
<p>{props.definition.template_notice}</p>
|
||||
<p>{companyPreparationNotice(props.definition.template_notice)}</p>
|
||||
<p>
|
||||
Guarde mesmo que faltem dados. Só se pedem informações desta
|
||||
preparação; valores indicados como realizados são declarações,
|
||||
@@ -619,6 +660,18 @@ function Preparation(props: Props) {
|
||||
<button type="submit" className={button}>
|
||||
Guardar ficha no dossier
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void save(
|
||||
`/simulador-fiscal?context=${props.case.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Guardar e continuar para a viabilidade
|
||||
</button>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import CatalogueChoiceSelect from '@/components/catalogue-choice-select';
|
||||
import DemographicEvidence, {
|
||||
type DemographicAnalysis,
|
||||
} from '@/components/demographic-evidence';
|
||||
import { Frame, button } from './shared';
|
||||
|
||||
export default function Demography({
|
||||
export default function Demography(
|
||||
props: Parameters<typeof DemographySelection>[0],
|
||||
) {
|
||||
// Server filters identify this consultation, including browser back/forward.
|
||||
return (
|
||||
<DemographySelection key={JSON.stringify(props.filters)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DemographySelection({
|
||||
evidence,
|
||||
filters,
|
||||
territories,
|
||||
@@ -27,6 +37,10 @@ export default function Demography({
|
||||
sectors: Record<string, string>;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(filters.territories ?? []);
|
||||
const [province, setProvince] = useState('');
|
||||
const [district, setDistrict] = useState('');
|
||||
const [selectionNotice, setSelectionNotice] = useState('');
|
||||
const candidate = district || province;
|
||||
const [start, setStart] = useState(
|
||||
filters.start_year ?? new Date().getFullYear(),
|
||||
);
|
||||
@@ -55,31 +69,96 @@ export default function Demography({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Províncias ou distritos (até quatro)
|
||||
<select
|
||||
multiple
|
||||
className="block h-48 w-full rounded border p-2"
|
||||
value={selected}
|
||||
onChange={(e) =>
|
||||
setSelected(
|
||||
Array.from(
|
||||
e.target.selectedOptions,
|
||||
(o) => o.value,
|
||||
).slice(0, 4),
|
||||
<fieldset className="min-w-0 space-y-3">
|
||||
<legend>Localizações a comparar (até quatro)</legend>
|
||||
<label htmlFor="demography-province" className="block">
|
||||
Província
|
||||
</label>
|
||||
<CatalogueChoiceSelect
|
||||
id="demography-province"
|
||||
label="Província da comparação"
|
||||
value={province}
|
||||
options={territories
|
||||
.filter((t) => t.location_type === 'province')
|
||||
.map((t) => ({ value: t.id, label: t.name }))}
|
||||
onChange={(value) => {
|
||||
setProvince(value);
|
||||
if (district)
|
||||
setSelectionNotice(
|
||||
'A escolha do distrito foi limpa. Os locais já acrescentados à comparação foram mantidos.',
|
||||
);
|
||||
setDistrict('');
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="demography-district" className="block">
|
||||
Distrito (opcional)
|
||||
</label>
|
||||
<CatalogueChoiceSelect
|
||||
id="demography-district"
|
||||
label="Distrito da comparação (opcional)"
|
||||
value={district}
|
||||
disabled={!province}
|
||||
options={territories
|
||||
.filter(
|
||||
(t) =>
|
||||
t.location_type === 'district' &&
|
||||
t.parent_id === province,
|
||||
)
|
||||
.map((t) => ({ value: t.id, label: t.name }))}
|
||||
onChange={setDistrict}
|
||||
/>
|
||||
<p>
|
||||
Pode comparar apenas a província ou escolher um dos seus
|
||||
distritos.
|
||||
</p>
|
||||
{selectionNotice && <p role="status">{selectionNotice}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={
|
||||
!candidate ||
|
||||
selected.length >= 4 ||
|
||||
selected.includes(candidate)
|
||||
}
|
||||
onClick={() =>
|
||||
setSelected((current) => [...current, candidate])
|
||||
}
|
||||
>
|
||||
{territories.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} —{' '}
|
||||
{t.location_type === 'province'
|
||||
? 'Província'
|
||||
: `Distrito (${territories.find((p) => p.id === t.parent_id)?.name ?? ''})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
Acrescentar à comparação
|
||||
</button>
|
||||
<ul className="space-y-2">
|
||||
{selected.map((id) => {
|
||||
const t = territories.find(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
const label = t
|
||||
? `${t.name} — ${t.location_type === 'province' ? 'Província' : 'Distrito'}`
|
||||
: 'Localização guardada';
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
aria-label={`Retirar ${label} da comparação`}
|
||||
onClick={() =>
|
||||
setSelected((current) =>
|
||||
current.filter(
|
||||
(value) => value !== id,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</fieldset>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
Sector
|
||||
@@ -123,6 +202,30 @@ export default function Demography({
|
||||
</div>
|
||||
</form>
|
||||
<DemographicEvidence evidence={evidence} />
|
||||
{evidence.territories.length > 0 && (
|
||||
<section className="space-y-3 rounded border p-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Transformar a análise numa ideia
|
||||
</h2>
|
||||
<p>
|
||||
Prepare uma ideia nesta localização e confronte-a com a
|
||||
oferta existente. Não é necessário haver uma
|
||||
oportunidade publicada para iniciar a análise.
|
||||
</p>
|
||||
{evidence.territories.map((t) => (
|
||||
<a
|
||||
key={t.id}
|
||||
className="block py-2 underline"
|
||||
href={
|
||||
'/investimentos/iniciar?source=' +
|
||||
encodeURIComponent('territory:' + t.id)
|
||||
}
|
||||
>
|
||||
Preparar ideia em {t.name}
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const labels: Record<string, string> = {
|
||||
investment_capacity: 'Capacidade de investimento declarada',
|
||||
name: 'Nome da empresa',
|
||||
registration: 'Referência de registo / NUIT',
|
||||
legal_form: 'Forma jurídica declarada',
|
||||
role: 'Função ou relação com a empresa',
|
||||
description: 'Actividade e observações',
|
||||
};
|
||||
@@ -45,9 +46,13 @@ export default function Profile({
|
||||
countries,
|
||||
organizations = [],
|
||||
organizationCountryAvailable = false,
|
||||
continuation = null,
|
||||
legalForms = [],
|
||||
}: {
|
||||
account: RecordData;
|
||||
actorId: number;
|
||||
continuation?: { id: string; title: string; href: string } | null;
|
||||
legalForms?: { value: string; label: string }[];
|
||||
companies: Company[];
|
||||
countries: Country[];
|
||||
organizations?: Organization[];
|
||||
@@ -69,6 +74,17 @@ export default function Profile({
|
||||
);
|
||||
return (
|
||||
<Frame title="Perfil de investimento e empresas">
|
||||
{continuation && (
|
||||
<aside className={card}>
|
||||
<p>
|
||||
Preparação de {continuation.title}. O cadastro no portal
|
||||
não comprova a constituição legal.
|
||||
</p>
|
||||
<Link className="underline" href={continuation.href}>
|
||||
Voltar ao dossier e associar esta empresa
|
||||
</Link>
|
||||
</aside>
|
||||
)}
|
||||
<Link className="underline" href="/formas-juridicas">
|
||||
Comparar formas jurídicas antes de constituir uma entidade
|
||||
</Link>
|
||||
@@ -88,6 +104,7 @@ export default function Profile({
|
||||
actorId={actorId}
|
||||
editorId="account"
|
||||
labels={labels}
|
||||
legalForms={legalForms}
|
||||
reportDirty={reportDirty}
|
||||
record={account}
|
||||
countries={countries}
|
||||
@@ -132,6 +149,7 @@ export default function Profile({
|
||||
actorId={actorId}
|
||||
editorId={company.id}
|
||||
labels={labels}
|
||||
legalForms={legalForms}
|
||||
reportDirty={reportDirty}
|
||||
record={company}
|
||||
organizations={organizations}
|
||||
@@ -140,6 +158,7 @@ export default function Profile({
|
||||
'name',
|
||||
'incorporation_country_code',
|
||||
'registration',
|
||||
'legal_form',
|
||||
'sector',
|
||||
'role',
|
||||
'description',
|
||||
@@ -172,6 +191,7 @@ export default function Profile({
|
||||
actorId={actorId}
|
||||
editorId="new-company"
|
||||
labels={labels}
|
||||
legalForms={legalForms}
|
||||
reportDirty={reportDirty}
|
||||
record={{ version: 0, payload: {} }}
|
||||
company
|
||||
@@ -180,6 +200,7 @@ export default function Profile({
|
||||
'name',
|
||||
'incorporation_country_code',
|
||||
'registration',
|
||||
'legal_form',
|
||||
'sector',
|
||||
'role',
|
||||
'description',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type OpportunityChoice,
|
||||
} from '@/lib/investor-context';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import InvestmentBudgetRange from '@/components/investment-budget-range';
|
||||
export default function ContextStart({
|
||||
contexts,
|
||||
source,
|
||||
@@ -36,7 +37,10 @@ export default function ContextStart({
|
||||
0,
|
||||
200,
|
||||
)
|
||||
: (source?.title ?? opportunityChoices[0]?.opportunity.title ?? ''),
|
||||
: (entryQuery.project_title ??
|
||||
source?.title ??
|
||||
opportunityChoices[0]?.opportunity.title ??
|
||||
''),
|
||||
);
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -80,6 +84,14 @@ export default function ContextStart({
|
||||
};
|
||||
return (
|
||||
<Frame title="Iniciar ou continuar um investimento">
|
||||
{!source && !opportunityChoices.length && (
|
||||
<p>
|
||||
Prepare a sua ideia no mesmo dossier, mesmo sem anúncios
|
||||
publicados. Corredores, parques e zonas são associações
|
||||
opcionais.
|
||||
</p>
|
||||
)}
|
||||
<InvestmentBudgetRange values={entryQuery} />
|
||||
<SelectedComplements items={entryComplements} />
|
||||
{!!opportunityChoices.length && (
|
||||
<section className={card}>
|
||||
|
||||
@@ -29,14 +29,30 @@ import OpportunityChoices from '@/components/investment-opportunity-choices';
|
||||
import InvestmentRequirements from '@/components/investment-requirements';
|
||||
import type { OpportunityChoice } from '@/lib/investor-context';
|
||||
import type { CorridorOption } from '@/components/investment-opportunity-choices';
|
||||
import CountrySelect, { type Country } from '@/components/country-select';
|
||||
import CurrencySelect from '@/components/currency-select';
|
||||
import InvestmentBudgetRange from '@/components/investment-budget-range';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import { preparationSearch } from '@/lib/preparation-intent';
|
||||
type Options = {
|
||||
opportunities: { key: string; title: string; kind: string }[];
|
||||
opportunities: {
|
||||
key: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
relevance?: string;
|
||||
}[];
|
||||
countries?: Country[];
|
||||
sectors: { id: number; name: string }[];
|
||||
parks: { id: string; name: string; version_no: number }[];
|
||||
search_limited: boolean;
|
||||
};
|
||||
type Slot = 'portfolio' | 'opportunity' | 'sector' | 'territory' | 'park';
|
||||
export default function ContextEditor({
|
||||
export default function ContextEditor(
|
||||
props: Parameters<typeof ScopedContextEditor>[0],
|
||||
) {
|
||||
return <ScopedContextEditor key={props.initial.case.id} {...props} />;
|
||||
}
|
||||
function ScopedContextEditor({
|
||||
initial,
|
||||
options: initialOptions,
|
||||
source,
|
||||
@@ -47,6 +63,7 @@ export default function ContextEditor({
|
||||
entryLocationDifferences = [],
|
||||
entryComplements = [],
|
||||
entryTerritory = null,
|
||||
entryIdea = {},
|
||||
}: {
|
||||
initial: ContextView;
|
||||
options: Options;
|
||||
@@ -56,6 +73,7 @@ export default function ContextEditor({
|
||||
entrySources?: string[];
|
||||
entryComplements?: Complement[];
|
||||
entryTerritory?: ContextReference | null;
|
||||
entryIdea?: Record<string, string>;
|
||||
entryLocationDifferences?: {
|
||||
title: string;
|
||||
saved: string;
|
||||
@@ -78,7 +96,21 @@ export default function ContextEditor({
|
||||
>([]);
|
||||
const [complementError, setComplementError] = useState('');
|
||||
const [choices, setChoices] = useState<OpportunityChoice[]>(
|
||||
requestedChoices ?? initial.context.opportunity_choices ?? [],
|
||||
requestedChoices ??
|
||||
(initial.context.opportunity_choices?.length
|
||||
? initial.context.opportunity_choices
|
||||
: initial.context.selection.opportunity?.kind === 'opportunity'
|
||||
? [
|
||||
{
|
||||
opportunity: initial.context.selection.opportunity,
|
||||
territory:
|
||||
initial.context.selection.territory ?? null,
|
||||
territory_path: territorySelection,
|
||||
location_reason: '',
|
||||
location_state: 'undetermined',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
);
|
||||
const [manageChoices, setManageChoices] = useState(
|
||||
requestedChoices !== null ||
|
||||
@@ -149,8 +181,17 @@ export default function ContextEditor({
|
||||
const [preparation, setPreparation] = useState(initial.private_investment);
|
||||
const [options, setOptions] = useState(initialOptions);
|
||||
const [query, setQuery] = useState('');
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
const [relatedSearch, setRelatedSearch] = useState<string | null>(null);
|
||||
const [consultationPath, setConsultationPath] =
|
||||
useState(territorySelection);
|
||||
const [changes, setChanges] = useState<Partial<Record<Slot, string>>>(
|
||||
() => ({
|
||||
...(entryIdea.sector_id &&
|
||||
!initial.context.selection.sector &&
|
||||
!draft?.selection?.sector
|
||||
? { sector: 'sector:' + entryIdea.sector_id }
|
||||
: {}),
|
||||
...draft?.selection,
|
||||
...(entryTerritory &&
|
||||
!draft?.selection?.territory &&
|
||||
@@ -159,7 +200,7 @@ export default function ContextEditor({
|
||||
: {}),
|
||||
...(source?.kind === 'portfolio'
|
||||
? { portfolio: source.key }
|
||||
: draft?.selection
|
||||
: draft?.selection || source?.kind === 'territory'
|
||||
? {}
|
||||
: source
|
||||
? {
|
||||
@@ -171,7 +212,20 @@ export default function ContextEditor({
|
||||
}),
|
||||
);
|
||||
const [declarations, setDeclarations] = useState<Record<string, string>>(
|
||||
draft?.declarations ?? {},
|
||||
() => ({
|
||||
...Object.fromEntries(
|
||||
Object.entries(entryIdea).filter(
|
||||
([key]) =>
|
||||
[
|
||||
'project_title',
|
||||
'investment_budget_min',
|
||||
'investment_budget_max',
|
||||
'currency',
|
||||
].includes(key) && !initial.context.fields[key],
|
||||
),
|
||||
),
|
||||
...draft?.declarations,
|
||||
}),
|
||||
);
|
||||
const [documentLinks, setDocumentLinks] = useState<
|
||||
Record<string, string | null>
|
||||
@@ -188,21 +242,32 @@ export default function ContextEditor({
|
||||
const [message, setMessage] = useState('');
|
||||
const [corridors, setCorridors] = useState<CorridorOption[]>([]);
|
||||
const [parks, setParks] = useState<Options['parks']>([]);
|
||||
const [corridorLoading, setCorridorLoading] = useState(true);
|
||||
const [corridorLoading, setCorridorLoading] = useState(
|
||||
choices.length > 0 || consultationPath.length > 0,
|
||||
);
|
||||
const [corridorError, setCorridorError] = useState('');
|
||||
const [corridorRetry, setCorridorRetry] = useState(0);
|
||||
const [draftStatus, setDraftStatus] = useState('');
|
||||
const [draftRetry, setDraftRetry] = useState(0);
|
||||
const queue = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const firstCorridor =
|
||||
choices[0]?.corridor_key ??
|
||||
corridors.find((c) => c.name === choices[0]?.coverage)?.key;
|
||||
const firstTerritory = choices[0]?.territory?.id;
|
||||
choices[0]?.corridor_key !== undefined
|
||||
? choices[0].corridor_key
|
||||
: corridors.find((c) => c.name === choices[0]?.coverage)?.key;
|
||||
const firstTerritory =
|
||||
choices[0]?.territory?.id ?? consultationPath.at(-1)?.id;
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!choices.length && !firstTerritory) {
|
||||
setCorridorLoading(false);
|
||||
setCorridorError('');
|
||||
setParks([]);
|
||||
return;
|
||||
}
|
||||
setCorridorLoading(true);
|
||||
setCorridorError('');
|
||||
const params = new URLSearchParams();
|
||||
if (!choices.length) params.set('include_corridors', '0');
|
||||
if (firstCorridor) params.set('corridor', firstCorridor);
|
||||
if (firstTerritory) params.set('territory', firstTerritory);
|
||||
void contextApi<{
|
||||
@@ -222,10 +287,9 @@ export default function ContextEditor({
|
||||
park &&
|
||||
!data.parks.some((p) => 'park:' + p.id === park)
|
||||
) {
|
||||
setChanges((old) => ({ ...old, park: '' }));
|
||||
setPlan(null);
|
||||
// Keep the previous association until the investor reviews it.
|
||||
setMessage(
|
||||
'A zona ou parque anterior foi retirado porque não tem enquadramento compatível com a localização escolhida.',
|
||||
'Reveja a zona ou parque anterior: a compatibilidade com esta escolha ainda nao foi confirmada.',
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -238,7 +302,7 @@ export default function ContextEditor({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [firstCorridor, firstTerritory, corridorRetry]);
|
||||
}, [firstCorridor, firstTerritory, corridorRetry, choices.length]);
|
||||
const draftBody = JSON.stringify({
|
||||
complements: complements.map(complementInput),
|
||||
entry_sources: entrySources,
|
||||
@@ -512,8 +576,8 @@ export default function ContextEditor({
|
||||
</Link>
|
||||
<p>{view.case.title}</p>
|
||||
<p>
|
||||
Confirme as oportunidades e a localização, complete os dados
|
||||
disponíveis e reveja antes de guardar.
|
||||
Confirme a sua ideia, o sector e a localização. Complete
|
||||
apenas o que falta; associar anúncios é opcional.
|
||||
</p>
|
||||
<SelectedComplements
|
||||
items={complements}
|
||||
@@ -616,7 +680,9 @@ export default function ContextEditor({
|
||||
</p>
|
||||
)}
|
||||
{corridorLoading && (
|
||||
<p role="status">A carregar opções do corredor…</p>
|
||||
<p role="status">
|
||||
A consultar associações documentadas…
|
||||
</p>
|
||||
)}
|
||||
<p role="status">{draftStatus}</p>
|
||||
{draft?.needs_review && (
|
||||
@@ -635,16 +701,20 @@ export default function ContextEditor({
|
||||
Tentar guardar novamente
|
||||
</button>
|
||||
)}
|
||||
<OpportunityChoices
|
||||
corridors={corridors}
|
||||
choices={choices}
|
||||
disabled={busy || corridorLoading || !!corridorError}
|
||||
onChange={(next) => {
|
||||
setChoices(next);
|
||||
setManageChoices(true);
|
||||
setPlan(null);
|
||||
}}
|
||||
/>
|
||||
{!!choices.length && (
|
||||
<OpportunityChoices
|
||||
corridors={corridors}
|
||||
choices={choices}
|
||||
disabled={
|
||||
busy || corridorLoading || !!corridorError
|
||||
}
|
||||
onChange={(next) => {
|
||||
setChoices(next);
|
||||
setManageChoices(true);
|
||||
setPlan(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(changes.portfolio ||
|
||||
view.context.selection.portfolio) && (
|
||||
<p className="rounded border p-3">
|
||||
@@ -694,145 +764,165 @@ export default function ContextEditor({
|
||||
contextId={view.case.id}
|
||||
/>
|
||||
)}
|
||||
<label className="block">
|
||||
Acrescentar uma oportunidade à escolha
|
||||
<select
|
||||
className={input}
|
||||
value=""
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const o = options.opportunities.find(
|
||||
(o) => o.key === e.target.value,
|
||||
);
|
||||
if (
|
||||
o &&
|
||||
!choices.some(
|
||||
(c) => c.opportunity.key === o.key,
|
||||
<details
|
||||
open={relatedOpen}
|
||||
onToggle={(e) => setRelatedOpen(e.currentTarget.open)}
|
||||
className="rounded border p-3"
|
||||
>
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
Oportunidades relacionadas (opcional)
|
||||
</summary>
|
||||
<p>
|
||||
Pode preparar a sua ideia sem associar um anuncio ou
|
||||
corredor. Pesquise apenas se uma oportunidade puder
|
||||
apoiar este investimento.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="min-w-0 flex-1">
|
||||
Pesquisar oportunidades
|
||||
<input
|
||||
className={input}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
void contextApi<Options>(
|
||||
'/investimentos/fontes-contexto?' +
|
||||
preparationSearch(
|
||||
query,
|
||||
selected('sector'),
|
||||
consultationPath,
|
||||
),
|
||||
)
|
||||
) {
|
||||
setChoices([
|
||||
...choices,
|
||||
{
|
||||
opportunity: {
|
||||
...o,
|
||||
id: o.key.split(':')[1],
|
||||
href: '/oportunidades',
|
||||
version: null,
|
||||
sha256: '',
|
||||
fields: {},
|
||||
},
|
||||
territory: null,
|
||||
location_reason: '',
|
||||
location_state: 'undetermined',
|
||||
},
|
||||
]);
|
||||
setManageChoices(true);
|
||||
setPlan(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccionar ficha</option>
|
||||
{options.opportunities
|
||||
.filter(
|
||||
(o) =>
|
||||
o.kind === 'opportunity' &&
|
||||
.then((data) => {
|
||||
setOptions(data);
|
||||
setRelatedSearch(
|
||||
preparationSearch(
|
||||
query,
|
||||
selected('sector'),
|
||||
consultationPath,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
>
|
||||
Pesquisar
|
||||
</button>
|
||||
</div>
|
||||
<label className="block">
|
||||
Acrescentar uma oportunidade à escolha
|
||||
<select
|
||||
className={input}
|
||||
value=""
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const o = options.opportunities.find(
|
||||
(o) => o.key === e.target.value,
|
||||
);
|
||||
if (o?.kind === 'business') {
|
||||
change('opportunity', o.key);
|
||||
setManageChoices(false);
|
||||
setPlan(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
o &&
|
||||
!selected('opportunity').startsWith(
|
||||
'business:',
|
||||
) &&
|
||||
!choices.some(
|
||||
(c) => c.opportunity.key === o.key,
|
||||
),
|
||||
)
|
||||
) {
|
||||
setChoices([
|
||||
...choices,
|
||||
{
|
||||
opportunity: {
|
||||
...o,
|
||||
id: o.key.split(':')[1],
|
||||
href: '/oportunidades',
|
||||
version: null,
|
||||
sha256: '',
|
||||
fields: {},
|
||||
},
|
||||
territory: null,
|
||||
location_reason: '',
|
||||
location_state: 'undetermined',
|
||||
},
|
||||
]);
|
||||
setManageChoices(true);
|
||||
setPlan(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccionar ficha</option>
|
||||
{(relatedSearch ===
|
||||
preparationSearch(
|
||||
query,
|
||||
selected('sector'),
|
||||
consultationPath,
|
||||
)
|
||||
.map((o) => (
|
||||
<option key={o.key} value={o.key}>
|
||||
{o.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<h2 className="text-xl font-bold">Dados do investimento</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="min-w-0 flex-1">
|
||||
Pesquisar oportunidades
|
||||
<input
|
||||
className={input}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
? options.opportunities
|
||||
: []
|
||||
)
|
||||
.filter(
|
||||
(o) =>
|
||||
!choices.some(
|
||||
(c) =>
|
||||
c.opportunity.key === o.key,
|
||||
),
|
||||
)
|
||||
.map((o) => (
|
||||
<option
|
||||
key={o.key}
|
||||
value={o.key}
|
||||
disabled={
|
||||
o.kind === 'business'
|
||||
? choices.length > 0
|
||||
: selected(
|
||||
'opportunity',
|
||||
).startsWith('business:')
|
||||
}
|
||||
>
|
||||
{o.title} —{' '}
|
||||
{o.relevance ??
|
||||
'Correspondência pelo título; confirme o sector e a localização.'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
void contextApi<Options>(
|
||||
'/investimentos/fontes-contexto?q=' +
|
||||
encodeURIComponent(query),
|
||||
)
|
||||
.then(setOptions)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
>
|
||||
Pesquisar
|
||||
</button>
|
||||
</div>
|
||||
<label className="grid min-w-0 gap-1">
|
||||
Referência individual (sem selecção múltipla)
|
||||
<select
|
||||
className={input + ' max-w-full min-w-0'}
|
||||
value={selected('opportunity')}
|
||||
disabled={busy || manageChoices}
|
||||
onChange={(e) =>
|
||||
change('opportunity', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Sem oportunidade associada</option>
|
||||
{source &&
|
||||
['opportunity', 'business'].includes(
|
||||
source.kind,
|
||||
) &&
|
||||
!options.opportunities.some(
|
||||
(x) => x.key === source.key,
|
||||
) && (
|
||||
<option value={source.key}>
|
||||
{source.title}
|
||||
</option>
|
||||
)}
|
||||
{view.context.selection.opportunity &&
|
||||
!options.opportunities.some(
|
||||
(x) =>
|
||||
x.key ===
|
||||
view.context.selection.opportunity!.key,
|
||||
) && (
|
||||
<option
|
||||
value={
|
||||
view.context.selection.opportunity
|
||||
.key
|
||||
}
|
||||
>
|
||||
{
|
||||
view.context.selection.opportunity
|
||||
.title
|
||||
}{' '}
|
||||
— referência guardada
|
||||
</option>
|
||||
)}
|
||||
{options.opportunities.map((o) => (
|
||||
<option key={o.key} value={o.key}>
|
||||
{o.title} ·{' '}
|
||||
{o.kind === 'business'
|
||||
? 'Negócios'
|
||||
: 'Catálogo'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{options.search_limited && (
|
||||
<p>
|
||||
Resultados limitados por página de pesquisa;
|
||||
especifique o título para encontrar outras
|
||||
oportunidades.
|
||||
</p>
|
||||
)}
|
||||
{selected('opportunity').startsWith('business:') && (
|
||||
<p>
|
||||
{options.opportunities.find(
|
||||
(o) => o.key === selected('opportunity'),
|
||||
)?.title ??
|
||||
view.context.selection.opportunity?.title}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 underline"
|
||||
disabled={busy}
|
||||
onClick={() => change('opportunity', '')}
|
||||
>
|
||||
Retirar esta referencia
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{options.search_limited && (
|
||||
<p>
|
||||
Resultados limitados por página de pesquisa;
|
||||
especifique o título para encontrar outras
|
||||
oportunidades.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
<label className="grid min-w-0 gap-1">
|
||||
Sector
|
||||
<select
|
||||
@@ -866,7 +956,8 @@ export default function ContextEditor({
|
||||
</select>
|
||||
</label>
|
||||
{!manageChoices && (
|
||||
<>
|
||||
<details className="rounded border p-3">
|
||||
<summary>Zonas e parques (opcional)</summary>
|
||||
<label className="grid min-w-0 gap-1">
|
||||
{choices.length > 1
|
||||
? 'Zona ou parque da primeira oportunidade'
|
||||
@@ -877,7 +968,7 @@ export default function ContextEditor({
|
||||
busy ||
|
||||
corridorLoading ||
|
||||
!!corridorError ||
|
||||
(!!firstCorridor && !firstTerritory)
|
||||
!firstTerritory
|
||||
}
|
||||
value={selected('park')}
|
||||
onChange={(e) =>
|
||||
@@ -887,10 +978,7 @@ export default function ContextEditor({
|
||||
<option value="">
|
||||
Sem ZEE, ZFI ou parque seleccionado
|
||||
</option>
|
||||
{(firstCorridor
|
||||
? parks
|
||||
: options.parks
|
||||
).map((p) => (
|
||||
{parks.map((p) => (
|
||||
<option
|
||||
key={p.id}
|
||||
value={'park:' + p.id}
|
||||
@@ -911,7 +999,7 @@ export default function ContextEditor({
|
||||
: 'A consulta não confirma disponibilidade de lotes, admissão ou incentivos.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</details>
|
||||
)}
|
||||
{!manageChoices && (
|
||||
<>
|
||||
@@ -922,15 +1010,16 @@ export default function ContextEditor({
|
||||
initial={territorySelection}
|
||||
disabled={busy || manageChoices}
|
||||
endpoint="/api/v1/investment/territory-options"
|
||||
onChange={(path) =>
|
||||
onChange={(path) => {
|
||||
setConsultationPath(path);
|
||||
change(
|
||||
'territory',
|
||||
path.length
|
||||
? 'territory:' +
|
||||
path[path.length - 1].id
|
||||
: '',
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{view.context.selection.territory && (
|
||||
<p>
|
||||
@@ -942,117 +1031,11 @@ export default function ContextEditor({
|
||||
)}
|
||||
</section>
|
||||
<section className={card + ' space-y-3'}>
|
||||
<h2 className="text-xl font-bold">
|
||||
Declarações deste investimento
|
||||
</h2>
|
||||
<p>
|
||||
Estes campos pertencem ao processo. Alterá-los não muda
|
||||
o cadastro da oportunidade nem o perfil da organização.
|
||||
</p>
|
||||
<p>
|
||||
Origem do investidor, residência fiscal e constituição
|
||||
da entidade são declarações distintas. A localização não
|
||||
confirma benefícios. A prévia identifica o impacto antes
|
||||
de guardar.
|
||||
</p>
|
||||
{[
|
||||
['currency', 'Moeda do projecto', null],
|
||||
[
|
||||
'expected_start_date',
|
||||
'Data prevista de início',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'investor_origin',
|
||||
'Origem do investidor',
|
||||
[
|
||||
['national', 'Nacional'],
|
||||
['foreign', 'Estrangeiro'],
|
||||
['mixed', 'Misto'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'person_type',
|
||||
'Tipo de entidade',
|
||||
[
|
||||
['singular', 'Pessoa singular'],
|
||||
['collective', 'Pessoa colectiva'],
|
||||
['consortium', 'Consórcio'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'entity_stage',
|
||||
'Situação da entidade',
|
||||
[
|
||||
['existing', 'Existente'],
|
||||
['being_formed', 'Em constituição'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'tax_residency_country',
|
||||
'País de residência fiscal declarado (ISO de duas letras)',
|
||||
null,
|
||||
],
|
||||
].map(([key, label, choices]) => (
|
||||
<label className="grid gap-1" key={String(key)}>
|
||||
{String(label)}
|
||||
{Array.isArray(choices) ? (
|
||||
<select
|
||||
className={input}
|
||||
disabled={busy}
|
||||
value={
|
||||
declarations[String(key)] ??
|
||||
view.context.fields[String(key)] ??
|
||||
''
|
||||
}
|
||||
onChange={(e) => {
|
||||
setDeclarations({
|
||||
...declarations,
|
||||
[String(key)]: e.target.value,
|
||||
});
|
||||
setPlan(null);
|
||||
}}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{choices.map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className={input}
|
||||
disabled={busy}
|
||||
type={
|
||||
key === 'expected_start_date'
|
||||
? 'date'
|
||||
: 'text'
|
||||
}
|
||||
maxLength={
|
||||
key === 'currency'
|
||||
? 3
|
||||
: key === 'tax_residency_country'
|
||||
? 2
|
||||
: 10
|
||||
}
|
||||
value={
|
||||
declarations[String(key)] ??
|
||||
view.context.fields[String(key)] ??
|
||||
''
|
||||
}
|
||||
onChange={(e) => {
|
||||
setDeclarations({
|
||||
...declarations,
|
||||
[String(key)]: e.target.value,
|
||||
});
|
||||
setPlan(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{['project_title', 'project_summary'].map((k) => (
|
||||
'project_title',
|
||||
'products_services',
|
||||
'project_summary',
|
||||
].map((k) => (
|
||||
<label className="grid gap-1" key={k}>
|
||||
{contextFieldNames[k]}
|
||||
<textarea
|
||||
@@ -1063,7 +1046,13 @@ export default function ContextEditor({
|
||||
''
|
||||
}
|
||||
disabled={busy}
|
||||
maxLength={k === 'project_title' ? 200 : 5000}
|
||||
maxLength={
|
||||
k === 'project_title'
|
||||
? 200
|
||||
: k === 'products_services'
|
||||
? 2000
|
||||
: 5000
|
||||
}
|
||||
onChange={(e) => {
|
||||
setDeclarations({
|
||||
...declarations,
|
||||
@@ -1074,6 +1063,181 @@ export default function ContextEditor({
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
<InvestmentBudgetRange
|
||||
values={{ ...view.context.fields, ...declarations }}
|
||||
onChange={(field, value) => {
|
||||
setDeclarations((old) => ({ ...old, [field]: value }));
|
||||
setPlan(null);
|
||||
}}
|
||||
/>
|
||||
<DemographicContext territoryId={consultationPath.at(-1)?.id} />
|
||||
<section className={card + ' space-y-3'}>
|
||||
<h2 className="text-xl font-bold">
|
||||
Declarações deste investimento
|
||||
</h2>
|
||||
<details className="rounded border p-3">
|
||||
<summary>
|
||||
Enquadramento da entidade e fiscal, quando
|
||||
necessário
|
||||
</summary>
|
||||
<p>
|
||||
Estes campos pertencem ao processo. Alterá-los não
|
||||
muda o cadastro da oportunidade nem o perfil da
|
||||
organização.
|
||||
</p>
|
||||
<p>
|
||||
Origem do investidor, residência fiscal e
|
||||
constituição da entidade são declarações distintas.
|
||||
A localização não confirma benefícios. A prévia
|
||||
identifica o impacto antes de guardar.
|
||||
</p>
|
||||
{[
|
||||
[
|
||||
'expected_start_date',
|
||||
'Data prevista de início',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'investor_origin',
|
||||
'Origem do investidor',
|
||||
[
|
||||
['national', 'Nacional'],
|
||||
['foreign', 'Estrangeiro'],
|
||||
['mixed', 'Misto'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'person_type',
|
||||
'Tipo de entidade',
|
||||
[
|
||||
['singular', 'Pessoa singular'],
|
||||
['collective', 'Pessoa colectiva'],
|
||||
['consortium', 'Consórcio'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'entity_stage',
|
||||
'Situação da entidade',
|
||||
[
|
||||
['existing', 'Existente'],
|
||||
['being_formed', 'Em constituição'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'tax_residency_country',
|
||||
'País de residência fiscal',
|
||||
null,
|
||||
],
|
||||
].map(([key, label, choices]) => (
|
||||
<label className="grid gap-1" key={String(key)}>
|
||||
{String(label)}
|
||||
{key === 'currency' ? (
|
||||
<CurrencySelect
|
||||
label="Moeda do projecto"
|
||||
disabled={busy}
|
||||
value={
|
||||
declarations.currency ??
|
||||
view.context.fields.currency ??
|
||||
''
|
||||
}
|
||||
error={
|
||||
(declarations.currency ??
|
||||
view.context.fields.currency) &&
|
||||
!/^[A-Z]{3}$/.test(
|
||||
declarations.currency ??
|
||||
view.context.fields
|
||||
.currency ??
|
||||
'',
|
||||
)
|
||||
? 'Seleccione a moeda na lista para confirmar este valor anterior.'
|
||||
: undefined
|
||||
}
|
||||
onChange={(value) => {
|
||||
setDeclarations({
|
||||
...declarations,
|
||||
currency: value,
|
||||
});
|
||||
setPlan(null);
|
||||
}}
|
||||
/>
|
||||
) : key === 'tax_residency_country' ? (
|
||||
<CountrySelect
|
||||
label="País de residência fiscal"
|
||||
countries={options.countries ?? []}
|
||||
disabled={busy}
|
||||
value={
|
||||
declarations.tax_residency_country ??
|
||||
view.context.fields
|
||||
.tax_residency_country ??
|
||||
''
|
||||
}
|
||||
onChange={(value) => {
|
||||
setDeclarations({
|
||||
...declarations,
|
||||
tax_residency_country: value,
|
||||
});
|
||||
setPlan(null);
|
||||
}}
|
||||
/>
|
||||
) : Array.isArray(choices) ? (
|
||||
<select
|
||||
className={input}
|
||||
disabled={busy}
|
||||
value={
|
||||
declarations[String(key)] ??
|
||||
view.context.fields[String(key)] ??
|
||||
''
|
||||
}
|
||||
onChange={(e) => {
|
||||
setDeclarations({
|
||||
...declarations,
|
||||
[String(key)]: e.target.value,
|
||||
});
|
||||
setPlan(null);
|
||||
}}
|
||||
>
|
||||
<option value="">Por indicar</option>
|
||||
{choices.map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className={input}
|
||||
disabled={busy}
|
||||
type={
|
||||
key === 'expected_start_date'
|
||||
? 'date'
|
||||
: 'text'
|
||||
}
|
||||
maxLength={
|
||||
key === 'currency'
|
||||
? 3
|
||||
: key ===
|
||||
'tax_residency_country'
|
||||
? 2
|
||||
: 10
|
||||
}
|
||||
value={
|
||||
declarations[String(key)] ??
|
||||
view.context.fields[String(key)] ??
|
||||
''
|
||||
}
|
||||
onChange={(e) => {
|
||||
setDeclarations({
|
||||
...declarations,
|
||||
[String(key)]: e.target.value,
|
||||
});
|
||||
setPlan(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</details>
|
||||
<details>
|
||||
<summary>
|
||||
Substituições explícitas de declarações
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
type Preparation,
|
||||
} from '@/components/private-investment-preparation';
|
||||
import ContextDocuments from '@/components/context-documents';
|
||||
import InvestmentBudgetRange from '@/components/investment-budget-range';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import InvestmentSupplyAggregate from '@/components/investment-supply-aggregate';
|
||||
import InvestmentPromoter from '@/components/investment-promoter';
|
||||
import type { SupplyAggregate } from '@/lib/investor-context';
|
||||
import { SelectedComplements } from '@/components/investment-complements';
|
||||
import InvestmentRequirements from '@/components/investment-requirements';
|
||||
import OpportunityChoices from '@/components/investment-opportunity-choices';
|
||||
@@ -91,6 +96,9 @@ type Path = {
|
||||
private_investment?: Preparation | null;
|
||||
fintech: FintechView | null;
|
||||
investment_context: DossierContext | null;
|
||||
can_set_promoter?: boolean;
|
||||
supply_aggregate_enabled?: boolean;
|
||||
supply_aggregates?: SupplyAggregate[];
|
||||
zone_contexts?: {
|
||||
zone_id: string;
|
||||
consulted_public_version: number;
|
||||
@@ -652,11 +660,16 @@ export default function InvestmentPath(path: Path) {
|
||||
)}
|
||||
{!!path.sector_references?.length && (
|
||||
<section className="space-y-3 rounded border p-4">
|
||||
<h2 className="text-lg font-semibold">Sectores e cadeias de valor</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Sectores e cadeias de valor
|
||||
</h2>
|
||||
{path.sector_references.map((reference) => (
|
||||
<article key={reference.key}>
|
||||
<h3>{reference.title}</h3>
|
||||
<p>{reference.location_label ?? reference.locations_text}</p>
|
||||
<p>
|
||||
{reference.location_label ??
|
||||
reference.locations_text}
|
||||
</p>
|
||||
<p>{reference.value_chain}</p>
|
||||
<Link className="underline" href={reference.href}>
|
||||
Consultar a referência mineral
|
||||
@@ -668,12 +681,39 @@ export default function InvestmentPath(path: Path) {
|
||||
<ContextDocuments
|
||||
source={path.investment_context?.selection.opportunity}
|
||||
/>
|
||||
{path.supply_aggregate_enabled && (
|
||||
<InvestmentSupplyAggregate
|
||||
key={path.case.id}
|
||||
caseId={path.case.id}
|
||||
version={path.case.version}
|
||||
revision={path.investment_context?.revision ?? 0}
|
||||
history={path.supply_aggregates ?? []}
|
||||
disabled={!path.canWrite}
|
||||
/>
|
||||
)}
|
||||
<InvestmentPromoter
|
||||
key={path.case.id}
|
||||
caseId={path.case.id}
|
||||
selected={path.investment_context?.promoter_entity}
|
||||
canEdit={path.can_set_promoter ?? false}
|
||||
/>
|
||||
<PreparationSummary
|
||||
data={path.private_investment}
|
||||
caseId={path.case.id}
|
||||
/>
|
||||
{path.investment_context && (
|
||||
<section className="space-y-2 rounded border border-emerald-300 bg-emerald-50 p-4">
|
||||
<InvestmentBudgetRange
|
||||
values={path.investment_context.fields}
|
||||
/>
|
||||
<DemographicContext
|
||||
territoryId={
|
||||
path.investment_context.fields.locality_id ||
|
||||
path.investment_context.fields.admin_post_id ||
|
||||
path.investment_context.fields.district_id ||
|
||||
path.investment_context.fields.province_id
|
||||
}
|
||||
/>
|
||||
<h2 className="font-semibold">As suas escolhas</h2>
|
||||
{Object.values(path.investment_context.selection).map(
|
||||
(ref) => (
|
||||
|
||||
@@ -459,6 +459,12 @@ export default function Search({
|
||||
Experimente outras palavras ou uma expressão
|
||||
mais curta.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
A pesquisa consulta informação disponível no
|
||||
portal. Não encontrar anúncios não comprova
|
||||
ausência de empresas, recursos ou potencial
|
||||
no território.
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useForm, router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { catalogueReturnHref } from '@/lib/catalogue-context';
|
||||
import PublishedOpportunitiesEmpty from '@/components/published-opportunities-empty';
|
||||
import {
|
||||
searchTerritoryHref,
|
||||
portfolioPreparationHref,
|
||||
@@ -366,7 +367,9 @@ function Editor({
|
||||
</article>
|
||||
))}
|
||||
{!opportunities.length && (
|
||||
<p>Sem oportunidades publicadas associadas a esta ficha.</p>
|
||||
<PublishedOpportunitiesEmpty
|
||||
href={searchTerritoryHref('/oportunidades', pageUrl)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{management ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CurrencySelect from '@/components/currency-select';
|
||||
import { Link, router, useForm } from '@inertiajs/react';
|
||||
import InvestmentTerritory, {
|
||||
TerritoryFields,
|
||||
@@ -287,19 +288,17 @@ export default function Editor({
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Moeda (código de três letras)"
|
||||
label="Moeda do orçamento"
|
||||
error={form.errors.budget_currency}
|
||||
>
|
||||
<input
|
||||
maxLength={3}
|
||||
<CurrencySelect
|
||||
label="Moeda do orçamento"
|
||||
className={control}
|
||||
value={form.data.budget_currency}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'budget_currency',
|
||||
e.target.value.toUpperCase(),
|
||||
)
|
||||
onChange={(value) =>
|
||||
form.setData('budget_currency', value)
|
||||
}
|
||||
error={form.errors.budget_currency}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
|
||||
@@ -38,6 +38,8 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
|
||||
Route::get('/{id}/contexto', [InvestorContextController::class, 'show'])->whereUuid('id');
|
||||
Route::get('/{id}/contexto/editar', [InvestorContextController::class, 'edit'])->whereUuid('id');
|
||||
Route::post('/{id}/contexto/rascunho', [InvestorContextController::class, 'saveDraft'])->whereUuid('id');
|
||||
Route::post('/{id}/contexto/oferta-agregada', [InvestorContextController::class, 'supplyAggregate'])->whereUuid('id');
|
||||
Route::match(['GET', 'POST'], '/{id}/contexto/entidade', [InvestorContextController::class, 'promoter'])->whereUuid('id');
|
||||
Route::post('/{id}/contexto/previa', [InvestorContextController::class, 'preview'])->whereUuid('id');
|
||||
Route::post('/{id}/contexto/confirmar', [InvestorContextController::class, 'commit'])->whereUuid('id');
|
||||
Route::get('/a-partir-do-parque/{zoneId}', [Path::class, 'zoneStart'])->whereUuid('zoneId');
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\FundingProgrammes;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class FundingCriteriaCoherenceTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
private User $editor;
|
||||
private array $geo = [];
|
||||
private int $sector;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->withoutVite();
|
||||
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
|
||||
$this->assertSame('investor_intent_20261115_test', $db->name); $this->assertSame(55441, (int) $db->port);
|
||||
$this->editor = User::factory()->create(); config(['portal.managers' => [$this->editor->email]]);
|
||||
$parent = null;
|
||||
foreach (['country', 'province', 'district'] as $type) {
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type, 'source_system' => 'cartografia-workbook', 'data_classification' => 'public', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
|
||||
$this->geo[$type] = $id; $parent = $id;
|
||||
}
|
||||
$this->sector = Sector::create(['slug' => 'synthetic-funding-qa', 'name' => 'SYNTHETIC sector', 'is_active' => true, 'published_at' => now()])->id;
|
||||
}
|
||||
|
||||
private function publish(string $code, string $kind = 'institution', array $overrides = []): string
|
||||
{
|
||||
$source = ['source_url' => 'https://example.invalid/synthetic-qa', 'source_reference' => 'SYNTHETIC QA ONLY', 'checked_on' => today()->toDateString()];
|
||||
$draft = ['title' => 'SYNTHETIC '.$code, 'kind' => $kind, 'manager_name' => 'SYNTHETIC manager', 'summary' => 'Synthetic isolated record, not real content.', 'source_url' => $source['source_url'], 'source_reference' => $source['source_reference'], 'source_checked_on' => $source['checked_on'], 'source_status' => 'identified', 'coverage' => 'national', 'sectors_scope' => 'all', 'funding_needs' => ['investment'], 'windows' => []];
|
||||
if ($kind === 'institution') $draft += ['institution_key' => $code, 'institution_nature' => 'bank', 'institution_categories' => ['banking'], 'institution_types' => ['commercial_bank'], 'institution_status' => 'reported',
|
||||
'locations' => [['role' => 'headquarters', 'label' => 'Synthetic headquarters', 'territory_id' => $this->geo['district'], 'evidence_status' => 'reported', ...$source]],
|
||||
'products' => [['code' => 'investment', 'title' => 'Synthetic investment product', 'needs' => ['investment'], 'coverage' => 'national', 'availability' => 'documented', ...$source]]];
|
||||
$service = app(FundingProgrammes::class);
|
||||
$id = $service->save($this->editor, null, ['version' => 0, 'code' => $code, 'reason' => 'Synthetic QA fixture', 'draft' => array_replace($draft, $overrides)]);
|
||||
$plan = $service->preview($this->editor, $id);
|
||||
$service->publish($this->editor, $id, ['version' => $plan['version'], 'hash' => $plan['hash'], 'confirm' => true, 'reason' => 'Synthetic QA only, rolled back']);
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_bank_filters_apply_to_the_shared_query_and_never_return_brokers_or_microinsurers(): void
|
||||
{
|
||||
$bank = $this->publish('qa-bank');
|
||||
$this->publish('qa-broker', overrides: ['institution_categories' => ['intermediation'], 'institution_types' => ['broker']]);
|
||||
$this->publish('qa-microinsurance', overrides: ['institution_categories' => ['insurance'], 'institution_types' => ['microinsurer']]);
|
||||
$fund = $this->publish('qa-fund', 'fund');
|
||||
$service = app(FundingProgrammes::class);
|
||||
$filters = ['category' => 'banking', 'institution_type' => 'commercial_bank', 'kind' => 'institution', 'certainty' => 'documented', 'location_mode' => 'physical', 'territory' => $this->geo['district'], 'sector' => $this->sector, 'need' => 'investment'];
|
||||
$this->assertSame([$bank], $service->catalogueQuery($filters)->pluck('p.id')->all());
|
||||
$this->assertSame([$fund], $service->catalogueQuery(['kind' => 'fund'])->pluck('p.id')->all());
|
||||
$this->assertSame(0, $service->catalogueQuery(['q' => 'no-such-record'])->count());
|
||||
$this->assertSame(0, $service->catalogueQuery(['category' => 'banking', 'institution_type' => 'investment_bank'])->count(), 'A valid combination without records is an empty result, not an invalid combination.');
|
||||
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($page) => $page->has('items.data', 1)->where('items.data.0.id', $bank)->where('territorySelection.0.id', $this->geo['province'])->where('territorySelection.1.id', $this->geo['district']));
|
||||
$returnFilters = [...$filters, 'q' => 'qa', 'state' => 'closed', 'kind' => 'institution', 'include_historical' => '0', 'province_id' => $this->geo['province'], 'district_id' => $this->geo['district'], 'page' => '2'];
|
||||
$this->get('/fundos/'.$bank.'?'.http_build_query($returnFilters))->assertOk()->assertInertia(function ($page) use ($returnFilters) {
|
||||
foreach ($returnFilters as $key => $value) $page->where('selection.'.$key, (string) $value);
|
||||
});
|
||||
$this->getJson('/api/v1/investment/territory-options?type=district')->assertOk()->assertJsonCount(0, 'data');
|
||||
$this->getJson('/api/v1/investment/territory-options?type=district&parent_id='.$this->geo['province'])->assertOk()->assertJsonCount(1, 'data');
|
||||
}
|
||||
|
||||
public function test_contradictory_type_or_record_kind_and_incomplete_hierarchy_are_rejected(): void
|
||||
{
|
||||
foreach ([['category' => 'banking', 'institution_type' => 'broker'], ['category' => 'banking', 'institution_type' => 'commercial_bank', 'kind' => 'fund', 'location_mode' => 'physical'], ['district_id' => $this->geo['district']]] as $filters) {
|
||||
try { app(FundingProgrammes::class)->catalogueQuery($filters)->count(); $this->fail('Contradictory criteria must not silently broaden a query.'); }
|
||||
catch (ValidationException $error) { $this->assertNotEmpty($error->errors()); }
|
||||
$this->getJson('/fundos?'.http_build_query($filters))->assertUnprocessable();
|
||||
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($page) => $page->has('criteriaErrors')->has('items.data', 0));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_unknown_scope_is_not_a_documented_match_and_physical_presence_does_not_prove_coverage(): void
|
||||
{
|
||||
$this->publish('qa-unknown', overrides: ['sectors_scope' => 'unknown', 'products' => []]);
|
||||
$service = app(FundingProgrammes::class);
|
||||
$this->assertSame(0, $service->catalogueQuery(['need' => 'investment', 'certainty' => 'documented'])->count());
|
||||
$this->assertSame(0, $service->catalogueQuery(['sector' => $this->sector, 'certainty' => 'documented'])->count());
|
||||
$this->assertSame(1, $service->catalogueQuery(['territory' => $this->geo['district'], 'location_mode' => 'physical', 'certainty' => 'documented'])->count());
|
||||
$this->assertSame(0, $service->catalogueQuery(['territory' => $this->geo['district'], 'location_mode' => 'coverage', 'certainty' => 'documented'])->count());
|
||||
$this->assertSame(1, $service->catalogueQuery(['need' => 'investment', 'certainty' => 'include_unknown'])->count());
|
||||
$this->assertSame(0, $service->catalogueQuery(['state' => 'closed'])->count(), 'An institution without a window is not a closed funding window.');
|
||||
}
|
||||
|
||||
public function test_window_state_and_scope_are_matched_on_the_same_window_without_inventing_a_local_window(): void
|
||||
{
|
||||
$source = ['source_url' => 'https://example.invalid/synthetic-qa', 'source_reference' => 'Synthetic window', 'checked_on' => today()->toDateString()];
|
||||
$id = $this->publish('qa-closed-window', overrides: ['windows' => [['code' => 'closed', 'title' => 'Synthetic closed window', 'mode' => 'closed', 'coverage' => 'unknown', ...$source]]]);
|
||||
$service = app(FundingProgrammes::class);
|
||||
$criteria = ['territory' => $this->geo['district'], 'certainty' => 'documented', 'state' => 'closed', 'location_mode' => 'coverage'];
|
||||
$this->assertSame(0, $service->catalogueQuery($criteria)->count());
|
||||
$this->assertSame([$id], $service->catalogueQuery([...$criteria, 'certainty' => 'include_unknown'])->pluck('p.id')->all());
|
||||
$this->assertSame([$id], $service->catalogueQuery([...$criteria, 'location_mode' => 'physical'])->pluck('p.id')->all());
|
||||
$this->assertSame(0, $service->catalogueQuery([...$criteria, 'state' => 'open'])->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Requests\StoreSimulationFormDraftRequest;
|
||||
use App\Models\InvestmentZone;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestorContext;
|
||||
use App\Services\ProactiveSupply;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class InvestorIntentContinuityTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
|
||||
$this->assertSame('investor_intent_20261115_test', $db->name);
|
||||
$this->assertSame(55441, (int) $db->port);
|
||||
$this->withoutVite();
|
||||
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
}
|
||||
|
||||
public function test_internal_business_observations_remain_closed_to_an_ordinary_investor(): void
|
||||
{
|
||||
$this->expectException(HttpException::class);
|
||||
app(ProactiveSupply::class)->authorize(User::factory()->create());
|
||||
}
|
||||
|
||||
public function test_demography_source_recovers_its_hierarchy_without_replacing_a_saved_location(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$parent = null;
|
||||
$ids = [];
|
||||
foreach (['country', 'province', 'district'] as $type) {
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type,
|
||||
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
|
||||
$ids[$type] = $id;
|
||||
$parent = $id;
|
||||
}
|
||||
$id = $this->actingAs($owner)->postJson('/investimentos', ['title' => 'SYNTHETIC demography source', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$url = '/investimentos/'.$id.'/contexto/editar?'.http_build_query(['source' => 'territory:'.$ids['district']]);
|
||||
$this->get($url)->assertOk()->assertInertia(fn ($page) => $page->where('entryTerritory.id', $ids['district'])->where('territorySelection.0.id', $ids['province'])->where('territorySelection.1.id', $ids['district']));
|
||||
$version = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json('case.version');
|
||||
$body = ['version' => $version, 'selection' => ['territory' => 'territory:'.$ids['province']], 'opportunity_choices' => [], 'declarations' => []];
|
||||
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
|
||||
$this->get($url)->assertOk()->assertInertia(fn ($page) => $page->where('initial.context.selection.territory.id', $ids['province'])->where('territorySelection.0.id', $ids['province'])->has('territorySelection', 1));
|
||||
$this->actingAs(User::factory()->create())->get($url)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_an_own_idea_preserves_sector_locality_and_budget_interval_without_any_advert_or_corridor(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$parent = null;
|
||||
$path = [];
|
||||
foreach (['country' => 'QA Mozambique', 'province' => 'Maputo Provincia', 'district' => 'Boane', 'admin_post' => 'Matola Rio', 'locality' => 'Matola Rio-Sede'] as $type => $name) {
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$name, 'code' => $id, 'location_type' => $type,
|
||||
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
|
||||
$path[$type.'_id'] = $id;
|
||||
$parent = $id;
|
||||
}
|
||||
$reviewer = User::factory()->create();
|
||||
$zone = InvestmentZone::query()->create(['id' => (string) Str::uuid(), 'code' => 'SINT-QA-'.Str::random(8), 'name' => 'SYNTHETIC documented site',
|
||||
'category' => 'industrial_park', 'physical_category' => 'industrial_park', 'primary_territory_id' => $parent, 'publication_status' => 'published', 'validation_status' => 'validated',
|
||||
'editor_id' => $owner->id, 'reviewer_id' => $reviewer->id, 'published_at' => now(), 'source_evidence' => [['source_type' => 'promoter', 'url' => 'https://example.invalid/synthetic-qa', 'supports' => 'Synthetic QA only, no operational or legal assertion.']]]);
|
||||
$zones = $this->actingAs($owner)->getJson('/investimentos/opcoes-corredor?include_corridors=0&territory='.$parent);
|
||||
$this->assertSame(200, $zones->status(), $zones->getContent());
|
||||
$zones->assertJsonPath('parks.0.id', $zone->id)->assertJsonCount(1, 'parks');
|
||||
$this->getJson('/investimentos/opcoes-corredor?include_corridors=0')->assertOk()->assertJsonPath('parks', []);
|
||||
config(['investor-path.aggregate_supply_qa_enabled' => true]);
|
||||
$source = hash('sha256', 'Synthetic private aggregate source');
|
||||
DB::table('proactive_supply_sources')->insert(['sha256' => $source, 'name' => 'PRIVATE-SOURCE-NOT-FOR-OUTPUT.xlsx', 'period' => '2020', 'limitations' => 'Synthetic only', 'receipt' => '{}', 'imported_at' => now()]);
|
||||
$base = ['source_hash' => $source, 'sheet' => 'PRIVATE-SHEET', 'entity_key' => hash('sha256', 'entity-a'), 'name' => 'PRIVATE-COMPANY-NAME', 'province' => 'Synthetic', 'district' => 'Synthetic', 'cae' => '01110', 'activity' => 'PRIVATE-CONTACT-+258000000000', 'sector' => 'PRIVATE-RAW-SECTOR', 'source_date' => '2020', 'fingerprint' => hash('sha256', 'fingerprint'), 'duplicate_of' => null, 'identity_conflict' => false, 'territory_id' => $path['district_id'], 'territory_resolution' => 'exact_normalized_province_district'];
|
||||
foreach ([1 => [], 2 => ['duplicate_of' => hash('sha256', 'row-1')], 3 => ['identity_conflict' => true, 'entity_key' => hash('sha256', 'uncertain')], 4 => ['entity_key' => hash('sha256', 'unclassified'), 'cae' => 'PRIVATE-UNCLASSIFIED-TEXT']] as $number => $changes) {
|
||||
DB::table('proactive_supply_observations')->insert([...$base, 'id' => hash('sha256', 'row-'.$number), 'row_number' => $number, ...$changes]);
|
||||
}
|
||||
foreach (['Agronegocio' => 'agro-qa', 'Criar Hotel' => 'hotel-qa'] as $title => $slug) {
|
||||
$sector = Sector::create(['name' => 'SYNTHETIC '.$slug, 'slug' => $slug, 'is_active' => true]);
|
||||
$id = $this->actingAs($owner)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
|
||||
$entry = [...array_diff_key($path, ['country_id' => true]), 'sector_id' => (string) $sector->id, 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000'];
|
||||
$this->get('/investimentos/iniciar?'.http_build_query($entry))->assertOk()->assertInertia(fn ($page) => $page->where('entryQuery.investment_budget_max', '5000000')->where('entryQuery.locality_id', $parent));
|
||||
$this->get('/investimentos/'.$id.'/contexto/editar?'.http_build_query($entry))->assertOk()->assertInertia(fn ($page) => $page->where('entryTerritory.id', $parent)->where('entryIdea.sector_id', (string) $sector->id));
|
||||
$body = ['version' => $view['case']['version'], 'selection' => ['sector' => 'sector:'.$sector->id, 'territory' => 'territory:'.$parent],
|
||||
'opportunity_choices' => [], 'declarations' => ['project_title' => $title, 'products_services' => 'SYNTHETIC explicit activity '.$slug, 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000']];
|
||||
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
|
||||
$saved = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.locality_id', $parent)
|
||||
->assertJsonPath('prefill.simulator.investment_budget_min', '1000000')->assertJsonPath('prefill.simulator.investment_budget_max', '5000000')
|
||||
->assertJsonPath('prefill.simulator.project_title', $title)->json();
|
||||
$this->assertSame('SYNTHETIC explicit activity '.$slug, $saved['prefill']['simulator']['products_services']);
|
||||
$this->assertEmpty($saved['context']['opportunity_choices'] ?? []);
|
||||
$this->assertEmpty($saved['context']['selection']['opportunity'] ?? null);
|
||||
$this->assertEmpty($saved['prefill']['simulator']['total_investment'] ?? null);
|
||||
$command = ['version' => $saved['case']['version'], 'operation_key' => (string) Str::uuid()];
|
||||
$url = '/investimentos/'.$id.'/contexto/oferta-agregada';
|
||||
$aggregate = $this->postJson($url, $command)->assertOk()->assertJsonPath('analysis.entities', 2)->assertJsonPath('analysis.repeated', 1)->assertJsonPath('analysis.uncertain', 1)
|
||||
->assertJsonPath('analysis.broader_context', true)->assertJsonPath('analysis.scope.id', $path['district_id']);
|
||||
$this->assertStringNotContainsString('PRIVATE-', $aggregate->getContent());
|
||||
$this->assertStringNotContainsString($source, $aggregate->getContent());
|
||||
$this->postJson($url, $command)->assertOk()->assertJsonPath('replayed', true);
|
||||
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonCount(1, 'supply_aggregates')->assertJsonPath('supply_aggregates.0.entities', 2);
|
||||
config(['investor-path.aggregate_supply_qa_enabled' => false]);
|
||||
$this->postJson($url, $command)->assertNotFound();
|
||||
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('supply_aggregates', [])->assertJsonPath('supply_aggregate_enabled', false);
|
||||
config(['investor-path.aggregate_supply_qa_enabled' => true]);
|
||||
$this->actingAs(User::factory()->create())->postJson($url, $command)->assertForbidden();
|
||||
$this->actingAs($owner);
|
||||
// UI keeps display labels in selection_labels, outside canonical fields.
|
||||
$economic = array_filter($saved['prefill']['simulator'], fn ($key) => ! str_ends_with($key, '_label') && $key !== 'email', ARRAY_FILTER_USE_KEY);
|
||||
$economic['purpose'] = 'own_investment';
|
||||
// Explicit synthetic scenario; the budget interval remains separate.
|
||||
$economic['total_investment'] = '2000000';
|
||||
$economic['secured_amount'] = '1000000';
|
||||
$economic['annual_revenue'] = '300000';
|
||||
$economic['planning_loan'] = 'no';
|
||||
$economic['planning_assumptions_snapshot'] = json_encode(['version' => 1, 'data' => ['model' => 'other', 'capacity' => '100', 'price' => '250', 'occupancy' => '', 'frequency' => '12', 'source' => 'Synthetic explicit assumptions', 'costs' => []]], JSON_THROW_ON_ERROR);
|
||||
$economic['financial_tools_snapshot'] = json_encode(['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
|
||||
'loan' => ['principal' => '', 'rate' => '', 'months' => '', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '', 'upfrontFee' => '', 'recurringFee' => ''],
|
||||
'breakEven' => ['fixed' => '100000', 'price' => '5000', 'variable' => '2000'], 'funding' => ['total' => '2000000', 'secured' => '1000000'],
|
||||
'incomes' => [['label' => 'Synthetic declared revenue', 'amount' => '300000']], 'expenses' => [['label' => 'Synthetic declared costs', 'amount' => '200000']]]], JSON_THROW_ON_ERROR);
|
||||
$allowed = explode(',', substr((new StoreSimulationFormDraftRequest)->rules()['economic_snapshot'][1], 6));
|
||||
$this->assertSame([], array_values(array_diff(array_keys($economic), $allowed)));
|
||||
$simulation = $this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
|
||||
$draft = $this->getJson('/api/v1/simulation-drafts/'.$simulation)->assertOk()->assertJsonPath('data.economic_snapshot.investment_budget_min', '1000000')->assertJsonPath('data.economic_snapshot.investment_budget_max', '5000000')->assertJsonPath('data.economic_snapshot.financial_tools_snapshot', $economic['financial_tools_snapshot'])->json('data');
|
||||
$dossier = $this->getJson('/investimentos/'.$id)->assertOk()->json();
|
||||
$return = ['version' => $dossier['case']['version'], 'draft_id' => $simulation, 'draft_version' => $draft['version_no'], 'context_revision' => $dossier['investment_context']['revision'], 'context_reviewed' => true];
|
||||
$result = $this->postJson('/investimentos/'.$id.'/resultados', $return)->assertOk()->assertJsonPath('result.financial_snapshot', $economic['financial_tools_snapshot'])->assertJsonPath('result.preparation_snapshot.planning_loan', 'no')->assertJsonPath('result.preparation_snapshot.annual_revenue', '300000')->assertJsonPath('result.preparation_snapshot.planning_assumptions_snapshot', $economic['planning_assumptions_snapshot'])->json('result.id');
|
||||
$this->postJson('/investimentos/'.$id.'/resultados', $return)->assertOk()->assertJsonPath('result.id', $result);
|
||||
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonCount(1, 'results')->assertJsonPath('results.0.id', $result)->assertJsonPath('results.0.currency', 'MZN');
|
||||
$this->actingAs(User::factory()->create())->getJson('/api/v1/simulation-drafts/'.$simulation)->assertNotFound();
|
||||
$this->actingAs($owner);
|
||||
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.investment_budget_max', '5000000');
|
||||
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_an_incomplete_currency_can_be_recovered_privately_but_cannot_be_confirmed(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$id = $this->postJson('/investimentos', ['title' => 'Synthetic draft', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
|
||||
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['currency' => 'MZ']];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 1])->assertOk();
|
||||
$this->assertSame('MZ', app(InvestorContext::class)->draft(auth()->user(), $id)['declarations']['currency']);
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertUnprocessable()->assertJsonValidationErrors('declarations.currency');
|
||||
$body['declarations'] = ['currency' => 'MZN', 'investment_budget_min' => '5000000', 'investment_budget_max' => '1000000'];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertUnprocessable()->assertJsonValidationErrors('declarations.investment_budget_max');
|
||||
}
|
||||
|
||||
public function test_district_demography_is_never_presented_as_locality_population(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$parent = null;
|
||||
$district = null;
|
||||
foreach (['country', 'province', 'district', 'admin_post', 'locality'] as $type) {
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type, 'data_classification' => 'public',
|
||||
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
|
||||
if ($type === 'district') {
|
||||
$district = $id;
|
||||
}
|
||||
$parent = $id;
|
||||
}
|
||||
$batch = (string) Str::uuid();
|
||||
DB::table('demographic_imports')->insert(['id' => $batch, 'sha256' => hash('sha256', $batch), 'file_name' => 'SYNTHETIC.xlsx', 'source' => 'Synthetic only', 'publication_version' => 'QA/1', 'status' => 'approved', 'report' => '{}', 'preview_sha256' => str_repeat('0', 64), 'prepared_by' => $owner->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('demographic_observations')->insert(['import_id' => $batch, 'geo_location_id' => $district, 'year' => 2026, 'indicator' => 'population', 'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas', 'data_kind' => 'projected', 'original_value' => '12345', 'numeric_value' => '12345', 'sheet' => '2026', 'cell' => 'B4', 'validation_status' => 'valid']);
|
||||
$url = '/api/v1/investment/demography?'.http_build_query(['territories' => [$parent], 'start_year' => 2026, 'investment_context' => 1]);
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('broader_context', true)->assertJsonPath('requested_territory.id', $parent)
|
||||
->assertJsonPath('territories.0.id', $district)->assertJsonPath('territories.0.level', 'district')->assertJsonPath('territories.0.series.0.values.0.original_value', '12345');
|
||||
DB::table('geo_locations')->where('id', $parent)->update(['data_classification' => 'internal']);
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('requested_territory', null)->assertJsonPath('territories', []);
|
||||
}
|
||||
|
||||
public function test_guided_unknown_answers_and_their_labels_save_and_recover_in_the_existing_simulator_contract(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$economic = ['purpose' => 'simulate_only', 'journey_type' => 'investor'];
|
||||
$economic['preparation_step'] = 'operating';
|
||||
$economic['fiscal_tools_selection'] = ['tax:sales', 'iva:acquisitions'];
|
||||
$economic['initial_budget_snapshot'] = json_encode(['version' => 1, 'rows' => [['label' => 'Synthetic equipment', 'amount' => '1000000']]], JSON_THROW_ON_ERROR);
|
||||
$economic['planning_assumptions_snapshot'] = json_encode(['version' => 1, 'data' => ['model' => 'hotel', 'capacity' => '10', 'price' => '5000', 'occupancy' => '50', 'frequency' => '300', 'source' => 'Synthetic only', 'costs' => []]], JSON_THROW_ON_ERROR);
|
||||
foreach (['customer_validation_status', 'location_assessment_status', 'cost_estimate_status', 'receivables_timing_status', 'funding_status'] as $field) {
|
||||
$economic[$field] = 'unknown';
|
||||
$economic['selection_labels'][$field] = 'Ainda não sei';
|
||||
}
|
||||
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
|
||||
$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->assertJsonPath('data.economic_snapshot.selection_labels.funding_status', 'Ainda não sei')->assertJsonPath('data.economic_snapshot.preparation_step', 'operating')->assertJsonPath('data.economic_snapshot.planning_assumptions_snapshot', $economic['planning_assumptions_snapshot'])->assertJsonPath('data.economic_snapshot.fiscal_tools_selection', $economic['fiscal_tools_selection'])->assertJsonPath('data.economic_snapshot.initial_budget_snapshot', $economic['initial_budget_snapshot']);
|
||||
$economic['selection_labels']['unsupported_field'] = 'Unsupported';
|
||||
$this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('economic_snapshot.selection_labels.unsupported_field');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class InvestorPromoterContinuityTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
|
||||
$this->assertSame('investor_intent_20261115_test', $db->name);
|
||||
$this->assertSame(55441, (int) $db->port);
|
||||
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
}
|
||||
|
||||
private function dossier(User $owner, string $title): string
|
||||
{
|
||||
$id = $this->actingAs($owner)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
|
||||
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['products_services' => 'SYNTHETIC activity '.$title, 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000']];
|
||||
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_existing_company_is_reused_without_changing_ownership_or_repeating_the_comparison(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$this->actingAs($owner);
|
||||
$company = $this->postJson('/api/v1/investment/companies', ['version' => 0, 'operation_key' => (string) Str::uuid(), 'payload' => ['name' => 'SYNTHETIC Hotel Company', 'incorporation_country_code' => 'MZ', 'registration' => 'SYNTHETIC-QA-REG', 'role' => 'SYNTHETIC representative', 'legal_form' => 'quotas']])->assertSuccessful()->json('id');
|
||||
$id = $this->dossier($owner, 'Criar Hotel');
|
||||
$options = $this->getJson('/investimentos/'.$id.'/contexto/entidade')->assertOk()->assertJsonPath('companies.0.id', $company)->json();
|
||||
$memberships = DB::table('memberships')->count();
|
||||
$command = ['version' => $options['version'], 'operation_key' => (string) Str::uuid(), 'kind' => 'company', 'id' => $company, 'source_version' => 1];
|
||||
$url = '/investimentos/'.$id.'/contexto/entidade';
|
||||
$this->postJson($url, $command)->assertOk()->assertJsonPath('continue', '/investimentos/'.$id);
|
||||
$this->postJson($url, $command)->assertOk()->assertJsonPath('replayed', true);
|
||||
$case = DB::table('desk_cases')->where('id', $id)->first();
|
||||
$this->assertSame($owner->id, (int) $case->user_id);
|
||||
$this->assertNull($case->organization_id);
|
||||
$this->assertSame($company, $case->company_id);
|
||||
$this->assertSame($memberships, DB::table('memberships')->count());
|
||||
$this->assertSame(1, DB::table('investment_company_declarations')->where('user_id', $owner->id)->count());
|
||||
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.legal_name', 'SYNTHETIC Hotel Company')->assertJsonPath('prefill.simulator.legal_form', 'quotas')->assertJsonPath('prefill.simulator.entity_stage', 'existing')->assertJsonPath('prefill.simulator.investment_budget_max', '5000000');
|
||||
$preparation = $this->getJson('/investimentos/'.$id.'/preparar-empresa')->assertOk()->assertJsonPath('facts.proposed_name', 'SYNTHETIC Hotel Company')->assertJsonPath('facts.form', 'quotas')->json();
|
||||
$facts = $preparation['facts'];
|
||||
$facts['notes'] = 'SYNTHETIC deliberate note';
|
||||
$this->postJson('/investimentos/'.$id.'/preparar-empresa', ['version' => $preparation['case']['version'], 'operation_key' => (string) Str::uuid(), 'facts' => $facts])->assertOk();
|
||||
$recovered = $this->getJson('/investimentos/'.$id.'/preparar-empresa')->assertOk()->assertJsonPath('facts.notes', 'SYNTHETIC deliberate note')->json();
|
||||
$facts['form'] = 'anonymous';
|
||||
$this->postJson('/investimentos/'.$id.'/preparar-empresa', ['version' => $recovered['case']['version'], 'operation_key' => (string) Str::uuid(), 'facts' => $facts])->assertUnprocessable();
|
||||
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('tools.simulator', '/simulador-fiscal?context='.$id);
|
||||
$this->actingAs(User::factory()->create())->postJson($url, $command)->assertForbidden();
|
||||
$otherCase = $this->dossier(auth()->user(), 'Another private idea');
|
||||
$v = $this->getJson('/investimentos/'.$otherCase.'/contexto/entidade')->assertOk()->assertJsonCount(0, 'companies')->json('version');
|
||||
$this->postJson('/investimentos/'.$otherCase.'/contexto/entidade', [...$command, 'version' => $v, 'operation_key' => (string) Str::uuid()])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_optional_comparison_can_continue_into_the_same_unconstituted_entity_and_recover_without_creating_a_company(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
foreach (['Criar Hotel', 'Agronegocio QA'] as $title) {
|
||||
$id = $this->dossier($owner, $title);
|
||||
$comparison = $this->getJson('/investimentos/'.$id.'/forma-juridica')->assertOk()->json();
|
||||
$answers = $comparison['answers'];
|
||||
$answers['preferred'] = 'quotas';
|
||||
$body = ['version' => $comparison['case']['version'], 'operation_key' => (string) Str::uuid(), 'answers' => $answers, 'continue_preparation' => true];
|
||||
$this->postJson('/investimentos/'.$id.'/forma-juridica', $body)->assertOk();
|
||||
$this->postJson('/investimentos/'.$id.'/forma-juridica', $body)->assertOk()->assertJsonPath('replayed', true);
|
||||
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.entity_stage', 'being_formed');
|
||||
$preparation = $this->getJson('/investimentos/'.$id.'/preparar-empresa?form=quotas')->assertOk()->assertJsonPath('facts.form', 'quotas')->assertJsonPath('facts.activities', 'SYNTHETIC activity '.$title)->json();
|
||||
$facts = $preparation['facts'];
|
||||
$facts['proposed_name'] = 'SYNTHETIC new company '.$title;
|
||||
$this->postJson('/investimentos/'.$id.'/preparar-empresa', ['version' => $preparation['case']['version'], 'operation_key' => (string) Str::uuid(), 'facts' => $facts])->assertOk();
|
||||
$this->getJson('/investimentos/'.$id.'/preparar-empresa?form=anonymous')->assertOk()->assertJsonPath('facts.form', 'quotas')->assertJsonPath('proposedForm', 'anonymous');
|
||||
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('investment_context.promoter_entity.kind', 'being_formed');
|
||||
$this->assertSame(0, DB::table('investment_company_declarations')->where('user_id', $owner->id)->count());
|
||||
$this->assertNull(DB::table('desk_cases')->where('id', $id)->value('company_id'));
|
||||
$version = $this->getJson('/investimentos/'.$id.'/contexto/entidade')->json('version');
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/entidade', ['version' => $version, 'operation_key' => (string) Str::uuid(), 'kind' => 'undetermined'])->assertOk();
|
||||
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('investment_context.promoter_entity.kind', 'undetermined')->assertJsonPath('tools.simulator', '/simulador-fiscal?context='.$id);
|
||||
$this->getJson('/investimentos/'.$id.'/preparar-empresa')->assertOk()->assertJsonPath('facts.proposed_name', $facts['proposed_name']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_an_authorised_organization_snapshot_does_not_transfer_the_case_or_grant_master_write_access(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$org = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $org, 'name' => 'SYNTHETIC represented organization', 'source_system' => 'unit-qa', 'organization_type' => 'private']);
|
||||
DB::table('memberships')->insert(['user_id' => $owner->id, 'organization_id' => $org, 'role' => 'viewer']);
|
||||
$id = $this->dossier($owner, 'Hotel with existing organization');
|
||||
$url = '/investimentos/'.$id.'/contexto/entidade';
|
||||
$options = $this->getJson($url)->assertOk()->assertJsonPath('organizations.0.id', $org)->json();
|
||||
$command = ['kind' => 'organization', 'id' => $org, 'version' => $options['version'], 'operation_key' => (string) Str::uuid(), 'source_version' => 99];
|
||||
$this->postJson($url, $command)->assertConflict();
|
||||
$command['source_version'] = 1;
|
||||
$this->postJson($url, $command)->assertOk();
|
||||
$case = DB::table('desk_cases')->where('id', $id)->first();
|
||||
$this->assertNull($case->organization_id); $this->assertNull($case->company_id); $this->assertSame($owner->id, (int) $case->user_id);
|
||||
$this->assertSame('viewer', DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $org)->value('role'));
|
||||
config(['portal.organization_country_enabled' => true]);
|
||||
$this->putJson('/api/v1/investment/organizations/'.$org.'/country', ['version' => 1, 'country' => 'MZ', 'source_reference' => 'Synthetic QA'])->assertForbidden();
|
||||
DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $org)->update(['deleted_at' => now()]);
|
||||
$current = $this->getJson($url)->assertOk()->assertJsonCount(0, 'organizations')->assertJsonPath('selected.snapshot.name', 'SYNTHETIC represented organization')->json();
|
||||
$this->postJson($url, [...$command, 'version' => $current['version'], 'operation_key' => (string) Str::uuid()])->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"provenance": "Texto colado(5).txt fornecido pela Tempo, conferido na página pública em 2026-11-15; Manica.xlsx, folha 2026, B/E/H54 e B/E/H56:72. Valores de apresentação, não reimportação.",
|
||||
"values": [
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "all",
|
||||
"residence": "all",
|
||||
"value": "248518.44160696748",
|
||||
"original_value": "248518.44160696748",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B54",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "all",
|
||||
"residence": "urban",
|
||||
"value": "86749.367563197302",
|
||||
"original_value": "86749.367563197302",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E54",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "all",
|
||||
"residence": "rural",
|
||||
"value": "161769.0740437702",
|
||||
"original_value": "161769.0740437702",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H54",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "0-4",
|
||||
"residence": "all",
|
||||
"value": "44289.138086159306",
|
||||
"original_value": "44289.138086159306",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B56",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "0-4",
|
||||
"residence": "urban",
|
||||
"value": "14514.88099755138",
|
||||
"original_value": "14514.88099755138",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E56",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "0-4",
|
||||
"residence": "rural",
|
||||
"value": "29774.257088607934",
|
||||
"original_value": "29774.257088607934",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H56",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "5-9",
|
||||
"residence": "all",
|
||||
"value": "35827.776591901806",
|
||||
"original_value": "35827.776591901806",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B57",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "5-9",
|
||||
"residence": "urban",
|
||||
"value": "11793.491606737534",
|
||||
"original_value": "11793.491606737534",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E57",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "5-9",
|
||||
"residence": "rural",
|
||||
"value": "24034.284985164271",
|
||||
"original_value": "24034.284985164271",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H57",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "10-14",
|
||||
"residence": "all",
|
||||
"value": "33152.970732328176",
|
||||
"original_value": "33152.970732328176",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B58",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "10-14",
|
||||
"residence": "urban",
|
||||
"value": "10676.607713136782",
|
||||
"original_value": "10676.607713136782",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E58",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "10-14",
|
||||
"residence": "rural",
|
||||
"value": "22476.363019191398",
|
||||
"original_value": "22476.363019191398",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H58",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "15-19",
|
||||
"residence": "all",
|
||||
"value": "29611.438388752566",
|
||||
"original_value": "29611.438388752566",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B59",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "15-19",
|
||||
"residence": "urban",
|
||||
"value": "10262.814463997336",
|
||||
"original_value": "10262.814463997336",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E59",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "15-19",
|
||||
"residence": "rural",
|
||||
"value": "19348.623924755229",
|
||||
"original_value": "19348.623924755229",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H59",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "20-24",
|
||||
"residence": "all",
|
||||
"value": "24355.209722981959",
|
||||
"original_value": "24355.209722981959",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B60",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "20-24",
|
||||
"residence": "urban",
|
||||
"value": "9042.4481146842572",
|
||||
"original_value": "9042.4481146842572",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E60",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "20-24",
|
||||
"residence": "rural",
|
||||
"value": "15312.761608297704",
|
||||
"original_value": "15312.761608297704",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H60",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "25-29",
|
||||
"residence": "all",
|
||||
"value": "19575.376496383324",
|
||||
"original_value": "19575.376496383324",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B61",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "25-29",
|
||||
"residence": "urban",
|
||||
"value": "7599.5545011499053",
|
||||
"original_value": "7599.5545011499053",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E61",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "25-29",
|
||||
"residence": "rural",
|
||||
"value": "11975.82199523342",
|
||||
"original_value": "11975.82199523342",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H61",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "30-34",
|
||||
"residence": "all",
|
||||
"value": "14806.636171744825",
|
||||
"original_value": "14806.636171744825",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B62",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "30-34",
|
||||
"residence": "urban",
|
||||
"value": "6262.1273781029959",
|
||||
"original_value": "6262.1273781029959",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E62",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "30-34",
|
||||
"residence": "rural",
|
||||
"value": "8544.5087936418295",
|
||||
"original_value": "8544.5087936418295",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H62",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "35-39",
|
||||
"residence": "all",
|
||||
"value": "11161.453343365156",
|
||||
"original_value": "11161.453343365156",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B63",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "35-39",
|
||||
"residence": "urban",
|
||||
"value": "4625.8522262806891",
|
||||
"original_value": "4625.8522262806891",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E63",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "35-39",
|
||||
"residence": "rural",
|
||||
"value": "6535.6011170844686",
|
||||
"original_value": "6535.6011170844686",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H63",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "40-44",
|
||||
"residence": "all",
|
||||
"value": "8538.8490092624561",
|
||||
"original_value": "8538.8490092624561",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B64",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "40-44",
|
||||
"residence": "urban",
|
||||
"value": "3443.8323932108847",
|
||||
"original_value": "3443.8323932108847",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E64",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "40-44",
|
||||
"residence": "rural",
|
||||
"value": "5095.0166160515719",
|
||||
"original_value": "5095.0166160515719",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H64",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "45-49",
|
||||
"residence": "all",
|
||||
"value": "7101.0415927196163",
|
||||
"original_value": "7101.0415927196163",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B65",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "45-49",
|
||||
"residence": "urban",
|
||||
"value": "2463.4886973717157",
|
||||
"original_value": "2463.4886973717157",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E65",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "45-49",
|
||||
"residence": "rural",
|
||||
"value": "4637.5528953479006",
|
||||
"original_value": "4637.5528953479006",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H65",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "50-54",
|
||||
"residence": "all",
|
||||
"value": "5226.3070949472531",
|
||||
"original_value": "5226.3070949472531",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B66",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "50-54",
|
||||
"residence": "urban",
|
||||
"value": "1679.6467072735772",
|
||||
"original_value": "1679.6467072735772",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E66",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "50-54",
|
||||
"residence": "rural",
|
||||
"value": "3546.6603876736754",
|
||||
"original_value": "3546.6603876736754",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H66",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "55-59",
|
||||
"residence": "all",
|
||||
"value": "3998.2566339012192",
|
||||
"original_value": "3998.2566339012192",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B67",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "55-59",
|
||||
"residence": "urban",
|
||||
"value": "1240.8880754969321",
|
||||
"original_value": "1240.8880754969321",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E67",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "55-59",
|
||||
"residence": "rural",
|
||||
"value": "2757.3685584042869",
|
||||
"original_value": "2757.3685584042869",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H67",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "60-64",
|
||||
"residence": "all",
|
||||
"value": "3135.4929526634714",
|
||||
"original_value": "3135.4929526634714",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B68",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "60-64",
|
||||
"residence": "urban",
|
||||
"value": "1009.9040617408737",
|
||||
"original_value": "1009.9040617408737",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E68",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "60-64",
|
||||
"residence": "rural",
|
||||
"value": "2125.5888909225978",
|
||||
"original_value": "2125.5888909225978",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H68",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "65-69",
|
||||
"residence": "all",
|
||||
"value": "2537.0624121248256",
|
||||
"original_value": "2537.0624121248256",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B69",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "65-69",
|
||||
"residence": "urban",
|
||||
"value": "753.99704079870799",
|
||||
"original_value": "753.99704079870799",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E69",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "65-69",
|
||||
"residence": "rural",
|
||||
"value": "1783.0653713261181",
|
||||
"original_value": "1783.0653713261181",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H69",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "70-74",
|
||||
"residence": "all",
|
||||
"value": "2016.6006284927157",
|
||||
"original_value": "2016.6006284927157",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B70",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "70-74",
|
||||
"residence": "urban",
|
||||
"value": "576.8557228333616",
|
||||
"original_value": "576.8557228333616",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E70",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "70-74",
|
||||
"residence": "rural",
|
||||
"value": "1439.7449056593541",
|
||||
"original_value": "1439.7449056593541",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H70",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "75-79",
|
||||
"residence": "all",
|
||||
"value": "1320.7863269208433",
|
||||
"original_value": "1320.7863269208433",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B71",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "75-79",
|
||||
"residence": "urban",
|
||||
"value": "331.39109408599069",
|
||||
"original_value": "331.39109408599069",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E71",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "75-79",
|
||||
"residence": "rural",
|
||||
"value": "989.39523283485266",
|
||||
"original_value": "989.39523283485266",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H71",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "80+",
|
||||
"residence": "all",
|
||||
"value": "1864.0454223179609",
|
||||
"original_value": "1864.0454223179609",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "B72",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "80+",
|
||||
"residence": "urban",
|
||||
"value": "471.58676874437788",
|
||||
"original_value": "471.58676874437788",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "E72",
|
||||
"validation_status": "warning"
|
||||
},
|
||||
{
|
||||
"year": 2026,
|
||||
"indicator": "population",
|
||||
"age_group": "80+",
|
||||
"residence": "rural",
|
||||
"value": "1392.458653573583",
|
||||
"original_value": "1392.458653573583",
|
||||
"unit": "pessoas",
|
||||
"data_kind": "projected",
|
||||
"sheet": "2026",
|
||||
"cell": "H72",
|
||||
"validation_status": "warning"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
catalogueDetailHref,
|
||||
catalogueReturnHref,
|
||||
catalogueQuery,
|
||||
} from '../../resources/js/lib/catalogue-context.ts';
|
||||
|
||||
test('return preserves deliberate destination edits, including cleared ancestors, without stale descendants', () => {
|
||||
const detail = catalogueDetailHref(
|
||||
'/carteira/a',
|
||||
'q=energia&province_id=p&district_id=d&page=4',
|
||||
);
|
||||
assert.equal(
|
||||
catalogueReturnHref('/carteira?q=turismo&province_id=', detail),
|
||||
'/carteira?q=turismo&province_id=&page=4',
|
||||
);
|
||||
assert.equal(
|
||||
catalogueReturnHref('/carteira?province_id=other', detail),
|
||||
'/carteira?province_id=other&q=energia&page=4',
|
||||
);
|
||||
});
|
||||
test('different catalogue families share geography but not text, kind, price, page or private context', () => {
|
||||
const detail =
|
||||
'/carteira/a?catalogue_query=' +
|
||||
encodeURIComponent(
|
||||
'q=airport&kind=project&page=4&province_id=p&context=private&min_amount=200',
|
||||
);
|
||||
assert.equal(
|
||||
catalogueReturnHref('/zonas?category=zee', detail),
|
||||
'/zonas?category=zee&province_id=p',
|
||||
);
|
||||
assert.equal(catalogueReturnHref('/documentos', detail), '/documentos');
|
||||
});
|
||||
test('resource-specific return queries do not inherit from other tabs or mutable storage', () => {
|
||||
const a = catalogueDetailHref(
|
||||
'/oportunidades/a',
|
||||
'q=rice&facets[]=2&facets[]=3¤cy=MZN',
|
||||
);
|
||||
const b = catalogueDetailHref('/oportunidades/b', 'q=gas¤cy=USD');
|
||||
const back = catalogueReturnHref('/oportunidades', a);
|
||||
assert.deepEqual(
|
||||
new URL(back, 'https://portal.invalid').searchParams.getAll('facets[]'),
|
||||
['2', '3'],
|
||||
);
|
||||
assert.match(back, /q=rice/);
|
||||
assert.match(catalogueReturnHref('/oportunidades', b), /q=gas/);
|
||||
assert.equal(
|
||||
catalogueReturnHref('/oportunidades', '/oportunidades/c'),
|
||||
'/oportunidades',
|
||||
);
|
||||
});
|
||||
test('detail URL preserves existing parameters and fragment; unsafe and recursive filters are not carried', () => {
|
||||
const href = catalogueDetailHref(
|
||||
'/oportunidades/a?from_dossier=origin#details',
|
||||
'q=rice&context=other&catalogue_query=recursive&password=secret&kind=cluster',
|
||||
);
|
||||
const url = new URL(href, 'https://portal.invalid');
|
||||
assert.equal(url.searchParams.get('from_dossier'), 'origin');
|
||||
assert.equal(url.hash, '#details');
|
||||
assert.equal(url.searchParams.get('catalogue_query'), 'q=rice');
|
||||
assert.equal(
|
||||
catalogueDetailHref('https://outside.test/a', 'q=rice'),
|
||||
'https://outside.test/a',
|
||||
);
|
||||
});
|
||||
test('only positive finite page numbers survive; repeated scalar criteria have one deliberate value', () => {
|
||||
assert.equal(
|
||||
catalogueQuery(
|
||||
'/carteira',
|
||||
'q=old&q=new&page=-1&per_page=Infinity',
|
||||
).toString(),
|
||||
'q=new',
|
||||
);
|
||||
assert.equal(
|
||||
catalogueReturnHref(
|
||||
'/carteira',
|
||||
'/carteira/a?catalogue_query=' +
|
||||
encodeURIComponent('https://outside.test'),
|
||||
),
|
||||
'/carteira',
|
||||
);
|
||||
});
|
||||
@@ -26,6 +26,23 @@ function load(file, mocks = {}) {
|
||||
return module.exports;
|
||||
}
|
||||
const lib = load('../../resources/js/lib/company-preparation.ts');
|
||||
test('historical delivery wording is presented professionally without changing the stored notice or legal limits', () => {
|
||||
const legal =
|
||||
' Esta ficha prepara dados para revisão; não é uma minuta contratual, assinatura, registo ou licença.';
|
||||
const stored =
|
||||
'Os sete exemplares referidos no complemento ainda não foram fornecidos.' +
|
||||
legal;
|
||||
assert.equal(
|
||||
lib.companyPreparationNotice(stored),
|
||||
'Os modelos contratuais não estão disponíveis nesta preparação.' +
|
||||
legal,
|
||||
);
|
||||
assert.match(stored, /sete exemplares/);
|
||||
assert.equal(
|
||||
lib.companyPreparationNotice('Outro aviso específico.'),
|
||||
'Outro aviso específico.',
|
||||
);
|
||||
});
|
||||
const party = {
|
||||
id: 'SYNTHETIC-party',
|
||||
name: '<SYNTHETIC>',
|
||||
@@ -148,6 +165,7 @@ function harness() {
|
||||
router: { visit: (...args) => visits.push(args) },
|
||||
},
|
||||
'./shared': { Frame: 'main', card: '', input: '', button: '' },
|
||||
'@/components/currency-select': { default: () => null },
|
||||
'@/lib/company-preparation': lib,
|
||||
'@/lib/operation-key': {
|
||||
createOperationKey: () => 'SYNTHETIC-operation',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user