Files
compras/resources/js/components/administrative-location.tsx
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

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