feat(ares): ARES company lookup with prefill in customer/supplier modals
New /api/admin/ares proxy (browser cannot reach ares.gov.cz - CORS+CSP): GET /ico/:ico and GET /search?q= map the MFCR REST API to the form shape (street incl. orientation numbers, "511 01" PSC format, DIC). Guarded by customers.create/edit + warehouse.manage; token errors map to Czech messages. Shared AresAdornment button sits in the Nazev and ICO fields of both modals: ICO mode fills directly, name mode searches with a result picker. Tested against live ARES (BOHA, ICO 22599851). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
153
src/admin/components/AresLookup.tsx
Normal file
153
src/admin/components/AresLookup.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useRef } from "react";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
/** Normalized company record returned by the /api/admin/ares proxy. */
|
||||
export interface AresCompany {
|
||||
ico: string;
|
||||
dic: string | null;
|
||||
name: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface AresAdornmentProps {
|
||||
/** "ico" looks the query up directly; "name" searches and offers a picker. */
|
||||
mode: "ico" | "name";
|
||||
/** Current value of the host field (IČO or name fragment). */
|
||||
query: string;
|
||||
/** Called with the chosen company — the modal prefills its form from it. */
|
||||
onFill: (company: AresCompany) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "ARES" end-adornment button for the Název/IČO fields of the customer and
|
||||
* supplier modals. IČO mode fetches the subject directly; name mode searches
|
||||
* ARES and shows a result picker (filling straight away on a single hit).
|
||||
* Pass via the TextField's `InputProps.endAdornment`.
|
||||
*/
|
||||
export default function AresAdornment({
|
||||
mode,
|
||||
query,
|
||||
onFill,
|
||||
}: AresAdornmentProps) {
|
||||
const alert = useAlert();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<AresCompany[]>([]);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const enabled =
|
||||
mode === "ico"
|
||||
? /^\d{8}$/.test(trimmed.replace(/\s+/g, ""))
|
||||
: trimmed.length >= 2;
|
||||
const tooltip =
|
||||
mode === "ico"
|
||||
? enabled
|
||||
? "Načíst údaje z ARES podle IČO"
|
||||
: "Vyplňte 8místné IČO"
|
||||
: enabled
|
||||
? "Vyhledat firmu v ARES podle názvu"
|
||||
: "Vyplňte alespoň 2 znaky názvu";
|
||||
|
||||
const lookup = async () => {
|
||||
if (!enabled || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === "ico") {
|
||||
const company = await jsonQuery<AresCompany>(
|
||||
`/api/admin/ares/ico/${encodeURIComponent(trimmed.replace(/\s+/g, ""))}`,
|
||||
);
|
||||
onFill(company);
|
||||
} else {
|
||||
const found = await jsonQuery<AresCompany[]>(
|
||||
`/api/admin/ares/search?q=${encodeURIComponent(trimmed)}`,
|
||||
);
|
||||
if (found.length === 0) {
|
||||
alert.error("Žádná firma tohoto názvu nebyla v ARES nalezena");
|
||||
} else if (found.length === 1) {
|
||||
onFill(found[0]);
|
||||
} else {
|
||||
setResults(found);
|
||||
setMenuOpen(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert.error(
|
||||
e instanceof Error ? e.message : "Registr ARES je nedostupný",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
size="small"
|
||||
onClick={lookup}
|
||||
disabled={!enabled || loading}
|
||||
aria-label="Načíst z ARES"
|
||||
edge="end"
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.06em",
|
||||
color: enabled ? "primary.main" : "text.disabled",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
ARES
|
||||
</Typography>
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorRef.current}
|
||||
open={menuOpen}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
>
|
||||
{results.map((c) => (
|
||||
<MenuItem
|
||||
key={c.ico}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onFill(c);
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{c.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
IČO {c.ico}
|
||||
{c.city ? ` · ${c.city}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</InputAdornment>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type CustomField,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import AresAdornment, { type AresCompany } from "../components/AresLookup";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -267,6 +268,21 @@ export default function OffersCustomers() {
|
||||
return key;
|
||||
};
|
||||
|
||||
// ARES prefill — overwrite the company fields with registry data (the
|
||||
// button is an explicit user action, so overwriting is the expected UX).
|
||||
const fillFromAres = (c: AresCompany) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
name: c.name || prev.name,
|
||||
street: c.street,
|
||||
city: c.city,
|
||||
postal_code: c.postal_code,
|
||||
country: c.country,
|
||||
company_id: c.ico,
|
||||
vat_id: c.dic || "",
|
||||
}));
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingCustomer(null);
|
||||
setForm({
|
||||
@@ -535,6 +551,15 @@ export default function OffersCustomers() {
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="Název firmy / jméno"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<AresAdornment
|
||||
mode="name"
|
||||
query={form.name}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ulice">
|
||||
@@ -596,6 +621,15 @@ export default function OffersCustomers() {
|
||||
company_id: e.target.value,
|
||||
}))
|
||||
}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<AresAdornment
|
||||
mode="ico"
|
||||
query={form.company_id}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DIČ">
|
||||
|
||||
Reference in New Issue
Block a user