1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +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,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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,16 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { CountryDTO } from '@effigenix/api-client';
import { useNavigation } from '../../../state/navigation-context.js';
import { useCustomers } from '../../../hooks/useCustomers.js';
import { FormInput } from '../../shared/FormInput.js';
import { CountryPicker } from '../../shared/CountryPicker.js';
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
import { client } from '../../../utils/api-client.js';
type Field = 'label' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'country' | 'contactPerson' | 'deliveryNotes';
const FIELDS: 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', 'countryPicker', 'contactPerson', 'deliveryNotes'];
const FIELD_LABELS: Record<Field, string> = {
label: 'Bezeichnung * (z.B. Hauptküche)',
@ -15,7 +18,7 @@ const FIELD_LABELS: Record<Field, string> = {
houseNumber: 'Hausnummer *',
postalCode: 'PLZ *',
city: 'Stadt *',
country: 'Land *',
countryPicker: 'Land *',
contactPerson: 'Ansprechpartner',
deliveryNotes: 'Lieferhinweis',
};
@ -25,14 +28,22 @@ export function AddDeliveryAddressScreen() {
const customerId = params['customerId'] ?? '';
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: '',
city: '', country: 'Deutschland', contactPerson: '', deliveryNotes: '',
city: '', contactPerson: '', deliveryNotes: '',
});
const [activeField, setActiveField] = useState<Field>('label');
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 }));
};
@ -60,7 +71,6 @@ export function AddDeliveryAddressScreen() {
if (!values.houseNumber.trim()) errors.houseNumber = 'Hausnummer ist erforderlich.';
if (!values.postalCode.trim()) errors.postalCode = 'PLZ ist erforderlich.';
if (!values.city.trim()) errors.city = 'Stadt ist erforderlich.';
if (!values.country.trim()) errors.country = 'Land ist erforderlich.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
@ -70,7 +80,7 @@ export function AddDeliveryAddressScreen() {
houseNumber: values.houseNumber.trim(),
postalCode: values.postalCode.trim(),
city: values.city.trim(),
country: values.country.trim(),
country: countryCode,
...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}),
...(values.deliveryNotes.trim() ? { deliveryNotes: values.deliveryNotes.trim() } : {}),
});
@ -94,17 +104,41 @@ export function AddDeliveryAddressScreen() {
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => (
<FormInput
key={field}
label={FIELD_LABELS[field]}
value={values[field]}
onChange={setField(field)}
onSubmit={handleFieldSubmit(field)}
focus={activeField === field}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
/>
))}
{FIELDS.map((field) => {
if (field === 'countryPicker') {
return (
<CountryPicker
key="countryPicker"
countries={countries}
query={countryQuery}
onQueryChange={setCountryQuery}
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 marginTop={1}>

View file

@ -1,14 +1,16 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
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 { useCustomers } from '../../../hooks/useCustomers.js';
import { FormInput } from '../../shared/FormInput.js';
import { CountryPicker } from '../../shared/CountryPicker.js';
import { LoadingSpinner } from '../../shared/LoadingSpinner.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';
const FIELDS: 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', 'countryPicker', 'paymentDueDays'];
const FIELD_LABELS: Record<Field, string> = {
name: 'Name *',
@ -18,7 +20,7 @@ const FIELD_LABELS: Record<Field, string> = {
houseNumber: 'Hausnummer *',
postalCode: 'PLZ *',
city: 'Stadt *',
country: 'Land *',
countryPicker: 'Land *',
paymentDueDays: 'Zahlungsziel (Tage)',
};
@ -28,15 +30,23 @@ export function CustomerCreateScreen() {
const { replace, back } = useNavigation();
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: '',
postalCode: '', city: '', country: 'Deutschland', paymentDueDays: '',
postalCode: '', city: '', paymentDueDays: '',
});
const [typeIndex, setTypeIndex] = useState(0);
const [activeField, setActiveField] = useState<Field | 'type'>('name');
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 }));
};
@ -75,7 +85,6 @@ export function CustomerCreateScreen() {
if (!values.houseNumber.trim()) errors.houseNumber = 'Hausnummer ist erforderlich.';
if (!values.postalCode.trim()) errors.postalCode = 'PLZ ist erforderlich.';
if (!values.city.trim()) errors.city = 'Stadt ist erforderlich.';
if (!values.country.trim()) errors.country = 'Land ist erforderlich.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
@ -87,7 +96,7 @@ export function CustomerCreateScreen() {
houseNumber: values.houseNumber.trim(),
postalCode: values.postalCode.trim(),
city: values.city.trim(),
country: values.country.trim(),
country: countryCode,
...(values.email.trim() ? { email: values.email.trim() } : {}),
...(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>}
</Box>
{FIELDS.map((field) => (
<FormInput
key={field}
label={FIELD_LABELS[field]}
value={values[field]}
onChange={setField(field)}
onSubmit={handleFieldSubmit(field)}
focus={activeField === field}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
/>
))}
{FIELDS.map((field) => {
if (field === 'countryPicker') {
return (
<CountryPicker
key="countryPicker"
countries={countries}
query={countryQuery}
onQueryChange={setCountryQuery}
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 marginTop={1}>

View file

@ -164,7 +164,7 @@ export function CustomerDetailScreen() {
)}
<Box gap={2}>
<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>
{customer.paymentTerms && (
<Box gap={2}>
@ -185,7 +185,7 @@ export function CustomerDetailScreen() {
<Box key={addr.label} paddingLeft={2} gap={1}>
<Text color="yellow"></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>

View file

@ -1,13 +1,16 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { CountryDTO } from '@effigenix/api-client';
import { useNavigation } from '../../../state/navigation-context.js';
import { useSuppliers } from '../../../hooks/useSuppliers.js';
import { FormInput } from '../../shared/FormInput.js';
import { CountryPicker } from '../../shared/CountryPicker.js';
import { LoadingSpinner } from '../../shared/LoadingSpinner.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';
const FIELDS: 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', 'countryPicker', 'paymentDueDays'];
const FIELD_LABELS: Record<Field, string> = {
name: 'Name *',
@ -18,7 +21,7 @@ const FIELD_LABELS: Record<Field, string> = {
houseNumber: 'Hausnummer',
postalCode: 'PLZ',
city: 'Stadt',
country: 'Land',
countryPicker: 'Land',
paymentDueDays: 'Zahlungsziel (Tage)',
};
@ -26,15 +29,23 @@ export function SupplierCreateScreen() {
const { replace, back } = useNavigation();
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: '',
street: '', houseNumber: '', postalCode: '', city: '', country: 'Deutschland',
street: '', houseNumber: '', postalCode: '', city: '',
paymentDueDays: '',
});
const [activeField, setActiveField] = useState<Field>('name');
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 }));
};
@ -71,7 +82,7 @@ export function SupplierCreateScreen() {
...(values.houseNumber.trim() ? { houseNumber: values.houseNumber.trim() } : {}),
...(values.postalCode.trim() ? { postalCode: values.postalCode.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) } : {}),
});
if (result) replace('supplier-list');
@ -100,17 +111,41 @@ export function SupplierCreateScreen() {
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => (
<FormInput
key={field}
label={FIELD_LABELS[field]}
value={values[field]}
onChange={setField(field)}
onSubmit={handleFieldSubmit(field)}
focus={activeField === field}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
/>
))}
{FIELDS.map((field) => {
if (field === 'countryPicker') {
return (
<CountryPicker
key="countryPicker"
countries={countries}
query={countryQuery}
onQueryChange={setCountryQuery}
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 marginTop={1}>

View file

@ -178,7 +178,7 @@ export function SupplierDetailScreen() {
{supplier.address && (
<Box gap={2}>
<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>
)}
{supplier.paymentTerms && (

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

View file

@ -27,6 +27,7 @@ export { createRecipesResource } from './resources/recipes.js';
export { createBatchesResource } from './resources/batches.js';
export { createProductionOrdersResource } from './resources/production-orders.js';
export { createStocksResource } from './resources/stocks.js';
export { createCountriesResource } from './resources/countries.js';
export {
ApiError,
AuthenticationError,
@ -111,6 +112,7 @@ export type {
ReservationDTO,
StockBatchAllocationDTO,
ReserveStockRequest,
CountryDTO,
} from '@effigenix/types';
// 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 { PRIORITY_LABELS } from './resources/production-orders.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';
import { createApiClient } from './client.js';
@ -156,6 +159,7 @@ import { createRecipesResource } from './resources/recipes.js';
import { createBatchesResource } from './resources/batches.js';
import { createProductionOrdersResource } from './resources/production-orders.js';
import { createStocksResource } from './resources/stocks.js';
import { createCountriesResource } from './resources/countries.js';
import type { TokenProvider } from './token-provider.js';
import type { ApiConfig } from '@effigenix/config';
@ -182,6 +186,7 @@ export function createEffigenixClient(
batches: createBatchesResource(axiosClient),
productionOrders: createProductionOrdersResource(axiosClient),
stocks: createStocksResource(axiosClient),
countries: createCountriesResource(axiosClient),
};
}

View 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>;

View file

@ -0,0 +1,4 @@
export interface CountryDTO {
code: string;
name: string;
}

View file

@ -17,6 +17,7 @@ export * from './article';
export * from './customer';
export * from './inventory';
export * from './production';
export * from './country';
// Re-export generated types for advanced usage
export type { components, paths } from './generated/api';