509 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|