1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:20:23 +01:00

feat(shared): Länderauswahl mit ISO 3166-1 Mapping und CountryPicker

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
This commit is contained in:
Sebastian Frick 2026-02-24 09:28:56 +01:00
parent 2811836039
commit a77f0ec5df
20 changed files with 1136 additions and 63 deletions

View file

@ -0,0 +1,129 @@
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>
);
}