Files
Investidor-V2/resources/js/components/dossier-prefill.tsx
T
2026-11-02 20:31:15 +02:00

509 lines
22 KiB
TypeScript

import { inputText } from '../lib/input-text.ts';
import { InvestmentConsumerIdentity } from './investment-consumer-identity';
import InvestmentRequirements from './investment-requirements';
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';
import OpportunityChoices from './investment-opportunity-choices';
import { SelectedComplements } from './investment-complements';
type Props = {
contextId?: string | null;
consumer: 'simulator' | 'land' | 'desk' | 'submission';
values: Record<string, unknown>;
enabled?: boolean;
readOnly?: boolean;
allowedFields?: string[];
persistInheritance?: boolean;
boundRevision?: number;
initialOpportunity?: string;
onApply: (patch: Record<string, unknown>, replace: boolean) => void;
onReview?: (revision: number, reviewed: boolean) => void;
};
const geo = ['province_id', 'district_id', 'admin_post_id', 'locality_id'];
const empty = (v: unknown) =>
v === null ||
v === undefined ||
v === '' ||
(Array.isArray(v) && v.length === 0);
const text = (v: unknown) =>
typeof v === 'string' ? v : JSON.stringify(v ?? '');
function savedOpportunity(values: Record<string, unknown>): string {
if (values.context_opportunity)
return inputText(values.context_opportunity);
try {
return (
JSON.parse(inputText(values.context_applicability_snapshot ?? ''))
.consumer_opportunity ?? ''
);
} catch {
return '';
}
}
const displayField = (values: Record<string, unknown>, key: string) =>
key.endsWith('_id')
? text(values[`${key}_label`]) ||
(empty(values[key])
? 'não informado'
: 'Referência escolhida; designação por recuperar')
: text(values[key]) || 'não informado';
export function DossierPrefill(props: Props) {
const {
contextId,
consumer,
enabled = true,
readOnly = false,
boundRevision = 0,
} = props;
const latest = useRef(props);
latest.current = props;
const [view, setView] = useState<ContextView | null>(null);
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const [selected, setSelected] = useState<string[]>([]);
const [reviewed, setReviewed] = useState(false);
const [message, setMessage] = useState('');
const userChose = useRef(false);
const [opportunity, setOpportunity] = useState(() =>
String(
props.initialOpportunity ||
(typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get(
'opportunity',
) || ''
: '') ||
savedOpportunity(props.values),
),
);
useEffect(() => {
if (!userChose.current && !opportunity && enabled) {
const saved = savedOpportunity(props.values);
if (saved) setOpportunity(saved);
}
}, [enabled, props.values, opportunity]);
const loadedOpportunity = useRef('');
useEffect(() => {
if (!contextId) return;
let stopped = false;
setView(null);
setError('');
void contextApi<ContextView>(
`/investimentos/${contextId}/contexto${opportunity ? '?opportunity=' + encodeURIComponent(opportunity) : ''}`,
)
.then((v) => {
if (!stopped) {
loadedOpportunity.current = opportunity;
setView(v);
setReviewed(false);
}
})
.catch((e) => {
if (!stopped) setError(e.message);
});
return () => {
stopped = true;
};
}, [contextId, retry, opportunity]);
useEffect(() => {
if (
!view ||
loadedOpportunity.current !== opportunity ||
view.case.id !== contextId ||
!enabled ||
readOnly ||
!view.can_write
)
return;
const choices = view.context.opportunity_choices ?? [];
const chosen =
opportunity ||
(choices.length === 1 ? choices[0].opportunity.key : '');
const source = view.prefill[consumer] ?? {};
const now = latest.current;
if (
(chosen || consumer === 'simulator') &&
now.values.context_opportunity !== chosen &&
(!now.allowedFields ||
now.allowedFields.includes('context_opportunity'))
)
now.onApply({ context_opportunity: chosen }, true);
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(
'Dados compatíveis reutilizados do mesmo dossier. Os valores herdados acompanham o contexto; as suas alterações locais foram preservadas.',
);
}
setReviewed(true);
now.onReview?.(view.context.revision, true);
}, [view, enabled, readOnly, contextId, consumer, props.values]);
if (!contextId) return null;
if (error)
return (
<section
className="my-4 rounded border border-amber-400 p-4"
role="alert"
>
<p>{error} Os dados do formulário foram preservados.</p>
<button
type="button"
className="underline"
onClick={() => setRetry((x) => x + 1)}
>
Recuperar contexto novamente
</button>
</section>
);
if (!view)
return (
<p role="status">A recuperar os dados autorizados do dossier…</p>
);
const source = Object.fromEntries(
Object.entries(view.prefill[consumer] ?? {}).filter(
([k]) => !props.allowedFields || props.allowedFields.includes(k),
),
);
const chosenKey =
opportunity ||
(view.context.opportunity_choices?.length === 1
? view.context.opportunity_choices[0].opportunity.key
: '');
const chosenChoice = view.context.opportunity_choices?.find(
(c) => c.opportunity.key === chosenKey,
);
const differences = Object.keys(source).filter(
(k) =>
k !== 'context_applicability_snapshot' &&
!k.endsWith('_label') &&
text(props.values[k]) !== text(source[k]),
);
const linkedRevision =
consumer === 'simulator'
? (view.simulator_context_revision ?? 0)
: boundRevision;
const stale = view.context.revision !== linkedRevision && !reviewed;
return (
<section
className="my-4 space-y-3 rounded border border-emerald-300 bg-emerald-50 p-4 [overflow-wrap:anywhere]"
aria-label="Dados do dossier neste formulário"
>
<h2 className="font-bold">Dados do seu investimento</h2>
<p>
<Link
className="underline"
href={`/investimentos/${contextId}${chosenKey ? '?opportunity=' + encodeURIComponent(chosenKey) : ''}`}
>
{view.case.title}
</Link>
{' · '}
<Link
className="underline"
href={`/investimentos/${contextId}${chosenKey ? '?opportunity=' + encodeURIComponent(chosenKey) : ''}`}
>
Voltar ao dossier
</Link>
</p>
{(opportunity ||
view.context.opportunity_choices?.length === 1) && (
<InvestmentContextAssessment
contextId={contextId}
raw={view.prefill.simulator?.context_applicability_snapshot}
/>
)}
<InvestmentConsumerIdentity
raw={props.values.context_opportunity_snapshot}
/>
{contextId && (
<InvestmentRequirements
contextId={contextId}
excludedSteps={view.steps
.filter((step) => step.state === 'dispensed')
.map((step) => step.code)}
assessment={
view.context.requirements_assessment
? {
...view.context.requirements_assessment,
requirements:
view.context.requirements_assessment.requirements.filter(
(r) =>
r.opportunity_key === chosenKey,
),
}
: null
}
/>
)}
{!!view.context.opportunity_choices?.length && (
<label className="block font-semibold">
Oportunidade em tratamento
<select
className="ml-2 rounded border bg-white p-2"
disabled={
readOnly ||
!view.can_write ||
(!!props.allowedFields &&
!props.allowedFields.includes(
'context_opportunity',
))
}
value={
opportunity ||
(view.context.opportunity_choices.length === 1
? view.context.opportunity_choices[0]
.opportunity.key
: '')
}
onChange={(e) => {
const key = e.target.value;
userChose.current = true;
setOpportunity(key);
setSelected([]);
setReviewed(false);
if (consumer !== 'simulator')
props.onApply(
{ context_opportunity: key },
true,
);
props.onReview?.(view.context.revision, false);
}}
>
<option value="">
Escolha a oportunidade deste pedido
</option>
{view.context.opportunity_choices.map((c) => (
<option
key={c.opportunity.key}
value={c.opportunity.key}
>
{c.opportunity.title} —{' '}
{c.territory?.title ??
'Localização por definir'}
</option>
))}
</select>
{!opportunity &&
view.context.opportunity_choices.length > 1 && (
<p>
Escolha a oportunidade para recuperar a
localização e os requisitos específicos. Os
dados comuns e as suas edições conservam-se.
</p>
)}
</label>
)}
{stale && (
<p className="font-semibold" role="status">
{linkedRevision > 0
? 'Os dados do dossier mudaram desde a última análise.'
: 'Confira os valores deste formulário para o contexto atual; não há uma revisão anterior vinculada a estas entradas.'}{' '}
Os valores e resultados anteriores permanecem guardados;
confira os pressupostos antes de os usar no cenário atual.
</p>
)}
{message && <p role="status">{message}</p>}
{!!chosenChoice && (
<OpportunityChoices
choices={(view.context.opportunity_choices ?? []).filter(
(c) => c.opportunity.key === chosenKey,
)}
/>
)}
{consumer !== 'simulator' &&
chosenChoice?.opportunity.fields.sector && (
<p>
Sector desta oportunidade:{' '}
{chosenChoice.opportunity.fields.sector}
</p>
)}
<SelectedComplements items={view.context.complements ?? []} />
{Object.entries(view.context.selection)
.filter(
([slot]) =>
!view.context.opportunity_choices?.length ||
![
'opportunity',
'territory',
'park',
...(consumer === 'simulator' ? [] : ['sector']),
].includes(slot),
)
.map(
([, r]) =>
r && (
<p key={r.key}>
<Link className="underline" href={r.href}>
{r.title}
</Link>
</p>
),
)}
{!readOnly && view.can_write && differences.length > 0 && (
<details>
<summary className="cursor-pointer font-semibold">
Comparar com os campos já preenchidos
</summary>
<p>
Os seus valores conservam-se por defeito. Selecione
apenas os campos que pretende substituir; o formulário
continua a precisar de ser guardado/submetido pela ação
existente.
</p>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr>
<th>Substituir</th>
<th>Valor atual</th>
<th>Valor proposto pelo dossier</th>
</tr>
</thead>
<tbody>
{differences.map((k) => (
<tr key={k}>
<td className="p-2 align-top">
<label>
<input
type="checkbox"
checked={selected.includes(
k,
)}
onChange={(e) => {
const keys =
geo.includes(k)
? geo
: [k];
setSelected(
e.target.checked
? [
...new Set(
[
...selected,
...keys,
],
),
]
: selected.filter(
(x) =>
!keys.includes(
x,
),
),
);
}}
/>{' '}
{k === 'parcels'
? 'Localização da primeira parcela'
: (contextFieldNames[k] ??
k)}
</label>
</td>
<td className="p-2 [overflow-wrap:anywhere]">
{displayField(props.values, k)}
</td>
<td className="p-2 [overflow-wrap:anywhere]">
{displayField(source, k)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="mt-3 rounded border bg-white px-3 py-2"
disabled={!selected.length || !enabled}
onClick={() => {
const patch: Record<string, unknown> = {};
for (const k of selected) {
patch[k] = source[k] ?? '';
if (`${k}_label` in source)
patch[`${k}_label`] = source[`${k}_label`];
}
const scope = `dossier:${contextId}:${consumer}`;
let raw = inputText(
props.values.context_reuse ?? '',
);
try {
const metadata = JSON.parse(raw);
if (metadata.scope === scope) {
for (const key of Object.keys(patch))
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(
Object.keys(patch).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);
setMessage(
'Dados seleccionados aplicados. Os restantes campos e as versões anteriores foram conservados.',
);
}}
>
Aplicar somente os campos selecionados
</button>
</details>
)}
{!readOnly && reviewed && (
<p>
Os dados disponíveis foram recuperados. Preencha o que
falta; as suas alterações conservam-se.
</p>
)}
<p className="text-xs">
Associar uma referência não confirma incentivos, licenças, terra
disponível, reserva ou autorização.
</p>
</section>
);
}