Files
Investidor-V2/resources/js/components/opportunity-fill-flow.tsx
T

150 lines
5.8 KiB
TypeScript

import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
opportunityErrorGroup,
opportunityMissingGroups,
type FillGroup,
} from '@/lib/opportunity-fill-flow';
export default function OpportunityFillFlow({
groups,
errors,
renderField,
onOpenResources,
missingPaths = [],
}: {
groups: FillGroup[];
errors: Record<string, string>;
renderField: (field: string) => ReactNode;
onOpenResources?: (group: string) => void;
missingPaths?: string[];
}) {
const [active, setActive] = useState(groups[0]?.id ?? '');
const groupIds = groups.map((group) => group.id).join(',');
useEffect(() => {
const saved = window.location.hash.replace('#fill-', '');
if (groupIds.split(',').includes(saved)) setActive(saved);
}, [groupIds]);
const heading = useRef<HTMLHeadingElement | null>(null);
const errorGroup = opportunityErrorGroup(groups, errors);
const missing = opportunityMissingGroups(groups, missingPaths);
const errorKey = JSON.stringify(errors);
useEffect(() => {
if (errorGroup) setActive(errorGroup);
}, [errorGroup, errorKey]);
const index = Math.max(
0,
groups.findIndex((group) => group.id === active),
);
const current = groups[index];
if (!current) return null;
const go = (id: string) => {
setActive(id);
window.history.replaceState(window.history.state, '', `#fill-${id}`);
requestAnimationFrame(() => heading.current?.focus());
};
return (
<div className="space-y-4">
<p>
Pode escolher qualquer grupo, guardar incompleto e retomar. As
informações já preenchidas mantêm-se ao avançar e regressar.
</p>
<nav
aria-label="Preencher a oportunidade"
className="flex flex-wrap gap-2"
>
{groups.map((group) => (
<a
key={group.id}
href={`#fill-${group.id}`}
aria-current={
current.id === group.id ? 'step' : undefined
}
className={`rounded border px-3 py-2 text-left ${current.id === group.id ? 'bg-primary text-primary-foreground' : 'bg-background'}`}
onClick={(event) => {
event.preventDefault();
go(group.id);
}}
>
{group.title}
{opportunityErrorGroup([group], errors)
? ' — verificar'
: missing.some((item) => item.id === group.id)
? ' — dados em falta'
: ''}
</a>
))}
</nav>
{missing.length > 0 && (
<p>
Pendências da última gravação:{' '}
{missing.map((group) => group.title).join(' · ')}. Guarde
para actualizar esta indicação.
</p>
)}
<h2 ref={heading} tabIndex={-1} className="text-xl font-semibold">
{current.title}
</h2>
<p>{current.hint}</p>
{groups.map((group) => (
<section
key={group.id}
id={`fill-${group.id}`}
hidden={current.id !== group.id}
aria-label={group.title}
className="space-y-4"
>
{group.fields.map((field) => (
<div key={field}>{renderField(field)}</div>
))}
{onOpenResources &&
group.fields.some((field) =>
['locations', 'documents', 'images'].includes(
field,
),
) && (
<button
type="button"
className="rounded border px-4 py-2 font-medium"
onClick={() => onOpenResources(group.id)}
>
Guardar e abrir documentos, imagens e
localizações
</button>
)}
</section>
))}
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
<span className="text-sm">
Grupo {index + 1} de {groups.length} · navegação livre
</span>
<div className="flex gap-3">
{index > 0 && (
<a
href={`#fill-${groups[index - 1].id}`}
className="rounded border px-4 py-2"
onClick={(event) => {
event.preventDefault();
go(groups[index - 1].id);
}}
>
Anterior
</a>
)}
{index < groups.length - 1 && (
<a
href={`#fill-${groups[index + 1].id}`}
className="rounded border px-4 py-2"
onClick={(event) => {
event.preventDefault();
go(groups[index + 1].id);
}}
>
Continuar: {groups[index + 1].title}
</a>
)}
</div>
</div>
</div>
);
}