diff --git a/backend/src/main/java/de/effigenix/application/shared/ListCountries.java b/backend/src/main/java/de/effigenix/application/shared/ListCountries.java new file mode 100644 index 0000000..d43b121 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/shared/ListCountries.java @@ -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 execute(String query) { + if (query == null || query.isBlank()) { + return countryRepository.findAll(); + } + return countryRepository.search(query); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/SharedUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/SharedUseCaseConfiguration.java new file mode 100644 index 0000000..43dd70b --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/config/SharedUseCaseConfiguration.java @@ -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); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/shared/InMemoryCountryRepository.java b/backend/src/main/java/de/effigenix/infrastructure/shared/InMemoryCountryRepository.java new file mode 100644 index 0000000..d957900 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/shared/InMemoryCountryRepository.java @@ -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 DACH_CODES = Set.of("DE", "AT", "CH"); + + private final Map byCode; + private final List 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 findAll() { + return allSorted; + } + + @Override + public List 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 findByCode(String code) { + return Optional.ofNullable(byCode.get(code)); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/shared/web/CountryController.java b/backend/src/main/java/de/effigenix/infrastructure/shared/web/CountryController.java new file mode 100644 index 0000000..35cc84b --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/shared/web/CountryController.java @@ -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> 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); + } +} diff --git a/backend/src/main/java/de/effigenix/shared/common/Country.java b/backend/src/main/java/de/effigenix/shared/common/Country.java new file mode 100644 index 0000000..c310dc2 --- /dev/null +++ b/backend/src/main/java/de/effigenix/shared/common/Country.java @@ -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"); + } + } +} diff --git a/backend/src/main/java/de/effigenix/shared/common/CountryRepository.java b/backend/src/main/java/de/effigenix/shared/common/CountryRepository.java new file mode 100644 index 0000000..c85196b --- /dev/null +++ b/backend/src/main/java/de/effigenix/shared/common/CountryRepository.java @@ -0,0 +1,10 @@ +package de.effigenix.shared.common; + +import java.util.List; +import java.util.Optional; + +public interface CountryRepository { + List findAll(); + List search(String query); + Optional findByCode(String code); +} diff --git a/backend/src/test/java/de/effigenix/application/shared/ListCountriesTest.java b/backend/src/test/java/de/effigenix/application/shared/ListCountriesTest.java new file mode 100644 index 0000000..5ddf3c0 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/shared/ListCountriesTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/shared/InMemoryCountryRepositoryTest.java b/backend/src/test/java/de/effigenix/infrastructure/shared/InMemoryCountryRepositoryTest.java new file mode 100644 index 0000000..7471e8c --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/shared/InMemoryCountryRepositoryTest.java @@ -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"); + } + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/shared/web/CountryControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/shared/web/CountryControllerIntegrationTest.java new file mode 100644 index 0000000..10498de --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/shared/web/CountryControllerIntegrationTest.java @@ -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")); + } +} diff --git a/backend/src/test/java/de/effigenix/shared/common/CountryTest.java b/backend/src/test/java/de/effigenix/shared/common/CountryTest.java new file mode 100644 index 0000000..9116034 --- /dev/null +++ b/backend/src/test/java/de/effigenix/shared/common/CountryTest.java @@ -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"); + } +} diff --git a/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx index 2ff391d..f8fea92 100644 --- a/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx @@ -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 = { label: 'Bezeichnung * (z.B. Hauptküche)', @@ -15,7 +18,7 @@ const FIELD_LABELS: Record = { 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>({ + const [values, setValues] = useState, string>>({ label: '', street: '', houseNumber: '', postalCode: '', - city: '', country: 'Deutschland', contactPerson: '', deliveryNotes: '', + city: '', contactPerson: '', deliveryNotes: '', }); const [activeField, setActiveField] = useState('label'); const [fieldErrors, setFieldErrors] = useState>>({}); + const [countryQuery, setCountryQuery] = useState(''); + const [countryCode, setCountryCode] = useState('DE'); + const [countryName, setCountryName] = useState('Deutschland'); + const [countries, setCountries] = useState([]); - const setField = (field: Field) => (value: string) => { + useEffect(() => { + client.countries.search().then(setCountries).catch(() => {}); + }, []); + + const setField = (field: Exclude) => (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 && } - {FIELDS.map((field) => ( - - ))} + {FIELDS.map((field) => { + if (field === 'countryPicker') { + return ( + { + 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; + return ( + + ); + })} diff --git a/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx index 063c872..5277f74 100644 --- a/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx @@ -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 = { name: 'Name *', @@ -18,7 +20,7 @@ const FIELD_LABELS: Record = { 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>({ + const [values, setValues] = useState, string>>({ name: '', phone: '', email: '', street: '', houseNumber: '', - postalCode: '', city: '', country: 'Deutschland', paymentDueDays: '', + postalCode: '', city: '', paymentDueDays: '', }); const [typeIndex, setTypeIndex] = useState(0); const [activeField, setActiveField] = useState('name'); const [fieldErrors, setFieldErrors] = useState>>({}); + const [countryQuery, setCountryQuery] = useState(''); + const [countryCode, setCountryCode] = useState('DE'); + const [countryName, setCountryName] = useState('Deutschland'); + const [countries, setCountries] = useState([]); - const setField = (field: Field) => (value: string) => { + useEffect(() => { + client.countries.search().then(setCountries).catch(() => {}); + }, []); + + const setField = (field: Exclude) => (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' && ←→ Typ · Tab/Enter weiter} - {FIELDS.map((field) => ( - - ))} + {FIELDS.map((field) => { + if (field === 'countryPicker') { + return ( + { + 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; + return ( + + ); + })} diff --git a/frontend/apps/cli/src/components/masterdata/customers/CustomerDetailScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/CustomerDetailScreen.tsx index 0e22505..8e35838 100644 --- a/frontend/apps/cli/src/components/masterdata/customers/CustomerDetailScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/customers/CustomerDetailScreen.tsx @@ -164,7 +164,7 @@ export function CustomerDetailScreen() { )} Rechnungsadresse: - {`${customer.billingAddress.street} ${customer.billingAddress.houseNumber}, ${customer.billingAddress.postalCode} ${customer.billingAddress.city}`} + {`${customer.billingAddress.street} ${customer.billingAddress.houseNumber}, ${customer.billingAddress.postalCode} ${customer.billingAddress.city}, ${customer.billingAddress.country}`} {customer.paymentTerms && ( @@ -185,7 +185,7 @@ export function CustomerDetailScreen() { {addr.label}: - {`${addr.address.street} ${addr.address.houseNumber}, ${addr.address.city}`} + {`${addr.address.street} ${addr.address.houseNumber}, ${addr.address.city}, ${addr.address.country}`} ))} diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx index 3479c49..1371039 100644 --- a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx @@ -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 = { name: 'Name *', @@ -18,7 +21,7 @@ const FIELD_LABELS: Record = { 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>({ + const [values, setValues] = useState, string>>({ name: '', phone: '', email: '', contactPerson: '', - street: '', houseNumber: '', postalCode: '', city: '', country: 'Deutschland', + street: '', houseNumber: '', postalCode: '', city: '', paymentDueDays: '', }); const [activeField, setActiveField] = useState('name'); const [fieldErrors, setFieldErrors] = useState>>({}); + const [countryQuery, setCountryQuery] = useState(''); + const [countryCode, setCountryCode] = useState('DE'); + const [countryName, setCountryName] = useState('Deutschland'); + const [countries, setCountries] = useState([]); - const setField = (field: Field) => (value: string) => { + useEffect(() => { + client.countries.search().then(setCountries).catch(() => {}); + }, []); + + const setField = (field: Exclude) => (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 && } - {FIELDS.map((field) => ( - - ))} + {FIELDS.map((field) => { + if (field === 'countryPicker') { + return ( + { + 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; + return ( + + ); + })} diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierDetailScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierDetailScreen.tsx index 05b5a75..f2e18a3 100644 --- a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierDetailScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierDetailScreen.tsx @@ -178,7 +178,7 @@ export function SupplierDetailScreen() { {supplier.address && ( Adresse: - {`${supplier.address.street} ${supplier.address.houseNumber}, ${supplier.address.postalCode} ${supplier.address.city}`} + {`${supplier.address.street} ${supplier.address.houseNumber}, ${supplier.address.postalCode} ${supplier.address.city}, ${supplier.address.country}`} )} {supplier.paymentTerms && ( diff --git a/frontend/apps/cli/src/components/shared/CountryPicker.tsx b/frontend/apps/cli/src/components/shared/CountryPicker.tsx new file mode 100644 index 0000000..45bd22a --- /dev/null +++ b/frontend/apps/cli/src/components/shared/CountryPicker.tsx @@ -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 ( + + Land * + + + ✓ {selectedName} + + + ); + } + + if (focus && selectedName && !query) { + return ( + + Land * (tippen zum Ändern) + + + ✓ {selectedName} + + {filtered.length > 0 && ( + + {filtered.map((c, i) => ( + + + {i === cursor ? '▶ ' : ' '}{c.code} – {c.name} + + + ))} + + )} + + ); + } + + return ( + + Land * (Suche) + + + {query || (focus ? '▌' : '')} + + {focus && filtered.length > 0 && ( + + {filtered.map((c, i) => ( + + + {i === cursor ? '▶ ' : ' '}{c.code} – {c.name} + + + ))} + + )} + {focus && query && filtered.length === 0 && ( + + Kein Land gefunden. + + )} + + ); +} diff --git a/frontend/packages/api-client/src/index.ts b/frontend/packages/api-client/src/index.ts index a227f50..9d3b64d 100644 --- a/frontend/packages/api-client/src/index.ts +++ b/frontend/packages/api-client/src/index.ts @@ -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), }; } diff --git a/frontend/packages/api-client/src/resources/countries.ts b/frontend/packages/api-client/src/resources/countries.ts new file mode 100644 index 0000000..aff8ca8 --- /dev/null +++ b/frontend/packages/api-client/src/resources/countries.ts @@ -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 { + const res = await client.get('/api/countries', { + params: query ? { q: query } : {}, + }); + return res.data; + }, + }; +} + +export type CountriesResource = ReturnType; diff --git a/frontend/packages/types/src/country.ts b/frontend/packages/types/src/country.ts new file mode 100644 index 0000000..4e9e31d --- /dev/null +++ b/frontend/packages/types/src/country.ts @@ -0,0 +1,4 @@ +export interface CountryDTO { + code: string; + name: string; +} diff --git a/frontend/packages/types/src/index.ts b/frontend/packages/types/src/index.ts index a536969..3be5999 100644 --- a/frontend/packages/types/src/index.ts +++ b/frontend/packages/types/src/index.ts @@ -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';