Files
Investidor-V2/resources/js/components/investment-territory.tsx
T
2026-10-23 13:24:58 +02:00

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