mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 12:09:35 +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:
parent
2811836039
commit
a77f0ec5df
20 changed files with 1136 additions and 63 deletions
|
|
@ -0,0 +1,22 @@
|
||||||
|
package de.effigenix.application.shared;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Country;
|
||||||
|
import de.effigenix.shared.common.CountryRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ListCountries {
|
||||||
|
|
||||||
|
private final CountryRepository countryRepository;
|
||||||
|
|
||||||
|
public ListCountries(CountryRepository countryRepository) {
|
||||||
|
this.countryRepository = countryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Country> execute(String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
return countryRepository.findAll();
|
||||||
|
}
|
||||||
|
return countryRepository.search(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package de.effigenix.infrastructure.config;
|
||||||
|
|
||||||
|
import de.effigenix.application.shared.ListCountries;
|
||||||
|
import de.effigenix.infrastructure.shared.InMemoryCountryRepository;
|
||||||
|
import de.effigenix.shared.common.CountryRepository;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SharedUseCaseConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CountryRepository countryRepository() {
|
||||||
|
return new InMemoryCountryRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ListCountries listCountries(CountryRepository countryRepository) {
|
||||||
|
return new ListCountries(countryRepository);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
package de.effigenix.infrastructure.shared;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Country;
|
||||||
|
import de.effigenix.shared.common.CountryRepository;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
public class InMemoryCountryRepository implements CountryRepository {
|
||||||
|
|
||||||
|
private static final Set<String> DACH_CODES = Set.of("DE", "AT", "CH");
|
||||||
|
|
||||||
|
private final Map<String, Country> byCode;
|
||||||
|
private final List<Country> allSorted;
|
||||||
|
|
||||||
|
public InMemoryCountryRepository() {
|
||||||
|
var countries = List.of(
|
||||||
|
new Country("AF", "Afghanistan"),
|
||||||
|
new Country("EG", "Ägypten"),
|
||||||
|
new Country("AX", "Åland-Inseln"),
|
||||||
|
new Country("AL", "Albanien"),
|
||||||
|
new Country("DZ", "Algerien"),
|
||||||
|
new Country("AS", "Amerikanisch-Samoa"),
|
||||||
|
new Country("VI", "Amerikanische Jungferninseln"),
|
||||||
|
new Country("AD", "Andorra"),
|
||||||
|
new Country("AO", "Angola"),
|
||||||
|
new Country("AI", "Anguilla"),
|
||||||
|
new Country("AQ", "Antarktis"),
|
||||||
|
new Country("AG", "Antigua und Barbuda"),
|
||||||
|
new Country("GQ", "Äquatorialguinea"),
|
||||||
|
new Country("AR", "Argentinien"),
|
||||||
|
new Country("AM", "Armenien"),
|
||||||
|
new Country("AW", "Aruba"),
|
||||||
|
new Country("AZ", "Aserbaidschan"),
|
||||||
|
new Country("ET", "Äthiopien"),
|
||||||
|
new Country("AU", "Australien"),
|
||||||
|
new Country("BS", "Bahamas"),
|
||||||
|
new Country("BH", "Bahrain"),
|
||||||
|
new Country("BD", "Bangladesch"),
|
||||||
|
new Country("BB", "Barbados"),
|
||||||
|
new Country("BY", "Belarus"),
|
||||||
|
new Country("BE", "Belgien"),
|
||||||
|
new Country("BZ", "Belize"),
|
||||||
|
new Country("BJ", "Benin"),
|
||||||
|
new Country("BM", "Bermuda"),
|
||||||
|
new Country("BT", "Bhutan"),
|
||||||
|
new Country("BO", "Bolivien"),
|
||||||
|
new Country("BQ", "Bonaire, Sint Eustatius und Saba"),
|
||||||
|
new Country("BA", "Bosnien und Herzegowina"),
|
||||||
|
new Country("BW", "Botsuana"),
|
||||||
|
new Country("BV", "Bouvetinsel"),
|
||||||
|
new Country("BR", "Brasilien"),
|
||||||
|
new Country("VG", "Britische Jungferninseln"),
|
||||||
|
new Country("IO", "Britisches Territorium im Indischen Ozean"),
|
||||||
|
new Country("BN", "Brunei Darussalam"),
|
||||||
|
new Country("BG", "Bulgarien"),
|
||||||
|
new Country("BF", "Burkina Faso"),
|
||||||
|
new Country("BI", "Burundi"),
|
||||||
|
new Country("CV", "Cabo Verde"),
|
||||||
|
new Country("CL", "Chile"),
|
||||||
|
new Country("CN", "China"),
|
||||||
|
new Country("CK", "Cookinseln"),
|
||||||
|
new Country("CR", "Costa Rica"),
|
||||||
|
new Country("CI", "Côte d'Ivoire"),
|
||||||
|
new Country("CW", "Curaçao"),
|
||||||
|
new Country("DK", "Dänemark"),
|
||||||
|
new Country("DE", "Deutschland"),
|
||||||
|
new Country("DM", "Dominica"),
|
||||||
|
new Country("DO", "Dominikanische Republik"),
|
||||||
|
new Country("DJ", "Dschibuti"),
|
||||||
|
new Country("EC", "Ecuador"),
|
||||||
|
new Country("SV", "El Salvador"),
|
||||||
|
new Country("ER", "Eritrea"),
|
||||||
|
new Country("EE", "Estland"),
|
||||||
|
new Country("SZ", "Eswatini"),
|
||||||
|
new Country("FK", "Falklandinseln"),
|
||||||
|
new Country("FO", "Färöer"),
|
||||||
|
new Country("FJ", "Fidschi"),
|
||||||
|
new Country("FI", "Finnland"),
|
||||||
|
new Country("FR", "Frankreich"),
|
||||||
|
new Country("GF", "Französisch-Guayana"),
|
||||||
|
new Country("PF", "Französisch-Polynesien"),
|
||||||
|
new Country("TF", "Französische Süd- und Antarktisgebiete"),
|
||||||
|
new Country("GA", "Gabun"),
|
||||||
|
new Country("GM", "Gambia"),
|
||||||
|
new Country("GE", "Georgien"),
|
||||||
|
new Country("GH", "Ghana"),
|
||||||
|
new Country("GI", "Gibraltar"),
|
||||||
|
new Country("GD", "Grenada"),
|
||||||
|
new Country("GR", "Griechenland"),
|
||||||
|
new Country("GL", "Grönland"),
|
||||||
|
new Country("GP", "Guadeloupe"),
|
||||||
|
new Country("GU", "Guam"),
|
||||||
|
new Country("GT", "Guatemala"),
|
||||||
|
new Country("GG", "Guernsey"),
|
||||||
|
new Country("GN", "Guinea"),
|
||||||
|
new Country("GW", "Guinea-Bissau"),
|
||||||
|
new Country("GY", "Guyana"),
|
||||||
|
new Country("HT", "Haiti"),
|
||||||
|
new Country("HM", "Heard und McDonaldinseln"),
|
||||||
|
new Country("HN", "Honduras"),
|
||||||
|
new Country("HK", "Hongkong"),
|
||||||
|
new Country("IN", "Indien"),
|
||||||
|
new Country("ID", "Indonesien"),
|
||||||
|
new Country("IQ", "Irak"),
|
||||||
|
new Country("IR", "Iran"),
|
||||||
|
new Country("IE", "Irland"),
|
||||||
|
new Country("IS", "Island"),
|
||||||
|
new Country("IM", "Isle of Man"),
|
||||||
|
new Country("IL", "Israel"),
|
||||||
|
new Country("IT", "Italien"),
|
||||||
|
new Country("JM", "Jamaika"),
|
||||||
|
new Country("JP", "Japan"),
|
||||||
|
new Country("YE", "Jemen"),
|
||||||
|
new Country("JE", "Jersey"),
|
||||||
|
new Country("JO", "Jordanien"),
|
||||||
|
new Country("KY", "Kaimaninseln"),
|
||||||
|
new Country("KH", "Kambodscha"),
|
||||||
|
new Country("CM", "Kamerun"),
|
||||||
|
new Country("CA", "Kanada"),
|
||||||
|
new Country("KZ", "Kasachstan"),
|
||||||
|
new Country("QA", "Katar"),
|
||||||
|
new Country("KE", "Kenia"),
|
||||||
|
new Country("KG", "Kirgisistan"),
|
||||||
|
new Country("KI", "Kiribati"),
|
||||||
|
new Country("CC", "Kokosinseln"),
|
||||||
|
new Country("CO", "Kolumbien"),
|
||||||
|
new Country("KM", "Komoren"),
|
||||||
|
new Country("CD", "Kongo, Demokratische Republik"),
|
||||||
|
new Country("CG", "Kongo, Republik"),
|
||||||
|
new Country("KP", "Korea, Demokratische Volksrepublik"),
|
||||||
|
new Country("KR", "Korea, Republik"),
|
||||||
|
new Country("HR", "Kroatien"),
|
||||||
|
new Country("CU", "Kuba"),
|
||||||
|
new Country("KW", "Kuwait"),
|
||||||
|
new Country("LA", "Laos"),
|
||||||
|
new Country("LS", "Lesotho"),
|
||||||
|
new Country("LV", "Lettland"),
|
||||||
|
new Country("LB", "Libanon"),
|
||||||
|
new Country("LR", "Liberia"),
|
||||||
|
new Country("LY", "Libyen"),
|
||||||
|
new Country("LI", "Liechtenstein"),
|
||||||
|
new Country("LT", "Litauen"),
|
||||||
|
new Country("LU", "Luxemburg"),
|
||||||
|
new Country("MO", "Macao"),
|
||||||
|
new Country("MG", "Madagaskar"),
|
||||||
|
new Country("MW", "Malawi"),
|
||||||
|
new Country("MY", "Malaysia"),
|
||||||
|
new Country("MV", "Malediven"),
|
||||||
|
new Country("ML", "Mali"),
|
||||||
|
new Country("MT", "Malta"),
|
||||||
|
new Country("MA", "Marokko"),
|
||||||
|
new Country("MH", "Marshallinseln"),
|
||||||
|
new Country("MQ", "Martinique"),
|
||||||
|
new Country("MR", "Mauretanien"),
|
||||||
|
new Country("MU", "Mauritius"),
|
||||||
|
new Country("YT", "Mayotte"),
|
||||||
|
new Country("MX", "Mexiko"),
|
||||||
|
new Country("FM", "Mikronesien"),
|
||||||
|
new Country("MD", "Moldau"),
|
||||||
|
new Country("MC", "Monaco"),
|
||||||
|
new Country("MN", "Mongolei"),
|
||||||
|
new Country("ME", "Montenegro"),
|
||||||
|
new Country("MS", "Montserrat"),
|
||||||
|
new Country("MZ", "Mosambik"),
|
||||||
|
new Country("MM", "Myanmar"),
|
||||||
|
new Country("NA", "Namibia"),
|
||||||
|
new Country("NR", "Nauru"),
|
||||||
|
new Country("NP", "Nepal"),
|
||||||
|
new Country("NC", "Neukaledonien"),
|
||||||
|
new Country("NZ", "Neuseeland"),
|
||||||
|
new Country("NI", "Nicaragua"),
|
||||||
|
new Country("NL", "Niederlande"),
|
||||||
|
new Country("NE", "Niger"),
|
||||||
|
new Country("NG", "Nigeria"),
|
||||||
|
new Country("NU", "Niue"),
|
||||||
|
new Country("MK", "Nordmazedonien"),
|
||||||
|
new Country("MP", "Nördliche Marianen"),
|
||||||
|
new Country("NF", "Norfolkinsel"),
|
||||||
|
new Country("NO", "Norwegen"),
|
||||||
|
new Country("OM", "Oman"),
|
||||||
|
new Country("AT", "Österreich"),
|
||||||
|
new Country("TL", "Osttimor"),
|
||||||
|
new Country("PK", "Pakistan"),
|
||||||
|
new Country("PW", "Palau"),
|
||||||
|
new Country("PS", "Palästina"),
|
||||||
|
new Country("PA", "Panama"),
|
||||||
|
new Country("PG", "Papua-Neuguinea"),
|
||||||
|
new Country("PY", "Paraguay"),
|
||||||
|
new Country("PE", "Peru"),
|
||||||
|
new Country("PH", "Philippinen"),
|
||||||
|
new Country("PN", "Pitcairninseln"),
|
||||||
|
new Country("PL", "Polen"),
|
||||||
|
new Country("PT", "Portugal"),
|
||||||
|
new Country("PR", "Puerto Rico"),
|
||||||
|
new Country("RE", "Réunion"),
|
||||||
|
new Country("RW", "Ruanda"),
|
||||||
|
new Country("RO", "Rumänien"),
|
||||||
|
new Country("RU", "Russland"),
|
||||||
|
new Country("SB", "Salomonen"),
|
||||||
|
new Country("ZM", "Sambia"),
|
||||||
|
new Country("WS", "Samoa"),
|
||||||
|
new Country("SM", "San Marino"),
|
||||||
|
new Country("ST", "São Tomé und Príncipe"),
|
||||||
|
new Country("SA", "Saudi-Arabien"),
|
||||||
|
new Country("SE", "Schweden"),
|
||||||
|
new Country("CH", "Schweiz"),
|
||||||
|
new Country("SN", "Senegal"),
|
||||||
|
new Country("RS", "Serbien"),
|
||||||
|
new Country("SC", "Seychellen"),
|
||||||
|
new Country("SL", "Sierra Leone"),
|
||||||
|
new Country("ZW", "Simbabwe"),
|
||||||
|
new Country("SG", "Singapur"),
|
||||||
|
new Country("SX", "Sint Maarten"),
|
||||||
|
new Country("SK", "Slowakei"),
|
||||||
|
new Country("SI", "Slowenien"),
|
||||||
|
new Country("SO", "Somalia"),
|
||||||
|
new Country("ES", "Spanien"),
|
||||||
|
new Country("LK", "Sri Lanka"),
|
||||||
|
new Country("BL", "St. Barthélemy"),
|
||||||
|
new Country("SH", "St. Helena"),
|
||||||
|
new Country("KN", "St. Kitts und Nevis"),
|
||||||
|
new Country("LC", "St. Lucia"),
|
||||||
|
new Country("MF", "St. Martin"),
|
||||||
|
new Country("PM", "St. Pierre und Miquelon"),
|
||||||
|
new Country("VC", "St. Vincent und die Grenadinen"),
|
||||||
|
new Country("ZA", "Südafrika"),
|
||||||
|
new Country("SD", "Sudan"),
|
||||||
|
new Country("GS", "Südgeorgien und die Südlichen Sandwichinseln"),
|
||||||
|
new Country("SS", "Südsudan"),
|
||||||
|
new Country("SR", "Suriname"),
|
||||||
|
new Country("SJ", "Svalbard und Jan Mayen"),
|
||||||
|
new Country("SY", "Syrien"),
|
||||||
|
new Country("TJ", "Tadschikistan"),
|
||||||
|
new Country("TW", "Taiwan"),
|
||||||
|
new Country("TZ", "Tansania"),
|
||||||
|
new Country("TH", "Thailand"),
|
||||||
|
new Country("TG", "Togo"),
|
||||||
|
new Country("TK", "Tokelau"),
|
||||||
|
new Country("TO", "Tonga"),
|
||||||
|
new Country("TT", "Trinidad und Tobago"),
|
||||||
|
new Country("TD", "Tschad"),
|
||||||
|
new Country("CZ", "Tschechien"),
|
||||||
|
new Country("TN", "Tunesien"),
|
||||||
|
new Country("TR", "Türkei"),
|
||||||
|
new Country("TM", "Turkmenistan"),
|
||||||
|
new Country("TC", "Turks- und Caicosinseln"),
|
||||||
|
new Country("TV", "Tuvalu"),
|
||||||
|
new Country("UG", "Uganda"),
|
||||||
|
new Country("UA", "Ukraine"),
|
||||||
|
new Country("HU", "Ungarn"),
|
||||||
|
new Country("UM", "United States Minor Outlying Islands"),
|
||||||
|
new Country("UY", "Uruguay"),
|
||||||
|
new Country("UZ", "Usbekistan"),
|
||||||
|
new Country("VU", "Vanuatu"),
|
||||||
|
new Country("VA", "Vatikanstadt"),
|
||||||
|
new Country("VE", "Venezuela"),
|
||||||
|
new Country("AE", "Vereinigte Arabische Emirate"),
|
||||||
|
new Country("US", "Vereinigte Staaten"),
|
||||||
|
new Country("GB", "Vereinigtes Königreich"),
|
||||||
|
new Country("VN", "Vietnam"),
|
||||||
|
new Country("WF", "Wallis und Futuna"),
|
||||||
|
new Country("CX", "Weihnachtsinsel"),
|
||||||
|
new Country("EH", "Westsahara"),
|
||||||
|
new Country("CF", "Zentralafrikanische Republik"),
|
||||||
|
new Country("CY", "Zypern")
|
||||||
|
);
|
||||||
|
|
||||||
|
this.byCode = countries.stream().collect(Collectors.toMap(Country::code, c -> c));
|
||||||
|
|
||||||
|
// Sort: DACH first (DE, AT, CH), then rest alphabetically by name
|
||||||
|
var dach = countries.stream()
|
||||||
|
.filter(c -> DACH_CODES.contains(c.code()))
|
||||||
|
.sorted(Comparator.comparing(c -> List.of("DE", "AT", "CH").indexOf(c.code())))
|
||||||
|
.toList();
|
||||||
|
var rest = countries.stream()
|
||||||
|
.filter(c -> !DACH_CODES.contains(c.code()))
|
||||||
|
.sorted(Comparator.comparing(Country::name))
|
||||||
|
.toList();
|
||||||
|
this.allSorted = Stream.concat(dach.stream(), rest.stream()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Country> findAll() {
|
||||||
|
return allSorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Country> search(String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
return findAll();
|
||||||
|
}
|
||||||
|
var q = query.toLowerCase();
|
||||||
|
var matches = allSorted.stream()
|
||||||
|
.filter(c -> c.name().toLowerCase().contains(q) || c.code().toLowerCase().contains(q))
|
||||||
|
.toList();
|
||||||
|
// DACH countries first in results too
|
||||||
|
var dach = matches.stream().filter(c -> DACH_CODES.contains(c.code())).toList();
|
||||||
|
var rest = matches.stream().filter(c -> !DACH_CODES.contains(c.code())).toList();
|
||||||
|
return Stream.concat(dach.stream(), rest.stream()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Country> findByCode(String code) {
|
||||||
|
return Optional.ofNullable(byCode.get(code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package de.effigenix.infrastructure.shared.web;
|
||||||
|
|
||||||
|
import de.effigenix.application.shared.ListCountries;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/countries")
|
||||||
|
@SecurityRequirement(name = "Bearer Authentication")
|
||||||
|
@Tag(name = "Countries", description = "ISO 3166-1 country reference data")
|
||||||
|
public class CountryController {
|
||||||
|
|
||||||
|
private final ListCountries listCountries;
|
||||||
|
|
||||||
|
public CountryController(ListCountries listCountries) {
|
||||||
|
this.listCountries = listCountries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CountryResponse(String code, String name) {}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<CountryResponse>> search(
|
||||||
|
@RequestParam(name = "q", required = false, defaultValue = "") String query) {
|
||||||
|
var countries = listCountries.execute(query);
|
||||||
|
var response = countries.stream()
|
||||||
|
.map(c -> new CountryResponse(c.code(), c.name()))
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package de.effigenix.shared.common;
|
||||||
|
|
||||||
|
public record Country(String code, String name) {
|
||||||
|
|
||||||
|
public Country {
|
||||||
|
if (code == null || !code.matches("^[A-Z]{2}$")) {
|
||||||
|
throw new IllegalArgumentException("Country code must be exactly 2 uppercase letters, got: " + code);
|
||||||
|
}
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Country name must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package de.effigenix.shared.common;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface CountryRepository {
|
||||||
|
List<Country> findAll();
|
||||||
|
List<Country> search(String query);
|
||||||
|
Optional<Country> findByCode(String code);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
package de.effigenix.application.shared;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Country;
|
||||||
|
import de.effigenix.shared.common.CountryRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ListCountriesTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CountryRepository countryRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ListCountries listCountries;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDelegateToFindAllWhenQueryIsNull() {
|
||||||
|
var expected = List.of(new Country("DE", "Deutschland"));
|
||||||
|
when(countryRepository.findAll()).thenReturn(expected);
|
||||||
|
|
||||||
|
var result = listCountries.execute(null);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(expected);
|
||||||
|
verify(countryRepository).findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDelegateToFindAllWhenQueryIsBlank() {
|
||||||
|
var expected = List.of(new Country("DE", "Deutschland"));
|
||||||
|
when(countryRepository.findAll()).thenReturn(expected);
|
||||||
|
|
||||||
|
var result = listCountries.execute(" ");
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(expected);
|
||||||
|
verify(countryRepository).findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDelegateToSearchWhenQueryIsProvided() {
|
||||||
|
var expected = List.of(new Country("DE", "Deutschland"));
|
||||||
|
when(countryRepository.search("deutsch")).thenReturn(expected);
|
||||||
|
|
||||||
|
var result = listCountries.execute("deutsch");
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(expected);
|
||||||
|
verify(countryRepository).search("deutsch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnEmptyListWhenNoMatch() {
|
||||||
|
when(countryRepository.search("xyz")).thenReturn(List.of());
|
||||||
|
|
||||||
|
var result = listCountries.execute("xyz");
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDelegateToFindAllWhenQueryIsEmpty() {
|
||||||
|
var expected = List.of(new Country("DE", "Deutschland"));
|
||||||
|
when(countryRepository.findAll()).thenReturn(expected);
|
||||||
|
|
||||||
|
var result = listCountries.execute("");
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(expected);
|
||||||
|
verify(countryRepository).findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
package de.effigenix.infrastructure.shared;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Country;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class InMemoryCountryRepositoryTest {
|
||||||
|
|
||||||
|
private InMemoryCountryRepository repository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository = new InMemoryCountryRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllShouldReturnAllCountries() {
|
||||||
|
var all = repository.findAll();
|
||||||
|
assertThat(all).hasSizeGreaterThanOrEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllShouldHaveDachFirst() {
|
||||||
|
var all = repository.findAll();
|
||||||
|
assertThat(all.get(0).code()).isEqualTo("DE");
|
||||||
|
assertThat(all.get(1).code()).isEqualTo("AT");
|
||||||
|
assertThat(all.get(2).code()).isEqualTo("CH");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByNameShouldFindGermany() {
|
||||||
|
var results = repository.search("deutsch");
|
||||||
|
assertThat(results).extracting(Country::code).contains("DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByCodeShouldFindGermany() {
|
||||||
|
var results = repository.search("DE");
|
||||||
|
assertThat(results).extracting(Country::code).contains("DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchShouldPrioritizeDach() {
|
||||||
|
var results = repository.search("sch");
|
||||||
|
// Schweiz (CH) should appear before non-DACH results like Tadschikistan
|
||||||
|
var codes = results.stream().map(Country::code).toList();
|
||||||
|
if (codes.contains("CH") && codes.contains("TJ")) {
|
||||||
|
assertThat(codes.indexOf("CH")).isLessThan(codes.indexOf("TJ"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchWithNoMatchShouldReturnEmptyList() {
|
||||||
|
var results = repository.search("xyz123");
|
||||||
|
assertThat(results).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchWithNullShouldReturnAll() {
|
||||||
|
var results = repository.search(null);
|
||||||
|
assertThat(results).hasSizeGreaterThanOrEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchWithEmptyStringShouldReturnAll() {
|
||||||
|
var results = repository.search("");
|
||||||
|
assertThat(results).hasSizeGreaterThanOrEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByCodeShouldReturnCountry() {
|
||||||
|
var result = repository.findByCode("DE");
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().name()).isEqualTo("Deutschland");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByCodeShouldReturnEmptyForUnknown() {
|
||||||
|
var result = repository.findByCode("XX");
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByCodeWithNullShouldReturnEmpty() {
|
||||||
|
var result = repository.findByCode(null);
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchShouldBeCaseInsensitive() {
|
||||||
|
var results = repository.search("DEUTSCH");
|
||||||
|
assertThat(results).extracting(Country::code).contains("DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByLowercaseCodeShouldMatch() {
|
||||||
|
var results = repository.search("de");
|
||||||
|
assertThat(results).extracting(Country::code).contains("DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchWithWhitespaceOnlyShouldReturnAll() {
|
||||||
|
var results = repository.search(" ");
|
||||||
|
assertThat(results).hasSizeGreaterThanOrEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllShouldSortNonDachAlphabetically() {
|
||||||
|
var all = repository.findAll();
|
||||||
|
// Skip DACH (first 3), check alphabetical order of the rest
|
||||||
|
var nonDach = all.subList(3, all.size());
|
||||||
|
for (int i = 1; i < nonDach.size(); i++) {
|
||||||
|
assertThat(nonDach.get(i).name().compareTo(nonDach.get(i - 1).name()))
|
||||||
|
.as("'%s' should come after '%s'", nonDach.get(i).name(), nonDach.get(i - 1).name())
|
||||||
|
.isGreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllShouldReturnImmutableSnapshot() {
|
||||||
|
var first = repository.findAll();
|
||||||
|
var second = repository.findAll();
|
||||||
|
assertThat(first).isEqualTo(second);
|
||||||
|
assertThat(first).hasSize(second.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchShouldMatchPartialName() {
|
||||||
|
var results = repository.search("Frank");
|
||||||
|
assertThat(results).extracting(Country::code).contains("FR");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDachPrioritizationInResults() {
|
||||||
|
// "land" matches Deutschland (DE) and other countries like Island, Irland, etc.
|
||||||
|
var results = repository.search("land");
|
||||||
|
var codes = results.stream().map(Country::code).toList();
|
||||||
|
assertThat(codes).isNotEmpty();
|
||||||
|
if (codes.contains("DE")) {
|
||||||
|
// DE should be first since it's DACH
|
||||||
|
assertThat(codes.get(0)).isEqualTo("DE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
package de.effigenix.infrastructure.shared.web;
|
||||||
|
|
||||||
|
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.hamcrest.Matchers.*;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
class CountryControllerIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private String validToken() {
|
||||||
|
return generateToken("test-user-id", "testuser", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnCountriesWithDachFirst() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries")
|
||||||
|
.header("Authorization", "Bearer " + validToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$", hasSize(greaterThan(200))))
|
||||||
|
.andExpect(jsonPath("$[0].code").value("DE"))
|
||||||
|
.andExpect(jsonPath("$[1].code").value("AT"))
|
||||||
|
.andExpect(jsonPath("$[2].code").value("CH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSearchByName() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries")
|
||||||
|
.param("q", "deutsch")
|
||||||
|
.header("Authorization", "Bearer " + validToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[?(@.code == 'DE')]").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnEmptyListForNoMatch() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries")
|
||||||
|
.param("q", "xyz123nevermatches")
|
||||||
|
.header("Authorization", "Bearer " + validToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$", hasSize(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn401WithoutToken() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSearchByCode() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries")
|
||||||
|
.param("q", "DE")
|
||||||
|
.header("Authorization", "Bearer " + validToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[?(@.code == 'DE')]").exists())
|
||||||
|
.andExpect(jsonPath("$[?(@.name == 'Deutschland')]").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnResponseWithCodeAndNameFields() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries")
|
||||||
|
.param("q", "deutsch")
|
||||||
|
.header("Authorization", "Bearer " + validToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].code").isString())
|
||||||
|
.andExpect(jsonPath("$[0].name").isString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn401WithExpiredToken() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries")
|
||||||
|
.header("Authorization", "Bearer " + generateExpiredToken("test-user-id", "testuser")))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnAllCountriesWithWhitespaceQuery() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries")
|
||||||
|
.param("q", " ")
|
||||||
|
.header("Authorization", "Bearer " + validToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$", hasSize(greaterThan(200))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchResultsShouldPrioritizeDach() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/countries")
|
||||||
|
.param("q", "land")
|
||||||
|
.header("Authorization", "Bearer " + validToken()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].code").value("DE"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package de.effigenix.shared.common;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class CountryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateValidCountry() {
|
||||||
|
var country = new Country("DE", "Deutschland");
|
||||||
|
assertThat(country.code()).isEqualTo("DE");
|
||||||
|
assertThat(country.name()).isEqualTo("Deutschland");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectNullCode() {
|
||||||
|
assertThatThrownBy(() -> new Country(null, "Test"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("2 uppercase letters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectLowercaseCode() {
|
||||||
|
assertThatThrownBy(() -> new Country("de", "Test"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("2 uppercase letters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectThreeLetterCode() {
|
||||||
|
assertThatThrownBy(() -> new Country("DEU", "Test"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("2 uppercase letters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectNullName() {
|
||||||
|
assertThatThrownBy(() -> new Country("DE", null))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectBlankName() {
|
||||||
|
assertThatThrownBy(() -> new Country("DE", " "))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectEmptyCode() {
|
||||||
|
assertThatThrownBy(() -> new Country("", "Test"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("2 uppercase letters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectSingleLetterCode() {
|
||||||
|
assertThatThrownBy(() -> new Country("A", "Test"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("2 uppercase letters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectCodeWithDigits() {
|
||||||
|
assertThatThrownBy(() -> new Country("D1", "Test"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("2 uppercase letters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectMixedCaseCode() {
|
||||||
|
assertThatThrownBy(() -> new Country("De", "Test"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("2 uppercase letters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectEmptyName() {
|
||||||
|
assertThatThrownBy(() -> new Country("DE", ""))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import type { CountryDTO } from '@effigenix/api-client';
|
||||||
import { useNavigation } from '../../../state/navigation-context.js';
|
import { useNavigation } from '../../../state/navigation-context.js';
|
||||||
import { useCustomers } from '../../../hooks/useCustomers.js';
|
import { useCustomers } from '../../../hooks/useCustomers.js';
|
||||||
import { FormInput } from '../../shared/FormInput.js';
|
import { FormInput } from '../../shared/FormInput.js';
|
||||||
|
import { CountryPicker } from '../../shared/CountryPicker.js';
|
||||||
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
||||||
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
||||||
|
import { client } from '../../../utils/api-client.js';
|
||||||
|
|
||||||
type Field = 'label' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'country' | 'contactPerson' | 'deliveryNotes';
|
type Field = 'label' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'countryPicker' | 'contactPerson' | 'deliveryNotes';
|
||||||
const FIELDS: Field[] = ['label', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'contactPerson', 'deliveryNotes'];
|
const FIELDS: Field[] = ['label', 'street', 'houseNumber', 'postalCode', 'city', 'countryPicker', 'contactPerson', 'deliveryNotes'];
|
||||||
|
|
||||||
const FIELD_LABELS: Record<Field, string> = {
|
const FIELD_LABELS: Record<Field, string> = {
|
||||||
label: 'Bezeichnung * (z.B. Hauptküche)',
|
label: 'Bezeichnung * (z.B. Hauptküche)',
|
||||||
|
|
@ -15,7 +18,7 @@ const FIELD_LABELS: Record<Field, string> = {
|
||||||
houseNumber: 'Hausnummer *',
|
houseNumber: 'Hausnummer *',
|
||||||
postalCode: 'PLZ *',
|
postalCode: 'PLZ *',
|
||||||
city: 'Stadt *',
|
city: 'Stadt *',
|
||||||
country: 'Land *',
|
countryPicker: 'Land *',
|
||||||
contactPerson: 'Ansprechpartner',
|
contactPerson: 'Ansprechpartner',
|
||||||
deliveryNotes: 'Lieferhinweis',
|
deliveryNotes: 'Lieferhinweis',
|
||||||
};
|
};
|
||||||
|
|
@ -25,14 +28,22 @@ export function AddDeliveryAddressScreen() {
|
||||||
const customerId = params['customerId'] ?? '';
|
const customerId = params['customerId'] ?? '';
|
||||||
const { addDeliveryAddress, loading, error, clearError } = useCustomers();
|
const { addDeliveryAddress, loading, error, clearError } = useCustomers();
|
||||||
|
|
||||||
const [values, setValues] = useState<Record<Field, string>>({
|
const [values, setValues] = useState<Record<Exclude<Field, 'countryPicker'>, string>>({
|
||||||
label: '', street: '', houseNumber: '', postalCode: '',
|
label: '', street: '', houseNumber: '', postalCode: '',
|
||||||
city: '', country: 'Deutschland', contactPerson: '', deliveryNotes: '',
|
city: '', contactPerson: '', deliveryNotes: '',
|
||||||
});
|
});
|
||||||
const [activeField, setActiveField] = useState<Field>('label');
|
const [activeField, setActiveField] = useState<Field>('label');
|
||||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||||
|
const [countryQuery, setCountryQuery] = useState('');
|
||||||
|
const [countryCode, setCountryCode] = useState('DE');
|
||||||
|
const [countryName, setCountryName] = useState('Deutschland');
|
||||||
|
const [countries, setCountries] = useState<CountryDTO[]>([]);
|
||||||
|
|
||||||
const setField = (field: Field) => (value: string) => {
|
useEffect(() => {
|
||||||
|
client.countries.search().then(setCountries).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setField = (field: Exclude<Field, 'countryPicker'>) => (value: string) => {
|
||||||
setValues((v) => ({ ...v, [field]: value }));
|
setValues((v) => ({ ...v, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -60,7 +71,6 @@ export function AddDeliveryAddressScreen() {
|
||||||
if (!values.houseNumber.trim()) errors.houseNumber = 'Hausnummer ist erforderlich.';
|
if (!values.houseNumber.trim()) errors.houseNumber = 'Hausnummer ist erforderlich.';
|
||||||
if (!values.postalCode.trim()) errors.postalCode = 'PLZ ist erforderlich.';
|
if (!values.postalCode.trim()) errors.postalCode = 'PLZ ist erforderlich.';
|
||||||
if (!values.city.trim()) errors.city = 'Stadt ist erforderlich.';
|
if (!values.city.trim()) errors.city = 'Stadt ist erforderlich.';
|
||||||
if (!values.country.trim()) errors.country = 'Land ist erforderlich.';
|
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
if (Object.keys(errors).length > 0) return;
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
|
@ -70,7 +80,7 @@ export function AddDeliveryAddressScreen() {
|
||||||
houseNumber: values.houseNumber.trim(),
|
houseNumber: values.houseNumber.trim(),
|
||||||
postalCode: values.postalCode.trim(),
|
postalCode: values.postalCode.trim(),
|
||||||
city: values.city.trim(),
|
city: values.city.trim(),
|
||||||
country: values.country.trim(),
|
country: countryCode,
|
||||||
...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}),
|
...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}),
|
||||||
...(values.deliveryNotes.trim() ? { deliveryNotes: values.deliveryNotes.trim() } : {}),
|
...(values.deliveryNotes.trim() ? { deliveryNotes: values.deliveryNotes.trim() } : {}),
|
||||||
});
|
});
|
||||||
|
|
@ -94,17 +104,41 @@ export function AddDeliveryAddressScreen() {
|
||||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||||
|
|
||||||
<Box flexDirection="column" gap={1} width={60}>
|
<Box flexDirection="column" gap={1} width={60}>
|
||||||
{FIELDS.map((field) => (
|
{FIELDS.map((field) => {
|
||||||
<FormInput
|
if (field === 'countryPicker') {
|
||||||
key={field}
|
return (
|
||||||
label={FIELD_LABELS[field]}
|
<CountryPicker
|
||||||
value={values[field]}
|
key="countryPicker"
|
||||||
onChange={setField(field)}
|
countries={countries}
|
||||||
onSubmit={handleFieldSubmit(field)}
|
query={countryQuery}
|
||||||
focus={activeField === field}
|
onQueryChange={setCountryQuery}
|
||||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
onSelect={(c) => {
|
||||||
|
setCountryCode(c.code);
|
||||||
|
setCountryName(c.name);
|
||||||
|
setCountryQuery('');
|
||||||
|
const idx = FIELDS.indexOf('countryPicker');
|
||||||
|
if (idx < FIELDS.length - 1) {
|
||||||
|
setActiveField(FIELDS[idx + 1] ?? 'countryPicker');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
focus={activeField === 'countryPicker'}
|
||||||
|
selectedName={countryName}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
const f = field as Exclude<Field, 'countryPicker'>;
|
||||||
|
return (
|
||||||
|
<FormInput
|
||||||
|
key={f}
|
||||||
|
label={FIELD_LABELS[f]}
|
||||||
|
value={values[f]}
|
||||||
|
onChange={setField(f)}
|
||||||
|
onSubmit={handleFieldSubmit(f)}
|
||||||
|
focus={activeField === f}
|
||||||
|
{...(fieldErrors[f] ? { error: fieldErrors[f] } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import type { CustomerType } from '@effigenix/api-client';
|
import type { CustomerType, CountryDTO } from '@effigenix/api-client';
|
||||||
import { useNavigation } from '../../../state/navigation-context.js';
|
import { useNavigation } from '../../../state/navigation-context.js';
|
||||||
import { useCustomers } from '../../../hooks/useCustomers.js';
|
import { useCustomers } from '../../../hooks/useCustomers.js';
|
||||||
import { FormInput } from '../../shared/FormInput.js';
|
import { FormInput } from '../../shared/FormInput.js';
|
||||||
|
import { CountryPicker } from '../../shared/CountryPicker.js';
|
||||||
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
||||||
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
||||||
|
import { client } from '../../../utils/api-client.js';
|
||||||
|
|
||||||
type Field = 'name' | 'phone' | 'email' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'country' | 'paymentDueDays';
|
type Field = 'name' | 'phone' | 'email' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'countryPicker' | 'paymentDueDays';
|
||||||
const FIELDS: Field[] = ['name', 'phone', 'email', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'paymentDueDays'];
|
const FIELDS: Field[] = ['name', 'phone', 'email', 'street', 'houseNumber', 'postalCode', 'city', 'countryPicker', 'paymentDueDays'];
|
||||||
|
|
||||||
const FIELD_LABELS: Record<Field, string> = {
|
const FIELD_LABELS: Record<Field, string> = {
|
||||||
name: 'Name *',
|
name: 'Name *',
|
||||||
|
|
@ -18,7 +20,7 @@ const FIELD_LABELS: Record<Field, string> = {
|
||||||
houseNumber: 'Hausnummer *',
|
houseNumber: 'Hausnummer *',
|
||||||
postalCode: 'PLZ *',
|
postalCode: 'PLZ *',
|
||||||
city: 'Stadt *',
|
city: 'Stadt *',
|
||||||
country: 'Land *',
|
countryPicker: 'Land *',
|
||||||
paymentDueDays: 'Zahlungsziel (Tage)',
|
paymentDueDays: 'Zahlungsziel (Tage)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -28,15 +30,23 @@ export function CustomerCreateScreen() {
|
||||||
const { replace, back } = useNavigation();
|
const { replace, back } = useNavigation();
|
||||||
const { createCustomer, loading, error, clearError } = useCustomers();
|
const { createCustomer, loading, error, clearError } = useCustomers();
|
||||||
|
|
||||||
const [values, setValues] = useState<Record<Field, string>>({
|
const [values, setValues] = useState<Record<Exclude<Field, 'countryPicker'>, string>>({
|
||||||
name: '', phone: '', email: '', street: '', houseNumber: '',
|
name: '', phone: '', email: '', street: '', houseNumber: '',
|
||||||
postalCode: '', city: '', country: 'Deutschland', paymentDueDays: '',
|
postalCode: '', city: '', paymentDueDays: '',
|
||||||
});
|
});
|
||||||
const [typeIndex, setTypeIndex] = useState(0);
|
const [typeIndex, setTypeIndex] = useState(0);
|
||||||
const [activeField, setActiveField] = useState<Field | 'type'>('name');
|
const [activeField, setActiveField] = useState<Field | 'type'>('name');
|
||||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field | 'type', string>>>({});
|
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field | 'type', string>>>({});
|
||||||
|
const [countryQuery, setCountryQuery] = useState('');
|
||||||
|
const [countryCode, setCountryCode] = useState('DE');
|
||||||
|
const [countryName, setCountryName] = useState('Deutschland');
|
||||||
|
const [countries, setCountries] = useState<CountryDTO[]>([]);
|
||||||
|
|
||||||
const setField = (field: Field) => (value: string) => {
|
useEffect(() => {
|
||||||
|
client.countries.search().then(setCountries).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setField = (field: Exclude<Field, 'countryPicker'>) => (value: string) => {
|
||||||
setValues((v) => ({ ...v, [field]: value }));
|
setValues((v) => ({ ...v, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -75,7 +85,6 @@ export function CustomerCreateScreen() {
|
||||||
if (!values.houseNumber.trim()) errors.houseNumber = 'Hausnummer ist erforderlich.';
|
if (!values.houseNumber.trim()) errors.houseNumber = 'Hausnummer ist erforderlich.';
|
||||||
if (!values.postalCode.trim()) errors.postalCode = 'PLZ ist erforderlich.';
|
if (!values.postalCode.trim()) errors.postalCode = 'PLZ ist erforderlich.';
|
||||||
if (!values.city.trim()) errors.city = 'Stadt ist erforderlich.';
|
if (!values.city.trim()) errors.city = 'Stadt ist erforderlich.';
|
||||||
if (!values.country.trim()) errors.country = 'Land ist erforderlich.';
|
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
if (Object.keys(errors).length > 0) return;
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
|
@ -87,7 +96,7 @@ export function CustomerCreateScreen() {
|
||||||
houseNumber: values.houseNumber.trim(),
|
houseNumber: values.houseNumber.trim(),
|
||||||
postalCode: values.postalCode.trim(),
|
postalCode: values.postalCode.trim(),
|
||||||
city: values.city.trim(),
|
city: values.city.trim(),
|
||||||
country: values.country.trim(),
|
country: countryCode,
|
||||||
...(values.email.trim() ? { email: values.email.trim() } : {}),
|
...(values.email.trim() ? { email: values.email.trim() } : {}),
|
||||||
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
|
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
|
||||||
});
|
});
|
||||||
|
|
@ -122,17 +131,41 @@ export function CustomerCreateScreen() {
|
||||||
{activeField === 'type' && <Text color="gray" dimColor>←→ Typ · Tab/Enter weiter</Text>}
|
{activeField === 'type' && <Text color="gray" dimColor>←→ Typ · Tab/Enter weiter</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{FIELDS.map((field) => (
|
{FIELDS.map((field) => {
|
||||||
<FormInput
|
if (field === 'countryPicker') {
|
||||||
key={field}
|
return (
|
||||||
label={FIELD_LABELS[field]}
|
<CountryPicker
|
||||||
value={values[field]}
|
key="countryPicker"
|
||||||
onChange={setField(field)}
|
countries={countries}
|
||||||
onSubmit={handleFieldSubmit(field)}
|
query={countryQuery}
|
||||||
focus={activeField === field}
|
onQueryChange={setCountryQuery}
|
||||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
onSelect={(c) => {
|
||||||
|
setCountryCode(c.code);
|
||||||
|
setCountryName(c.name);
|
||||||
|
setCountryQuery('');
|
||||||
|
const idx = FIELDS.indexOf('countryPicker');
|
||||||
|
if (idx < FIELDS.length - 1) {
|
||||||
|
setActiveField(FIELDS[idx + 1] ?? 'countryPicker');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
focus={activeField === 'countryPicker'}
|
||||||
|
selectedName={countryName}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
const f = field as Exclude<Field, 'countryPicker'>;
|
||||||
|
return (
|
||||||
|
<FormInput
|
||||||
|
key={f}
|
||||||
|
label={FIELD_LABELS[f]}
|
||||||
|
value={values[f]}
|
||||||
|
onChange={setField(f)}
|
||||||
|
onSubmit={handleFieldSubmit(f)}
|
||||||
|
focus={activeField === f}
|
||||||
|
{...(fieldErrors[f] ? { error: fieldErrors[f] } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ export function CustomerDetailScreen() {
|
||||||
)}
|
)}
|
||||||
<Box gap={2}>
|
<Box gap={2}>
|
||||||
<Text color="gray">Rechnungsadresse:</Text>
|
<Text color="gray">Rechnungsadresse:</Text>
|
||||||
<Text>{`${customer.billingAddress.street} ${customer.billingAddress.houseNumber}, ${customer.billingAddress.postalCode} ${customer.billingAddress.city}`}</Text>
|
<Text>{`${customer.billingAddress.street} ${customer.billingAddress.houseNumber}, ${customer.billingAddress.postalCode} ${customer.billingAddress.city}, ${customer.billingAddress.country}`}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{customer.paymentTerms && (
|
{customer.paymentTerms && (
|
||||||
<Box gap={2}>
|
<Box gap={2}>
|
||||||
|
|
@ -185,7 +185,7 @@ export function CustomerDetailScreen() {
|
||||||
<Box key={addr.label} paddingLeft={2} gap={1}>
|
<Box key={addr.label} paddingLeft={2} gap={1}>
|
||||||
<Text color="yellow">•</Text>
|
<Text color="yellow">•</Text>
|
||||||
<Text bold>{addr.label}:</Text>
|
<Text bold>{addr.label}:</Text>
|
||||||
<Text>{`${addr.address.street} ${addr.address.houseNumber}, ${addr.address.city}`}</Text>
|
<Text>{`${addr.address.street} ${addr.address.houseNumber}, ${addr.address.city}, ${addr.address.country}`}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import type { CountryDTO } from '@effigenix/api-client';
|
||||||
import { useNavigation } from '../../../state/navigation-context.js';
|
import { useNavigation } from '../../../state/navigation-context.js';
|
||||||
import { useSuppliers } from '../../../hooks/useSuppliers.js';
|
import { useSuppliers } from '../../../hooks/useSuppliers.js';
|
||||||
import { FormInput } from '../../shared/FormInput.js';
|
import { FormInput } from '../../shared/FormInput.js';
|
||||||
|
import { CountryPicker } from '../../shared/CountryPicker.js';
|
||||||
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
||||||
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
||||||
|
import { client } from '../../../utils/api-client.js';
|
||||||
|
|
||||||
type Field = 'name' | 'phone' | 'email' | 'contactPerson' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'country' | 'paymentDueDays';
|
type Field = 'name' | 'phone' | 'email' | 'contactPerson' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'countryPicker' | 'paymentDueDays';
|
||||||
const FIELDS: Field[] = ['name', 'phone', 'email', 'contactPerson', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'paymentDueDays'];
|
const FIELDS: Field[] = ['name', 'phone', 'email', 'contactPerson', 'street', 'houseNumber', 'postalCode', 'city', 'countryPicker', 'paymentDueDays'];
|
||||||
|
|
||||||
const FIELD_LABELS: Record<Field, string> = {
|
const FIELD_LABELS: Record<Field, string> = {
|
||||||
name: 'Name *',
|
name: 'Name *',
|
||||||
|
|
@ -18,7 +21,7 @@ const FIELD_LABELS: Record<Field, string> = {
|
||||||
houseNumber: 'Hausnummer',
|
houseNumber: 'Hausnummer',
|
||||||
postalCode: 'PLZ',
|
postalCode: 'PLZ',
|
||||||
city: 'Stadt',
|
city: 'Stadt',
|
||||||
country: 'Land',
|
countryPicker: 'Land',
|
||||||
paymentDueDays: 'Zahlungsziel (Tage)',
|
paymentDueDays: 'Zahlungsziel (Tage)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -26,15 +29,23 @@ export function SupplierCreateScreen() {
|
||||||
const { replace, back } = useNavigation();
|
const { replace, back } = useNavigation();
|
||||||
const { createSupplier, loading, error, clearError } = useSuppliers();
|
const { createSupplier, loading, error, clearError } = useSuppliers();
|
||||||
|
|
||||||
const [values, setValues] = useState<Record<Field, string>>({
|
const [values, setValues] = useState<Record<Exclude<Field, 'countryPicker'>, string>>({
|
||||||
name: '', phone: '', email: '', contactPerson: '',
|
name: '', phone: '', email: '', contactPerson: '',
|
||||||
street: '', houseNumber: '', postalCode: '', city: '', country: 'Deutschland',
|
street: '', houseNumber: '', postalCode: '', city: '',
|
||||||
paymentDueDays: '',
|
paymentDueDays: '',
|
||||||
});
|
});
|
||||||
const [activeField, setActiveField] = useState<Field>('name');
|
const [activeField, setActiveField] = useState<Field>('name');
|
||||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||||
|
const [countryQuery, setCountryQuery] = useState('');
|
||||||
|
const [countryCode, setCountryCode] = useState('DE');
|
||||||
|
const [countryName, setCountryName] = useState('Deutschland');
|
||||||
|
const [countries, setCountries] = useState<CountryDTO[]>([]);
|
||||||
|
|
||||||
const setField = (field: Field) => (value: string) => {
|
useEffect(() => {
|
||||||
|
client.countries.search().then(setCountries).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setField = (field: Exclude<Field, 'countryPicker'>) => (value: string) => {
|
||||||
setValues((v) => ({ ...v, [field]: value }));
|
setValues((v) => ({ ...v, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -71,7 +82,7 @@ export function SupplierCreateScreen() {
|
||||||
...(values.houseNumber.trim() ? { houseNumber: values.houseNumber.trim() } : {}),
|
...(values.houseNumber.trim() ? { houseNumber: values.houseNumber.trim() } : {}),
|
||||||
...(values.postalCode.trim() ? { postalCode: values.postalCode.trim() } : {}),
|
...(values.postalCode.trim() ? { postalCode: values.postalCode.trim() } : {}),
|
||||||
...(values.city.trim() ? { city: values.city.trim() } : {}),
|
...(values.city.trim() ? { city: values.city.trim() } : {}),
|
||||||
...(values.country.trim() ? { country: values.country.trim() } : {}),
|
...(countryCode ? { country: countryCode } : {}),
|
||||||
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
|
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
|
||||||
});
|
});
|
||||||
if (result) replace('supplier-list');
|
if (result) replace('supplier-list');
|
||||||
|
|
@ -100,17 +111,41 @@ export function SupplierCreateScreen() {
|
||||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||||
|
|
||||||
<Box flexDirection="column" gap={1} width={60}>
|
<Box flexDirection="column" gap={1} width={60}>
|
||||||
{FIELDS.map((field) => (
|
{FIELDS.map((field) => {
|
||||||
<FormInput
|
if (field === 'countryPicker') {
|
||||||
key={field}
|
return (
|
||||||
label={FIELD_LABELS[field]}
|
<CountryPicker
|
||||||
value={values[field]}
|
key="countryPicker"
|
||||||
onChange={setField(field)}
|
countries={countries}
|
||||||
onSubmit={handleFieldSubmit(field)}
|
query={countryQuery}
|
||||||
focus={activeField === field}
|
onQueryChange={setCountryQuery}
|
||||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
onSelect={(c) => {
|
||||||
|
setCountryCode(c.code);
|
||||||
|
setCountryName(c.name);
|
||||||
|
setCountryQuery('');
|
||||||
|
const idx = FIELDS.indexOf('countryPicker');
|
||||||
|
if (idx < FIELDS.length - 1) {
|
||||||
|
setActiveField(FIELDS[idx + 1] ?? 'countryPicker');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
focus={activeField === 'countryPicker'}
|
||||||
|
selectedName={countryName}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
const f = field as Exclude<Field, 'countryPicker'>;
|
||||||
|
return (
|
||||||
|
<FormInput
|
||||||
|
key={f}
|
||||||
|
label={FIELD_LABELS[f]}
|
||||||
|
value={values[f]}
|
||||||
|
onChange={setField(f)}
|
||||||
|
onSubmit={handleFieldSubmit(f)}
|
||||||
|
focus={activeField === f}
|
||||||
|
{...(fieldErrors[f] ? { error: fieldErrors[f] } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export function SupplierDetailScreen() {
|
||||||
{supplier.address && (
|
{supplier.address && (
|
||||||
<Box gap={2}>
|
<Box gap={2}>
|
||||||
<Text color="gray">Adresse:</Text>
|
<Text color="gray">Adresse:</Text>
|
||||||
<Text>{`${supplier.address.street} ${supplier.address.houseNumber}, ${supplier.address.postalCode} ${supplier.address.city}`}</Text>
|
<Text>{`${supplier.address.street} ${supplier.address.houseNumber}, ${supplier.address.postalCode} ${supplier.address.city}, ${supplier.address.country}`}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{supplier.paymentTerms && (
|
{supplier.paymentTerms && (
|
||||||
|
|
|
||||||
129
frontend/apps/cli/src/components/shared/CountryPicker.tsx
Normal file
129
frontend/apps/cli/src/components/shared/CountryPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ export { createRecipesResource } from './resources/recipes.js';
|
||||||
export { createBatchesResource } from './resources/batches.js';
|
export { createBatchesResource } from './resources/batches.js';
|
||||||
export { createProductionOrdersResource } from './resources/production-orders.js';
|
export { createProductionOrdersResource } from './resources/production-orders.js';
|
||||||
export { createStocksResource } from './resources/stocks.js';
|
export { createStocksResource } from './resources/stocks.js';
|
||||||
|
export { createCountriesResource } from './resources/countries.js';
|
||||||
export {
|
export {
|
||||||
ApiError,
|
ApiError,
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
|
|
@ -111,6 +112,7 @@ export type {
|
||||||
ReservationDTO,
|
ReservationDTO,
|
||||||
StockBatchAllocationDTO,
|
StockBatchAllocationDTO,
|
||||||
ReserveStockRequest,
|
ReserveStockRequest,
|
||||||
|
CountryDTO,
|
||||||
} from '@effigenix/types';
|
} from '@effigenix/types';
|
||||||
|
|
||||||
// Resource types (runtime, stay in resource files)
|
// Resource types (runtime, stay in resource files)
|
||||||
|
|
@ -141,6 +143,7 @@ export { BATCH_STATUS_LABELS } from './resources/batches.js';
|
||||||
export type { ProductionOrdersResource, Priority } from './resources/production-orders.js';
|
export type { ProductionOrdersResource, Priority } from './resources/production-orders.js';
|
||||||
export { PRIORITY_LABELS } from './resources/production-orders.js';
|
export { PRIORITY_LABELS } from './resources/production-orders.js';
|
||||||
export type { StocksResource, BatchType, StockBatchStatus, StockFilter, ReferenceType, ReservationPriority } from './resources/stocks.js';
|
export type { StocksResource, BatchType, StockBatchStatus, StockFilter, ReferenceType, ReservationPriority } from './resources/stocks.js';
|
||||||
|
export type { CountriesResource } from './resources/countries.js';
|
||||||
export { BATCH_TYPE_LABELS, STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from './resources/stocks.js';
|
export { BATCH_TYPE_LABELS, STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from './resources/stocks.js';
|
||||||
|
|
||||||
import { createApiClient } from './client.js';
|
import { createApiClient } from './client.js';
|
||||||
|
|
@ -156,6 +159,7 @@ import { createRecipesResource } from './resources/recipes.js';
|
||||||
import { createBatchesResource } from './resources/batches.js';
|
import { createBatchesResource } from './resources/batches.js';
|
||||||
import { createProductionOrdersResource } from './resources/production-orders.js';
|
import { createProductionOrdersResource } from './resources/production-orders.js';
|
||||||
import { createStocksResource } from './resources/stocks.js';
|
import { createStocksResource } from './resources/stocks.js';
|
||||||
|
import { createCountriesResource } from './resources/countries.js';
|
||||||
import type { TokenProvider } from './token-provider.js';
|
import type { TokenProvider } from './token-provider.js';
|
||||||
import type { ApiConfig } from '@effigenix/config';
|
import type { ApiConfig } from '@effigenix/config';
|
||||||
|
|
||||||
|
|
@ -182,6 +186,7 @@ export function createEffigenixClient(
|
||||||
batches: createBatchesResource(axiosClient),
|
batches: createBatchesResource(axiosClient),
|
||||||
productionOrders: createProductionOrdersResource(axiosClient),
|
productionOrders: createProductionOrdersResource(axiosClient),
|
||||||
stocks: createStocksResource(axiosClient),
|
stocks: createStocksResource(axiosClient),
|
||||||
|
countries: createCountriesResource(axiosClient),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
17
frontend/packages/api-client/src/resources/countries.ts
Normal file
17
frontend/packages/api-client/src/resources/countries.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { AxiosInstance } from 'axios';
|
||||||
|
import type { CountryDTO } from '@effigenix/types';
|
||||||
|
|
||||||
|
export type { CountryDTO };
|
||||||
|
|
||||||
|
export function createCountriesResource(client: AxiosInstance) {
|
||||||
|
return {
|
||||||
|
async search(query?: string): Promise<CountryDTO[]> {
|
||||||
|
const res = await client.get<CountryDTO[]>('/api/countries', {
|
||||||
|
params: query ? { q: query } : {},
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountriesResource = ReturnType<typeof createCountriesResource>;
|
||||||
4
frontend/packages/types/src/country.ts
Normal file
4
frontend/packages/types/src/country.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface CountryDTO {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ export * from './article';
|
||||||
export * from './customer';
|
export * from './customer';
|
||||||
export * from './inventory';
|
export * from './inventory';
|
||||||
export * from './production';
|
export * from './production';
|
||||||
|
export * from './country';
|
||||||
|
|
||||||
// Re-export generated types for advanced usage
|
// Re-export generated types for advanced usage
|
||||||
export type { components, paths } from './generated/api';
|
export type { components, paths } from './generated/api';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue