471 lines
19 KiB
TypeScript
471 lines
19 KiB
TypeScript
import { Link, router, usePage } from '@inertiajs/react';
|
|
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { contextApi, type DossierContext } from '@/lib/investor-context';
|
|
export type NavigationView = {
|
|
case: { id: string; title: string; version: number };
|
|
phases: Record<string, string>;
|
|
steps: {
|
|
code: string;
|
|
title: string;
|
|
phase: number;
|
|
state: string;
|
|
applicability: string;
|
|
}[];
|
|
next: { code: string; title: string; action: string; href: string } | null;
|
|
facts: { land_needed: string };
|
|
sector_contexts: { id: number; name: string; slug: string }[];
|
|
zone_contexts: {
|
|
zone_id: string;
|
|
title: string;
|
|
province: string | null;
|
|
district: string | null;
|
|
consulted_public_version: number;
|
|
}[];
|
|
results: {
|
|
id: string;
|
|
engine: string;
|
|
recorded_at: string;
|
|
context_state?: string;
|
|
}[];
|
|
requests: {
|
|
id: string;
|
|
protocol: string;
|
|
status: string;
|
|
version: number;
|
|
}[];
|
|
documents: { id: string; file_name: string; status: string }[];
|
|
tools: {
|
|
simulator: string | null;
|
|
desk: string;
|
|
land: string | null;
|
|
guidance: string | null;
|
|
};
|
|
investment_context: DossierContext | null;
|
|
};
|
|
type Focus = { case_id: string; step_code: string | null; version: number };
|
|
type State = {
|
|
active: string;
|
|
actor?: number;
|
|
cases: { id: string; title: string; version: number }[];
|
|
view: NavigationView | null;
|
|
step: string | null;
|
|
error: string;
|
|
busy: boolean;
|
|
choose: (id: string, step?: string | null) => void;
|
|
refresh: () => void;
|
|
};
|
|
const Context = createContext<State>({
|
|
active: '',
|
|
cases: [],
|
|
view: null,
|
|
step: null,
|
|
error: '',
|
|
busy: false,
|
|
choose: () => {},
|
|
refresh: () => {},
|
|
});
|
|
export const useNavigationContext = () => useContext(Context);
|
|
export function NavigationContextProvider({
|
|
children,
|
|
}: {
|
|
children: ReactNode;
|
|
}) {
|
|
const { props, url } = usePage<{
|
|
auth: { user: { id: number } | null };
|
|
investorPathEnabled?: boolean;
|
|
}>();
|
|
const actor = props.auth?.user?.id;
|
|
const [loaded, setLoaded] = useState<{
|
|
actor: number;
|
|
cases: State['cases'];
|
|
focus: Focus | null;
|
|
view: NavigationView | null;
|
|
} | null>(null);
|
|
const [error, setError] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [reload, setReload] = useState(0);
|
|
const [hash, setHash] = useState(
|
|
typeof window === 'undefined' ? '' : window.location.hash,
|
|
);
|
|
useEffect(() => {
|
|
const changed = () => setHash(window.location.hash);
|
|
window.addEventListener('hashchange', changed);
|
|
return () => window.removeEventListener('hashchange', changed);
|
|
}, []);
|
|
useEffect(() => {
|
|
setHash(window.location.hash);
|
|
}, [url]);
|
|
const current = loaded?.actor === actor ? loaded : null;
|
|
const active = current?.focus?.case_id ?? '';
|
|
useEffect(() => {
|
|
if (!actor || !props.investorPathEnabled) return;
|
|
let stopped = false;
|
|
setError('');
|
|
setBusy(true);
|
|
void (async () => {
|
|
const [listing, pref] = await Promise.all([
|
|
contextApi<{ contexts: State['cases'] }>('/investimentos'),
|
|
contextApi<{
|
|
installed: boolean;
|
|
focus: Focus | null;
|
|
message?: string;
|
|
}>('/investimentos/contexto-atual'),
|
|
]);
|
|
if (stopped) return;
|
|
if (!pref.installed)
|
|
throw new Error(
|
|
'A instalação do contexto está pendente; os dossiers existentes permanecem disponíveis.',
|
|
);
|
|
const direct =
|
|
url.match(
|
|
/^\/investimentos\/([a-f0-9-]{36})(?:[/?#]|$)/i,
|
|
)?.[1] ??
|
|
new URL(url, 'https://context.invalid').searchParams.get(
|
|
'context',
|
|
);
|
|
let focus = pref.focus;
|
|
const code = /^#S(0[1-9]|1[0-9])$/.test(window.location.hash)
|
|
? window.location.hash.slice(1)
|
|
: null;
|
|
if (
|
|
direct &&
|
|
listing.contexts.some((c) => c.id === direct) &&
|
|
(focus?.case_id !== direct ||
|
|
(code && focus?.step_code !== code))
|
|
) {
|
|
focus = (
|
|
await contextApi<{ focus: Focus }>(
|
|
'/investimentos/contexto-atual',
|
|
{
|
|
case_id: direct,
|
|
step_code: code,
|
|
version: focus?.version ?? 0,
|
|
},
|
|
'PUT',
|
|
)
|
|
).focus;
|
|
}
|
|
const view = focus
|
|
? await contextApi<NavigationView>(
|
|
`/investimentos/${focus.case_id}${direct === focus.case_id && new URL(url, 'https://context.invalid').searchParams.get('opportunity') ? '?opportunity=' + encodeURIComponent(new URL(url, 'https://context.invalid').searchParams.get('opportunity')!) : ''}`,
|
|
)
|
|
: null;
|
|
if (!stopped) {
|
|
setLoaded({ actor, cases: listing.contexts, focus, view });
|
|
if (pref.message) setError(pref.message);
|
|
}
|
|
})()
|
|
.catch((e) => {
|
|
if (!stopped) {
|
|
setLoaded(null);
|
|
setError(
|
|
e instanceof Error
|
|
? e.message
|
|
: 'Contexto indisponível.',
|
|
);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!stopped) setBusy(false);
|
|
});
|
|
return () => {
|
|
stopped = true;
|
|
};
|
|
}, [actor, url, hash, reload, props.investorPathEnabled]);
|
|
function choose(id: string, step: string | null = null) {
|
|
if (!actor || !current?.cases.some((c) => c.id === id)) return;
|
|
setBusy(true);
|
|
setError('');
|
|
void contextApi(
|
|
'/investimentos/contexto-atual',
|
|
{
|
|
case_id: id,
|
|
step_code: step,
|
|
version: current.focus?.version ?? 0,
|
|
},
|
|
'PUT',
|
|
)
|
|
.then(() => {
|
|
const target = `/investimentos/${id}${step ? '#' + step : ''}`;
|
|
if (url !== target) router.visit(target);
|
|
else setReload((x) => x + 1);
|
|
})
|
|
.catch((e) =>
|
|
setError(
|
|
e instanceof Error
|
|
? e.message
|
|
: 'Não foi possível guardar a escolha.',
|
|
),
|
|
)
|
|
.finally(() => setBusy(false));
|
|
}
|
|
return (
|
|
<Context.Provider
|
|
value={{
|
|
actor,
|
|
active,
|
|
cases: current?.cases ?? [],
|
|
view: current?.view ?? null,
|
|
step: current?.focus?.step_code ?? null,
|
|
error,
|
|
busy,
|
|
choose,
|
|
refresh: () => setReload((x) => x + 1),
|
|
}}
|
|
>
|
|
{children}
|
|
</Context.Provider>
|
|
);
|
|
}
|
|
export function NavigationContextSummary() {
|
|
const { actor, active, cases, view, step, error, busy, choose } =
|
|
useNavigationContext();
|
|
const { url } = usePage();
|
|
const [open, setOpen] = useState(false);
|
|
const [pending, setPending] = useState('');
|
|
if (!actor) return null;
|
|
if (/^\/(management|perfis|minhas-oportunidades|dashboard)(\/|$)/.test(url))
|
|
return null;
|
|
const saved = view?.investment_context;
|
|
const ref = saved?.selection;
|
|
const params = new URLSearchParams(url.split('?')[1] ?? '');
|
|
const scopedChoice = saved?.opportunity_choices?.find(
|
|
(c) => c.opportunity.key === params.get('opportunity'),
|
|
);
|
|
const multiple = (saved?.opportunity_choices?.length ?? 0) > 1;
|
|
const currentStep = view?.steps.find((s) => s.code === step);
|
|
const sector = scopedChoice
|
|
? scopedChoice.opportunity.fields.sector || 'Não indicado'
|
|
: ref?.sector?.title ||
|
|
saved?.fields.sector ||
|
|
view?.sector_contexts.map((s) => s.name).join(' · ') ||
|
|
'Não indicado';
|
|
const location = scopedChoice
|
|
? scopedChoice.territory?.title || 'Por definir'
|
|
: multiple
|
|
? 'Consulte a localização de cada oportunidade'
|
|
: saved?.fields.location ||
|
|
ref?.territory?.title ||
|
|
ref?.park?.title ||
|
|
view?.zone_contexts
|
|
.map((z) => `${z.title} (v${z.consulted_public_version})`)
|
|
.join(' · ') ||
|
|
'Não indicada';
|
|
return (
|
|
<aside
|
|
className="sticky top-0 z-30 border-y border-emerald-200 bg-emerald-50 px-4 py-3 text-xs text-slate-900 shadow-sm"
|
|
aria-label="Resumo do investimento atual"
|
|
>
|
|
<dl className="mx-auto grid max-w-7xl grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3 lg:grid-cols-6">
|
|
{Object.entries({
|
|
'Investimento / dossier':
|
|
saved?.fields.project_title ||
|
|
view?.case.title ||
|
|
'Selecione um dossier',
|
|
Oportunidade:
|
|
scopedChoice?.opportunity.title ||
|
|
(multiple
|
|
? 'Várias — oportunidade em tratamento no formulário'
|
|
: ref?.opportunity?.title ||
|
|
'Sem oportunidade associada'),
|
|
Sector: sector,
|
|
Localização: location,
|
|
'Etapa atual': currentStep
|
|
? `${currentStep.code} — ${currentStep.title}`
|
|
: 'Visão geral do dossier',
|
|
'Próxima ação': view?.next
|
|
? `${view.next.action} ${view.next.code} — ${view.next.title}`
|
|
: 'Escolher ou recuperar investimento',
|
|
}).map(([label, value]) => (
|
|
<div key={label} className="min-w-0">
|
|
<dt className="font-semibold">{label}</dt>
|
|
<dd className="truncate" title={value}>
|
|
{value}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<div className="mx-auto mt-2 flex max-w-7xl flex-wrap gap-3">
|
|
{view && (
|
|
<Link
|
|
className="font-semibold underline"
|
|
href={`/investimentos/${active}${scopedChoice ? '?opportunity=' + encodeURIComponent(scopedChoice.opportunity.key) : ''}`}
|
|
>
|
|
Abrir dossier atual
|
|
</Link>
|
|
)}
|
|
{view?.next && (
|
|
<Link
|
|
className="underline"
|
|
href={`/investimentos/${active}${scopedChoice ? '?opportunity=' + encodeURIComponent(scopedChoice.opportunity.key) : ''}#${view.next.code}`}
|
|
>
|
|
Preparar {view.next.code}
|
|
</Link>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="underline"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen(!open)}
|
|
>
|
|
Detalhes e mudar contexto
|
|
</button>
|
|
<Link className="underline" href="/oportunidades">
|
|
Pesquisar oportunidades
|
|
</Link>
|
|
</div>
|
|
{error && (
|
|
<p role="alert" className="mx-auto mt-2 max-w-7xl">
|
|
{error}{' '}
|
|
<button
|
|
className="underline"
|
|
onClick={() => window.location.reload()}
|
|
>
|
|
Recuperar novamente
|
|
</button>
|
|
</p>
|
|
)}
|
|
{busy && (
|
|
<p role="status" className="mx-auto max-w-7xl">
|
|
A recuperar o contexto autorizado…
|
|
</p>
|
|
)}
|
|
{open && (
|
|
<div className="mx-auto mt-3 max-w-7xl space-y-3 rounded border bg-white p-3 [overflow-wrap:anywhere]">
|
|
{!actor ? (
|
|
<p>
|
|
<Link className="underline" href="/investimentos">
|
|
Entrar para recuperar o mesmo dossier
|
|
</Link>
|
|
. A consulta pública continua disponível.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<label className="grid gap-1">
|
|
Dossier autorizado
|
|
<select
|
|
disabled={busy}
|
|
className="w-full max-w-full min-w-0 rounded border p-2"
|
|
value={pending || active}
|
|
onChange={(e) => setPending(e.target.value)}
|
|
>
|
|
<option value="">
|
|
Escolher investimento
|
|
</option>
|
|
{cases.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{pending && pending !== active && (
|
|
<div role="status">
|
|
<p>
|
|
Muda o dossier em foco nesta conta e nos
|
|
seus dispositivos. As associações,
|
|
documentos e submissões dos dois
|
|
dossiers permanecem separados e
|
|
preservados.
|
|
</p>
|
|
<button
|
|
className="mr-4 underline"
|
|
onClick={() => setPending('')}
|
|
>
|
|
Cancelar mudança de dossier
|
|
</button>
|
|
<button
|
|
className="underline"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
choose(pending);
|
|
setPending('');
|
|
}}
|
|
>
|
|
Confirmar dossier em foco
|
|
</button>
|
|
</div>
|
|
)}
|
|
{view && (
|
|
<>
|
|
<p>
|
|
UUID: {active} · versão{' '}
|
|
{view.case.version} · contexto v
|
|
{saved?.revision ?? 0} ·{' '}
|
|
{view.steps.length} etapas em{' '}
|
|
{Object.keys(view.phases).length} fases.
|
|
</p>
|
|
<label className="grid gap-1">
|
|
Etapa em consulta
|
|
<select
|
|
disabled={busy}
|
|
className="w-full min-w-0 rounded border p-2"
|
|
value={step ?? ''}
|
|
onChange={(e) =>
|
|
choose(
|
|
active,
|
|
e.target.value || null,
|
|
)
|
|
}
|
|
>
|
|
<option value="">
|
|
Visão geral
|
|
</option>
|
|
{view.steps.map((s) => (
|
|
<option
|
|
key={s.code}
|
|
value={s.code}
|
|
>
|
|
{s.code} — {s.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{view.investment_context && (
|
|
<Link
|
|
className="block underline"
|
|
href={`/investimentos/${active}/contexto/editar`}
|
|
>
|
|
Comparar e guardar oportunidade,
|
|
sector ou localização
|
|
</Link>
|
|
)}
|
|
{ref &&
|
|
Object.values(ref).map((r) => (
|
|
<p key={r.key}>
|
|
<Link
|
|
className="underline"
|
|
href={r.href}
|
|
>
|
|
{r.title}
|
|
</Link>{' '}
|
|
· versão{' '}
|
|
{r.version ?? 'não exposta'} ·{' '}
|
|
{r.kind}. Referência consultada
|
|
para preparação.
|
|
</p>
|
|
))}
|
|
{view.tools.simulator && (
|
|
<Link
|
|
className="block underline"
|
|
href={view.tools.simulator}
|
|
>
|
|
Simulador deste investimento
|
|
</Link>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|