mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +01:00
Backend: Country-Record (Shared Kernel), InMemoryCountryRepository mit ~249 Ländern und DACH-Priorisierung, ListCountries-UseCase, GET /api/countries?q= Endpoint. Frontend: CountryPicker-Komponente mit Fuzzy-Suche, DACH-Favoriten bei leerem Query. SupplierCreate-, CustomerCreate- und AddDeliveryAddress- Screens verwenden jetzt den CountryPicker statt Freitext. Detail-Screens zeigen den Ländercode in der Adressanzeige. Closes #71
129 lines
3.4 KiB
TypeScript
129 lines
3.4 KiB
TypeScript
import { useState, useMemo } from 'react';
|
||
import { Box, Text, useInput } from 'ink';
|
||
import type { CountryDTO } from '@effigenix/api-client';
|
||
|
||
interface CountryPickerProps {
|
||
countries: CountryDTO[];
|
||
query: string;
|
||
onQueryChange: (q: string) => void;
|
||
onSelect: (country: CountryDTO) => void;
|
||
focus: boolean;
|
||
selectedName?: string;
|
||
maxVisible?: number;
|
||
}
|
||
|
||
const DACH_CODES = ['DE', 'AT', 'CH'];
|
||
|
||
export function CountryPicker({
|
||
countries,
|
||
query,
|
||
onQueryChange,
|
||
onSelect,
|
||
focus,
|
||
selectedName,
|
||
maxVisible = 5,
|
||
}: CountryPickerProps) {
|
||
const [cursor, setCursor] = useState(0);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!query) {
|
||
// Show DACH favorites when no query
|
||
return countries.filter((c) => DACH_CODES.includes(c.code)).slice(0, maxVisible);
|
||
}
|
||
const q = query.toLowerCase();
|
||
return countries.filter(
|
||
(c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q),
|
||
).slice(0, maxVisible);
|
||
}, [countries, query, maxVisible]);
|
||
|
||
useInput((input, key) => {
|
||
if (!focus) return;
|
||
|
||
if (key.upArrow) {
|
||
setCursor((c) => Math.max(0, c - 1));
|
||
return;
|
||
}
|
||
if (key.downArrow) {
|
||
setCursor((c) => Math.min(filtered.length - 1, c + 1));
|
||
return;
|
||
}
|
||
if (key.return && filtered.length > 0) {
|
||
const selected = filtered[cursor];
|
||
if (selected) onSelect(selected);
|
||
return;
|
||
}
|
||
if (key.backspace || key.delete) {
|
||
onQueryChange(query.slice(0, -1));
|
||
setCursor(0);
|
||
return;
|
||
}
|
||
|
||
if (key.tab || key.escape || key.ctrl || key.meta) return;
|
||
|
||
if (input && !key.upArrow && !key.downArrow) {
|
||
onQueryChange(query + input);
|
||
setCursor(0);
|
||
}
|
||
}, { isActive: focus });
|
||
|
||
if (!focus && selectedName) {
|
||
return (
|
||
<Box flexDirection="column">
|
||
<Text color="gray">Land *</Text>
|
||
<Box>
|
||
<Text color="gray"> › </Text>
|
||
<Text color="green">✓ {selectedName}</Text>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
if (focus && selectedName && !query) {
|
||
return (
|
||
<Box flexDirection="column">
|
||
<Text color="cyan">Land * (tippen zum Ändern)</Text>
|
||
<Box>
|
||
<Text color="gray"> › </Text>
|
||
<Text color="green">✓ {selectedName}</Text>
|
||
</Box>
|
||
{filtered.length > 0 && (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
{filtered.map((c, i) => (
|
||
<Box key={c.code}>
|
||
<Text color={i === cursor ? 'cyan' : 'white'}>
|
||
{i === cursor ? '▶ ' : ' '}{c.code} – {c.name}
|
||
</Text>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box flexDirection="column">
|
||
<Text color={focus ? 'cyan' : 'gray'}>Land * (Suche)</Text>
|
||
<Box>
|
||
<Text color="gray"> › </Text>
|
||
<Text>{query || (focus ? '▌' : '')}</Text>
|
||
</Box>
|
||
{focus && filtered.length > 0 && (
|
||
<Box flexDirection="column" paddingLeft={2}>
|
||
{filtered.map((c, i) => (
|
||
<Box key={c.code}>
|
||
<Text color={i === cursor ? 'cyan' : 'white'}>
|
||
{i === cursor ? '▶ ' : ' '}{c.code} – {c.name}
|
||
</Text>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
)}
|
||
{focus && query && filtered.length === 0 && (
|
||
<Box paddingLeft={2}>
|
||
<Text color="yellow">Kein Land gefunden.</Text>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|