Files
Investidor-V2/resources/js/components/dossier-prefill.tsx
T
nala cf003c8623
tests / ci (push) Canceled after 0s
versao nrgocios 4
2026-10-18 15:57:06 +02:00

298 lines
13 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Link } from '@inertiajs/react';
import {
contextApi,
contextFieldNames,
type ContextView,
} from '@/lib/investor-context';
type Props = {
contextId?: string | null;
consumer: 'simulator' | 'land' | 'desk' | 'submission';
values: Record<string, unknown>;
enabled?: boolean;
readOnly?: boolean;
allowedFields?: string[];
boundRevision?: number;
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 ?? '');
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 filled = useRef('');
useEffect(() => {
if (!contextId) return;
let stopped = false;
setError('');
void contextApi<ContextView>(`/investimentos/${contextId}/contexto`)
.then((v) => {
if (!stopped) {
setView(v);
setReviewed(false);
latest.current.onReview?.(v.context.revision, false);
}
})
.catch((e) => {
if (!stopped) setError(e.message);
});
return () => {
stopped = true;
};
}, [contextId, retry]);
useEffect(() => {
if (!view || !enabled || readOnly || !view.can_write) return;
const generation = `${contextId}:${view.context.revision}:${consumer}`;
if (filled.current === generation) return;
const source = view.prefill[consumer] ?? {};
const now = latest.current;
const patch: Record<string, unknown> = {};
const conflict = geo.some(
(k) =>
!empty(now.values[k]) &&
String(now.values[k]) !== text(source[k]),
);
for (const [k, v] of Object.entries(source)) {
if (
(now.allowedFields && !now.allowedFields.includes(k)) ||
(geo.includes(k) && conflict)
)
continue;
if (k === 'parcels') {
const old = now.values.parcels as
| Record<string, unknown>[]
| undefined;
if (
(!old?.length || geo.every((g) => empty(old[0]?.[g]))) &&
Array.isArray(v) &&
v[0] &&
Object.keys(v[0]).length
)
patch[k] = v;
} else if (empty(now.values[k]) && !empty(v)) patch[k] = v;
}
filled.current = generation;
if (Object.keys(patch).length) {
now.onApply(patch, false);
setMessage(
'Campos vazios preenchidos a partir do dossier. Reveja-os; os campos já preenchidos foram preservados.',
);
}
}, [view, enabled, readOnly, contextId, consumer]);
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 differences = Object.keys(source).filter(
(k) => 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">
Mesmo investimento · contexto v{view.context.revision}
</h2>
<p>
UUID {contextId} ·{' '}
<Link
className="underline"
href={`/investimentos/${contextId}`}
>
recuperar dossier
</Link>
. Mestres, declarações deste formulário e conteúdo já submetido
mantêm-se separados.
</p>
{stale && (
<p className="font-semibold" role="status">
{linkedRevision > 0
? `O contexto do dossier difere da revisão vinculada a este registo (v${linkedRevision}).`
: '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>}
{Object.values(view.context.selection).map(
(r) =>
r && (
<p key={r.key}>
Fonte disponível:{' '}
<Link className="underline" href={r.href}>
{r.title}
</Link>{' '}
· {r.kind} · v{r.version ?? 'não exposta'}. {r.key}
</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]">
{text(props.values[k]) ||
'não informado'}
</td>
<td className="p-2 [overflow-wrap:anywhere]">
{text(source[k]) || 'não informado'}
</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] ?? '';
props.onApply(patch, true);
setSelected([]);
setReviewed(false);
props.onReview?.(view.context.revision, false);
setMessage(
'Valores selecionados aplicados ao formulário. Os outros valores e os snapshots anteriores foram preservados.',
);
}}
>
Aplicar somente os campos selecionados
</button>
</details>
)}
{!readOnly &&
view.can_write &&
props.onReview &&
view.context.revision > 0 && (
<label className="block">
<input
type="checkbox"
checked={reviewed}
onChange={(e) => {
setReviewed(e.target.checked);
props.onReview?.(
view.context.revision,
e.target.checked,
);
}}
/>{' '}
Conferi os dados e pressupostos deste formulário para o
contexto v{view.context.revision}; os valores diferentes
que conservei são declarações deste processo.
</label>
)}
<p className="text-xs">
Associar uma referência não confirma incentivos, licenças, terra
disponível, reserva ou autorização.
</p>
</section>
);
}