Files
Investidor-V2/resources/js/components/opportunity-canonical-fields.tsx
T

1405 lines
61 KiB
TypeScript

import {
ClassificationEditor,
type ClassificationOptions,
type ClassificationRelation,
} from './opportunity-classification';
import { valueText } from '@/lib/value-text';
import OpportunityParcelGeometry from './opportunity-parcel-geometry';
import TerritoryGeometryPreview from './territory-geometry-preview';
import { control } from '@/pages/management/shared';
import OpportunityFillFlow from './opportunity-fill-flow';
import { opportunityFillGroups } from '@/lib/opportunity-fill-flow';
import { modalityLabels } from './opportunity-search-filters';
type Data = Record<string, unknown>;
type Schema = {
$ref?: string;
type?: string | string[];
properties?: Record<string, Schema>;
items?: Schema;
enum?: unknown[];
const?: unknown;
format?: string;
maxLength?: number;
required?: string[];
};
type Resource = {
id: string;
payload: { title?: string; role?: string; [key: string]: unknown };
file?: { file_name: string };
};
function resourceTerritory(resource?: Resource) {
const p = resource?.payload;
const id =
p?.locality_id ?? p?.admin_post_id ?? p?.district_id ?? p?.province_id;
return typeof id === 'string' ? id : undefined;
}
export type CanonicalOptions = {
classification?: ClassificationOptions;
version: string;
modularProfiles?: Record<string, string>;
historyVersion?: string | null;
schema: Schema;
submissionTextLimits?: Record<
string,
{ minLength?: number; maxLength?: number }
>;
definitions: Record<string, Schema>;
contacts: { id: string; full_name: string }[];
contactInvestors?: { id: string; legal_name: string }[];
activities: {
id: string;
parent_id: string | null;
sector_id: number | null;
code?: string;
level?: number | null;
path?: { id: string; code: string; label: string }[];
display_label?: string;
historical_only?: boolean;
source_version?: string | null;
edition?: string | null;
label_pt_mz: string;
}[];
resources: Record<string, Resource[]>;
gates: string[];
countries: { code: string; name: string }[];
authorities: { id: string; label_pt_mz: string }[];
};
const labels: Record<string, string> = {
official_title: 'Título oficial',
promoter_type: 'Tipo de Promotor',
internal_contact_id: 'Contacto interno',
sector_id: 'Sector',
subsector_id: 'Subsector',
economic_activity_id: 'Atividade económica',
location_mode: 'Uma ou várias localizações',
geographic_scope: 'Âmbito geográfico',
multi_district_rationale: 'Justificação territorial',
locations: 'Onde se localiza — locais documentados',
executive_summary: 'Resumo executivo',
demand_statement: 'Necessidade e proposta',
visibility: 'Visibilidade após publicação',
truth_declaration: 'Declaração de veracidade',
publication_languages: 'Versões por idioma',
language_code: 'Idioma',
title: 'Título',
summary: 'Resumo',
documents: 'Documentos guardados',
evidences: 'Evidências',
document_id: 'Documento associado',
evidence_type: 'Tipo de evidência',
description: 'Descrição',
images: 'Imagens guardadas',
review_due_date: 'Data de revisão editorial',
real_estate: 'Imobiliário',
land_rights: 'DUAT e direitos fundiários',
business_transaction: 'Negócio',
module_profile_version: 'Versão do contrato QA (seleção explícita)',
module_details: 'Condições do módulo',
mode: 'Percurso: procura, oferta ou modalidade',
conditions_status: 'Condições propostas ou compromisso documentado',
object: 'Objeto',
representation_document_id: 'Documento de representação',
object_document_id: 'Documento do objeto/procedimento',
disclosure_authorization_document_id:
'Autorização de divulgação da intenção/chamada',
external_decision_document_id: 'Ato competente do direito atribuído',
granting_entity: 'Entidade concedente declarada',
procedure_regime: 'Regime/procedimento declarado',
scope: 'Âmbito territorial ou digital',
term_value: 'Duração ou prazo',
term_unit: 'Unidade da duração',
remuneration_obligations: 'Remuneração e obrigações',
participation_conditions: 'Condições de participação',
land_required: 'Precisa de terra?',
construction_required: 'Inclui construção?',
applicable_authorizations: 'Autorizações aplicáveis e situação',
instrument: 'Instrumento de financiamento',
offered_capacity: 'Capacidade oferecida pelo financiador',
eligibility_criteria: 'Critérios declarados do financiador',
capacity_document_id: 'Evidência da capacidade declarada',
financial_plan_document_id: 'Plano financeiro do projeto',
secured_evidence_document_id: 'Evidência do financiamento assegurado',
use_of_funds: 'Finalidade do financiamento procurado',
disbursement_calendar: 'Calendário proposto de desembolso',
repayment_periodicity: 'Periodicidade de reembolso',
proposed_rights: 'Direitos propostos',
equity_percentage: 'Percentagem de capital proposta',
terms: 'Condições',
scenario: 'Cenário identificado',
source: 'Fonte identificada',
terms_date: 'Data da fonte/condições',
commitment_document_id: 'Documento do compromisso assumido',
acceptance_criteria: 'Critérios de aceitação do fornecimento',
quotation_method: 'Método de cotação',
total_price: 'Preço total (impostos identificados separadamente)',
declared_taxes: 'Impostos declarados, sem cálculo implícito',
advance_or_guarantee: 'Há adiantamento ou garantia?',
subtype: 'Subtipo para classificação funcional',
recipients: 'Destinatários',
expected_output: 'Resultado esperado',
history_status: 'Situação do histórico financeiro',
history_declaration: 'Declaração de ausência de exercícios completos',
history_declaration_document_id: 'Documento da declaração',
projections: 'Projeções identificadas — não são contas realizadas',
transaction_type: 'Modalidade',
property_type: 'Tipo de imóvel',
construction_status: 'Estado construtivo',
property_condition: 'Condição do imóvel',
land_area: 'Área do terreno',
built_area: 'Área construída',
area_unit: 'Unidade da área',
units: 'Unidades',
rooms: 'Compartimentos',
floors: 'Pisos',
amenities: 'Comodidades',
utilities: 'Serviços e acessos',
occupancy_status: 'Ocupação',
title_type: 'Tipo de título',
registration_number: 'Número de registo',
encumbrances: 'Ónus e restrições',
price: 'Preço do imóvel',
rent: 'Renda',
rent_periodicity: 'Periodicidade',
deposit: 'Caução',
valuation: 'Avaliação',
viewing: 'Visitas',
planning_permit_document_id: 'Licença de construção',
use_license_document_id: 'Licença de utilização',
operation_type: 'Operação fundiária admissível',
duat_status: 'Situação do DUAT',
duat_number: 'Número do DUAT',
holder_id: 'Titular cadastrado',
holder_name: 'Nome do titular',
issuing_authority_id: 'Entidade emissora',
issue_date: 'Data de emissão',
expiry_date: 'Data de validade',
authorized_purpose: 'Finalidade autorizada',
exploitation_plan_document_id: 'Plano de exploração',
parcels: 'Parcelas',
location_id: 'Localização associada',
area: 'Área',
cadastral_number: 'Número cadastral',
overlap_status: 'Sobreposição',
improvements: 'Benfeitorias',
communities: 'Comunidades',
community_consultation: 'Consulta comunitária',
overlaps_conflicts: 'Sobreposições e conflitos',
required_authorizations: 'Autorizações necessárias',
consideration: 'Contraprestação',
legal_entity_id: 'Entidade representada',
legal_form: 'Forma jurídica',
percentage_offered: 'Percentagem oferecida',
asking_price: 'Valor solicitado',
financial_periods: 'Exercícios financeiros',
assets_liabilities: 'Ativos e passivos',
employees: 'Empregos e obrigações',
contracts_licenses: 'Contratos e licenças',
litigation_contingencies: 'Litígios e contingências',
reason_for_transaction: 'Fundamentação da operação',
partner_contribution: 'Contribuição do parceiro',
governance_rights: 'Direitos de governação',
franchise_terms: 'Condições da franquia',
confidentiality_level: 'Confidencialidade',
due_diligence_status: 'Preparação da análise',
opportunity_type: 'Tipo de investimento',
maturity_stage: 'Etapa de maturidade',
investment_modalities: 'Modalidades de investimento',
investor_profiles: 'Perfis de investidor',
products: 'Produtos',
assets: 'Ativos',
inputs: 'Insumos',
markets: 'Mercados',
competitive_advantage: 'Vantagem competitiva',
employment: 'Emprego',
impact_indicators: 'Impactos',
total_investment: 'Investimento total',
currency: 'Moeda',
investment_breakdown: 'Rubricas de investimento',
secured_financing: 'Financiamento assegurado',
funding_sought: 'Financiamento procurado',
financial_indicators: 'Indicadores financeiros',
milestones: 'Marcos',
licenses: 'Licenças',
environmental_social: 'Aspetos ambientais e sociais',
risks: 'Riscos',
relations: 'Relações',
enterprise_size: 'Dimensão empresarial declarada (separada do setor)',
amount: 'Montante',
name: 'Nome',
label: 'Designação',
status: 'Estado',
date: 'Data',
method: 'Método',
appraiser: 'Avaliador',
year: 'Exercício',
revenue: 'Receita',
profit: 'Resultado',
type: 'Tipo',
value: 'Valor',
details: 'Detalhes',
mitigation: 'Mitigação',
due_date: 'Prazo',
authority: 'Entidade competente',
assessment_status: 'Estado da avaliação',
environmental_category: 'Categoria ambiental',
direct_jobs: 'Empregos diretos',
indirect_jobs: 'Empregos indirectos',
female_percentage: 'Participação de mulheres (%)',
youth_percentage: 'Participação de jovens (%)',
social_risks: 'Riscos sociais',
mitigation_plan: 'Plano de mitigação',
employment_type: 'Tipo de emprego',
geometry: 'Geometria da parcela',
start_date: 'Data de início',
end_date: 'Data de fim',
responsible_name: 'Responsável declarado',
dependencies: 'Marcos dos quais depende',
npv: 'Valor atual líquido',
irr: 'Taxa interna de retorno (%)',
payback_years: 'Prazo de retorno (anos)',
horizon_years: 'Horizonte (anos)',
discount_rate: 'Taxa de desconto (%)',
model_document_id: 'Modelo financeiro associado',
title_holder_name: 'Titular do título',
title_document_id: 'Documento do título',
territorial_right_document_id: 'Documento do direito territorial aplicável',
construction_license_document_id:
'Documento da licença de construção aplicável',
holder_document_id: 'Documento do titular',
expected_completion_date: 'Conclusão prevista',
occupancy_details: 'Condições de ocupação',
occupancy_document_id: 'Contrato de ocupação',
urban_limit_document_id: 'Fonte do limite urbanístico',
max_built_area: 'Área edificável máxima declarada',
amenities_other: 'Outra comodidade',
communities_affected: 'Existem comunidades afetadas?',
exploitation_plan_required: 'Plano de exploração aplicável?',
entity_registration: 'Registo e beneficiário efetivo',
tax_identifier: 'Identificação fiscal',
legal_document_id: 'Documento de constituição/registo',
beneficial_owner_document_id: 'Documento do beneficiário efetivo',
available_financial_years: 'Exercícios financeiros existentes',
liabilities: 'Passivos',
cash_flow: 'Fluxo de caixa',
excluded_assets: 'Ativos excluídos',
guarantees: 'Garantias',
reconciliation_document_id: 'Documento de reconciliação',
obligations: 'Obrigações',
labor_liabilities: 'Passivos laborais',
transferability: 'Transmissibilidade',
consent_document_id: 'Consentimento de transmissão',
nda_document_id: 'Acordo de confidencialidade',
teaser: 'Resumo autorizado para publicação',
board: 'Órgãos de gestão',
reserved_matters: 'Matérias reservadas',
dividends: 'Dividendos',
exit: 'Saída',
deadlock: 'Resolução de impasse',
initial_fee: 'Encargo inicial',
royalties: 'Royalties e encargos',
support: 'Apoio',
exclusivity: 'Exclusividade',
renewal: 'Renovação',
termination: 'Cessação',
brand_license_document_id: 'Licença da marca',
publish_contact: 'Autoriza publicar o contacto?',
public_contact_id: 'Contacto para divulgação',
};
function FieldErrors({
path,
errors,
}: {
path: string;
errors: Record<string, string>;
}) {
const message = errors[path];
return message ? (
<p id={path + '-error'} className="text-red-700" role="alert">
{message}
</p>
) : null;
}
function Editor({
name,
path,
schema,
value,
onChange,
options,
errors,
}: {
name: string;
path: string;
schema: Schema;
value: unknown;
onChange: (v: unknown) => void;
options: CanonicalOptions;
errors: Record<string, string>;
}) {
if (schema.$ref)
schema = options.definitions[schema.$ref.split('/').pop()!] ?? schema;
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const label = labels[name] ?? name.replaceAll('_', ' ');
const parcelUnit =
name === 'area_unit' && path.includes('land_rights.parcels.');
const textLimits =
path === 'canonical.demand_statement'
? options.submissionTextLimits?.demand_statement
: undefined;
const descriptions = [
errors[path] ? path + '-error' : '',
parcelUnit ? path + '-help' : '',
textLimits ? path + '-length-help' : '',
]
.filter(Boolean)
.join(' ');
const props = {
id: path,
'aria-invalid': !!errors[path],
'aria-describedby': descriptions || undefined,
};
let input;
if (name === 'geometry')
input = <OpportunityParcelGeometry value={value} onChange={onChange} />;
else if (
name.endsWith('document_id') ||
name === 'location_id' ||
name === 'image_id'
) {
const kind =
name === 'location_id'
? 'locations'
: name === 'image_id'
? 'images'
: 'documents';
const rows = options.resources[kind] ?? [];
input = (
<>
<select
{...props}
className={control}
value={valueText(value ?? '')}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">Por indicar</option>
{rows.map((r) => (
<option key={r.id} value={r.id}>
{r.payload.title ??
r.payload.role ??
r.file?.file_name ??
r.id}
</option>
))}
</select>
{name === 'location_id' && !!value && (
<TerritoryGeometryPreview
territoryId={resourceTerritory(
rows.find((r) => r.id === value),
)}
endpoint="/minhas-oportunidades/territory-geometries"
label={`Limite administrativo associado à parcela ${Number(path.match(/parcels\.(\d+)\./)?.[1] ?? 0) + 1}`}
/>
)}
</>
);
} else if (name === 'country_code')
input = (
<select
{...props}
className={control}
value={valueText(value ?? '')}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">Por indicar</option>
{options.countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
);
else if (name === 'issuing_authority_id' || name === 'authority_id')
input = (
<select
{...props}
className={control}
value={valueText(value ?? '')}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">Por indicar</option>
{options.authorities.map((a) => (
<option key={a.id} value={a.id}>
{a.label_pt_mz}
</option>
))}
</select>
);
else if (schema.enum)
input = (
<select
{...props}
className={control}
value={valueText(value ?? '')}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">Por indicar</option>
{schema.enum
.filter((v) => v !== null)
.map((v) => (
<option key={valueText(v)} value={valueText(v)}>
{name === 'area_unit'
? ({
sqm: 'Metros quadrados (m²)',
ha: 'Hectares (ha)',
sqkm: 'Quilómetros quadrados (km²)',
}[valueText(v) as 'sqm' | 'ha' | 'sqkm'] ??
valueText(v))
: valueText(v).replaceAll('_', ' ')}
</option>
))}
</select>
);
else if (types.includes('boolean') || schema.const === true)
input = (
<select
{...props}
className={control}
value={value === true ? 'true' : value === false ? 'false' : ''}
onChange={(e) =>
onChange(
e.target.value === ''
? null
: e.target.value === 'true',
)
}
>
<option value="">Por indicar</option>
<option value="true">Sim</option>
<option value="false">Não</option>
</select>
);
else if (types.includes('array')) {
const rows = Array.isArray(value) ? value : [];
let item = schema.items ?? { type: 'string' };
if (item.$ref)
item = options.definitions[item.$ref.split('/').pop()!] ?? item;
const itemTypes = Array.isArray(item.type) ? item.type : [item.type];
return (
<fieldset className="space-y-2 rounded border p-3">
<legend>{label}</legend>
<FieldErrors path={path} errors={errors} />
{rows.map((v, i) => (
<div key={i} className="space-y-2">
<Editor
name={name}
path={path + '.' + i}
schema={schema.items ?? { type: 'string' }}
value={v}
options={options}
errors={errors}
onChange={(next) =>
onChange(
rows.map((r, j) => (j === i ? next : r)),
)
}
/>
<button
type="button"
className="underline"
onClick={() =>
onChange(rows.filter((_, j) => j !== i))
}
>
Retirar linha {i + 1}
</button>
</div>
))}
<button
type="button"
className="underline"
onClick={() =>
onChange([
...rows,
itemTypes.includes('object')
? {}
: itemTypes.includes('array')
? []
: '',
])
}
>
Adicionar linha
</button>
</fieldset>
);
} else if (types.includes('object') || schema.properties) {
const fields = schema.properties ?? {};
const data =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Data)
: {};
return (
<fieldset className="space-y-2 rounded border p-3">
<legend>{label}</legend>
<FieldErrors path={path} errors={errors} />
{Object.entries(fields).map(([key, s]) => (
<Editor
key={key}
name={key}
path={path + '.' + key}
schema={s}
value={data[key]}
options={options}
errors={errors}
onChange={(v) => onChange({ ...data, [key]: v })}
/>
))}
{!Object.keys(fields).length && (
<p>
Este detalhe exige definição contratual antes da
submissão.
</p>
)}
</fieldset>
);
} else {
const numeric = types.includes('number') || types.includes('integer');
input =
numeric || schema.format === 'date' ? (
<input
{...props}
className={control}
type={numeric ? 'number' : 'date'}
step={types.includes('integer') ? 1 : 'any'}
value={valueText(value ?? '')}
onChange={(e) =>
onChange(
e.target.value === ''
? null
: numeric
? Number(e.target.value)
: e.target.value,
)
}
/>
) : (
<textarea
{...props}
className={control}
value={valueText(value ?? '')}
maxLength={schema.maxLength}
onChange={(e) => onChange(e.target.value)}
/>
);
}
return (
<div>
<label htmlFor={path} className="block">
{label}
</label>
{input}
{textLimits && (
<p id={path + '-length-help'} className="text-sm text-gray-600">
Descreva a necessidade e a finalidade da oportunidade. Para
submeter: {textLimits.minLength} a {textLimits.maxLength}{' '}
caracteres. Texto atual:{' '}
{Array.from(valueText(value ?? '').trim()).length}{' '}
caracteres. Pode guardar o rascunho incompleto.
</p>
)}
{parcelUnit && (
<p id={path + '-help'} className="text-sm text-gray-600">
A unidade deve ser indicada explicitamente. Registos sem
unidade continuam por identificar; escolher outra unidade
não converte o valor da área.
</p>
)}
<FieldErrors path={path} errors={errors} />
</div>
);
}
export default function OpportunityCanonicalFields({
value,
onChange,
options,
category,
organization,
actorId,
sectors,
disabled = false,
errors = {},
onOpenResources,
missingPaths,
}: {
value: Data;
onChange: (v: Data) => void;
options: CanonicalOptions;
category: string;
organization: string | null;
actorId: number;
sectors: { id: number; name: string }[];
disabled?: boolean;
errors?: Record<string, string>;
onOpenResources?: (group: string) => void;
missingPaths?: string[];
}) {
const initiative = (value.initiative ?? {}) as Data;
const isInitiative =
category === 'investment_project' &&
['program', 'portfolio'].includes(valueText(initiative.kind ?? ''));
const applicability = (initiative.applicability ?? {}) as Record<
string,
string
>;
const groups: Record<string, { label: string; fields: string[] }> = {
physical_site: {
label: 'Implantação física, obra e licenças próprias',
fields: ['location_mode', 'licenses', 'environmental_social'],
},
individual_business: {
label: 'Empresa ou operação individual',
fields: [
'promoter_id',
'promoter_type',
'opportunity_type',
'investor_profiles',
'products',
'assets',
'inputs',
'markets',
'competitive_advantage',
'employment',
'impact_indicators',
'enterprise_size',
],
},
unit_investment: {
label: 'Investimento e financiamento por projecto',
fields: [
'total_investment',
'currency',
'investment_breakdown',
'secured_financing',
'funding_sought',
'financial_indicators',
],
},
};
const ignoredFields = isInitiative
? [
'promoter_type',
...Object.entries(groups).flatMap(([key, group]) =>
applicability[key] === 'not_applicable' ? group.fields : [],
),
]
: [];
const setInitiative = (key: string, next: unknown) =>
onChange({
...value,
opportunity_category: category,
initiative: { ...initiative, [key]: next },
});
const set = (key: string, v: unknown) =>
onChange({
...value,
[key]: v,
...(key === 'sector_id'
? { subsector_id: null, economic_activity_id: null }
: key === 'subsector_id'
? { economic_activity_id: null }
: {}),
opportunity_category: category,
...(isInitiative ? {} : { promoter_id: organization ?? actorId }),
});
const common = [
'official_title',
'promoter_type',
'internal_contact_id',
'publish_contact',
'public_contact_id',
'sector_id',
'subsector_id',
'economic_activity_id',
'location_mode',
'geographic_scope',
'multi_district_rationale',
'locations',
'executive_summary',
'demand_statement',
'documents',
'evidences',
'publication_languages',
'visibility',
'currency',
'review_due_date',
'images',
'truth_declaration',
'enterprise_size',
'relations',
];
const specialized =
category === 'real_estate'
? ['real_estate']
: category === 'land_rights'
? ['land_rights']
: ['business_sale', 'business_partnership', 'franchise'].includes(
category,
)
? ['module_profile_version', 'business_transaction']
: category === 'investment_project'
? [
'opportunity_type',
'maturity_stage',
'investment_modalities',
'investor_profiles',
'products',
'assets',
'inputs',
'markets',
'competitive_advantage',
'employment',
'impact_indicators',
'total_investment',
'currency',
'investment_breakdown',
'secured_financing',
'funding_sought',
'financial_indicators',
'milestones',
'licenses',
'environmental_social',
'risks',
'relations',
]
: [
'module_profile_version',
'module_details',
...(category === 'financing'
? [
'total_investment',
'investment_breakdown',
'secured_financing',
'funding_sought',
]
: []),
];
const details = (value.module_details ?? {}) as Data;
const mode = valueText(details.mode ?? '');
const modularFields = [
'mode',
'object',
'representation_document_id',
'object_document_id',
...(category === 'concession'
? [
'granting_entity',
'procedure_regime',
'scope',
'term_value',
'term_unit',
'remuneration_obligations',
'participation_conditions',
'land_required',
'construction_required',
'applicable_authorizations',
'start_date',
'end_date',
'disclosure_authorization_document_id',
'external_decision_document_id',
'territorial_right_document_id',
'construction_license_document_id',
]
: []),
...(['financing', 'supply'].includes(category)
? [
'conditions_status',
'terms',
'scenario',
'source',
'terms_date',
'commitment_document_id',
]
: []),
...(category === 'financing'
? [
'instrument',
'disbursement_calendar',
'term_value',
'term_unit',
'repayment_periodicity',
'equity_percentage',
'proposed_rights',
...(mode === 'funding_offer'
? [
'offered_capacity',
'eligibility_criteria',
'capacity_document_id',
]
: [
'use_of_funds',
'financial_plan_document_id',
'secured_evidence_document_id',
]),
]
: []),
...(category === 'supply'
? [
'quantity',
'unit',
'destination',
'start_date',
'end_date',
'acceptance_criteria',
'quotation_method',
'unit_price',
'total_price',
'declared_taxes',
'advance_or_guarantee',
...(mode === 'supply_offer' ? ['capacity_document_id'] : []),
]
: []),
...(category === 'other'
? ['subtype', 'purpose', 'recipients', 'expected_output', 'risks']
: []),
];
const modes =
category === 'concession'
? ['intention_call', 'awarded_right']
: category === 'financing'
? ['seeking_funding', 'funding_offer']
: category === 'supply'
? ['purchase_request', 'supply_offer']
: ['classified_other'];
return (
<fieldset disabled={disabled} className="space-y-4 rounded border p-4">
<legend>
Preparar oportunidade —{' '}
{modalityLabels[category] ?? 'Modalidade por escolher'}
</legend>
<p>
O rascunho pode ficar incompleto. A submissão verifica os campos
aplicáveis e os recursos guardados. Validação interna não
equivale a autorização externa.
</p>
{options.modularProfiles?.[category] && (
<p className="rounded border border-amber-400 p-3">
Primeira versão QA: {options.modularProfiles[category]}.
Selecione-a explicitamente no campo de versão. Financiamento
distingue procura do projeto de oferta do financiador;
fornecimento distingue pedido de compra de oferta. Condições
propostas não representam compromisso nem decisão externa.
</p>
)}
{category === 'investment_project' && (
<section
aria-label="Enquadramento e aplicabilidade"
className="space-y-4 rounded border p-4"
>
<label className="block">
Natureza da ficha
<select
className={control}
aria-label="Natureza da ficha"
value={valueText(initiative.kind ?? '')}
onChange={(e) => {
if (!e.target.value) {
const next = { ...value };
delete next.initiative;
onChange(next);
} else setInitiative('kind', e.target.value);
}}
>
<option value="">
Projecto ou componente concreta
</option>
<option value="program">
Programa ou iniciativa agregadora
</option>
<option value="portfolio">
Carteira de oportunidades/PME
</option>
</select>
</label>
{isInitiative && (
<>
<p>
O responsável do programa/carteira não substitui
os promotores das componentes. Abrangência não
comprova implantação. A classificação não
confirma financiamento, licenças ou
disponibilidade.
</p>
<label className="block">
Responsável do programa ou carteira
<input
className={control}
aria-label="Responsável do programa ou carteira"
value={valueText(
initiative.responsible_name ?? '',
)}
onChange={(e) =>
setInitiative(
'responsible_name',
e.target.value || null,
)
}
/>
</label>
<FieldErrors
path="canonical.initiative.responsible_name"
errors={errors}
/>
<label className="block">
Abrangência territorial documentada
<textarea
className={control}
aria-label="Abrangência territorial documentada"
value={valueText(
initiative.coverage_statement ?? '',
)}
onChange={(e) =>
setInitiative(
'coverage_statement',
e.target.value || null,
)
}
/>
</label>
<FieldErrors
path="canonical.initiative.coverage_statement"
errors={errors}
/>
<label className="block">
Orçamento agregado — fonte e situação, sem
confirmação automática
<textarea
className={control}
aria-label="Orçamento agregado"
value={valueText(
initiative.aggregate_budget_statement ??
'',
)}
onChange={(e) =>
setInitiative(
'aggregate_budget_statement',
e.target.value || null,
)
}
/>
</label>
{Object.entries(groups).map(([key, group]) => (
<div key={key}>
<label className="block">
{group.label}
<select
className={control}
aria-label={group.label}
value={
applicability[key] ?? 'missing'
}
onChange={(e) =>
setInitiative('applicability', {
...applicability,
[key]: e.target.value,
})
}
>
<option value="missing">
Informação em falta —
aplicabilidade por determinar
</option>
<option value="not_applicable">
Não aplicável a esta ficha
agregadora
</option>
<option value="applicable">
Aplicável — preencher os dados
próprios
</option>
</select>
</label>
<FieldErrors
path={
'canonical.initiative.applicability.' +
key
}
errors={errors}
/>
{applicability[key] ===
'not_applicable' && (
<details>
<summary>
Não aplicável — consultar dados
anteriores preservados
</summary>
<pre className="overflow-auto whitespace-pre-wrap">
{JSON.stringify(
Object.fromEntries(
group.fields
.filter(
(field) =>
field in
value,
)
.map((field) => [
field,
value[field],
]),
),
null,
2,
)}
</pre>
<p>
Sem eliminação dos valores. As
componentes concretas mantêm os
seus requisitos.
</p>
</details>
)}
</div>
))}
<p>
Os recursos territoriais abaixo documentam a
abrangência quando a implantação não é
aplicável. A informação em falta continua
pendente; não é convertida em “não aplicável”.
</p>
</>
)}
</section>
)}
<OpportunityFillFlow
groups={opportunityFillGroups(
[...new Set([...common, ...specialized])].filter(
(key) => !ignoredFields.includes(key),
),
)}
errors={errors}
missingPaths={missingPaths}
onOpenResources={onOpenResources}
renderField={(key) => {
if (ignoredFields.includes(key)) return null;
if (key === 'relations' && options.classification) {
const rows = (value.relations ??
[]) as ClassificationRelation[];
const legacySchema = options.definitions.relationCreate;
return (
<div key={key} className="space-y-4">
<ClassificationEditor
rows={rows}
onChange={(rows) => set('relations', rows)}
options={options.classification}
documents={
options.resources.documents ?? []
}
authorities={options.authorities}
errors={errors}
/>
{rows.map((row, i) =>
!row.classification ? (
<Editor
key={i}
name={`Relação anterior ${i + 1}`}
path={`canonical.relations.${i}`}
schema={{
...legacySchema,
properties: Object.fromEntries(
Object.entries(
legacySchema.properties ??
{},
).filter(
([k]) =>
k !==
'classification',
),
),
}}
value={row}
options={options}
errors={errors}
onChange={(v) =>
set(
'relations',
rows.map((r, j) =>
j === i ? v : r,
),
)
}
/>
) : null,
)}
</div>
);
}
if (key === 'module_profile_version') {
const current = valueText(value[key] ?? '');
const version =
options.modularProfiles?.[category] ??
([
'business_sale',
'business_partnership',
'franchise',
].includes(category)
? options.historyVersion
: null);
return (
<label key={key} className="block">
{labels[key]}
<select
className={control}
value={current}
onChange={(e) =>
set(key, e.target.value || null)
}
>
<option value="">
Contrato original / por selecionar
</option>
{current && current !== version && (
<option value={current}>
{current} — versão existente
preservada
</option>
)}
{version && (
<option value={version}>
{version}
</option>
)}
</select>
<FieldErrors
path={'canonical.' + key}
errors={errors}
/>
</label>
);
}
if (
[
'sector_id',
'subsector_id',
'economic_activity_id',
'internal_contact_id',
'public_contact_id',
].includes(key)
) {
const rows =
key === 'sector_id'
? sectors.map((s) => ({
id: s.id,
label: s.name,
}))
: [
'internal_contact_id',
'public_contact_id',
].includes(key)
? options.contacts.map((c) => ({
id: c.id,
label: c.full_name,
}))
: options.activities
.filter((a) => {
if (a.id === value[key])
return true;
if (a.historical_only) return false;
if (a.level) {
return key === 'subsector_id'
? a.level === 2 &&
a.code === '01' &&
a.sector_id ===
Number(
value.sector_id,
)
: a.level === 5 &&
a.code === '01120' &&
!!a.path?.some(
(p) =>
p.id ===
value.subsector_id,
);
}
return key === 'subsector_id'
? options.activities.some(
(root) =>
root.sector_id ===
Number(
value.sector_id,
) &&
a.parent_id ===
root.id,
)
: a.parent_id ===
value.subsector_id;
})
.map((a) => ({
id: a.id,
label:
(a.display_label ??
a.label_pt_mz) +
(a.historical_only
? ' — histórico preservado'
: ''),
}));
return (
<label key={key} className="block">
{labels[key]}
<FieldErrors
path={'canonical.' + key}
errors={errors}
/>
<select
className={control}
value={valueText(value[key] ?? '')}
onChange={(e) =>
set(
key,
e.target.value === ''
? null
: key === 'sector_id'
? Number(e.target.value)
: e.target.value,
)
}
>
<option value="">Por indicar</option>
{rows.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
{key === 'economic_activity_id' &&
options.activities.find(
(a) => a.id === value[key],
)?.level && (
<p
className="mt-1 text-sm"
data-economic-path="five-levels"
>
{options.activities
.find(
(a) => a.id === value[key],
)
?.path?.map(
(p) =>
`${p.code} — ${p.label}`,
)
.join(' → ')}
{
' · Exemplar eBAU; edição normativa não determinada; divergência 600/579 aberta.'
}
</p>
)}
</label>
);
}
if (['locations', 'documents', 'images'].includes(key)) {
const selected = Array.isArray(value[key])
? (value[key] as string[])
: [];
return (
<fieldset key={key}>
<legend>
{isInitiative && key === 'locations'
? 'Abrangência territorial — recursos guardados'
: labels[key]}
</legend>
{key === 'locations' && (
<p>
Seleccione os locais já guardados em
Recursos; não volte a introduzir a
hierarquia. O local principal, os
adicionais e a finalidade de cada um são
definidos nesse registo. Abrangência,
impacto e contexto não significam
implantação. Pode guardar incompleto; a
submissão conserva as verificações
existentes.
</p>
)}
<FieldErrors
path={'canonical.' + key}
errors={errors}
/>
{(options.resources[key] ?? []).map((r) => (
<label key={r.id} className="block">
<input
type="checkbox"
checked={selected.includes(r.id)}
onChange={(e) =>
set(
key,
e.target.checked
? [...selected, r.id]
: selected.filter(
(id) =>
id !== r.id,
),
)
}
/>{' '}
{r.payload.title ??
r.payload.role ??
r.file?.file_name ??
r.id}
<FieldErrors
path={`canonical.${key}.${r.id}`}
errors={errors}
/>
</label>
))}
{key === 'locations' &&
selected.map((id) => (
<TerritoryGeometryPreview
key={id}
territoryId={resourceTerritory(
options.resources.locations?.find(
(r) => r.id === id,
),
)}
endpoint="/minhas-oportunidades/territory-geometries"
label={`Cobertura cartográfica da localização ${options.resources.locations?.find((r) => r.id === id)?.payload.title ?? selected.indexOf(id) + 1}`}
/>
))}
{!options.resources[key]?.length && (
<p>
Guarde primeiro em Recursos e regresse a
este contrato.
</p>
)}
</fieldset>
);
}
let schema = options.schema.properties?.[key];
if (key === 'module_details' && schema?.properties) {
schema = {
...schema,
properties: Object.fromEntries(
Object.entries(schema.properties).filter(
([field]) => modularFields.includes(field),
),
),
};
schema.properties!.mode = { enum: modes };
}
return schema ? (
<Editor
key={key}
name={key}
path={'canonical.' + key}
errors={errors}
schema={schema}
value={value[key]}
options={options}
onChange={(v) => set(key, v)}
/>
) : null;
}}
/>
</fieldset>
);
}