Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a76369eb86 | ||
|
|
57fc914821 | ||
|
|
f3ec686f8c | ||
|
|
d3fe8a02ec | ||
|
|
f5b1d67ee5 | ||
|
|
90179149c7 | ||
|
|
364be835fe | ||
|
|
480effa5fc |
@@ -170,8 +170,10 @@ class InvestmentSubmissionController extends Controller
|
||||
// Binding does not save form edits or assert that the context has been reviewed.
|
||||
$this->event($row, $request, 'context_bound');
|
||||
$desk->event($desk->case($request->user(), $case->id), $request->user(), 'manifestation_context_bound', ['submission_id' => $id, 'previous_case_version' => (int) $case->version]);
|
||||
|
||||
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version + 1, 'replayed' => false];
|
||||
}, 3);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
@@ -190,11 +192,12 @@ class InvestmentSubmissionController extends Controller
|
||||
{
|
||||
$required = $submitting ? 'required' : 'nullable';
|
||||
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
|
||||
'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
|
||||
'payload' => ['required', 'array:context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
|
||||
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
|
||||
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
|
||||
'payload.other_details' => ['nullable', 'string', 'max:5000'],
|
||||
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
|
||||
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
|
||||
|
||||
@@ -33,7 +33,8 @@ class StoreLandAssistanceCaseRequest extends FormRequest
|
||||
return [
|
||||
'version' => [$this->isMethod('post') && ! $this->routeIs('land-assistance.support') ? 'nullable' : 'required', 'integer', 'min:1'],
|
||||
'context_revision' => ['nullable', 'integer', 'min:0'], 'context_reviewed' => ['nullable', 'boolean'],
|
||||
'payload' => ['required', 'array:support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
|
||||
'payload' => ['required', 'array:context_reuse,support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
|
||||
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
|
||||
'payload.support_kind' => ['nullable', 'in:investment,home,family_exploitation,recognition,regularization,delimitation,change_of_use,transfer,existing_process,conflict_guidance,other'],
|
||||
'payload.request_description' => ['nullable', 'string', 'max:4000'],
|
||||
'payload.project_reference' => ['nullable', 'string', 'max:160'],
|
||||
|
||||
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'contact_province', 'contact_district', 'preferred_channel',
|
||||
];
|
||||
$economic = [
|
||||
'monthly_iva_settings', 'monthly_iva_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',
|
||||
'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', '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',
|
||||
@@ -72,6 +72,14 @@ class StoreSimulationFormDraftRequest extends FormRequest
|
||||
'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'],
|
||||
'economic_snapshot.context_applicability_snapshot' => ['nullable', 'string', 'max:20000'],
|
||||
'economic_snapshot.investment_regime' => ['nullable', 'in:general,industrial_park,zee,zfi'],
|
||||
'economic_snapshot.context_reuse' => ['nullable', 'string', 'max:10000'],
|
||||
'economic_snapshot.compatible_fields_snapshot' => ['nullable', 'string', 'max:10000'],
|
||||
'economic_snapshot.published_iva_base' => ['nullable', 'string', 'max:40'],
|
||||
'economic_snapshot.published_iva_currency' => ['nullable', 'string', 'max:3'],
|
||||
'economic_snapshot.published_iva_date' => ['nullable', 'string', 'max:10'],
|
||||
'economic_snapshot.published_iva_scope' => ['nullable', 'string', 'in:yes,no'],
|
||||
'economic_snapshot.monthly_iva_settings' => ['nullable', 'string', 'max:2000'],
|
||||
'economic_snapshot.monthly_iva_snapshot' => ['nullable', 'string', 'max:60000'],
|
||||
'economic_snapshot.project_team_snapshot' => ['nullable', 'string', 'max:30000'],
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Models\SimulationFormDraft;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -21,7 +22,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'];
|
||||
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'];
|
||||
|
||||
public function __construct(private readonly VirtualDesk $desk) {}
|
||||
|
||||
@@ -34,8 +35,26 @@ final class InvestorContext
|
||||
{
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
|
||||
return $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [],
|
||||
$context = $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [],
|
||||
'fields' => ['project_title' => $case->title], 'provenance' => ['project_title' => ['kind' => 'dossier', 'id' => $case->id, 'version' => $case->version]], 'review_steps' => []];
|
||||
// Explicit dossier anchor only: never the account's most recent simulation or another owner's draft.
|
||||
if (($case->simulation_id ?? null) && request()->user()?->id === (int) $case->user_id) {
|
||||
$draft = SimulationFormDraft::whereKey($case->simulation_id)->where('owner_user_id', $case->user_id)->whereNull('deleted_at')->first();
|
||||
if ($draft) {
|
||||
$values = [...SimulationFormDraft::decryptSnapshot($draft->personal_snapshot), ...SimulationFormDraft::decryptSnapshot($draft->economic_snapshot)];
|
||||
foreach (Arr::only($values, self::FIELDS) as $key => $value) {
|
||||
if (is_scalar($value) && (string) $value !== '' && (! array_key_exists($key, $context['fields']) || ($context['provenance'][$key]['kind'] ?? null) === 'linked_simulation')) {
|
||||
$context['fields'][$key] = (string) $value;
|
||||
$context['provenance'][$key] = ['kind' => 'linked_simulation', 'id' => $draft->id, 'version' => $draft->version_no, 'sha256' => $draft->snapshot_sha256];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$zone = $context['selection']['park'] ?? null;
|
||||
$context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general';
|
||||
$context['fields']['special_zone_id'] = $zone['id'] ?? '';
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
public function dependencyHash(array $selection): string
|
||||
@@ -53,17 +72,20 @@ final class InvestorContext
|
||||
$documents = [];
|
||||
$visibility = 'public';
|
||||
$state = null;
|
||||
$zoneAnalysis = null;
|
||||
if ($kind === 'opportunity') {
|
||||
abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.');
|
||||
$o = Opportunity::with('sector')->findOrFail((int) $id);
|
||||
$published = Opportunity::published()->whereKey($o->id)->exists();
|
||||
if (!$published) {
|
||||
if (! $published) {
|
||||
abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.');
|
||||
$visibility = 'private';
|
||||
$state = app(PortalProfileAccess::class)->state($o);
|
||||
}
|
||||
if (request()->user()) $documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
|
||||
$draft = !$published ? app(OpportunityManagement::class)->values($o) : [];
|
||||
if (request()->user()) {
|
||||
$documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
|
||||
}
|
||||
$draft = ! $published ? app(OpportunityManagement::class)->values($o) : [];
|
||||
$title = $draft['canonical']['official_title'] ?? $o->title;
|
||||
$href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view';
|
||||
$version = $o->management_version;
|
||||
@@ -122,19 +144,28 @@ final class InvestorContext
|
||||
$fields = $this->territoryFields($id);
|
||||
} elseif ($kind === 'park') {
|
||||
abort_unless(Str::isUuid($id), 422);
|
||||
$p = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->findOrFail($id);
|
||||
$p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->findOrFail($id);
|
||||
$title = $p->name;
|
||||
$fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category];
|
||||
$version = $p->version_no;
|
||||
$zoneAnalysis = ['conditions' => app(InvestmentZoneOperationalDetails::class)->publicData($p)['conditions'] ?? [],
|
||||
'source' => $p->legal_source_id ? DB::table('investment_legal_sources')->where('id', $p->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'valid_from', 'valid_until']) : null,
|
||||
'recognition' => 'undetermined', 'executable_benefit' => false];
|
||||
$href = '/zonas/'.$id;
|
||||
if ($p->primary_territory_id) {
|
||||
$fields = $this->territoryFields($p->primary_territory_id);
|
||||
$fields = [...$fields, ...$this->territoryFields($p->primary_territory_id)];
|
||||
}
|
||||
} else {
|
||||
abort(422, 'Tipo de referência inválido.');
|
||||
}
|
||||
$fields = array_filter($fields, fn ($v) => is_scalar($v) && trim((string) $v) !== '');
|
||||
$record = ['key' => $key, 'kind' => $kind, 'id' => $id, 'title' => $title, 'version' => $version, 'href' => $href, 'fields' => $fields];
|
||||
if ($documents || $visibility === 'private') $record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
|
||||
if ($zoneAnalysis !== null) {
|
||||
$record['analysis'] = $zoneAnalysis;
|
||||
}
|
||||
if ($documents || $visibility === 'private') {
|
||||
$record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
|
||||
}
|
||||
|
||||
return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))];
|
||||
}
|
||||
@@ -170,7 +201,11 @@ final class InvestorContext
|
||||
}
|
||||
if (request()->user()) {
|
||||
$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)) $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
|
||||
foreach ($private as $o) {
|
||||
if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) {
|
||||
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config('existing-business.enabled')) {
|
||||
foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) {
|
||||
@@ -179,7 +214,7 @@ final class InvestorContext
|
||||
}
|
||||
|
||||
return ['opportunities' => $items, 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
|
||||
'parks' => app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->orderBy('name')->get(['id', 'name', 'version_no'])];
|
||||
'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])];
|
||||
}
|
||||
|
||||
public function current(User $actor): array
|
||||
@@ -235,7 +270,8 @@ final class InvestorContext
|
||||
abort_if(array_diff(array_keys($input), ['version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', '_token']), 422, 'Campo de controlo desconhecido.');
|
||||
|
||||
return Validator::make($input, ['version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
|
||||
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary', 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
|
||||
'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', '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',
|
||||
'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
|
||||
}
|
||||
|
||||
@@ -299,7 +335,7 @@ final class InvestorContext
|
||||
}
|
||||
// Remove inherited subordinate geography only when its source was actually replaced.
|
||||
foreach ($fields as $key => $value) {
|
||||
if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector'], true) && isset($provenance[$key]['key'])) {
|
||||
if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector', 'special_zone_id', 'investment_regime'], true) && isset($provenance[$key]['key'])) {
|
||||
unset($fields[$key],$provenance[$key]);
|
||||
}
|
||||
}
|
||||
@@ -314,11 +350,16 @@ final class InvestorContext
|
||||
$changed[] = $slot;
|
||||
}
|
||||
}
|
||||
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection);
|
||||
$changedFields = array_column($replaced, 'field');
|
||||
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
|
||||
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
|
||||
$payload = $this->desk->decode($case->payload);
|
||||
$results = $payload['investor_path']['results'] ?? [];
|
||||
$requests = DB::table('desk_requests')->where('case_id', $id)->orderBy('id')->get(['id', 'status', 'protocol', 'version'])->all();
|
||||
$steps = $dependencyChanged ? array_values(array_unique([...(array_intersect($changed, ['opportunity', 'sector']) ? ['S02', 'S03', 'S04', 'S08', 'S12', 'S13', 'S14', 'S15', 'S17'] : []), ...(array_intersect($changed, ['territory', 'park']) ? ['S08', 'S09', 'S10', 'S11', 'S17'] : [])])) : [];
|
||||
if ($conditionsChanged) {
|
||||
$steps = array_values(array_unique([...$steps, 'S02', 'S03', 'S08', 'S12', 'S13', 'S17']));
|
||||
}
|
||||
$plan = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance,
|
||||
'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $dependencyChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [],
|
||||
'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, 'documents_preserved' => DB::table('desk_documents')->where('case_id', $id)->count(),
|
||||
@@ -362,7 +403,7 @@ final class InvestorContext
|
||||
$review[$step] = ['revision' => $revision, 'reason' => 'O contexto mudou; reveja os pressupostos e a evidência aplicável.'];
|
||||
}
|
||||
$context = ['revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review,
|
||||
'dependency_hash' => $this->dependencyHash($plan['selection']), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
|
||||
'dependency_hash' => $this->dependencyHash(['selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
|
||||
if ($plan['dependency_changed']) {
|
||||
foreach ($payload['investor_path']['results'] ?? [] as $i => $r) {
|
||||
$payload['investor_path']['results'][$i] = [...$r, 'context_state' => 'review_required', 'context_invalidated_revision' => $revision, 'context_invalidated_at' => now()->toIso8601String()];
|
||||
@@ -384,6 +425,8 @@ final class InvestorContext
|
||||
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
|
||||
$ctx = $this->state($case);
|
||||
$path = app(InvestorPath::class)->view($actor, $id);
|
||||
$ctx['path_applicability'] = ['definition_id' => $path['definition_id'], 'definition_version' => $path['definition_version'], 'source_sha256' => $path['source_sha256'],
|
||||
'steps' => array_values(array_map(fn ($step) => Arr::only($step, ['code', 'title', 'applicability', 'reason', 'state', 'context_review_required']), array_filter($path['steps'], fn ($step) => ! empty($step['branch']))))];
|
||||
|
||||
$simulatorRevision = null;
|
||||
if ((int) $case->user_id === $actor->id && $case->simulation_id) {
|
||||
@@ -404,10 +447,19 @@ final class InvestorContext
|
||||
public function prefill(array $ctx): array
|
||||
{
|
||||
$f = $ctx['fields'];
|
||||
$sim = Arr::only($f, ['project_title', 'project_summary', 'sector_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id']);
|
||||
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location']);
|
||||
$zone = $ctx['selection']['park'] ?? null;
|
||||
$sim['context_applicability_snapshot'] = json_encode([
|
||||
'version' => 1, 'context_revision' => $ctx['revision'],
|
||||
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
|
||||
'provenance' => $ctx['provenance'],
|
||||
'path' => $ctx['path_applicability'] ?? null,
|
||||
'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']),
|
||||
'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.',
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return ['simulator' => $sim, 'land' => array_filter(['project_reference' => $f['project_title'] ?? null, 'activity' => $f['sector'] ?? null, 'purpose' => $f['project_summary'] ?? null, 'parcels' => [Arr::only($f, array_keys(InvestmentGeography::FIELDS))]], fn ($v) => $v !== null),
|
||||
'desk' => array_filter(['title' => $f['project_title'] ?? null, 'territory_id' => $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null, 'location_source' => isset($f['province_id']) ? 'Referência consultada no contexto v'.($ctx['revision'] ?? 0).': '.($f['location'] ?? 'território identificado').'. Não confirma disponibilidade.' : null, 'description' => $f['project_summary'] ?? null, 'sector' => $f['sector'] ?? null, ...Arr::only($f, array_keys(InvestmentGeography::FIELDS))], fn ($v) => $v !== null),
|
||||
'submission' => array_filter(['sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
|
||||
'submission' => array_filter(['currency' => $f['currency'] ?? null, 'sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,8 @@ class VirtualDesk
|
||||
* @return array<string,mixed> */
|
||||
public function validateFields(array $payload, array $definition, bool $submitting): array
|
||||
{
|
||||
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code'])],
|
||||
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code', 'context_reuse'])],
|
||||
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
|
||||
'payload.country' => ['nullable', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
|
||||
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]];
|
||||
foreach ($definition['fields'] as $key => $field) {
|
||||
@@ -388,7 +389,7 @@ class VirtualDesk
|
||||
abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.');
|
||||
abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403);
|
||||
if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) {
|
||||
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor,$request), 403);
|
||||
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor, $request), 403);
|
||||
}
|
||||
abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.');
|
||||
}
|
||||
@@ -412,13 +413,13 @@ class VirtualDesk
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $data) {
|
||||
$case = $this->case($actor, $id, false, true);
|
||||
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id), 403);
|
||||
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id), 403);
|
||||
abort_unless((int) $case->version === (int) $data['version'], 409);
|
||||
$workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR);
|
||||
$current = $workflow[$data['stage']];
|
||||
$decision = in_array($data['status'], ['completed', 'not_applicable'], true);
|
||||
if ($decision) {
|
||||
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
|
||||
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
|
||||
abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.');
|
||||
foreach ($current['dependencies'] as $dependency) {
|
||||
abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.');
|
||||
|
||||
@@ -1611,5 +1611,40 @@
|
||||
"new_rules_published": false,
|
||||
"consumer_activation_changed": false,
|
||||
"proof_directory": "outputs/modelo-a-20261021"
|
||||
},
|
||||
"contextual_continuity_qa_20261022": {
|
||||
"branch": "codex/project-context-continuity-QA",
|
||||
"base": "57fc914821d0cefa02caa80a74b3d42e663f2742",
|
||||
"scope": "Primeiro incremento de continuidade e explicação contextual; adaptação jurídica integral não encerrada",
|
||||
"status": "QA, revisão; sem integração main ou alteração fiscal activa",
|
||||
"http_tests": {
|
||||
"tests": 4,
|
||||
"assertions": 74,
|
||||
"transaction_rollback": true
|
||||
},
|
||||
"frontend_tests": 8,
|
||||
"typescript": "passed",
|
||||
"build": "passed",
|
||||
"visual_evidence_directory": "outputs/context-continuity-20261022",
|
||||
"same_project_new_session": true,
|
||||
"deliberate_edit_preserved": true,
|
||||
"second_project_isolated": true,
|
||||
"fiscal_execution_id": "ae401465-834f-4761-9406-5af060c499fb",
|
||||
"new_executions_today": 1,
|
||||
"reused_proofs": [
|
||||
"13f68f1b: recuperação/exportadores/PDF",
|
||||
"83d2f97d e 226fd308: precisão/versões/históricos",
|
||||
"3f5430c3: cálculo mensal",
|
||||
"0b0cf761: correspondência parcial Modelo A"
|
||||
],
|
||||
"limitations": [
|
||||
"Catálogo visual QA sintético; ZEE/ZFI apenas controlos HTTP sintéticos revertidos, sem elegibilidade real demonstrada",
|
||||
"Condições jurídicas por sector/actividade/regime e benefícios executáveis não concluídos",
|
||||
"Outros três consumidores usam origem/reconciliador comum; não houve demonstração visual integral de cada processo",
|
||||
"Sem regra fiscal nova, publicação, activação ou integração em main",
|
||||
"Edge interrompeu a inspecção após navegação de regresso; endereço e gravação confirmados por inventário/serviço, visual final não observada. Limitação da ferramenta."
|
||||
],
|
||||
"return_evidence_id": "43317c22-50c8-40cf-98e4-e5d4d108c372",
|
||||
"B_recovered_context_revision": 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2018,3 +2018,26 @@ Cotejo dirigido: Lei22/2022 art.17-A/aditamento e eficácia; Lei10/2025 arts.18/
|
||||
Provas novas apenas do incremento:3testes frontend (agendas, zero/falta, sem IVA/dedução fabricados e relatório sem mutar resultado), TypeScript/build, Edge de consulta após recuperação e biblioteca idempotente/scan. Avaliador, selecção publicada, precisão, históricos e exportadores aceites reutilizados. DossierQA e fixtureIVA v4 continuam disponíveis para revisão; este incremento não calcula novamente. Provas privadas outputs/modelo-a-20261021; recibo e inventário existentes actualizados. Branch codex/iva-modelo-a-reconciliation-QA, parent3f5430c3, separada de main; homologação/regra real/interruptor inalterados.
|
||||
|
||||
Saldo explícito: **reconciliação e primeiro incremento de interface concluídos, apuramento completo não implementado**. Próximo lote: classificação por operação e origem prevista/efectiva, cadastro fundamentado de âmbito/dedução e ligação a totais14–18 propagando indeterminação; 8–16h cadastro/consolidação e20–36h código/aceitação, total28–52h condicionado ao âmbito/consolidação, sem créditos anteriores/substituições/pagamento/reembolso ou simulador integral. O documento contém o delta e critérios concretos. Integração do código, publicação e activação continuam operações distintas; esta preparação não executa nenhuma no destino.
|
||||
|
||||
|
||||
### Continuidade e adaptação contextual — primeiro incremento QA, 22/10/2026
|
||||
|
||||
Instrução expressa da Tempo de 22/10: o contexto do investimento é transversal ao percurso, formulários, documentos e simulações, mesmo sem IA. Base Git e revisão servida confirmadas em leitura: `57fc914821d0cefa02caa80a74b3d42e663f2742`. Trabalho isolado em `codex/project-context-continuity-QA`, sem integração ou publicação/activação fiscal no destino.
|
||||
|
||||
Causas identificadas no código e reprodução dirigida: `InvestorContext` só distribuía título, descrição, sector e geografia; o preenchimento comum copiava apenas campos vazios sem distinguir herança de edição deliberada; o IVA pontual mantinha moeda/data/base em estado local que desaparecia ao desmontar o ecrã. A projecção empresarial sobrepunha a moeda do projecto à moeda de origem do plano ao apresentar/calcular/exportar. No ensaio anterior, projecto guardado em MZN e selector IVA vazio. A primeira tentativa de abertura encontrou uma sessão expirada, confirmada pela leitura sem autenticação e resolvida pelo início de sessão normal; não foi classificada como perda do dossier.
|
||||
|
||||
Correcção comum: consolidar exclusivamente o rascunho explicitamente ligado ao mesmo dossier e proprietário; conservar origem, versão e hash. Não procurar o último projecto da conta. Herança persistida com assinaturas de alteração por campo e âmbito dossier/consumidor; não são tokens de autorização nem prova jurídica. Actualizar valores ainda herdados, preservar alterações deliberadas e campos apagados; manter a hierarquia geográfica e os restantes dados das parcelas. O armazenamento do navegador passa a incluir conta e projecto. Consumidores do padrão: simulador, apoio à terra, Balcão e manifestação de investimento; esta última recebe também a moeda compatível. Campos sem correspondência semântica não são copiados. Nacionalidade, residência e país de constituição não são equiparados.
|
||||
|
||||
O IVA pontual guarda as entradas com o rascunho. A exploração fiscal, oportunidade e componente pontual reutilizam a moeda compatível; a projecção mensal reutiliza o plano e o início previsto. Não se infere exigibilidade fiscal a partir da data prevista de início, nem residência jurídica a partir do país. USD é conservado e explicado como não suportado pela versão MZN. Uma divergência entre moeda guardada no plano e moeda do projecto é apresentada, sem conversão ou reclassificação. O plano conserva a moeda de origem também na apresentação e nos exportadores existentes. Vendas, zero/falta e snapshots de IVA continuam a usar o motor publicado e os históricos aceites; nenhum imposto mensal fixo é introduzido.
|
||||
|
||||
Adaptação deste incremento: o editor existente passa a permitir moeda, início previsto, origem do investidor, tipo/situação da entidade e residência fiscal declarada, sempre pela prévia/confirmar existente. A prévia continua a identificar dados/documentos conservados, resultados e etapas afectados; alterações apenas ao título/descrição não invalidam a aplicabilidade. O selector territorial reutiliza o catálogo público `InvestmentZones` para parques, ZEE e ZFI, preservando versão/hash, fontes e condições públicas já divulgadas no complemento existente. A mesma síntese aparece no contexto e simulador e segue para o relatório do dossier. Os ramos de terra/financiamento reutilizam a definição publicada, factos, estados e razões de `InvestorPath`; não foi criado outro avaliador nem activado benefício.
|
||||
|
||||
Escolher uma zona abre a análise específica: referência consultada, condições documentadas ou em falta, período/fonte cadastrados quando disponíveis e reconhecimento por determinar. O benefício e o cenário condicionado permanecem indeterminados quando não existe ligação executável às condições/reconhecimento. O IVA disponível é identificado como cenário geral sem benefício; não representa automaticamente o regime escolhido nem apuramento final. Reutilizam-se `InvestorContext`, `InvestmentZones`, `InvestmentZoneOperationalDetails`, `InvestorPath`/definição publicada, resolvedor/avaliador fiscal e snapshots existentes. A escolha técnica de exigir uma nova confirmação genérica dos mesmos dados em cada formulário foi retirada por instrução da Tempo; permissões, declarações, submissões formais, revisões independentes aprovadas e campos autorizados de pedidos devolvidos mantêm os controlos existentes.
|
||||
|
||||
Provas novas: 4 testes HTTP/74 asserções, em transacções revertidas na base isolada `simulator_fiscal_delta_20261021_test`/loopback55441; 8 testes frontend de herança, edição/apagamento, isolamento, parcelas, moeda divergente e contexto no relatório; TypeScript/build. Percurso Edge: A nacional/MZN e residência fiscal ZA, vendas guardadas de quatro meses → cálculo mensal → gravação → edição deliberada do título → recarregamento → terminar sessão/iniciar sessão → recuperação e relatório pelos dados efectivamente guardados. Resultado `ae401465-834f-4761-9406-5af060c499fb`, uma única execução nova no dia; Janeiro1000.03/IVA160.0048, Fevereiro2000.06/320.0096, Marçozero e Abrilsem informação. O modelo de relatório recuperado conserva os valores exactos e não altera o snapshot. B estrangeiro/USD, primeiro divergência explícita planoMZN/projectoUSD, depois declaração manual USD no plano, IVA recusado sem conversão; selecção de parque pela prévia e análise específica. Os testes de ZEE/ZFI usam apenas metadados sintéticos revertidos: não demonstram elegibilidade real. Provas privadas em `outputs/context-continuity-20261022`; não entregar fixtures ou credenciais.
|
||||
|
||||
Limites concretos: este é o primeiro incremento utilizável de continuidade e explicação contextual, não a conclusão da adaptação de todos os sectores/regimes. O catálogo QA consultado contém parques sintéticos; a ligação jurídica completa de requisitos/documentos/licenças e benefícios às condições de cada actividade/regime não está demonstrada. Condições sem estrutura/relação executável continuam explicitamente em falta; nenhuma alegação nova de direito ou benefício é feita. Não houve nova validação jurídica nem revalidação genérica de credibilidade das fontes oficiais aceites em08/10. Não repetir cálculo decimal/exportadores/paginação já aceites. A demonstração visual foi feita no percurso e simulador; os outros três consumidores receberam o reconciliador comum, sem se afirmar um ensaio visual integral de cada processo.
|
||||
|
||||
Revisão: abrir o simulador QA com o dossier A `92d0836c-0ffb-4e07-a7f5-0e8ca3f594b9` ou B `1872412f-251a-4f6d-bf18-70388e71a16a`; usar a conta sintética QA existente, nunca actores sintéticos na homologação. O router privado de QA usa a instalação já existente do percurso, activada apenas neste ensaio isolado; não há migration ou nova exigência de funções. Entregar a candidata para revisão pelo processo Git existente, preservando alterações concorrentes. Uma integração futura exige rever o delta relativamente a main e o estado de instalação/configuração do percurso no destino; não publicar regras ou alterar interruptores fiscais como consequência desta correcção. Preservar todos os históricos na suspensão ou reversão de código.
|
||||
|
||||
Fecho da observação deste incremento: o regresso A ao dossier gravou a evidência financeira `43317c22-50c8-40cf-98e4-e5d4d108c372`, rascunho `cf4dec46-c96c-4aa6-aa9c-1a8d6ecb1bea` v8, contexto v1, com hash preservado. A ligação de inspecção do Edge interrompeu-se depois da navegação; o inventário mostrou o endereço do mesmo dossier e a leitura autenticada do serviço confirmou a gravação. A parte visual final do regresso não foi observada e fica como limitação da ferramenta, não falha do simulador. B recuperou contexto v2 e gravou rascunho v6, mantendo USD e sem execução fiscal. O relatório gerado destes dados recuperados conserva a versão do parque e benefício indeterminado. O enriquecimento automático do rascunho privado é exclusivo do proprietário autenticado; o acesso de gestão ao dossier não concede por esta alteração acesso ao rascunho privado da conta.
|
||||
|
||||
File diff suppressed because one or more lines are too long
+5
-409
@@ -7,413 +7,8 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Institutional shell adapted from Tempo; page content keeps its own theme. */
|
||||
.tempo-header {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
background: #090909;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-header-inner {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-rows: 72px;
|
||||
column-gap: 0.5rem;
|
||||
align-items: center;
|
||||
max-width: 1440px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
grid-area: 1 / 1;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.tempo-header-logo {
|
||||
width: auto;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.tempo-primary-navigation {
|
||||
display: none;
|
||||
grid-area: 2 / 1 / auto / -1;
|
||||
min-width: 0;
|
||||
border-top: 1px solid #ffffff1a;
|
||||
}
|
||||
.tempo-primary-navigation > ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.25rem 0.5rem;
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
.tempo-primary-navigation .tempo-nav-trigger {
|
||||
font-size: 0.9375rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
.tempo-nav-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: #eee;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
.tempo-nav-trigger:hover,
|
||||
.tempo-nav-trigger[data-state='open'] {
|
||||
background: #ffffff0d;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-nav-trigger[data-current-group] {
|
||||
color: #fff;
|
||||
box-shadow: inset 0 -2px #ed1c24;
|
||||
}
|
||||
.tempo-nav-trigger[data-state='open'] > svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.tempo-primary-navigation .tempo-nav-trigger > svg {
|
||||
transform: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tempo-section-bar {
|
||||
border-top: 1px solid #ffffff14;
|
||||
background: inherit;
|
||||
}
|
||||
.tempo-section-inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.75rem;
|
||||
max-width: 1440px;
|
||||
margin-inline: auto;
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
}
|
||||
.tempo-section-navigation {
|
||||
grid-area: 1 / 1 / auto / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tempo-section-navigation > ul {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tempo-section-navigation .tempo-nav-trigger {
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.375rem;
|
||||
border-radius: 0;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.tempo-section-navigation .tempo-nav-trigger {
|
||||
min-height: 48px;
|
||||
gap: 0.5rem;
|
||||
padding-inline: 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.tempo-section-inner {
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
padding: 0.25rem 1.25rem;
|
||||
}
|
||||
.tempo-section-navigation {
|
||||
grid-area: 1 / 2;
|
||||
}
|
||||
.tempo-section-inner .tempo-panel-summary {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.tempo-section-inner .tempo-interest-button {
|
||||
grid-area: 1 / 3;
|
||||
}
|
||||
}
|
||||
.tempo-header-extras {
|
||||
display: none;
|
||||
grid-area: 3 / 1 / auto / -1;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding-block: 0.75rem;
|
||||
border-top: 1px solid #ffffff1a;
|
||||
}
|
||||
.tempo-header[data-expanded] .tempo-primary-navigation {
|
||||
display: block;
|
||||
}
|
||||
.tempo-header[data-expanded] .tempo-header-extras {
|
||||
display: flex;
|
||||
}
|
||||
.tempo-header-utilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tempo-utility-button {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.tempo-utility-button:hover {
|
||||
background: #ffffff10;
|
||||
}
|
||||
.tempo-header-social {
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--social-color);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
opacity 0.2s;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.tempo-header-social:hover {
|
||||
transform: translateY(-2px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.tempo-login-button {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #ffffff50;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: #fff;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
.tempo-login-button:hover {
|
||||
background: #ed1c24;
|
||||
border-color: #ed1c24;
|
||||
}
|
||||
.tempo-login-button > span {
|
||||
max-width: clamp(2rem, calc(100vw - 18rem), 6rem);
|
||||
}
|
||||
.tempo-header-actions {
|
||||
grid-area: 1 / 2;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.tempo-interest-button {
|
||||
display: inline-flex;
|
||||
grid-area: 2 / 2;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid #ed1c24;
|
||||
border-radius: 0.4rem;
|
||||
background: #ed1c24;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.tempo-interest-button:hover {
|
||||
background: #c9151c;
|
||||
}
|
||||
.tempo-panel-summary {
|
||||
grid-area: 2 / 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
min-height: 44px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: #fff;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.tempo-panel-summary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tempo-panel-count {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.tempo-header-toggle {
|
||||
grid-area: 1 / 3;
|
||||
display: inline-flex;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #ffffff33;
|
||||
border-radius: 0.4rem;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tempo-header-toggle:hover,
|
||||
.tempo-header-toggle[aria-expanded='true'] {
|
||||
background: #ffffff10;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.tempo-header-inner {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
.tempo-header-logo {
|
||||
height: 44px;
|
||||
}
|
||||
.tempo-primary-navigation > ul {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 88px;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
.tempo-header-logo {
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
/* Shared columns keep each portal area below its institutional link. */
|
||||
.tempo-header-layout {
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(4, max-content) minmax(0, 1fr) auto;
|
||||
column-gap: 0.25rem;
|
||||
max-width: 1440px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
.tempo-header-inner {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
min-height: 88px;
|
||||
column-gap: normal;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.tempo-header-logo {
|
||||
height: 64px;
|
||||
}
|
||||
.tempo-header[data-expanded] .tempo-primary-navigation,
|
||||
.tempo-primary-navigation {
|
||||
display: grid;
|
||||
grid-area: 1 / 2 / 2 / 6;
|
||||
grid-template-columns: subgrid;
|
||||
border: 0;
|
||||
}
|
||||
.tempo-primary-navigation > ul {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
gap: normal;
|
||||
padding: 0;
|
||||
}
|
||||
.tempo-nav-trigger {
|
||||
justify-content: center;
|
||||
padding-inline: 0.875rem;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.tempo-primary-navigation .tempo-nav-trigger {
|
||||
padding-inline: 0.625rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.tempo-header-extras {
|
||||
display: flex;
|
||||
grid-area: 1 / 6;
|
||||
justify-self: end;
|
||||
flex-shrink: 0;
|
||||
padding: 0 0.75rem;
|
||||
border-top: 0;
|
||||
border-inline: 1px solid #ffffff1a;
|
||||
}
|
||||
.tempo-header-utilities {
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tempo-header-social {
|
||||
width: 26px;
|
||||
}
|
||||
.tempo-header-toggle {
|
||||
display: none;
|
||||
}
|
||||
.tempo-header-actions {
|
||||
grid-area: 1 / 7;
|
||||
}
|
||||
.tempo-section-bar {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
.tempo-section-inner {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
gap: normal;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
.tempo-section-navigation {
|
||||
display: grid;
|
||||
grid-area: 1 / 2 / 2 / 5;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
.tempo-section-navigation > ul {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
gap: normal;
|
||||
}
|
||||
.tempo-section-navigation .tempo-nav-trigger {
|
||||
padding-inline: 0.625rem;
|
||||
}
|
||||
.tempo-primary-navigation .tempo-nav-trigger,
|
||||
.tempo-section-navigation .tempo-nav-trigger {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.tempo-interest-button {
|
||||
padding-inline: 0.85rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.tempo-section-inner .tempo-interest-button {
|
||||
grid-area: 1 / 6 / 2 / -1;
|
||||
}
|
||||
}
|
||||
.tempo-header :is(a, button):focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
@import './tempo-header.css';
|
||||
|
||||
.tempo-dropdown {
|
||||
z-index: 60;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -441,7 +36,7 @@
|
||||
outline: none;
|
||||
}
|
||||
.tempo-footer {
|
||||
margin-top: 2rem;
|
||||
margin-top: 1.5rem;
|
||||
background: #050505;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -475,7 +70,8 @@
|
||||
.tempo-contact-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
min-height: 40px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.tempo-contact-link:hover {
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */
|
||||
.tempo-header {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
background: #090909;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #ffffff14;
|
||||
}
|
||||
.tempo-header-inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-rows: 71px;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
max-width: 1600px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
grid-area: 1 / 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 112px;
|
||||
max-width: 100%;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.tempo-header-logo {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
.tempo-primary-navigation {
|
||||
display: none;
|
||||
grid-area: 2 / 1 / auto / -1;
|
||||
min-width: 0;
|
||||
border-top: 1px solid #ffffff1a;
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
.tempo-primary-navigation > ul {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tempo-nav-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
.tempo-nav-trigger:hover,
|
||||
.tempo-nav-trigger[data-state='open'] {
|
||||
background: #ffffff0d;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-nav-trigger[data-current-group] {
|
||||
color: #fff;
|
||||
background: #ffffff08;
|
||||
box-shadow: inset 0 -2px #ed1c24;
|
||||
}
|
||||
.tempo-nav-trigger > svg {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.tempo-nav-trigger[data-state='open'] > svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.tempo-header-extras {
|
||||
display: none;
|
||||
grid-area: 3 / 1 / auto / -1;
|
||||
min-width: 0;
|
||||
padding-block: 0.75rem;
|
||||
border-top: 1px solid #ffffff1a;
|
||||
}
|
||||
.tempo-header-utilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tempo-utility-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.tempo-utility-button:hover {
|
||||
background: #ffffff10;
|
||||
}
|
||||
.tempo-header-actions {
|
||||
grid-area: 1 / 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tempo-login-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid #ffffff40;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
.tempo-login-button:hover {
|
||||
background: #ffffff10;
|
||||
border-color: #ffffff80;
|
||||
}
|
||||
.tempo-login-button > span {
|
||||
max-width: clamp(2rem, calc(100vw - 18rem), 6rem);
|
||||
}
|
||||
.tempo-panel-summary {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.5rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.tempo-panel-summary:hover {
|
||||
background: #ffffff10;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-panel-count {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -0.125rem;
|
||||
min-width: 17px;
|
||||
padding-inline: 0.2rem;
|
||||
border: 2px solid #090909;
|
||||
border-radius: 999px;
|
||||
background: #ed1c24;
|
||||
color: #fff;
|
||||
font-size: 0.5625rem;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
.tempo-interest-button {
|
||||
display: none;
|
||||
grid-area: 4 / 1 / auto / -1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid #ed1c24;
|
||||
border-radius: 0.5rem;
|
||||
background: #ed1c24;
|
||||
color: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.tempo-interest-button:hover {
|
||||
background: #c9151c;
|
||||
}
|
||||
.tempo-header-toggle {
|
||||
grid-area: 1 / 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid #ffffff33;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tempo-header-toggle:hover,
|
||||
.tempo-header-toggle[aria-expanded='true'] {
|
||||
background: #ffffff10;
|
||||
}
|
||||
.tempo-header[data-expanded] .tempo-primary-navigation {
|
||||
display: block;
|
||||
}
|
||||
.tempo-header[data-expanded] .tempo-header-extras {
|
||||
display: flex;
|
||||
}
|
||||
.tempo-header[data-expanded] .tempo-interest-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
.tempo-header :is(a, button):focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.tempo-submenu {
|
||||
max-width: min(
|
||||
calc(100vw - 1.5rem),
|
||||
var(--radix-dropdown-menu-content-available-width)
|
||||
);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
column-gap: 0.75rem;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 132px;
|
||||
}
|
||||
.tempo-interest-button {
|
||||
display: inline-flex;
|
||||
grid-area: 1 / 3;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tempo-header-toggle {
|
||||
grid-area: 1 / 4;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
|
||||
column-gap: 0.25rem;
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 100px;
|
||||
}
|
||||
.tempo-login-button > span {
|
||||
max-width: 3rem;
|
||||
}
|
||||
.tempo-primary-navigation,
|
||||
.tempo-header[data-expanded] .tempo-primary-navigation {
|
||||
display: block;
|
||||
grid-area: 1 / 2;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
justify-self: center;
|
||||
}
|
||||
.tempo-primary-navigation > ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
.tempo-nav-trigger {
|
||||
justify-content: center;
|
||||
padding-inline: 0.25rem;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.tempo-header-extras,
|
||||
.tempo-header[data-expanded] .tempo-header-extras {
|
||||
display: flex;
|
||||
grid-area: 1 / 3;
|
||||
padding: 0 0.5rem 0 0;
|
||||
border: 0;
|
||||
border-right: 1px solid #ffffff1a;
|
||||
}
|
||||
.tempo-header-utilities {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.tempo-header-actions {
|
||||
grid-area: 1 / 4;
|
||||
}
|
||||
.tempo-interest-button {
|
||||
grid-area: 1 / 5;
|
||||
}
|
||||
.tempo-header-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1440px) {
|
||||
.tempo-header-inner {
|
||||
column-gap: 0.75rem;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
.tempo-nav-trigger {
|
||||
padding-inline: 0.375rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 132px;
|
||||
}
|
||||
.tempo-login-button > span {
|
||||
max-width: 5rem;
|
||||
}
|
||||
.tempo-header-extras {
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.tempo-primary-navigation > ul {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1536px) {
|
||||
.tempo-header-inner {
|
||||
column-gap: 1rem;
|
||||
}
|
||||
.tempo-nav-trigger {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
.tempo-login-button > span {
|
||||
max-width: 6rem;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { InvestmentContextAssessment } from './investment-context-assessment';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import {
|
||||
contextApi,
|
||||
contextFieldNames,
|
||||
type ContextView,
|
||||
} from '@/lib/investor-context';
|
||||
import { inheritCompatibleFields } from '@/lib/context-inheritance';
|
||||
type Props = {
|
||||
contextId?: string | null;
|
||||
consumer: 'simulator' | 'land' | 'desk' | 'submission';
|
||||
@@ -12,6 +14,7 @@ type Props = {
|
||||
enabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
allowedFields?: string[];
|
||||
persistInheritance?: boolean;
|
||||
boundRevision?: number;
|
||||
onApply: (patch: Record<string, unknown>, replace: boolean) => void;
|
||||
onReview?: (revision: number, reviewed: boolean) => void;
|
||||
@@ -40,17 +43,16 @@ export function DossierPrefill(props: Props) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [reviewed, setReviewed] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const filled = useRef('');
|
||||
useEffect(() => {
|
||||
if (!contextId) return;
|
||||
let stopped = false;
|
||||
setView(null);
|
||||
setError('');
|
||||
void contextApi<ContextView>(`/investimentos/${contextId}/contexto`)
|
||||
.then((v) => {
|
||||
if (!stopped) {
|
||||
setView(v);
|
||||
setReviewed(false);
|
||||
latest.current.onReview?.(v.context.revision, false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -61,44 +63,20 @@ export function DossierPrefill(props: Props) {
|
||||
};
|
||||
}, [contextId, retry]);
|
||||
useEffect(() => {
|
||||
if (!view || !enabled || readOnly || !view.can_write) return;
|
||||
const generation = `${contextId}:${view.context.revision}:${consumer}`;
|
||||
if (filled.current === generation) return;
|
||||
if (!view || view.case.id !== contextId || !enabled || readOnly || !view.can_write) return;
|
||||
const source = view.prefill[consumer] ?? {};
|
||||
const now = latest.current;
|
||||
const patch: Record<string, unknown> = {};
|
||||
const conflict = geo.some(
|
||||
(k) =>
|
||||
!empty(now.values[k]) &&
|
||||
String(now.values[k]) !== text(source[k]),
|
||||
);
|
||||
for (const [k, v] of Object.entries(source)) {
|
||||
if (
|
||||
(now.allowedFields && !now.allowedFields.includes(k)) ||
|
||||
(geo.includes(k) && conflict)
|
||||
)
|
||||
continue;
|
||||
if (k === 'parcels') {
|
||||
const old = now.values.parcels as
|
||||
| Record<string, unknown>[]
|
||||
| undefined;
|
||||
if (
|
||||
(!old?.length || geo.every((g) => empty(old[0]?.[g]))) &&
|
||||
Array.isArray(v) &&
|
||||
v[0] &&
|
||||
Object.keys(v[0]).length
|
||||
)
|
||||
patch[k] = v;
|
||||
} else if (empty(now.values[k]) && !empty(v)) patch[k] = v;
|
||||
}
|
||||
filled.current = generation;
|
||||
if (Object.keys(patch).length) {
|
||||
now.onApply(patch, false);
|
||||
const inherited = inheritCompatibleFields(`dossier:${contextId}:${consumer}`, now.values, source, now.values.context_reuse, now.allowedFields);
|
||||
if (Object.keys(inherited.patch).length || (now.persistInheritance !== false && now.values.context_reuse !== inherited.metadata)) {
|
||||
// The shared reconciliation has already selected safe fields; metadata travels with the same saved form.
|
||||
now.onApply({...inherited.patch, ...(now.persistInheritance === false ? {} : {context_reuse:inherited.metadata})}, true);
|
||||
setMessage(
|
||||
'Campos vazios preenchidos a partir do dossier. Reveja-os; os campos já preenchidos foram preservados.',
|
||||
'Dados compatíveis reutilizados do mesmo dossier. Os valores herdados acompanham o contexto; as suas alterações locais foram preservadas.',
|
||||
);
|
||||
}
|
||||
}, [view, enabled, readOnly, contextId, consumer]);
|
||||
setReviewed(true);
|
||||
now.onReview?.(view.context.revision, true);
|
||||
}, [view, enabled, readOnly, contextId, consumer, props.values]);
|
||||
if (!contextId) return null;
|
||||
if (error)
|
||||
return (
|
||||
@@ -126,7 +104,7 @@ export function DossierPrefill(props: Props) {
|
||||
),
|
||||
);
|
||||
const differences = Object.keys(source).filter(
|
||||
(k) => text(props.values[k]) !== text(source[k]),
|
||||
(k) => k !== 'context_applicability_snapshot' && text(props.values[k]) !== text(source[k]),
|
||||
);
|
||||
const linkedRevision =
|
||||
consumer === 'simulator'
|
||||
@@ -152,6 +130,7 @@ export function DossierPrefill(props: Props) {
|
||||
. Mestres, declarações deste formulário e conteúdo já submetido
|
||||
mantêm-se separados.
|
||||
</p>
|
||||
<InvestmentContextAssessment contextId={contextId} raw={view.prefill.simulator?.context_applicability_snapshot} />
|
||||
{stale && (
|
||||
<p className="font-semibold" role="status">
|
||||
{linkedRevision > 0
|
||||
@@ -254,7 +233,17 @@ export function DossierPrefill(props: Props) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const k of selected)
|
||||
patch[k] = source[k] ?? '';
|
||||
props.onApply(patch, true);
|
||||
const scope = `dossier:${contextId}:${consumer}`;
|
||||
let raw = String(props.values.context_reuse ?? '');
|
||||
try {
|
||||
const metadata = JSON.parse(raw);
|
||||
if(metadata.scope === scope) {
|
||||
for(const key of selected) delete metadata.inherited?.[key];
|
||||
raw=JSON.stringify(metadata);
|
||||
}
|
||||
} catch { /* Legacy metadata does not grant ownership of other fields. */ }
|
||||
const adopted=inheritCompatibleFields(scope,{...props.values,...Object.fromEntries(selected.map(key=>[key,undefined]))},patch,raw,props.allowedFields);
|
||||
props.onApply({...patch,...(props.persistInheritance === false ? {} : {context_reuse:adopted.metadata})}, true);
|
||||
setSelected([]);
|
||||
setReviewed(false);
|
||||
props.onReview?.(view.context.revision, false);
|
||||
@@ -267,27 +256,7 @@ export function DossierPrefill(props: Props) {
|
||||
</button>
|
||||
</details>
|
||||
)}
|
||||
{!readOnly &&
|
||||
view.can_write &&
|
||||
props.onReview &&
|
||||
view.context.revision > 0 && (
|
||||
<label className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reviewed}
|
||||
onChange={(e) => {
|
||||
setReviewed(e.target.checked);
|
||||
props.onReview?.(
|
||||
view.context.revision,
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
/>{' '}
|
||||
Conferi os dados e pressupostos deste formulário para o
|
||||
contexto v{view.context.revision}; os valores diferentes
|
||||
que conservei são declarações deste processo.
|
||||
</label>
|
||||
)}
|
||||
{!readOnly && reviewed && <p>Contexto v{view.context.revision} recuperado neste formulário, sem nova confirmação dos dados compatíveis. As edições locais conservam-se; as declarações e submissões formais mantêm os seus controlos próprios.</p>}
|
||||
<p className="text-xs">
|
||||
Associar uma referência não confirma incentivos, licenças, terra
|
||||
disponível, reserva ou autorização.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
|
||||
type Snapshot = {context_revision:number;path?:{definition_version:number;steps:Array<{code:string;title:string;applicability:string;reason:string;state:string;context_review_required?:boolean}>}; zone?:{title:string;href:string;version:number|string;fields?:Record<string,string>;analysis?:{conditions?:Array<{id:string;label:string;knowledge:string;source_reference?:string;valid_from?:string;valid_until?:string}>;source?:{title:string;url:string;diploma?:string;article?:string;valid_from?:string;valid_until?:string}|null}}|null;fields?:Record<string,string>};
|
||||
export function InvestmentContextAssessment({raw,contextId}:{raw:unknown;contextId?:string|null}) {
|
||||
let snapshot:Snapshot|null=null;
|
||||
try {const value=JSON.parse(String(raw ?? ''));if(value.version===1) snapshot=value;} catch { /* Missing context remains explicit. */ }
|
||||
if(!snapshot) return null;
|
||||
const fields=snapshot.fields ?? {};const zone=snapshot.zone;
|
||||
const regime=zone?.fields?.investment_regime ?? fields.investment_regime;
|
||||
const names:Record<string,string>={zee:'ZEE',zfi:'ZFI',industrial_park:'Parque industrial'};
|
||||
return <section className="space-y-2 rounded border border-amber-400 p-3" aria-label="Aplicabilidade ao investimento">
|
||||
<h3 className="font-semibold">Aplicabilidade ao investimento · contexto v{snapshot.context_revision}</h3>
|
||||
<p>Origem do investidor: {{national:'nacional',foreign:'estrangeiro',mixed:'misto'}[fields.investor_origin] ?? 'informação em falta'}. Residência fiscal declarada: {fields.tax_residency_country || 'informação em falta'}. Estas condições são distintas; uma não determina a outra.</p>
|
||||
{zone ? <><p><strong>Potencialmente aplicável — análise específica de {names[regime ?? ''] ?? 'zona seleccionada'}.</strong> <Link className="underline" href={zone.href}>{zone.title} · versão {zone.version} · consultar ficha e fundamentos</Link>.</p>
|
||||
<p>A selecção identifica a referência consultada; não confirma o reconhecimento da entidade, actividade autorizada, licença ou acesso a benefícios. Consulte as condições documentadas na ficha e reúna a autorização ou evidência correspondente no dossier.</p>
|
||||
{zone.analysis?.source && <p>Fundamento cadastrado: <a className="underline" href={zone.analysis.source.url}>{zone.analysis.source.diploma || zone.analysis.source.title} {zone.analysis.source.article}</a>. Período cadastrado: {zone.analysis.source.valid_from || 'início por indicar'} a {zone.analysis.source.valid_until || 'sem termo indicado'}. A cobertura do investimento exige correspondência com estas condições.</p>}
|
||||
{zone.analysis?.conditions?.length ? <ul className="list-disc pl-5">{zone.analysis.conditions.map(condition=><li key={condition.id}><strong>{condition.label}</strong>: {condition.knowledge==='documented'?'condição documentada; cumprimento pelo investimento por confirmar':condition.knowledge==='disputed'?'condição com informação divergente':'informação em falta'}. {condition.source_reference && <>Fundamento: {condition.source_reference}.</>} {condition.valid_from && <>Desde {condition.valid_from}.</>} {condition.valid_until && <>Até {condition.valid_until}.</>}</li>)}</ul> : <p>Condições específicas: informação estruturada em falta nesta referência. A ficha e os documentos continuam consultáveis; não se presume cumprimento.</p>}
|
||||
<p role="status">Benefício fiscal deste enquadramento: indeterminado. Não existe neste consumidor uma regra executável ligada ao reconhecimento e condições da zona. No simulador, o IVA disponível corresponde apenas ao cenário geral sem benefício, sobre vendas declaradas compatíveis; não simula o regime especial. O cenário com benefício e o apuramento final estão indisponíveis.</p></>
|
||||
: <p>Enquadramento geral de preparação: sem ZEE, ZFI ou parque seleccionado. Isso não confirma a elegibilidade para uma operação fiscal. O cálculo mantém o âmbito e as exclusões da versão publicada.</p>}
|
||||
<p>Confirmado neste quadro significa apenas dado recuperado do dossier; declarações e referências não são decisões jurídicas. Requisitos específicos não documentados permanecem indeterminados.</p>
|
||||
{snapshot.path && <details><summary>Passos adaptados pelo percurso existente · versão {snapshot.path.definition_version}</summary><ul className="list-disc pl-5">{snapshot.path.steps.map(step=><li key={step.code}>{step.code} — {step.title}: {step.context_review_required?'informação alterada; rever fundamento':step.applicability==='not_needed_for_preparation'?'não aplicável à preparação declarada':step.applicability==='undetermined'?'informação em falta':['completed','approved'].includes(step.state)?'preparação confirmada no percurso; não é decisão fiscal':'potencialmente aplicável'}. {step.reason}</li>)}</ul></details>}
|
||||
{contextId && <Link className="underline" href={`/investimentos/${contextId}/contexto/editar`}>Alterar contexto e comparar impacto antes de guardar</Link>}
|
||||
</section>;
|
||||
}
|
||||
@@ -3,14 +3,16 @@ import { Link, router, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ArrowUpRight,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Menu,
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Fragment, useEffect, useId, useRef, useState } from 'react';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useNavigationContext } from '@/components/navigation-context';
|
||||
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
|
||||
import { tempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
|
||||
import {
|
||||
TempoLanguageSelector,
|
||||
TempoWeather,
|
||||
@@ -52,6 +54,7 @@ export function PortalGroupedMenu() {
|
||||
scopedAdministration?: {label:string;href:string}[];
|
||||
}>();
|
||||
const { t, language, requestCount } = useTempoUi();
|
||||
const mobile = useIsMobile();
|
||||
const navigation = useNavigationContext();
|
||||
const groups = portalNavigation({
|
||||
context: navigation.view
|
||||
@@ -72,24 +75,34 @@ export function PortalGroupedMenu() {
|
||||
const currentItem = currentNavigationItem(groups, url);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const institutionalId = useId();
|
||||
const navigationId = useId();
|
||||
const toolsId = useId();
|
||||
const interestId = useId();
|
||||
const toggle = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => router.on('navigate', () => setExpanded(false)), []);
|
||||
const items = (children: PortalNavItem[]) =>
|
||||
children.map((item) =>
|
||||
item.children ? (
|
||||
<DropdownMenuSub key={item.id}>
|
||||
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
|
||||
{t(item.label)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[min(70vh,34rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
mobile ? (
|
||||
<Fragment key={item.id}>
|
||||
<DropdownMenuLabel className="mt-2 border-t px-3 pt-3 text-xs text-slate-500">
|
||||
{t(item.label)}
|
||||
</DropdownMenuLabel>
|
||||
{items(item.children)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</Fragment>
|
||||
) : (
|
||||
<DropdownMenuSub key={item.id}>
|
||||
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
|
||||
{t(item.label)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown tempo-submenu max-h-[min(70vh,34rem)] w-72 overflow-y-auto p-2"
|
||||
>
|
||||
{items(item.children)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
@@ -111,6 +124,8 @@ export function PortalGroupedMenu() {
|
||||
(group) =>
|
||||
!['home', 'discovery', 'realization', 'support'].includes(group.id),
|
||||
);
|
||||
const panelLabel = `${t('O meu painel')} — ${requestCount} ${t(requestCount === 1 ? 'pedido recebido' : 'pedidos recebidos')}`;
|
||||
const institutionalCurrent = url.split('?')[0] === '/contacto';
|
||||
return (
|
||||
<header
|
||||
className="tempo-header"
|
||||
@@ -120,245 +135,303 @@ export function PortalGroupedMenu() {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
expanded &&
|
||||
!event.defaultPrevented
|
||||
!event.defaultPrevented &&
|
||||
!(
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('[role="menu"]')
|
||||
)
|
||||
) {
|
||||
setExpanded(false);
|
||||
toggle.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="tempo-header-layout">
|
||||
<div className="tempo-header-inner">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="InvesteMoz — início"
|
||||
className="tempo-logo-link"
|
||||
>
|
||||
<PortalProductIdentity />
|
||||
</Link>
|
||||
<nav
|
||||
id={institutionalId}
|
||||
aria-label="Navegação institucional"
|
||||
className="tempo-primary-navigation"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<Link
|
||||
href="/"
|
||||
className="tempo-nav-trigger"
|
||||
aria-current={
|
||||
url.split('?')[0] === '/'
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
data-current-group={
|
||||
url.split('?')[0] === '/' || undefined
|
||||
}
|
||||
>
|
||||
{t('Início')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://revista.tempo.co.mz/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="tempo-nav-trigger"
|
||||
aria-label={`${t('Revista Tempo')} — ${t('Abre num novo separador')}`}
|
||||
>
|
||||
{t('Revista Tempo')}
|
||||
<ArrowUpRight
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{[
|
||||
['/sobre', 'Sobre nós'],
|
||||
['/contacto', 'Contacto'],
|
||||
].map(([href, label]) => (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className="tempo-nav-trigger"
|
||||
aria-current={
|
||||
url.split('?')[0] === href
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
data-current-group={
|
||||
url.split('?')[0] === href ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
{t(label)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div id={toolsId} className="tempo-header-extras">
|
||||
<div className="tempo-header-utilities">
|
||||
<div className="flex items-center gap-1">
|
||||
<TempoWeather />
|
||||
<TempoLanguageSelector />
|
||||
</div>
|
||||
<TempoSocialLinks />
|
||||
</div>
|
||||
</div>
|
||||
<div className="tempo-header-actions">
|
||||
{props.auth?.user ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger className="tempo-login-button">
|
||||
<UserRound size={16} aria-hidden="true" />
|
||||
<span className="max-w-24 truncate">
|
||||
{props.auth.user.name ||
|
||||
t('A minha área')}
|
||||
</span>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
<DropdownMenuLabel className="truncate text-xs text-slate-500">
|
||||
{props.auth.user.email}
|
||||
</DropdownMenuLabel>
|
||||
{items(accountGroups)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="tempo-menu-item text-red-700"
|
||||
>
|
||||
<Link
|
||||
href={logout()}
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={loggingOut}
|
||||
data-test="portal-logout-button"
|
||||
onClick={() => router.flushAll()}
|
||||
onStart={() => setLoggingOut(true)}
|
||||
onFinish={() =>
|
||||
setLoggingOut(false)
|
||||
<div className="tempo-header-inner">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Tempo — início"
|
||||
className="tempo-logo-link"
|
||||
>
|
||||
<img
|
||||
src="/images/logo/logo-vermelho.png"
|
||||
alt="Tempo"
|
||||
width="1787"
|
||||
height="564"
|
||||
className="tempo-header-logo"
|
||||
/>
|
||||
</Link>
|
||||
<nav
|
||||
id={navigationId}
|
||||
aria-label="Áreas do portal"
|
||||
className="tempo-primary-navigation"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<Link
|
||||
href="/"
|
||||
className="tempo-nav-trigger"
|
||||
aria-current={
|
||||
url.split('?')[0] === '/'
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
data-current-group={
|
||||
url.split('?')[0] === '/' || undefined
|
||||
}
|
||||
>
|
||||
{t('Início')}
|
||||
</Link>
|
||||
</li>
|
||||
{groups
|
||||
.filter((group) =>
|
||||
[
|
||||
'discovery',
|
||||
'realization',
|
||||
'support',
|
||||
].includes(group.id),
|
||||
)
|
||||
.map((group) => (
|
||||
<li key={group.id}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className="tempo-nav-trigger"
|
||||
data-current-group={
|
||||
current === group.id ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<LogOut
|
||||
size={16}
|
||||
{t(group.label)}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{loggingOut
|
||||
? 'A terminar sessão…'
|
||||
: t('Terminar sessão')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Link href="/login" className="tempo-login-button">
|
||||
<UserRound size={15} aria-hidden="true" />
|
||||
{t('Entrar')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
ref={toggle}
|
||||
type="button"
|
||||
className="tempo-header-toggle"
|
||||
aria-label={
|
||||
expanded
|
||||
? 'Fechar menu e ferramentas'
|
||||
: 'Abrir menu e ferramentas'
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`${institutionalId} ${toolsId}`}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<X size={22} aria-hidden="true" />
|
||||
) : (
|
||||
<Menu size={22} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tempo-section-bar">
|
||||
<div className="tempo-section-inner">
|
||||
<Link
|
||||
href="/investidor/manifestacoes"
|
||||
className="tempo-panel-summary"
|
||||
>
|
||||
<span>{t('O meu painel')}</span>
|
||||
<span
|
||||
className="tempo-panel-count"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
{current === group.id && (
|
||||
<span className="sr-only">
|
||||
Grupo actual
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
{items(group.children ?? [])}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link
|
||||
href="/sobre"
|
||||
className="tempo-nav-trigger"
|
||||
aria-current={
|
||||
url.split('?')[0] === '/sobre'
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
data-current-group={
|
||||
url.split('?')[0] === '/sobre' || undefined
|
||||
}
|
||||
>
|
||||
{requestCount}{' '}
|
||||
{t(
|
||||
requestCount === 1
|
||||
? 'pedido recebido'
|
||||
: 'pedidos recebidos',
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<nav
|
||||
aria-label="Áreas do portal"
|
||||
className="tempo-section-navigation"
|
||||
>
|
||||
<ul>
|
||||
{groups
|
||||
.filter((group) =>
|
||||
[
|
||||
'discovery',
|
||||
'realization',
|
||||
'support',
|
||||
].includes(group.id),
|
||||
)
|
||||
.map((group) => (
|
||||
<li key={group.id}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className="tempo-nav-trigger"
|
||||
data-current-group={
|
||||
current === group.id ||
|
||||
undefined
|
||||
{t('Sobre nós')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className="tempo-nav-trigger"
|
||||
data-current-group={
|
||||
institutionalCurrent || undefined
|
||||
}
|
||||
>
|
||||
Tempo
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[75vh] w-64 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="tempo-menu-item"
|
||||
>
|
||||
<a
|
||||
href="https://revista.tempo.co.mz/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${t('Revista Tempo')} — ${t('Abre num novo separador')}`}
|
||||
>
|
||||
{t('Revista Tempo')}
|
||||
<ArrowUpRight
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{[['/contacto', 'Contacto']].map(
|
||||
([href, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={href}
|
||||
asChild
|
||||
className="tempo-menu-item"
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
aria-current={
|
||||
url.split('?')[0] ===
|
||||
href
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t(group.label)}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
{t(label)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-slate-500">
|
||||
Revista Tempo
|
||||
</DropdownMenuLabel>
|
||||
{tempoSocialLinks.map(
|
||||
({ name, href, icon: Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={name}
|
||||
asChild
|
||||
className="tempo-menu-item"
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Revista Tempo — ${name}`}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{current === group.id && (
|
||||
<span className="sr-only">
|
||||
Grupo actual
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
{items(
|
||||
group.children ?? [],
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<Link
|
||||
href="/manifestar-interesse"
|
||||
className="tempo-interest-button"
|
||||
>
|
||||
{t('Manifestar interesse')}
|
||||
</Link>
|
||||
{name}
|
||||
<ArrowUpRight
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div id={toolsId} className="tempo-header-extras">
|
||||
<div className="tempo-header-utilities">
|
||||
<div className="flex items-center gap-1">
|
||||
<TempoWeather />
|
||||
<TempoLanguageSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tempo-header-actions">
|
||||
<Link
|
||||
href="/investidor/manifestacoes"
|
||||
className="tempo-panel-summary"
|
||||
aria-label={panelLabel}
|
||||
title={panelLabel}
|
||||
>
|
||||
<LayoutDashboard size={19} aria-hidden="true" />
|
||||
<span className="tempo-panel-count" aria-hidden="true">
|
||||
{requestCount}
|
||||
</span>
|
||||
</Link>
|
||||
<span
|
||||
className="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{panelLabel}
|
||||
</span>
|
||||
{props.auth?.user ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger className="tempo-login-button">
|
||||
<UserRound size={16} aria-hidden="true" />
|
||||
<span className="max-w-24 truncate">
|
||||
{props.auth.user.name || t('A minha área')}
|
||||
</span>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
collisionPadding={12}
|
||||
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
|
||||
>
|
||||
<DropdownMenuLabel className="truncate text-xs text-slate-500">
|
||||
{props.auth.user.email}
|
||||
</DropdownMenuLabel>
|
||||
{items(accountGroups)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="tempo-menu-item text-red-700"
|
||||
>
|
||||
<Link
|
||||
href={logout()}
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={loggingOut}
|
||||
data-test="portal-logout-button"
|
||||
onClick={() => router.flushAll()}
|
||||
onStart={() => setLoggingOut(true)}
|
||||
onFinish={() => setLoggingOut(false)}
|
||||
>
|
||||
<LogOut size={16} aria-hidden="true" />
|
||||
{loggingOut
|
||||
? 'A terminar sessão…'
|
||||
: t('Terminar sessão')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Link href="/login" className="tempo-login-button">
|
||||
<UserRound size={15} aria-hidden="true" />
|
||||
{t('Entrar')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
id={interestId}
|
||||
href="/manifestar-interesse"
|
||||
className="tempo-interest-button"
|
||||
>
|
||||
{t('Manifestar interesse')}
|
||||
<ArrowUpRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
<button
|
||||
ref={toggle}
|
||||
type="button"
|
||||
className="tempo-header-toggle"
|
||||
aria-label={
|
||||
expanded
|
||||
? 'Fechar menu e ferramentas'
|
||||
: 'Abrir menu e ferramentas'
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`${navigationId} ${toolsId} ${interestId}`}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<X size={22} aria-hidden="true" />
|
||||
) : (
|
||||
<Menu size={22} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ function MoneyInput({
|
||||
}
|
||||
|
||||
export function SimulationBusinessPlan({
|
||||
currency,
|
||||
currency: projectCurrency,
|
||||
snapshot,
|
||||
onCurrencyChange,
|
||||
onSnapshotChange,
|
||||
@@ -253,8 +253,10 @@ export function SimulationBusinessPlan({
|
||||
const plan = workingPlan ??
|
||||
restoreBusinessPlanSnapshot(snapshot) ?? {
|
||||
...emptyPlan,
|
||||
currency,
|
||||
currency: projectCurrency,
|
||||
};
|
||||
// A project edit is not a conversion or permission to relabel stored sales.
|
||||
const currency = plan.currency || projectCurrency;
|
||||
const update = (
|
||||
key: keyof BusinessFinancialPlanInput,
|
||||
value:
|
||||
@@ -858,6 +860,7 @@ export function SimulationBusinessPlan({
|
||||
<summary className="cursor-pointer text-lg font-semibold">
|
||||
Plano financeiro empresarial (pressupostos editáveis)
|
||||
</summary>
|
||||
{plan.currency && projectCurrency && plan.currency !== projectCurrency && <p role="alert">A projecção guardada está em {plan.currency}; o projecto está em {projectCurrency}. Os valores e a moeda de origem foram preservados. Reveja o enquadramento monetário antes de os reutilizar; não foi feita conversão.</p>}
|
||||
{snapshotError && (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
|
||||
import { SimulationMonthlyIva } from './simulation-monthly-iva';
|
||||
import { inheritSimulatorFields } from '@/lib/context-inheritance';
|
||||
import {
|
||||
PublicDocumentContent,
|
||||
type PublicDocument,
|
||||
@@ -778,7 +779,7 @@ function SearchChoice({
|
||||
|
||||
export function SimulationForm() {
|
||||
const { auth } = usePage<{
|
||||
auth: { user: { email_verified_at?: string | null } | null };
|
||||
auth: { user: { id: number; email_verified_at?: string | null } | null };
|
||||
}>().props;
|
||||
const pageUrl = usePage().url;
|
||||
const draftContext = useMemo(() => {
|
||||
@@ -790,11 +791,12 @@ export function SimulationForm() {
|
||||
};
|
||||
}, [pageUrl]);
|
||||
const contextId = draftContext.context;
|
||||
const storageScope = contextId
|
||||
const projectScope = contextId
|
||||
? `:case:${contextId}`
|
||||
: draftContext.requestedDraft
|
||||
? `:draft:${draftContext.requestedDraft}`
|
||||
: '';
|
||||
const storageScope = `${auth?.user ? `:account:${auth.user.id}` : ':anonymous'}${projectScope}`;
|
||||
const draftKey = legacyDraftKey + storageScope;
|
||||
const draftCreateKey = legacyDraftCreateKey + storageScope;
|
||||
const draftIdKey = legacyDraftIdKey + storageScope;
|
||||
@@ -806,6 +808,7 @@ export function SimulationForm() {
|
||||
const canAssociateDraft = Boolean(auth?.user?.email_verified_at);
|
||||
const [draft, setDraft] = useState<SimulationDraft>({});
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => { if (ready) setDraft(current => inheritSimulatorFields(current)); }, [draft, ready]);
|
||||
const [contextReview, setContextReview] = useState({
|
||||
revision: 0,
|
||||
reviewed: false,
|
||||
@@ -3611,7 +3614,7 @@ export function SimulationForm() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{current.id === 'tax' && <SimulationPublishedIva busy={isCalculating} onCalculate={runSimulation} />}
|
||||
{current.id === 'tax' && <SimulationPublishedIva draft={draft} onChange={change} busy={isCalculating} onCalculate={runSimulation} />}
|
||||
{current.id === 'tax' && (
|
||||
<div
|
||||
id="draft-tool-fiscal"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { InvestmentContextAssessment } from './investment-context-assessment';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, readMonthlyIvaSettings, monthlyIvaCashTreatment, type MonthlyIvaInput, type MonthlyIvaSettings } from '@/lib/monthly-iva';
|
||||
import { fiscalQaRows } from '@/lib/fiscal-qa-preview';
|
||||
@@ -31,7 +32,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
|
||||
return () => { active = false; };
|
||||
}, [retry]);
|
||||
useEffect(() => {
|
||||
if (!settings.auto || !input || busy || !available || completed || attempt.current === key) return;
|
||||
if (!settings.auto || !input || input.currency !== 'MZN' || busy || !available || completed || attempt.current === key) return;
|
||||
const request = input;
|
||||
const pending = current && record && ['queued','running'].includes(record.execution.status) ? record.execution.id : null;
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -56,6 +57,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
|
||||
const snapshot = record?.execution.result_snapshot;
|
||||
const projection = snapshot?.monthly_projection as {rows?: Array<{date:string; sales_declared:string|null; net_sales:string|null; rate:string|null; vat:string|null; gross_sales:string|null; status:string}>} | undefined;
|
||||
return <section className="space-y-3 rounded border border-emerald-700 p-4" aria-label="IVA mensal das vendas projectadas">
|
||||
<InvestmentContextAssessment raw={draft.context_applicability_snapshot} />
|
||||
<h3 className="font-semibold">IVA mensal das vendas projectadas</h3>
|
||||
<p>{source}</p><p>Declare se os preços projectados são sem IVA ou com IVA incluído. Nenhuma taxa é fixada neste formulário; o servidor selecciona a versão publicada.</p>
|
||||
<label className="block">Primeiro mês da projecção<input type="month" value={settings.start} min="2026-01" max="2026-12" onChange={(event) => saveSettings({start:event.target.value})} className="block rounded border p-2" /></label>
|
||||
@@ -63,6 +65,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
|
||||
<label className="block"><input type="checkbox" checked={settings.declared} onChange={(event) => saveSettings({declared:event.target.checked})} /> Declaro que as vendas projectadas são tributáveis à taxa geral interna e não incluem as operações excluídas deste lote.</label>
|
||||
<p className="text-sm">Sem importações, taxas reduzidas, isenções, regimes especiais, deduções, reembolsos ou apuramento final. Valores com IVA incluído permanecem indeterminados neste lote; não se aplica a taxa ao preço bruto.</p>
|
||||
<p className="text-sm">{monthlyIvaCashTreatment}</p>
|
||||
{!!draft.currency && draft.currency !== "MZN" && <p role="alert">Moeda do projecto: {String(draft.currency)}. A regra disponível aceita apenas MZN. As vendas e a moeda são preservadas, sem conversão; o IVA mensal não pode ser determinado por esta versão.</p>}
|
||||
{inputError && <p role="alert">{inputError}</p>}
|
||||
{available === null && !error && <p role="status">A consultar a disponibilidade da versão publicada…</p>}
|
||||
{available === false && <p role="status">A versão publicada de IVA está indisponível neste ambiente para novas execuções. Os pressupostos e históricos mantêm-se.</p>}
|
||||
|
||||
@@ -10,16 +10,22 @@ export type PublishedIvaInput = MonthlyIvaInput | {
|
||||
scope_declared: boolean;
|
||||
};
|
||||
|
||||
export function SimulationPublishedIva({ busy, onCalculate }: {
|
||||
export function SimulationPublishedIva({ busy, onCalculate, draft, onChange }: {
|
||||
draft: Record<string, unknown>;
|
||||
onChange: (field: string, value: string) => void;
|
||||
busy: boolean;
|
||||
onCalculate: (input: PublishedIvaInput) => Promise<unknown>;
|
||||
}) {
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [message, setMessage] = useState('A consultar disponibilidade do cálculo de IVA…');
|
||||
const [base, setBase] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [currency, setCurrency] = useState('');
|
||||
const [declared, setDeclared] = useState(false);
|
||||
const base = String(draft.published_iva_base ?? '');
|
||||
const date = String(draft.published_iva_date ?? '');
|
||||
const currency = String(draft.published_iva_currency ?? '');
|
||||
const declared = draft.published_iva_scope === 'yes';
|
||||
const setBase = (value: string) => onChange('published_iva_base', value);
|
||||
const setDate = (value: string) => onChange('published_iva_date', value);
|
||||
const setCurrency = (value: string) => onChange('published_iva_currency', value);
|
||||
const setDeclared = (value: boolean) => onChange('published_iva_scope', value ? 'yes' : 'no');
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -58,12 +64,13 @@ export function SimulationPublishedIva({ busy, onCalculate }: {
|
||||
<p className="text-sm">Cobertura: exigibilidade em 2026, operação interna à taxa geral. A base tributável é indicada por si; não é copiada do orçamento. Sem isenções, taxas reduzidas, bases especiais, importações, deduções, declaração ou pagamento. O cálculo não valida o enquadramento jurídico.</p>
|
||||
<fieldset disabled={busy} className="space-y-3">
|
||||
<label className="block">Base tributável líquida (decimal, com ponto)<input aria-label="Base tributável líquida" inputMode="decimal" value={base} onChange={(e) => setBase(e.target.value)} className="block rounded border p-2" /></label>
|
||||
<label className="block">Moeda da base<select aria-label="Moeda da base IVA" value={currency} onChange={(e) => setCurrency(e.target.value)} className="block rounded border p-2"><option value="">Seleccione</option><option value="MZN">MZN</option></select></label>
|
||||
<label className="block">Moeda da base<select aria-label="Moeda da base IVA" value={currency} onChange={(e) => setCurrency(e.target.value)} className="block rounded border p-2"><option value="">Seleccione</option><option value="MZN">MZN</option>{currency && currency !== "MZN" && <option value={currency}>{currency} — moeda declarada</option>}</select></label>
|
||||
<label className="block">Data de exigibilidade declarada<input aria-label="Data de exigibilidade IVA" type="date" min="2026-01-01" max="2026-12-31" value={date} onChange={(e) => setDate(e.target.value)} className="block rounded border p-2" /></label>
|
||||
<label className="block"><input type="checkbox" checked={declared} onChange={(e) => setDeclared(e.target.checked)} /> Declaro operação interna à taxa geral, base líquida já apurada e ausência das excepções acima.</label>
|
||||
<p className="text-sm">Sem esta declaração, o resultado fica indeterminado. O valor calculado conserva a precisão decimal; não é saldo a pagar.</p>
|
||||
<button type="button" disabled={!available || busy} onClick={() => void calculate()} className="rounded bg-emerald-800 px-4 py-2 text-white disabled:opacity-50">{busy ? 'A calcular…' : 'Calcular IVA e gravar'}</button>
|
||||
</fieldset>
|
||||
{currency && currency !== "MZN" && <p role="alert">Moeda: {currency}. A versão disponível aceita apenas MZN. O valor declarado foi preservado; não foi feita conversão. Este cálculo permanece indisponível para esta moeda.</p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -10,14 +10,16 @@ const navigation = [
|
||||
|
||||
export function TempoFooter() {
|
||||
const { t, language } = useTempoUi();
|
||||
const { portalInstitutionalContactEmail } = usePage<{ portalInstitutionalContactEmail?: string | null }>().props;
|
||||
const { portalInstitutionalContactEmail } = usePage<{
|
||||
portalInstitutionalContactEmail?: string | null;
|
||||
}>().props;
|
||||
return (
|
||||
<footer
|
||||
className="tempo-footer border-t border-white/5"
|
||||
lang={language}
|
||||
>
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 px-6 py-10 md:grid-cols-2 lg:grid-cols-5 lg:gap-16 lg:px-12">
|
||||
<div className="space-y-8">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-6 py-6 md:grid-cols-2 lg:grid-cols-5 lg:gap-8 lg:px-12">
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="InvesteMoz — início"
|
||||
@@ -37,9 +39,9 @@ export function TempoFooter() {
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="tempo-footer-heading">{t('Contactos')}</h2>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
['+258868010880', '+258 86 801 0880'],
|
||||
['+258833219644', '+258 83 321 9644'],
|
||||
@@ -58,21 +60,26 @@ export function TempoFooter() {
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
href={portalInstitutionalContactEmail ? `mailto:${portalInstitutionalContactEmail}` : '/apoio'}
|
||||
href={
|
||||
portalInstitutionalContactEmail
|
||||
? `mailto:${portalInstitutionalContactEmail}`
|
||||
: '/apoio'
|
||||
}
|
||||
className="tempo-contact-link"
|
||||
>
|
||||
<span className="tempo-contact-icon">
|
||||
<Mail size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<strong className="min-w-0 text-sm font-medium [overflow-wrap:anywhere]">
|
||||
{portalInstitutionalContactEmail ?? t('Apoio ao investidor')}
|
||||
<strong className="min-w-0 text-[13px] leading-5 font-medium [overflow-wrap:anywhere]">
|
||||
{portalInstitutionalContactEmail ??
|
||||
t('Apoio ao investidor')}
|
||||
</strong>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="Navegação do rodapé" className="space-y-8">
|
||||
<nav aria-label="Navegação do rodapé" className="space-y-4">
|
||||
<h2 className="tempo-footer-heading">{t('Navegação')}</h2>
|
||||
<ul className="space-y-4">
|
||||
<ul className="space-y-2">
|
||||
{navigation.map(([href, label]) => (
|
||||
<li key={href}>
|
||||
<Link href={href} className="tempo-footer-link">
|
||||
@@ -86,9 +93,9 @@ export function TempoFooter() {
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<nav aria-label="Informação legal" className="space-y-8">
|
||||
<nav aria-label="Informação legal" className="space-y-4">
|
||||
<h2 className="tempo-footer-heading">Legal</h2>
|
||||
<ul className="space-y-4">
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
[
|
||||
'politica-de-privacidade',
|
||||
@@ -111,7 +118,7 @@ export function TempoFooter() {
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="tempo-footer-heading">Social</h2>
|
||||
<p className="text-xs leading-relaxed font-medium text-slate-300">
|
||||
{t(
|
||||
@@ -121,8 +128,8 @@ export function TempoFooter() {
|
||||
<TempoSocialLinks footer />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-white/10 py-2">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 px-6 text-center text-[10px] tracking-[0.2em] text-slate-400 md:flex-row md:text-left md:text-xs">
|
||||
<div className="border-t border-white/10 py-1.5">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-1 px-6 text-center text-[10px] tracking-[0.1em] text-slate-400 md:flex-row md:text-left md:text-[11px]">
|
||||
<p>
|
||||
© {new Date().getFullYear()}{' '}
|
||||
<span className="text-[#ed1c24]">Tempo</span>.{' '}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { restoreBusinessPlanSnapshot } from './business-financial-plan.ts';
|
||||
const empty = (value: unknown) => value == null || value === '' || (Array.isArray(value) && value.length === 0);
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,canonical(item)]));
|
||||
return value;
|
||||
}
|
||||
// Change tracking only, never an authorization, integrity or legal-acceptance token. No personal values in metadata.
|
||||
function signature(value: unknown): string {
|
||||
const text=JSON.stringify(canonical(value)) ?? 'undefined';let hash=14695981039346656037n;
|
||||
for(let index=0;index<text.length;index++) hash=BigInt.asUintN(64,(hash^BigInt(text.charCodeAt(index)))*1099511628211n);
|
||||
return `${text.length}:${hash.toString(16)}`;
|
||||
}
|
||||
export function inheritCompatibleFields(scope:string, values:Record<string,unknown>, source:Record<string,unknown>, raw:unknown, allowed?:string[]): {patch:Record<string,unknown>; metadata:string; inherited:string[]; conflicts:string[]} {
|
||||
let previous:Record<string,string>={};
|
||||
try { const saved=JSON.parse(String(raw ?? ''));if(saved.version===1 && saved.scope===scope && saved.inherited && typeof saved.inherited==='object') previous=saved.inherited; } catch { /* Legacy inputs are retained, not overwritten. */ }
|
||||
const next={...previous};const patch:Record<string,unknown>={};const inherited:string[]=[];const conflicts:string[]=[];
|
||||
const geo=['province_id','district_id','admin_post_id','locality_id'];
|
||||
const geoConflict=geo.some(key=>(!empty(values[key]) || key in previous) && signature(values[key])!==signature(source[key]) && previous[key]!==signature(values[key]));
|
||||
for(const [key,value] of Object.entries(source)) {
|
||||
if ((allowed && !allowed.includes(key)) || (empty(value) && !(key in previous))) continue;
|
||||
const current=values[key];
|
||||
const track = (item:unknown) => key==='parcels' ? Object.fromEntries(geo.map(field=>[field,(item as Array<Record<string,unknown>>)?.[0]?.[field] ?? ''])) : item;
|
||||
const owned=previous[key]===signature(track(current));
|
||||
const vacant=key==='parcels' ? geo.every(field=>empty((current as Array<Record<string,unknown>>)?.[0]?.[field])) : empty(current);
|
||||
if ((geo.includes(key) && geoConflict) || (!owned && (!vacant || key in previous))) {
|
||||
if(signature(current)!==signature(value)) conflicts.push(key);
|
||||
continue;
|
||||
}
|
||||
if(key==='parcels') {
|
||||
const parcels=current as Array<Record<string,unknown>>|undefined;
|
||||
if(!owned && parcels?.length && geo.some(field=>!empty(parcels[0]?.[field]))) {conflicts.push(key);continue;}
|
||||
}
|
||||
if(signature(track(current))!==signature(track(value))) {
|
||||
if(key==='parcels') {
|
||||
const parcels=(current as Array<Record<string,unknown>>) ?? [];
|
||||
patch[key]=[{...parcels[0],...Object.fromEntries(geo.map(field=>[field,(value as Array<Record<string,unknown>>)[0]?.[field] ?? '']))},...parcels.slice(1)];
|
||||
} else patch[key]=value;
|
||||
}
|
||||
next[key]=signature(track(value));inherited.push(key);
|
||||
}
|
||||
return {patch,metadata:JSON.stringify({version:1,scope,inherited:next}),inherited,conflicts};
|
||||
}
|
||||
|
||||
export function inheritSimulatorFields<T extends Record<string,unknown>>(draft:T): T {
|
||||
const source:Record<string,unknown>={};
|
||||
const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null;
|
||||
if(plan?.currency) source.currency=plan.currency;
|
||||
const currency=draft.currency || source.currency;
|
||||
if(currency) {source.fiscal_currency=currency;source.opportunity_currency=currency;source.published_iva_currency=currency;}
|
||||
if(draft.tax_residency_country) source.fiscal_residency_country=draft.tax_residency_country;
|
||||
if(draft.fiscal_reference_date) source.published_iva_date=draft.fiscal_reference_date;
|
||||
let settings:Record<string,unknown>={start:'',basis:'unknown',declared:false,auto:false};
|
||||
let settingsValid=!draft.monthly_iva_settings;
|
||||
try {const saved=JSON.parse(String(draft.monthly_iva_settings ?? ''));if(saved && !Array.isArray(saved) && typeof saved.start==='string' && ['net','included','unknown'].includes(saved.basis) && typeof saved.declared==='boolean' && typeof saved.auto==='boolean') {settings=saved;settingsValid=true;}} catch { /* Invalid stored inputs remain intact. */ }
|
||||
const start=String(draft.expected_start_date ?? '');
|
||||
if(/^\d{4}-\d{2}-\d{2}$/.test(start)) source.monthly_iva_start=start.slice(0,7);
|
||||
else if(/^\d{4}$/.test(String(draft.fiscal_period)) && draft.fiscal_periodicity==='monthly' && /^(0?[1-9]|1[0-2])$/.test(String(draft.fiscal_month))) source.monthly_iva_start=`${draft.fiscal_period}-${String(draft.fiscal_month).padStart(2,'0')}`;
|
||||
if(!settingsValid) delete source.monthly_iva_start;
|
||||
const values={...draft,monthly_iva_start:settings.start ?? ''};
|
||||
const patch=inheritCompatibleFields('simulator-compatible-fields',values,source,draft.compatible_fields_snapshot);
|
||||
const next:Record<string,unknown>={...draft,...patch.patch,compatible_fields_snapshot:patch.metadata};
|
||||
if('monthly_iva_start' in patch.patch) next.monthly_iva_settings=JSON.stringify({...settings,start:patch.patch.monthly_iva_start});
|
||||
delete next.monthly_iva_start;
|
||||
return JSON.stringify(next)===JSON.stringify(draft) ? draft : next as T;
|
||||
}
|
||||
@@ -134,4 +134,19 @@ export const contextFieldNames: Record<string, string> = {
|
||||
province: 'Província',
|
||||
district: 'Distrito',
|
||||
location: 'Localização',
|
||||
currency: 'Moeda do projecto',
|
||||
person_type: 'Tipo de pessoa',
|
||||
fiscal_period: 'Ano fiscal',
|
||||
fiscal_periodicity: 'Periodicidade',
|
||||
fiscal_month: 'Mês fiscal',
|
||||
fiscal_residency_country: 'País de residência fiscal declarado',
|
||||
activity_id: 'Actividade',
|
||||
project_type: 'Modalidade do investimento',
|
||||
maturity_stage: 'Fase do investimento',
|
||||
expected_start_date: 'Início previsto',
|
||||
investor_origin: 'Origem do investidor',
|
||||
entity_stage: 'Situação da entidade',
|
||||
tax_residency_country: 'Residência fiscal declarada',
|
||||
investment_regime: 'Enquadramento territorial consultado',
|
||||
special_zone_id: 'ZEE, ZFI ou parque',
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export function monthlySalesFromDraft(draft: Record<string, unknown>): { amounts
|
||||
if (draft.business_plan_snapshot) {
|
||||
const plan = restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot));
|
||||
if (!plan) throw new Error('Não foi possível recuperar a projecção de vendas. Os dados guardados não foram substituídos.');
|
||||
if (draft.currency && plan.currency && draft.currency !== plan.currency) throw new Error(`A projecção de vendas está em ${plan.currency}, mas o projecto está em ${String(draft.currency)}. Reveja a moeda da projecção; os valores não foram convertidos nem reclassificados.`);
|
||||
return {amounts: monthlyProjectedSales(plan), source: 'Vendas por produto e mês do plano empresarial; mesmos pressupostos e representação monetária da projecção de vendas.'};
|
||||
}
|
||||
if (draft.cashflow_snapshot) {
|
||||
|
||||
@@ -444,7 +444,6 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
||||
id: 'discovery',
|
||||
label: 'Descoberta',
|
||||
children: [
|
||||
link('home', 'Início', '/'),
|
||||
link(
|
||||
'idea-entry',
|
||||
'Partir de uma ideia',
|
||||
|
||||
@@ -277,6 +277,19 @@ function makeSections(
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.context_applicability_snapshot) {
|
||||
try {
|
||||
const context = JSON.parse(String(draft.context_applicability_snapshot));
|
||||
if (context.version === 1) {
|
||||
const section = sections.get('sources');
|
||||
const zone = context.zone;
|
||||
section?.rows.push({field:'Contexto do investimento',value:`Revisão ${context.context_revision}; ${zone ? `${zone.title}, versão ${zone.version}` : 'sem ZEE, ZFI ou parque seleccionado'}`,origin:'Referência do mesmo dossier no momento da gravação',classification:'Contexto versionado; não é reconhecimento jurídico'});
|
||||
section?.paragraphs.push(zone ? 'Benefício fiscal da zona indeterminado: falta regra executável ligada às condições e reconhecimento. O IVA apresentado é apenas cenário geral sem benefício; não representa o regime especial nem o apuramento final.' : 'Cenário geral de preparação; sem confirmação automática de elegibilidade fiscal.');
|
||||
if(zone?.href) section?.rows.push({field:'Ficha e fundamentos da zona',value:zone.href,origin:`Versão consultada ${zone.version}`,classification:'Referência consultável; condições por verificar'});
|
||||
}
|
||||
} catch { sections.get('sources')?.paragraphs.push('Não foi possível recuperar o contexto guardado; o enquadramento territorial permanece indeterminado.'); }
|
||||
}
|
||||
|
||||
for (const [sectionName, field, value, origin] of rows.slice(1)) {
|
||||
// Questionnaire schema identifiers remain stored; they do not help the
|
||||
// reader assess the project and are not a business report field.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inheritSimulatorFields } from './context-inheritance.ts';
|
||||
import { csvCell } from './csv.js';
|
||||
import { fiscalQaRows } from './fiscal-qa-preview.ts';
|
||||
import { isApiSimulationResultSnapshotV1 } from './simulation-contract.ts';
|
||||
@@ -1530,7 +1531,7 @@ export function restoreSimulationDraft(
|
||||
}
|
||||
}
|
||||
}
|
||||
return draft as SimulationDraft;
|
||||
return inheritSimulatorFields(draft as SimulationDraft);
|
||||
}
|
||||
|
||||
export function simulationDraftFingerprint(draft: SimulationDraft): string {
|
||||
|
||||
@@ -135,6 +135,7 @@ export default function RequestPage({
|
||||
<DossierPrefill
|
||||
contextId={dossier.id}
|
||||
consumer="desk"
|
||||
persistInheritance={request.status !== 'returned'}
|
||||
values={form.data.payload}
|
||||
boundRevision={boundContextRevision}
|
||||
readOnly={!canEdit}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { InvestmentContextAssessment } from '@/components/investment-context-assessment';
|
||||
import ContextDocuments from '@/components/context-documents';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
@@ -131,6 +132,7 @@ export default function ContextEditor({
|
||||
}
|
||||
return (
|
||||
<Frame title="Contexto do investimento">
|
||||
<InvestmentContextAssessment contextId={view.case.id} raw={view.prefill.simulator?.context_applicability_snapshot} />
|
||||
<ContextDocuments source={view.context.selection.opportunity} />
|
||||
<div className="space-y-5 [overflow-wrap:anywhere]">
|
||||
<Link
|
||||
@@ -286,7 +288,7 @@ export default function ContextEditor({
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid min-w-0 gap-1">
|
||||
Parque consultado
|
||||
ZEE, ZFI ou parque consultado
|
||||
<select
|
||||
className={input + ' max-w-full min-w-0'}
|
||||
disabled={busy}
|
||||
@@ -294,7 +296,7 @@ export default function ContextEditor({
|
||||
onChange={(e) => change('park', e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
Sem parque no contexto atual
|
||||
Sem ZEE, ZFI ou parque seleccionado
|
||||
</option>
|
||||
{options.parks.map((p) => (
|
||||
<option key={p.id} value={'park:' + p.id}>
|
||||
@@ -335,6 +337,17 @@ export default function ContextEditor({
|
||||
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} 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} 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) => (
|
||||
<label className="grid gap-1" key={k}>
|
||||
{contextFieldNames[k]}
|
||||
@@ -366,7 +379,18 @@ export default function ContextEditor({
|
||||
declarações do utilizador. Autorize somente os
|
||||
campos que pretende substituir pelo cadastro.
|
||||
</p>
|
||||
{['project_title', 'project_summary'].map((k) => (
|
||||
<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} 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} 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) => (
|
||||
<label className="block" key={k}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InvestmentContextContinuityTest 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('simulator_fiscal_delta_20261021_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 $user, string $title): string
|
||||
{
|
||||
return $this->actingAs($user)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
}
|
||||
|
||||
public function test_same_dossier_context_reuses_linked_draft_and_preserves_account_and_project_boundaries(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$a = $this->dossier($owner, 'A');
|
||||
$b = $this->dossier($owner, 'B');
|
||||
foreach ([[$a, 'MZN', 'national'], [$b, 'USD', 'foreign']] as [$id,$currency,$origin]) {
|
||||
$this->actingAs($owner)->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => ['investor_origin' => $origin, 'tax_residency_country' => 'ZA', 'person_type' => 'collective'], 'economic_snapshot' => ['purpose' => 'simulate_only', 'project_title' => $id, 'currency' => $currency, 'expected_start_date' => '2026-01-01']], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
|
||||
$v = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk();
|
||||
$v->assertJsonPath('prefill.simulator.currency', $currency)->assertJsonPath('prefill.simulator.investor_origin', $origin)->assertJsonPath('prefill.simulator.tax_residency_country', 'ZA');
|
||||
$this->assertSame('linked_simulation', $v->json('context.provenance.currency.kind'));
|
||||
}
|
||||
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$a.'/contexto')->assertForbidden();
|
||||
$this->actingAs($owner)->getJson('/investimentos/'.$a.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.currency', 'MZN');
|
||||
}
|
||||
|
||||
public function test_context_change_is_previewed_versioned_and_idempotent_without_changing_the_source_draft(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$id = $this->dossier($owner, 'Impacto');
|
||||
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
|
||||
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['currency' => 'USD', 'investor_origin' => 'foreign', 'tax_residency_country' => 'MZ'], 'replace_fields' => []];
|
||||
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
|
||||
$this->assertNotEmpty($plan['updated']);
|
||||
$this->assertTrue($plan['dependency_changed']);
|
||||
$control = [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk()->assertJsonPath('replayed', true);
|
||||
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.currency', 'USD')->assertJsonPath('prefill.simulator.tax_residency_country', 'MZ');
|
||||
}
|
||||
|
||||
public function test_zone_selection_reuses_existing_reference_and_impact_workflow_without_granting_a_benefit(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$id = $this->dossier($owner, 'Zonas QA');
|
||||
$template = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->firstOrFail();
|
||||
foreach (['zee', 'zfi', 'industrial_park'] as $category) {
|
||||
// Synthetic catalogue metadata, rolled back. Never a legal source or published rule in homologation.
|
||||
$zone = $template->replicate();
|
||||
$zone->id = (string) Str::uuid();
|
||||
$zone->code = 'SINT-CONTEXT-'.Str::random(12);
|
||||
$zone->name = 'SINTÉTICO QA '.$category;
|
||||
$zone->category = $category;
|
||||
$zone->legal_regime = $category === 'industrial_park' ? 'pending' : $category;
|
||||
$zone->source_evidence = [['source_type' => 'government', 'url' => 'https://example.test/synthetic-zone', 'supports' => 'Synthetic test only; no legal or eligibility conclusion']];
|
||||
$zone->save();
|
||||
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
|
||||
$body = ['version' => $view['case']['version'], 'selection' => ['park' => 'park:'.$zone->id]];
|
||||
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
|
||||
$this->assertSame($category, $plan['fields']['investment_regime']);
|
||||
$this->assertContains('S17', $plan['review_steps']);
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
|
||||
$after = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk();
|
||||
$after->assertJsonPath('prefill.simulator.investment_regime', $category);
|
||||
$snapshot = json_decode($after->json('prefill.simulator.context_applicability_snapshot'), true);
|
||||
$this->assertSame($zone->id, $snapshot['zone']['id']);
|
||||
$this->assertArrayNotHasKey('benefit_rate', $snapshot);
|
||||
}
|
||||
$view = $this->getJson('/investimentos/'.$id.'/contexto')->json();
|
||||
$body = ['version' => $view['case']['version'], 'selection' => ['park' => null]];
|
||||
$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->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.investment_regime', 'general')->assertJsonPath('prefill.simulator.special_zone_id', '');
|
||||
}
|
||||
|
||||
public function test_a_title_edit_does_not_invalidate_applicability_or_ask_for_irrelevant_reviews(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$id = $this->dossier($owner, 'Título original');
|
||||
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
|
||||
$this->postJson('/investimentos/'.$id.'/contexto/previa', ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['project_title' => 'Título revisto']])
|
||||
->assertOk()->assertJsonPath('dependency_changed', false)->assertJsonPath('review_steps', []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { inheritCompatibleFields, inheritSimulatorFields } from '../../resources/js/lib/context-inheritance.ts';
|
||||
import { serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
|
||||
import { monthlySalesFromDraft } from '../../resources/js/lib/monthly-iva.ts';
|
||||
import { restoreSimulationDraft, partitionSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
|
||||
|
||||
test('inherited source updates follow through recovery while local edits and deliberate clears survive',()=>{
|
||||
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN',project_title:'A'}, null);
|
||||
const saved={...first.patch,context_reuse:first.metadata};
|
||||
const changed=inheritCompatibleFields('dossier:A:simulator',saved,{currency:'USD',project_title:'A2'},saved.context_reuse);
|
||||
assert.deepEqual(changed.patch,{currency:'USD',project_title:'A2'});
|
||||
const edited=inheritCompatibleFields('dossier:A:simulator',{...saved,currency:'EUR',project_title:''},{currency:'USD',project_title:'A2'},saved.context_reuse);
|
||||
assert.deepEqual(edited.patch,{});assert.deepEqual(edited.conflicts,['currency','project_title']);
|
||||
const recovered=JSON.parse(JSON.stringify({...saved,currency:'EUR',project_title:''}));
|
||||
assert.deepEqual(inheritCompatibleFields('dossier:A:simulator',recovered,{currency:'USD',project_title:'A2'},recovered.context_reuse).patch,{});
|
||||
assert.ok(!first.metadata.includes('MZN'));assert.ok(!first.metadata.includes('project_title":"A'));
|
||||
});
|
||||
test('a different dossier cannot take ownership of existing values using another dossier metadata',()=>{
|
||||
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN'}, null);
|
||||
const second=inheritCompatibleFields('dossier:B:simulator',first.patch,{currency:'USD'},first.metadata);
|
||||
assert.deepEqual(second.patch,{});assert.deepEqual(second.conflicts,['currency']);
|
||||
});
|
||||
test('a local geographic override protects the entire hierarchy',()=>{
|
||||
const first=inheritCompatibleFields('A',{}, {province_id:'p1',district_id:'d1'},null);
|
||||
const update=inheritCompatibleFields('A',{...first.patch,province_id:'p-local'},{province_id:'p2',district_id:'d2'},first.metadata);
|
||||
assert.deepEqual(update.patch,{});
|
||||
});
|
||||
test('compatible currency and date reuse survives backend partition and new session recovery without invented tax scope',()=>{
|
||||
const initial=inheritSimulatorFields({currency:'USD',expected_start_date:'2026-03-01',purpose:'simulate_only'});
|
||||
assert.equal(initial.fiscal_currency,'USD');assert.equal(initial.published_iva_currency,'USD');assert.equal(initial.opportunity_currency,'USD');
|
||||
assert.equal(JSON.parse(initial.monthly_iva_settings).start,'2026-03');assert.equal(JSON.parse(initial.monthly_iva_settings).declared,false);
|
||||
assert.equal(initial.published_iva_date,undefined);
|
||||
assert.equal(inheritSimulatorFields({expected_start_date:'2026-01-01',monthly_iva_settings:'[invalid stored input'}).monthly_iva_settings,'[invalid stored input');
|
||||
const saved=partitionSimulationDraft(initial,true);const recovered=restoreSimulationDraft(saved.economic_snapshot,saved.personal_snapshot);
|
||||
assert.equal(recovered.published_iva_currency,'USD');assert.strictEqual(inheritSimulatorFields(recovered),recovered);
|
||||
const changed=inheritSimulatorFields({...recovered,currency:'EUR'});assert.equal(changed.fiscal_currency,'EUR');
|
||||
const override=inheritSimulatorFields({...changed,fiscal_currency:'MZN',published_iva_currency:''});assert.equal(override.fiscal_currency,'MZN');assert.equal(override.published_iva_currency,'');
|
||||
});
|
||||
test('land consumer reuses geography inside an empty parcel and preserves all other parcel data',()=>{
|
||||
const first=inheritCompatibleFields('land', {parcels:[{area:'12',province_id:''},{area:'20',province_id:'other'}]}, {parcels:[{province_id:'P',district_id:'D'}]},null);
|
||||
assert.equal(first.patch.parcels[0].area,'12');assert.equal(first.patch.parcels[0].district_id,'D');assert.equal(first.patch.parcels[1].province_id,'other');
|
||||
const changed=inheritCompatibleFields('land',{parcels:[{...first.patch.parcels[0],area:'13'},first.patch.parcels[1]]},{parcels:[{province_id:'P2'}]},first.metadata);
|
||||
assert.equal(changed.patch.parcels[0].area,'13');assert.equal(changed.patch.parcels[0].district_id,'');
|
||||
});
|
||||
test('clearing an inherited zone reference clears only the inherited value, preserving a deliberate override',()=>{
|
||||
const first=inheritCompatibleFields('A',{}, {special_zone_id:'zone'},null);
|
||||
assert.equal(inheritCompatibleFields('A',first.patch,{special_zone_id:''},first.metadata).patch.special_zone_id,'');
|
||||
assert.deepEqual(inheritCompatibleFields('A',{special_zone_id:'local'},{special_zone_id:''},first.metadata).patch,{});
|
||||
});
|
||||
test('conflicting saved sales currency is explained instead of relabelled or converted',()=>{
|
||||
const fields=['openingCash','monthlyCollections','monthlyVariableCostPayments','monthlyInventoryPurchases','monthlyOperatingExpenses','monthlyOperatingPayments','monthlyPayrollExpense','monthlyPayrollPayments','assetInvestment','inventory','receivables','payables','equity','shareholderLoans','bankLoanProceeds','grants','assetDepreciation','monthlyInterestExpense','monthlyInterestPayment','monthlyPrincipalRepayment'];
|
||||
const plan={...Object.fromEntries(fields.map(key=>[key,'0'])),currency:'MZN',periods:4,depreciationLifeMonths:60,products:[{label:'QA',monthlyUnits:'1',unitPrice:'100',unitVariableCost:'0'}]};
|
||||
assert.throws(()=>monthlySalesFromDraft({currency:'USD',business_plan_snapshot:serializeBusinessPlanSnapshot(plan)}),/projecção de vendas está em MZN.*projecto está em USD/);
|
||||
});
|
||||
|
||||
test('report preserves consulted zone version and makes unsupported benefit explicit without inventing a fiscal result',()=>{
|
||||
const context={version:1,context_revision:3,zone:{title:'Referência QA',version:7,href:'/zonas/qa'},fields:{currency:'USD'}};
|
||||
const draft={project_title:'QA',currency:'USD',context_applicability_snapshot:JSON.stringify(context)};
|
||||
const saved=JSON.stringify(draft);const report=createProjectDossierReport(draft,{selectedSections:new Set(['sources'])});
|
||||
assert.match(JSON.stringify(report),/Referência QA, versão 7/);assert.match(JSON.stringify(report),/Benefício fiscal da zona indeterminado/);assert.equal(JSON.stringify(draft),saved);
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
// Isolated UI fixture: real header/providers, synthetic Inertia props, no database.
|
||||
// Requires the local Vite server. No requests to Laravel or external services.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { portalNavigation } from '../resources/js/lib/portal-navigation.ts';
|
||||
|
||||
const root = process.cwd();
|
||||
const dir = path.join(root, 'outputs/header-navbar-20261021');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(process.env.HEADER_QA_PLAYWRIGHT || 'playwright');
|
||||
const { expect } = require(
|
||||
(process.env.HEADER_QA_PLAYWRIGHT || 'playwright') + '/test',
|
||||
);
|
||||
const base = process.env.HEADER_QA_VITE || 'http://127.0.0.1:5173';
|
||||
assert.equal(new URL(base).hostname, '127.0.0.1');
|
||||
const before = process.argv.includes('--before');
|
||||
if (before && fs.existsSync(path.join(dir, 'before-results.json'))) {
|
||||
throw new Error(
|
||||
'The original baseline already exists and must be preserved.',
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'fixture.html'),
|
||||
`<!doctype html>
|
||||
<html lang="pt"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>InvesteMoz · revisão do header</title></head><body><div id="app"></div>
|
||||
<script type="module" src="/outputs/header-navbar-20261021/fixture.tsx"></script></body></html>`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'fixture.tsx'),
|
||||
`
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import { PortalGroupedMenu } from '../../resources/js/components/portal-grouped-menu';
|
||||
import { TempoUiProvider, useTempoUi } from '../../resources/js/components/tempo-brand';
|
||||
import '../../resources/css/app.css';
|
||||
const role = new URLSearchParams(location.search).get('role') || 'guest';
|
||||
const props = {
|
||||
auth: { user: role === 'guest' ? null : { id: 1, name: 'Investidor de demonstração com nome extenso', email: 'qa@example.invalid' }, canManagePortal: role === 'manager' },
|
||||
existingBusinessEnabled: true, businessReviewUrl: role === 'manager' ? '/management/negocios' : null,
|
||||
educationEnabled: true, aiEnabled: true, legacyContentEnabled: true,
|
||||
};
|
||||
function Fixture() {
|
||||
const ui = useTempoUi();
|
||||
return <><PortalGroupedMenu /><main style={{padding:'48px 24px',maxWidth:1200,margin:'auto'}}>
|
||||
<p style={{color:'#b91c1c',fontSize:12,fontWeight:600,letterSpacing:2}}>INVESTEMOZ · REVISÃO LOCAL</p>
|
||||
<h1 style={{fontSize:36,fontWeight:600,margin:'16px 0'}}>O seu próximo investimento começa aqui</h1>
|
||||
<p style={{color:'#64748b'}}>Navegação real do portal com dados fictícios para testar a apresentação.</p>
|
||||
<button data-test="increment" onClick={ui.registerSubmittedInterest}>Simular pedido no contador</button>
|
||||
</main></>;
|
||||
}
|
||||
await createInertiaApp({page:{component:'fixture',props,url:'/',version:null},resolve:()=>Fixture,
|
||||
setup({el,App,props}) {createRoot(el).render(<TempoUiProvider><App {...props}/></TempoUiProvider>);}});
|
||||
`,
|
||||
);
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.HEADER_QA_CHROMIUM
|
||||
? { executablePath: process.env.HEADER_QA_CHROMIUM }
|
||||
: {}),
|
||||
});
|
||||
const results = [];
|
||||
const errors = [];
|
||||
try {
|
||||
for (const [width, role, language] of before
|
||||
? [
|
||||
[1440, 'guest', 'pt'],
|
||||
[390, 'guest', 'pt'],
|
||||
]
|
||||
: [
|
||||
[1280, 'manager', 'fr'],
|
||||
[1280, 'manager', 'pt'],
|
||||
[1440, 'manager', 'fr'],
|
||||
[320, 'guest', 'pt'],
|
||||
[320, 'investor', 'pt'],
|
||||
[390, 'guest', 'pt'],
|
||||
[390, 'manager', 'pt'],
|
||||
[768, 'investor', 'pt'],
|
||||
[1024, 'manager', 'pt'],
|
||||
[1440, 'guest', 'pt'],
|
||||
[1920, 'investor', 'en'],
|
||||
]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
});
|
||||
await context.addInitScript(
|
||||
(lang) => localStorage.setItem('tempo-navigation-language', lang),
|
||||
language,
|
||||
);
|
||||
await context.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin === base && url.pathname.startsWith('/images/')) {
|
||||
const asset = path.resolve(root, 'public', `.${url.pathname}`);
|
||||
if (
|
||||
asset.startsWith(
|
||||
path.join(root, 'public', 'images') + path.sep,
|
||||
) &&
|
||||
fs.existsSync(asset)
|
||||
) {
|
||||
return route.fulfill({ path: asset });
|
||||
}
|
||||
}
|
||||
if (url.origin === base || url.protocol === 'data:')
|
||||
return route.continue();
|
||||
return route.abort();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.error(e.message);
|
||||
});
|
||||
await page.goto(
|
||||
`${base}/outputs/header-navbar-20261021/fixture.html?role=${role}`,
|
||||
{ waitUntil: 'domcontentloaded', timeout: 60000 },
|
||||
);
|
||||
const header = page.locator('.tempo-header');
|
||||
await header.waitFor();
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
dir,
|
||||
`${before ? 'before' : 'after'}-${width}-${role}-${language}.png`,
|
||||
),
|
||||
});
|
||||
if (before) {
|
||||
results.push({
|
||||
width,
|
||||
height: await header.evaluate(
|
||||
(el) => el.getBoundingClientRect().height,
|
||||
),
|
||||
});
|
||||
await context.close();
|
||||
continue;
|
||||
}
|
||||
assert.equal(
|
||||
await header.locator('nav').count(),
|
||||
1,
|
||||
'One navigation landmark in header',
|
||||
);
|
||||
assert.equal(await header.locator('.tempo-section-bar').count(), 0);
|
||||
const collapsedHeight = await header.evaluate(
|
||||
(el) => el.getBoundingClientRect().height,
|
||||
);
|
||||
assert.ok(collapsedHeight <= 80, `Header height ${collapsedHeight}`);
|
||||
if (width < 1280) {
|
||||
const toggle = header.locator('.tempo-header-toggle');
|
||||
await toggle.click();
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'true');
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
dir,
|
||||
`expanded-${width}-${role}-${language}.png`,
|
||||
),
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await toggle.getAttribute('aria-expanded'), 'false');
|
||||
await expect(toggle).toBeFocused();
|
||||
await toggle.click();
|
||||
}
|
||||
const nav = header.locator('nav');
|
||||
if (width >= 1280) {
|
||||
const boxes = await Promise.all(
|
||||
[
|
||||
'.tempo-logo-link',
|
||||
'.tempo-primary-navigation',
|
||||
'.tempo-header-extras',
|
||||
'.tempo-header-actions',
|
||||
'.tempo-interest-button',
|
||||
].map((selector) => header.locator(selector).boundingBox()),
|
||||
);
|
||||
for (let i = 1; i < boxes.length; i++)
|
||||
assert.ok(
|
||||
boxes[i - 1].x + boxes[i - 1].width <= boxes[i].x + 1,
|
||||
`Header elements overlap at ${width}px: ${JSON.stringify(boxes)}`,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
await nav
|
||||
.locator(':scope > ul > li')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map(
|
||||
(node) =>
|
||||
node
|
||||
.querySelector('a,button')
|
||||
?.getAttribute('href') ||
|
||||
node.textContent.trim(),
|
||||
),
|
||||
),
|
||||
language === 'pt'
|
||||
? [
|
||||
'/',
|
||||
'Descoberta',
|
||||
'Realização',
|
||||
'Suporte',
|
||||
'/sobre',
|
||||
'Tempo',
|
||||
]
|
||||
: language === 'fr'
|
||||
? [
|
||||
'/',
|
||||
'Découvrir',
|
||||
'Investir',
|
||||
'Assistance',
|
||||
'/sobre',
|
||||
'Tempo',
|
||||
]
|
||||
: ['/', 'Discover', 'Invest', 'Support', '/sobre', 'Tempo'],
|
||||
);
|
||||
assert.equal(await nav.locator('a[href="/sobre"]').count(), 1);
|
||||
assert.equal(
|
||||
await nav
|
||||
.locator('a')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).fontSize),
|
||||
width >= 1280 ? '18px' : '16px',
|
||||
);
|
||||
const destinations = new Set(
|
||||
await header
|
||||
.locator('a[href]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getAttribute('href')),
|
||||
),
|
||||
);
|
||||
async function inspectMenu(menu) {
|
||||
await menu.waitFor();
|
||||
menu = page.locator(`[id="${await menu.getAttribute('id')}"]`);
|
||||
try {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await menu.boundingBox();
|
||||
return (
|
||||
!!box &&
|
||||
box.x >= 0 &&
|
||||
box.x + box.width <= width + 1
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
width,
|
||||
role,
|
||||
menu: await menu.getAttribute('aria-labelledby'),
|
||||
box: await menu.boundingBox(),
|
||||
}),
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(dir, 'submenu-failure.png'),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
for (const href of await menu
|
||||
.locator('a[href]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getAttribute('href')),
|
||||
))
|
||||
destinations.add(href);
|
||||
const subIds = await menu
|
||||
.locator('[role="menuitem"][aria-haspopup="menu"]')
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.id));
|
||||
for (const subId of subIds) {
|
||||
const sub = page.locator(`[id="${subId}"]`);
|
||||
await sub.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(sub).toHaveAttribute('aria-expanded', 'true');
|
||||
await inspectMenu(page.locator('[role="menu"]').last());
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(sub).toBeFocused();
|
||||
}
|
||||
}
|
||||
for (const trigger of await nav
|
||||
.locator(':scope > ul > li > button')
|
||||
.all()) {
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
|
||||
const menu = page.locator('[role="menu"]').last();
|
||||
await inspectMenu(menu);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(trigger).toBeFocused();
|
||||
}
|
||||
await header
|
||||
.getByRole('button', { name: 'Tempo', exact: true })
|
||||
.click();
|
||||
for (const href of [
|
||||
'/contacto',
|
||||
'https://revista.tempo.co.mz/',
|
||||
'https://www.facebook.com/revistatempo.mz/',
|
||||
'https://www.instagram.com/revistatempo.mz/',
|
||||
'https://www.linkedin.com/company/revistatempo/',
|
||||
'https://www.youtube.com/@RevistaTempoMz/',
|
||||
]) {
|
||||
assert.equal(
|
||||
await page.locator(`[role="menu"] a[href="${href}"]`).count(),
|
||||
1,
|
||||
href,
|
||||
);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(
|
||||
await header
|
||||
.getByRole('button', { name: 'Idioma da navegação' })
|
||||
.isVisible(),
|
||||
);
|
||||
assert.ok(
|
||||
await header
|
||||
.getByRole('button', { name: 'Temperatura em Maputo' })
|
||||
.isVisible(),
|
||||
);
|
||||
assert.ok(
|
||||
await header.locator('a[href="/manifestar-interesse"]').isVisible(),
|
||||
);
|
||||
const dashboard = header.locator('a[href="/investidor/manifestacoes"]');
|
||||
assert.ok(await dashboard.isVisible());
|
||||
await page.locator('[data-test="increment"]').click();
|
||||
assert.match(await dashboard.getAttribute('aria-label'), /1 /);
|
||||
if (role !== 'guest') {
|
||||
await header.locator('.tempo-login-button').click();
|
||||
await inspectMenu(page.locator('[role="menu"]').last());
|
||||
assert.ok(
|
||||
await page
|
||||
.locator('[data-test="portal-logout-button"]')
|
||||
.isVisible(),
|
||||
);
|
||||
const hasManagement = await page
|
||||
.getByRole('menu')
|
||||
.getByText('Gestão', { exact: true })
|
||||
.count();
|
||||
assert.equal(hasManagement > 0, role === 'manager');
|
||||
await page.keyboard.press('Escape');
|
||||
} else {
|
||||
assert.ok(await header.locator('a[href="/login"]').isVisible());
|
||||
}
|
||||
const catalog = portalNavigation({
|
||||
authenticated: role !== 'guest',
|
||||
manager: role === 'manager',
|
||||
businesses: true,
|
||||
businessReviewUrl:
|
||||
role === 'manager' ? '/management/negocios' : null,
|
||||
education: true,
|
||||
ai: true,
|
||||
legacyContent: true,
|
||||
});
|
||||
function checkDestinations(groups) {
|
||||
for (const group of groups) {
|
||||
if (group.children) checkDestinations(group.children);
|
||||
else if (group.href)
|
||||
assert.ok(
|
||||
destinations.has(group.href),
|
||||
`Preserved destination: ${group.href}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
checkDestinations(
|
||||
role === 'guest'
|
||||
? catalog.filter((group) => group.id !== 'account')
|
||||
: catalog,
|
||||
);
|
||||
const overflow = await header.evaluate((el) =>
|
||||
Array.from(el.querySelectorAll('a,button,img'))
|
||||
.filter((n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return r.width && (r.x < -1 || r.right > innerWidth + 1);
|
||||
})
|
||||
.map((n) => n.textContent),
|
||||
);
|
||||
assert.deepEqual(overflow, []);
|
||||
results.push({
|
||||
width,
|
||||
role,
|
||||
language,
|
||||
collapsedHeight,
|
||||
destinations: destinations.size,
|
||||
result: 'passed',
|
||||
});
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, before ? 'before-results.json' : 'qa-results.json'),
|
||||
JSON.stringify({ results, errors }, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify({ results, errors }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user