Files
Investidor-V2/resources/js/components/dossier-navigation-context.tsx
T

461 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>
);
}