254 lines
8.4 KiB
TypeScript
254 lines
8.4 KiB
TypeScript
import { useEffect, useId, useState } from 'react';
|
|
import { useForm } from '@inertiajs/react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { FormErrors, controlClass } from '@/components/procurement';
|
|
|
|
export type GeographyReference = {
|
|
country: string;
|
|
catalogue: string;
|
|
record_id: string;
|
|
};
|
|
type Node = {
|
|
id: string;
|
|
name: string;
|
|
level: number;
|
|
parent_id: string | null;
|
|
};
|
|
export type GeographyDescription = {
|
|
reference: GeographyReference;
|
|
path: Node[];
|
|
requires_review: boolean;
|
|
} | null;
|
|
const levels = [
|
|
'Província',
|
|
'Distrito',
|
|
'Posto administrativo',
|
|
'Localidade',
|
|
'Bairro urbano',
|
|
'Aldeia / quarteirão',
|
|
];
|
|
|
|
export function AdministrativeLocation({
|
|
value,
|
|
initial = null,
|
|
onChange,
|
|
endpoint = '/api/divisao-administrativa',
|
|
maxLevel = 6,
|
|
legend = 'Localização administrativa de Moçambique (opcional)',
|
|
hint = 'Escolha apenas até ao nível conhecido. A morada mantém-se. Para endereços estrangeiros, deixe esta referência vazia.',
|
|
}: {
|
|
value: GeographyReference | null;
|
|
initial?: GeographyDescription;
|
|
endpoint?: string;
|
|
maxLevel?: number;
|
|
legend?: string;
|
|
hint?: string;
|
|
onChange: (
|
|
value: GeographyReference | null,
|
|
description?: GeographyDescription,
|
|
) => void;
|
|
}) {
|
|
const id = useId();
|
|
const [path, setPath] = useState<Node[]>(initial?.path ?? []);
|
|
const [options, setOptions] = useState<Node[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [more, setMore] = useState(false);
|
|
const [catalogue, setCatalogue] = useState(value?.catalogue ?? '');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const activePath = value ? path : [];
|
|
const parent = activePath.at(-1)?.id ?? '';
|
|
const atLastLevel = (activePath.at(-1)?.level ?? 0) >= maxLevel;
|
|
const queryPage = value ? page : 1;
|
|
const [retry, setRetry] = useState(0);
|
|
useEffect(() => {
|
|
if (atLastLevel) {
|
|
setOptions([]);
|
|
setMore(false);
|
|
setLoading(false);
|
|
setError('');
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
setLoading(true);
|
|
setError('');
|
|
fetch(
|
|
`${endpoint}?${new URLSearchParams({ parent, page: String(queryPage) })}`,
|
|
{
|
|
headers: { Accept: 'application/json' },
|
|
signal: controller.signal,
|
|
},
|
|
)
|
|
.then(async (response) => {
|
|
if (!response.ok)
|
|
throw new Error(
|
|
'Não foi possível carregar as localidades. Tente novamente.',
|
|
);
|
|
return response.json() as Promise<{
|
|
data: Node[];
|
|
has_more: boolean;
|
|
catalogue: string;
|
|
}>;
|
|
})
|
|
.then((result) => {
|
|
if (controller.signal.aborted) return;
|
|
setOptions((previous) =>
|
|
queryPage === 1
|
|
? result.data
|
|
: [...previous, ...result.data],
|
|
);
|
|
setMore(result.has_more);
|
|
setCatalogue(result.catalogue);
|
|
})
|
|
.catch((failure: Error) => {
|
|
if (!controller.signal.aborted) setError(failure.message);
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) setLoading(false);
|
|
});
|
|
return () => controller.abort();
|
|
}, [parent, queryPage, retry, endpoint, atLastLevel]);
|
|
const select = (next: Node[]) => {
|
|
setPath(next);
|
|
setOptions([]);
|
|
setPage(1);
|
|
const node = next.at(-1);
|
|
onChange(
|
|
node ? { country: 'MZ', catalogue, record_id: node.id } : null,
|
|
node
|
|
? {
|
|
reference: {
|
|
country: 'MZ',
|
|
catalogue,
|
|
record_id: node.id,
|
|
},
|
|
path: next,
|
|
requires_review: false,
|
|
}
|
|
: null,
|
|
);
|
|
};
|
|
return (
|
|
<fieldset className="space-y-3 rounded-lg border p-4">
|
|
<legend className="px-2 font-medium">{legend}</legend>
|
|
<p className="text-sm text-muted-foreground">{hint}</p>
|
|
<p className="text-sm">
|
|
{activePath.map((node) => node.name).join(' / ') ||
|
|
'Sem referência administrativa'}
|
|
</p>
|
|
{initial?.requires_review && (
|
|
<p role="status">
|
|
A referência anterior precisa de revisão. Escolha um
|
|
percurso confirmado.
|
|
</p>
|
|
)}
|
|
<label htmlFor={id} className="block text-sm">
|
|
{levels[options[0]?.level - 1] ?? 'Próximo nível'}
|
|
</label>
|
|
<select
|
|
id={id}
|
|
className={controlClass}
|
|
value=""
|
|
disabled={loading || !!error || options.length === 0}
|
|
onChange={(event) => {
|
|
const node = options.find(
|
|
(option) => option.id === event.target.value,
|
|
);
|
|
if (node) select([...activePath, node]);
|
|
}}
|
|
>
|
|
<option value="">
|
|
{loading
|
|
? 'A carregar…'
|
|
: options.length
|
|
? 'Escolher localização'
|
|
: 'Sem níveis adicionais confirmados'}
|
|
</option>
|
|
{options.map((node) => (
|
|
<option key={node.id} value={node.id}>
|
|
{node.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{error && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setRetry(retry + 1)}
|
|
>
|
|
Tentar novamente
|
|
</Button>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="text-sm text-red-700">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
{more && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={loading}
|
|
onClick={() => setPage(page + 1)}
|
|
>
|
|
Mais localidades
|
|
</Button>
|
|
)}
|
|
{activePath.length > 0 && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => select(activePath.slice(0, -1))}
|
|
>
|
|
Voltar um nível
|
|
</Button>
|
|
)}
|
|
{value && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => select([])}
|
|
>
|
|
Retirar referência
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
export function CompanyAdministrativeLocation({
|
|
geography,
|
|
}: {
|
|
geography: GeographyDescription;
|
|
}) {
|
|
const form = useForm({
|
|
geography_reference:
|
|
geography?.reference ?? (null as GeographyReference | null),
|
|
expected_reference: geography?.reference ?? null,
|
|
});
|
|
useEffect(() => {
|
|
form.setData('expected_reference', geography?.reference ?? null);
|
|
}, [geography]);
|
|
return (
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post('/empresa/localizacao', { preserveScroll: true });
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<AdministrativeLocation
|
|
value={form.data.geography_reference}
|
|
initial={geography}
|
|
onChange={(value) => form.setData('geography_reference', value)}
|
|
/>
|
|
<Button disabled={form.processing}>
|
|
Guardar localização da sede
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|