1405 lines
61 KiB
TypeScript
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>
|
|
);
|
|
}
|