494 lines
17 KiB
TypeScript
494 lines
17 KiB
TypeScript
import SearchState from '@/components/search-state';
|
|
import { useEffect, useId, useState } from 'react';
|
|
import { api } from '@/pages/investment/shared';
|
|
import { replaceTerritoryPath } from '@/lib/territory-path.js';
|
|
import TerritoryGeometryPreview from './territory-geometry-preview';
|
|
|
|
export type TerritoryOption = {
|
|
id: string;
|
|
name: string;
|
|
parent_id: string | null;
|
|
location_type: string;
|
|
deleted_at?: string | null;
|
|
source_code?: string | null;
|
|
};
|
|
type Option = TerritoryOption;
|
|
const levels = ['province', 'district', 'admin_post', 'locality'];
|
|
const labels = ['Província', 'Distrito', 'Posto administrativo', 'Localidade'];
|
|
const control = 'w-full rounded border bg-background p-2 text-foreground';
|
|
const emptyTerritoryPath: TerritoryOption[] = [];
|
|
|
|
export function ProvinceSelect({
|
|
value,
|
|
onChange,
|
|
endpoint = '/api/v1/investment/territory-options',
|
|
placeholder = 'Seleccione uma província',
|
|
className = control,
|
|
label = 'Província',
|
|
required = false,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
endpoint?: string;
|
|
placeholder?: string;
|
|
className?: string;
|
|
label?: string;
|
|
required?: boolean;
|
|
}) {
|
|
const id = useId();
|
|
const [options, setOptions] = useState<Option[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [retry, setRetry] = useState(0);
|
|
const [search, setSearch] = useState('');
|
|
const [geometrySelection, setGeometrySelection] = useState<{
|
|
value: string;
|
|
id: string;
|
|
} | null>(null);
|
|
const [geometryFeedback, setGeometryFeedback] = useState('');
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
setGeometrySelection(null);
|
|
setGeometryFeedback('');
|
|
if (!value) return;
|
|
// Preserve historical text. Only one exact catalogue match supplies a map ID.
|
|
void fetch(
|
|
`${endpoint}?${new URLSearchParams({ type: 'province', page: '1', q: value })}`,
|
|
{
|
|
signal: controller.signal,
|
|
headers: { Accept: 'application/json' },
|
|
},
|
|
)
|
|
.then(async (response) => {
|
|
if (!response.ok)
|
|
throw new Error(
|
|
'Não foi possível consultar a correspondência cartográfica.',
|
|
);
|
|
const data = await response.json();
|
|
const matches = (data.data as Option[]).filter(
|
|
(option) => option.name === value,
|
|
);
|
|
if (controller.signal.aborted) return;
|
|
if (matches.length === 1 && !data.next_page_url)
|
|
setGeometrySelection({ value, id: matches[0].id });
|
|
else
|
|
setGeometryFeedback(
|
|
'Seleção guardada preservada; sem correspondência cartográfica inequívoca neste cadastro.',
|
|
);
|
|
})
|
|
.catch((cause: unknown) => {
|
|
if (!controller.signal.aborted)
|
|
setGeometryFeedback(
|
|
cause instanceof Error
|
|
? cause.message
|
|
: 'Erro de ligação.',
|
|
);
|
|
});
|
|
return () => controller.abort();
|
|
}, [endpoint, value, retry]);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
setLoading(true);
|
|
setError('');
|
|
const timer = setTimeout(
|
|
() =>
|
|
fetch(
|
|
`${endpoint}?${new URLSearchParams({ type: 'province', page: '1', q: search })}`,
|
|
{
|
|
signal: controller.signal,
|
|
headers: { Accept: 'application/json' },
|
|
},
|
|
)
|
|
.then(async (response) => {
|
|
if (!response.ok)
|
|
throw new Error(
|
|
'Não foi possível carregar as províncias.',
|
|
);
|
|
return response.json();
|
|
})
|
|
.then((data) => {
|
|
if (!controller.signal.aborted) setOptions(data.data);
|
|
})
|
|
.catch(() => {
|
|
if (!controller.signal.aborted)
|
|
setError('Não foi possível concluir a pesquisa.');
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) setLoading(false);
|
|
}),
|
|
250,
|
|
);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [endpoint, retry, search]);
|
|
|
|
return (
|
|
<>
|
|
<SearchState
|
|
scope="Localizações"
|
|
loading={loading}
|
|
error={error}
|
|
total={options.length}
|
|
retry={() => setRetry((value) => value + 1)}
|
|
/>
|
|
<input
|
|
className={className}
|
|
aria-label={`Pesquisar ${label}`}
|
|
maxLength={100}
|
|
placeholder="Pesquisar nome ou código"
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
/>
|
|
<select
|
|
id={id}
|
|
aria-label={label}
|
|
className={className}
|
|
required={required}
|
|
value={value}
|
|
disabled={loading || Boolean(error)}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
>
|
|
<option value="">
|
|
{loading ? 'A carregar províncias…' : placeholder}
|
|
</option>
|
|
{value && !options.some((option) => option.name === value) && (
|
|
<option value={value}>
|
|
{value} (seleção guardada; conferir cadastro)
|
|
</option>
|
|
)}
|
|
{options.map((option) => (
|
|
<option key={option.id} value={option.name}>
|
|
{option.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{geometryFeedback && <p role="status">{geometryFeedback}</p>}
|
|
<TerritoryGeometryPreview
|
|
territoryId={
|
|
geometrySelection && geometrySelection.value === value
|
|
? geometrySelection.id
|
|
: undefined
|
|
}
|
|
management={endpoint.startsWith('/management/')}
|
|
endpoint={
|
|
endpoint.startsWith('/minhas-oportunidades/')
|
|
? '/minhas-oportunidades/territory-geometries'
|
|
: undefined
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function TerritoryFields({
|
|
initial = emptyTerritoryPath,
|
|
onChange,
|
|
endpoint = '/api/v1/investment/territory-options',
|
|
disabled = false,
|
|
corridor,
|
|
}: {
|
|
initial?: TerritoryOption[];
|
|
onChange?: (path: TerritoryOption[]) => void;
|
|
endpoint?: string;
|
|
disabled?: boolean;
|
|
corridor?: string | null;
|
|
}) {
|
|
const [path, setPath] = useState(initial);
|
|
useEffect(() => setPath(initial), [initial]);
|
|
return (
|
|
<div className="grid gap-4 md:col-span-full md:grid-cols-2">
|
|
{(
|
|
[
|
|
'province_id',
|
|
'district_id',
|
|
'admin_post_id',
|
|
'locality_id',
|
|
] as const
|
|
).map((field, index) => (
|
|
<div key={`${field}-${path[index - 1]?.id ?? ''}`}>
|
|
<input
|
|
type="hidden"
|
|
name={field}
|
|
value={path[index]?.id ?? ''}
|
|
/>
|
|
<TerritoryLevel
|
|
disabled={disabled}
|
|
index={index}
|
|
parent={path[index - 1]?.id ?? ''}
|
|
selected={path[index]}
|
|
endpoint={endpoint}
|
|
corridor={corridor}
|
|
onChange={(value) => {
|
|
const next = replaceTerritoryPath(
|
|
path,
|
|
index,
|
|
value,
|
|
);
|
|
setPath(next);
|
|
onChange?.(next);
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
<TerritoryGeometryPreview
|
|
territoryId={path.at(-1)?.id}
|
|
management={endpoint.startsWith('/management/')}
|
|
endpoint={
|
|
endpoint.startsWith('/minhas-oportunidades/')
|
|
? '/minhas-oportunidades/territory-geometries'
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TerritoryLevel({
|
|
index,
|
|
parent,
|
|
selected,
|
|
onChange,
|
|
endpoint = '/management/territory-options',
|
|
fieldError,
|
|
disabled = false,
|
|
corridor,
|
|
}: {
|
|
index: number;
|
|
parent: string;
|
|
selected?: Option;
|
|
onChange: (value?: Option) => void;
|
|
endpoint?: string;
|
|
fieldError?: string;
|
|
disabled?: boolean;
|
|
corridor?: string | null;
|
|
}) {
|
|
const id = useId();
|
|
const [page, setPage] = useState(1);
|
|
const [options, setOptions] = useState<Option[]>([]);
|
|
const [more, setMore] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [retry, setRetry] = useState(0);
|
|
const [search, setSearch] = useState('');
|
|
const enabled = !disabled && (index === 0 || Boolean(parent));
|
|
useEffect(() => {
|
|
setPage(1);
|
|
setSearch('');
|
|
}, [parent, index]);
|
|
useEffect(() => {
|
|
setOptions([]);
|
|
setMore(false);
|
|
if (!enabled) return;
|
|
setLoading(true);
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const params = new URLSearchParams({
|
|
type: levels[index],
|
|
parent_id: parent,
|
|
page: String(page),
|
|
q: search,
|
|
});
|
|
if (corridor) params.set('corridor', corridor);
|
|
const response = await fetch(`${endpoint}?${params}`, {
|
|
signal: controller.signal,
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok)
|
|
throw new Error(
|
|
'Não foi possível carregar as localizações.',
|
|
);
|
|
const data = await response.json();
|
|
if (controller.signal.aborted) return;
|
|
setOptions(data.data);
|
|
setMore(Boolean(data.next_page_url));
|
|
} catch {
|
|
if (!controller.signal.aborted)
|
|
setError('Não foi possível concluir a pesquisa.');
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false);
|
|
}
|
|
}, 250);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [index, parent, page, enabled, endpoint, retry, search, corridor]);
|
|
return (
|
|
<div className="space-y-2">
|
|
{enabled && (
|
|
<SearchState
|
|
scope={labels[index]}
|
|
loading={loading}
|
|
error={error}
|
|
total={options.length}
|
|
retry={() => setRetry((value) => value + 1)}
|
|
/>
|
|
)}
|
|
<label htmlFor={id} className="font-medium">
|
|
{labels[index]}
|
|
</label>
|
|
<input
|
|
className={control}
|
|
aria-label={`Pesquisar ${labels[index]}`}
|
|
disabled={!enabled}
|
|
maxLength={100}
|
|
value={search}
|
|
onChange={(event) => {
|
|
setSearch(event.target.value);
|
|
setPage(1);
|
|
}}
|
|
placeholder={`Pesquisar ${labels[index].toLowerCase()}`}
|
|
/>
|
|
<select
|
|
id={id}
|
|
aria-invalid={Boolean(fieldError)}
|
|
aria-describedby={fieldError ? `${id}-error` : undefined}
|
|
className={control}
|
|
disabled={!enabled || loading || !!error}
|
|
value={selected?.id ?? ''}
|
|
onChange={(e) =>
|
|
onChange(options.find((o) => o.id === e.target.value))
|
|
}
|
|
>
|
|
<option value="">
|
|
{enabled ? 'Seleccione' : 'Seleccione o nível anterior'}
|
|
</option>
|
|
{selected && !options.some((o) => o.id === selected.id) && (
|
|
<option value={selected.id}>
|
|
{selected.name}
|
|
{selected.deleted_at
|
|
? ' (arquivada)'
|
|
: ' (selecção actual)'}
|
|
</option>
|
|
)}
|
|
{options.map((o) => (
|
|
<option key={o.id} value={o.id}>
|
|
{o.name}
|
|
{o.source_code ? ` (${o.source_code})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{loading && <p role="status">A carregar…</p>}
|
|
{fieldError && (
|
|
<p id={`${id}-error`} role="alert">
|
|
{fieldError}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-3 text-sm">
|
|
{page > 1 && (
|
|
<button type="button" onClick={() => setPage(page - 1)}>
|
|
Anterior
|
|
</button>
|
|
)}
|
|
{more && (
|
|
<button type="button" onClick={() => setPage(page + 1)}>
|
|
Mais opções
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function InvestmentTerritory({
|
|
kind,
|
|
id,
|
|
}: {
|
|
kind: string;
|
|
id: string | number;
|
|
}) {
|
|
const [path, setPath] = useState<Option[]>([]);
|
|
const [version, setVersion] = useState<number | null>(null);
|
|
const [message, setMessage] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
useEffect(() => {
|
|
let active = true;
|
|
api(`/management/territories/${kind}/${id}`)
|
|
.then((data) => {
|
|
if (!active) return;
|
|
setPath(
|
|
levels.flatMap((level) =>
|
|
data.path.filter(
|
|
(o: Option) => o.location_type === level,
|
|
),
|
|
),
|
|
);
|
|
setVersion(data.version);
|
|
})
|
|
.catch((e) => {
|
|
if (active) setMessage(e.message);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [kind, id]);
|
|
return (
|
|
<section
|
|
className="my-6 space-y-4 rounded border p-4"
|
|
aria-label="Localização do investimento"
|
|
>
|
|
<h2 className="text-lg font-semibold">
|
|
Localização do investimento
|
|
</h2>
|
|
<p>
|
|
Seleccione até ao nível conhecido. Alterar um nível limpa as
|
|
selecções seguintes.
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{levels.map((level, index) => (
|
|
<TerritoryLevel
|
|
key={`${level}-${path[index - 1]?.id ?? ''}`}
|
|
index={index}
|
|
parent={path[index - 1]?.id ?? ''}
|
|
selected={path[index]}
|
|
onChange={(value) => {
|
|
setPath(replaceTerritoryPath(path, index, value));
|
|
setMessage('');
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<TerritoryGeometryPreview
|
|
territoryId={path.at(-1)?.id}
|
|
management
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="bg-primary text-primary-foreground rounded px-4 py-2 disabled:opacity-50"
|
|
disabled={version === null || busy}
|
|
onClick={async () => {
|
|
setBusy(true);
|
|
setMessage('');
|
|
try {
|
|
const data = await api(
|
|
`/management/territories/${kind}/${id}`,
|
|
{
|
|
version,
|
|
path: path.map((o) => o.id),
|
|
},
|
|
);
|
|
setVersion(data.version);
|
|
setMessage('Localização associada e guardada.');
|
|
} catch (e) {
|
|
setMessage(
|
|
e instanceof Error ? e.message : 'Erro ao guardar.',
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
Guardar localização
|
|
</button>
|
|
{message && <p role="status">{message}</p>}
|
|
</section>
|
|
);
|
|
}
|