From 5640472bdf8b241cb0ab17dcd1b3eceb38ba9923 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 2 Sep 2026 14:26:20 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B8=B0=EB=B0=98=20=EA=B2=80=EC=88=98=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/application/dto/ProductPage.java | 31 ++ .../application/dto/ProductResult.java | 26 ++ .../application/service/ProductService.java | 57 +++- .../admin/product/domain/entity/Product.java | 263 +++++++++------ .../enums/ProductFunctionalCategory.java | 31 ++ .../product/domain/enums/ProductStage.java | 76 +++++ .../product/domain/enums/ProductStatus.java | 96 +++++- .../domain/repository/ProductRepository.java | 22 +- .../document/ProductDocument.java | 67 +++- .../repository/ProductMongoRepository.java | 7 - .../repository/ProductRepositoryImpl.java | 92 +++++- .../controller/ProductController.java | 218 ++++++++++++- .../dto/ProductRegisterRequest.java | 56 ++-- .../domain/ProductStatusTransitionTest.java | 67 ++++ .../presentation/ProductControllerTest.java | 305 ++++++++++++++++-- 15 files changed, 1246 insertions(+), 168 deletions(-) create mode 100644 src/main/java/com/seoulection/admin/product/application/dto/ProductPage.java create mode 100644 src/main/java/com/seoulection/admin/product/domain/enums/ProductFunctionalCategory.java create mode 100644 src/main/java/com/seoulection/admin/product/domain/enums/ProductStage.java delete mode 100644 src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductMongoRepository.java create mode 100644 src/test/java/com/seoulection/admin/product/domain/ProductStatusTransitionTest.java diff --git a/src/main/java/com/seoulection/admin/product/application/dto/ProductPage.java b/src/main/java/com/seoulection/admin/product/application/dto/ProductPage.java new file mode 100644 index 0000000..06a836d --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/application/dto/ProductPage.java @@ -0,0 +1,31 @@ +package com.seoulection.admin.product.application.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 목록 한 페이지. 템플릿에서 Spring의 Page를 직접 다루면 표현식이 지저분해져서 + * 화면이 실제로 쓰는 값만 추려 둔다. + */ +public record ProductPage( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + + public static ProductPage from(Page source) { + return new ProductPage(source.getContent(), source.getNumber(), source.getSize(), + source.getTotalElements(), source.getTotalPages()); + } + + public boolean isEmpty() { return content.isEmpty(); } + public boolean hasPrevious() { return page > 0; } + public boolean hasNext() { return page + 1 < totalPages; } + + /** "21-40 / 132"의 앞 두 숫자. 비어 있으면 0을 돌려준다. */ + public long firstItem() { return content.isEmpty() ? 0 : (long) page * size + 1; } + public long lastItem() { return content.isEmpty() ? 0 : (long) page * size + content.size(); } +} diff --git a/src/main/java/com/seoulection/admin/product/application/dto/ProductResult.java b/src/main/java/com/seoulection/admin/product/application/dto/ProductResult.java index 60f2e29..2fa654b 100644 --- a/src/main/java/com/seoulection/admin/product/application/dto/ProductResult.java +++ b/src/main/java/com/seoulection/admin/product/application/dto/ProductResult.java @@ -1,6 +1,7 @@ package com.seoulection.admin.product.application.dto; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; import com.seoulection.admin.product.domain.enums.ProductStatus; import java.math.BigDecimal; @@ -10,29 +11,54 @@ public record ProductResult( String id, + String asin, String name, String brand, String category, + String description, + BigDecimal price, + String thumbnailUrl, + String productUrl, long mentionCount, BigDecimal adRatio, + BigDecimal adLikelihoodSum, + String ingredientSource, List ingredients, Map inciapiRawData, Instant analyzedAt, + List function, ProductStatus status ) { public static ProductResult from(Product product) { return new ProductResult( product.id(), + product.asin(), product.name(), product.brand(), product.category(), + product.description(), + product.price(), + product.thumbnailUrl(), + product.productUrl(), product.mentionCount(), product.adRatio(), + product.adLikelihoodSum(), + product.ingredientSource(), product.ingredients(), product.inciapiRawData(), product.analyzedAt(), + product.function(), product.status() ); } + + public int ingredientCount() { + return ingredients == null ? 0 : ingredients.size(); + } + + /** 기능성 검수를 마쳤고 확인된 유형이 있는가. 검수 자체를 했는지는 status가 안다. */ + public boolean hasFunction() { + return function != null && !function.isEmpty(); + } } diff --git a/src/main/java/com/seoulection/admin/product/application/service/ProductService.java b/src/main/java/com/seoulection/admin/product/application/service/ProductService.java index 27935a7..126c2c5 100644 --- a/src/main/java/com/seoulection/admin/product/application/service/ProductService.java +++ b/src/main/java/com/seoulection/admin/product/application/service/ProductService.java @@ -1,15 +1,25 @@ package com.seoulection.admin.product.application.service; +import com.seoulection.admin.product.application.dto.ProductPage; import com.seoulection.admin.product.application.dto.ProductResult; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; +import com.seoulection.admin.product.domain.enums.ProductStatus; import com.seoulection.admin.product.domain.repository.ProductRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Map; +import java.util.Objects; @Service public class ProductService { + /** ObjectId 앞자리에 생성 시각이 들어가므로 _id 내림차순이 곧 최신순이다. */ + private static final Sort NEWEST_FIRST = Sort.by(Sort.Direction.DESC, "_id"); + private final ProductRepository repository; public ProductService(ProductRepository repository) { @@ -17,14 +27,47 @@ public ProductService(ProductRepository repository) { } public ProductResult register(String name, String brand, String category) { - Product product = Product.pending(name, brand, category); - return ProductResult.from(repository.insert(product)); + return ProductResult.from(repository.insert(Product.pending(name, brand, category))); + } + + public ProductResult register(String name, String brand, String category, List ingredients) { + return ProductResult.from(repository.insert(Product.pending(name, brand, category, ingredients))); + } + + /** 목록 한 페이지. statuses가 비어 있으면 상태 조건 없이 조회한다(전체 탭). */ + public ProductPage getProducts(List statuses, String keyword, int page, int size) { + var found = repository.find(keyword, statuses, PageRequest.of(page, size, NEWEST_FIRST)); + return ProductPage.from(found.map(ProductResult::from)); + } + + /** 탭 배지·소계용 상태별 건수. 검색 중이면 검색 결과 기준으로 센다. */ + public Map countByStatus(String keyword) { + return repository.countByStatus(keyword); + } + + public ProductResult getProduct(String id) { + return ProductResult.from(repository.findById(id)); + } + + /** 1단계 검수 — 전성분만 저장한다. 기능성(function)은 그대로 남는다. */ + public ProductResult reviewIngredients(String id, List ingredients, boolean ingredientNotFound) { + Product product = repository.findById(id); + return ProductResult.from(repository.save(product.reviewIngredients(ingredients, ingredientNotFound))); + } + + /** 2단계 검수 — 식약처 기능성만 저장한다. 빈 목록은 "확인했으나 기능성 아님"이다. */ + public ProductResult reviewFunction(String id, List function) { + Product product = repository.findById(id); + return ProductResult.from(repository.save(product.reviewFunction(parseFunction(function)))); + } + + public void delete(String id) { + repository.findById(id); // 없는 id면 여기서 IllegalArgumentException으로 걸린다. + repository.deleteById(id); } - public List getProducts() { - return repository.findAll() - .stream() - .map(ProductResult::from) - .toList(); + private List parseFunction(List values) { + return values == null ? List.of() : values.stream().filter(Objects::nonNull) + .map(ProductFunctionalCategory::from).distinct().toList(); } } diff --git a/src/main/java/com/seoulection/admin/product/domain/entity/Product.java b/src/main/java/com/seoulection/admin/product/domain/entity/Product.java index ccfc1e5..27d3fca 100644 --- a/src/main/java/com/seoulection/admin/product/domain/entity/Product.java +++ b/src/main/java/com/seoulection/admin/product/domain/entity/Product.java @@ -1,6 +1,7 @@ package com.seoulection.admin.product.domain.entity; import com.seoulection.admin.product.domain.enums.ProductCategory; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; import com.seoulection.admin.product.domain.enums.ProductStatus; import java.math.BigDecimal; @@ -9,84 +10,84 @@ import java.util.Map; import java.util.Objects; +/** + * products 컬렉션의 제품 한 건. + * + *

필드는 셋으로 나뉜다 — (1) 수집 단계가 채우는 카탈로그 정보(asin·description·price· + * thumbnailUrl·productUrl), (2) 분석 파이프라인이 채우는 값(mentionCount·adRatio· + * adLikelihoodSum·inciapiRawData·analyzedAt), (3) 어드민이 검수로 채우는 값(ingredients· + * ingredientSource·function). 어드민 화면이 직접 쓰는 건 (3)뿐이고 나머지는 읽기 전용이다. + * + *

기능성 검수 여부를 담는 별도 필드는 두지 않는다. {@link ProductStatus}가 그 역할을 한다 — + * INGREDIENTS_ADDED면 아직 안 본 것이고, READY_FOR_INCIAPI 이후면 본 것이다. 그리고 "봤는데 + * 기능성이 아니었다"와 "기능성이 확인됐다"는 {@code function}이 비었는지로 갈린다. + */ public class Product { private final String id; + private final String asin; private final String name; private final String brand; private final ProductCategory category; + private final String description; + private final BigDecimal price; + private final String thumbnailUrl; + private final String productUrl; private final long mentionCount; private final BigDecimal adRatio; + private final BigDecimal adLikelihoodSum; + private final String ingredientSource; private final List ingredients; private final Map inciapiRawData; private final Instant analyzedAt; + private final List function; private final ProductStatus status; - private Product( - String id, - String name, - String brand, - ProductCategory category, - long mentionCount, - BigDecimal adRatio, - List ingredients, - Map inciapiRawData, - Instant analyzedAt, - ProductStatus status - ) { - this.id = id; - this.name = requireText(name, "name"); - this.brand = requireText(brand, "brand"); - this.category = Objects.requireNonNull(category); - this.mentionCount = mentionCount; - this.adRatio = Objects.requireNonNull(adRatio); - this.ingredients = ingredients; - this.inciapiRawData = inciapiRawData; - this.analyzedAt = analyzedAt; - this.status = Objects.requireNonNull(status); + private Product(Builder builder) { + this.id = builder.id; + this.asin = builder.asin; + this.name = requireText(builder.name, "name"); + this.brand = requireText(builder.brand, "brand"); + this.category = Objects.requireNonNull(builder.category); + this.description = builder.description; + this.price = builder.price; + this.thumbnailUrl = builder.thumbnailUrl; + this.productUrl = builder.productUrl; + this.mentionCount = builder.mentionCount; + this.adRatio = builder.adRatio == null ? BigDecimal.ZERO : builder.adRatio; + this.adLikelihoodSum = builder.adLikelihoodSum; + this.ingredientSource = builder.ingredientSource; + this.ingredients = builder.ingredients; + this.inciapiRawData = builder.inciapiRawData; + this.analyzedAt = builder.analyzedAt; + this.function = builder.function == null ? List.of() : List.copyOf(builder.function); + this.status = Objects.requireNonNull(builder.status); } + public static Builder builder() { + return new Builder(); + } + + /** 어드민이 새로 등록하는 제품. 성분을 같이 넣으면 바로 기능성 확인 단계로 간다. */ public static Product pending(String name, String brand, String category) { - return new Product( - null, - name, - brand, - ProductCategory.from(category), - 0L, - BigDecimal.ZERO, - null, - null, - null, - ProductStatus.PENDING - ); + return pending(name, brand, category, null); } - public static Product restore( - String id, - String name, - String brand, - String category, - long mentionCount, - BigDecimal adRatio, - List ingredients, - Map inciapiRawData, - Instant analyzedAt, - ProductStatus status - ) { - return new Product( - id, - name, - brand, - ProductCategory.from(category), - mentionCount, - adRatio, - ingredients, - inciapiRawData, - analyzedAt, - status - ); + public static Product pending(String name, String brand, String category, List ingredients) { + boolean hasIngredients = ingredients != null && !ingredients.isEmpty(); + return builder() + .name(name) + .brand(brand) + .category(ProductCategory.from(category)) + .ingredients(hasIngredients ? ingredients : null) + .ingredientSource(hasIngredients ? ADMIN_SOURCE : null) + .status(hasIngredients ? ProductStatus.INGREDIENTS_ADDED : ProductStatus.PENDING) + .build(); } + /** 어드민이 직접 입력한 성분임을 표시하는 값. 파이프라인은 자기 출처 값을 따로 넣는다. */ + public static final String ADMIN_SOURCE = "ADMIN"; + private static String requireText(String value, String fieldName) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(fieldName + "은(는) 필수입니다."); @@ -94,43 +95,125 @@ private static String requireText(String value, String fieldName) { return value.trim(); } - public String id() { - return id; - } - - public String name() { - return name; - } - - public String brand() { - return brand; - } - - public String category() { - return category.value(); - } - - public long mentionCount() { - return mentionCount; - } - - public BigDecimal adRatio() { - return adRatio; - } - - public List ingredients() { - return ingredients; + public String id() { return id; } + public String asin() { return asin; } + public String name() { return name; } + public String brand() { return brand; } + public String category() { return category.value(); } + public String description() { return description; } + public BigDecimal price() { return price; } + public String thumbnailUrl() { return thumbnailUrl; } + public String productUrl() { return productUrl; } + public long mentionCount() { return mentionCount; } + public BigDecimal adRatio() { return adRatio; } + public BigDecimal adLikelihoodSum() { return adLikelihoodSum; } + public String ingredientSource() { return ingredientSource; } + public List ingredients() { return ingredients; } + public Map inciapiRawData() { return inciapiRawData; } + public Instant analyzedAt() { return analyzedAt; } + public List function() { return function; } + public ProductStatus status() { return status; } + + /** + * 1단계 검수 — 전성분만 갱신한다. function은 손대지 않는다. + * + *

이미 기능성 검수를 지난 제품(READY_FOR_INCIAPI 이후)은 성분을 고쳐도 앞 단계로 + * 되돌리지 않는다. 성분 오타 하나 고쳤다고 기능성을 다시 보게 만들 이유가 없다. + */ + public Product reviewIngredients(List ingredients, boolean ingredientNotFound) { + ProductStatus nextStatus; + if (ingredientNotFound) { + nextStatus = ProductStatus.NOT_FOUND; + } else if (ingredients == null || ingredients.isEmpty()) { + // 빈 저장은 아무것도 확인하지 못한 것이다. INSUFFICIENT_INGREDIENTS는 "크롤링은 됐는데 + // 성분이 5개 미만"이라는 파이프라인의 판정이라 어드민 저장으로 만들어 내면 안 된다. + nextStatus = status; + } else if (status.functionalReviewDone()) { + nextStatus = status; + } else { + nextStatus = ProductStatus.INGREDIENTS_ADDED; + } + boolean cleared = ingredientNotFound || ingredients == null || ingredients.isEmpty(); + return toBuilder() + .ingredients(cleared ? null : ingredients) + .ingredientSource(cleared ? null : ADMIN_SOURCE) + .status(nextStatus) + .build(); } - public Map inciapiRawData() { - return inciapiRawData; + /** + * 2단계 검수 — 식약처 기능성 결과만 갱신한다. 성분은 손대지 않는다. + * + *

빈 목록도 "검수했고 기능성이 아니었다"는 결과이므로 상태는 똑같이 전진한다. + * 검수 행위가 status를 옮기고, 무엇이 확인됐는지는 function이 담는다. + * + *

NOT_FOUND는 그대로 둔다 — 성분을 못 찾았다는 사실이 기능성 검수로 뒤집히지는 않는다. + */ + public Product reviewFunction(List function) { + ProductStatus nextStatus; + if (status == ProductStatus.NOT_FOUND + || status == ProductStatus.COMPLETE + || status == ProductStatus.SUMMARIZED) { + nextStatus = status; + } else if (ingredients == null || ingredients.isEmpty()) { + nextStatus = status; // 성분 없이 기능성만 확정할 수는 없다 — 상태를 그대로 둔다. + } else { + nextStatus = ProductStatus.READY_FOR_INCIAPI; + } + return toBuilder().function(function).status(nextStatus).build(); } - public Instant analyzedAt() { - return analyzedAt; + public Builder toBuilder() { + return new Builder() + .id(id).asin(asin).name(name).brand(brand).category(category) + .description(description).price(price).thumbnailUrl(thumbnailUrl).productUrl(productUrl) + .mentionCount(mentionCount).adRatio(adRatio).adLikelihoodSum(adLikelihoodSum) + .ingredientSource(ingredientSource).ingredients(ingredients) + .inciapiRawData(inciapiRawData).analyzedAt(analyzedAt) + .function(function).status(status); } - public ProductStatus status() { - return status; + /** 필드가 18개라 위치 인자 생성자는 읽을 수 없다 — 복원·수정 모두 이 빌더를 쓴다. */ + public static class Builder { + private String id; + private String asin; + private String name; + private String brand; + private ProductCategory category; + private String description; + private BigDecimal price; + private String thumbnailUrl; + private String productUrl; + private long mentionCount; + private BigDecimal adRatio; + private BigDecimal adLikelihoodSum; + private String ingredientSource; + private List ingredients; + private Map inciapiRawData; + private Instant analyzedAt; + private List function; + private ProductStatus status; + + public Builder id(String v) { this.id = v; return this; } + public Builder asin(String v) { this.asin = v; return this; } + public Builder name(String v) { this.name = v; return this; } + public Builder brand(String v) { this.brand = v; return this; } + public Builder category(ProductCategory v) { this.category = v; return this; } + public Builder category(String v) { this.category = ProductCategory.from(v); return this; } + public Builder description(String v) { this.description = v; return this; } + public Builder price(BigDecimal v) { this.price = v; return this; } + public Builder thumbnailUrl(String v) { this.thumbnailUrl = v; return this; } + public Builder productUrl(String v) { this.productUrl = v; return this; } + public Builder mentionCount(long v) { this.mentionCount = v; return this; } + public Builder adRatio(BigDecimal v) { this.adRatio = v; return this; } + public Builder adLikelihoodSum(BigDecimal v) { this.adLikelihoodSum = v; return this; } + public Builder ingredientSource(String v) { this.ingredientSource = v; return this; } + public Builder ingredients(List v) { this.ingredients = v; return this; } + public Builder inciapiRawData(Map v) { this.inciapiRawData = v; return this; } + public Builder analyzedAt(Instant v) { this.analyzedAt = v; return this; } + public Builder function(List v) { this.function = v; return this; } + public Builder status(ProductStatus v) { this.status = v; return this; } + + public Product build() { return new Product(this); } } } diff --git a/src/main/java/com/seoulection/admin/product/domain/enums/ProductFunctionalCategory.java b/src/main/java/com/seoulection/admin/product/domain/enums/ProductFunctionalCategory.java new file mode 100644 index 0000000..0105750 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/domain/enums/ProductFunctionalCategory.java @@ -0,0 +1,31 @@ +package com.seoulection.admin.product.domain.enums; + +import java.util.Arrays; + +/** 식약처 기능성화장품 표시 목적 분류. 성분 효능(ingredient_effect)과는 별도다. */ +public enum ProductFunctionalCategory { + WHITENING("WHITENING", "미백"), + WRINKLE_IMPROVEMENT("WRINKLE_IMPROVEMENT", "주름 개선"), + UV_PROTECTION("UV_PROTECTION", "자외선 차단"), + ACNE_RELIEF("ACNE_RELIEF", "여드름성 피부 완화"), + SKIN_BARRIER_RECOVERY("SKIN_BARRIER_RECOVERY", "피부장벽 기능 회복"), + STRETCH_MARKS("STRETCH_MARKS", "튼살 완화"); + + private final String code; + private final String displayName; + + ProductFunctionalCategory(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String code() { return code; } + public String displayName() { return displayName; } + + public static ProductFunctionalCategory from(String value) { + return Arrays.stream(values()) + .filter(category -> category.code.equalsIgnoreCase(value.trim())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 기능성 분류입니다: " + value)); + } +} diff --git a/src/main/java/com/seoulection/admin/product/domain/enums/ProductStage.java b/src/main/java/com/seoulection/admin/product/domain/enums/ProductStage.java new file mode 100644 index 0000000..d25c423 --- /dev/null +++ b/src/main/java/com/seoulection/admin/product/domain/enums/ProductStage.java @@ -0,0 +1,76 @@ +package com.seoulection.admin.product.domain.enums; + +import java.util.Arrays; +import java.util.List; + +/** + * 목록 화면의 작업 단계(탭). 여러 {@link ProductStatus}를 "지금 누가 움직여야 하는가"로 묶는다. + * + *

DB에는 status만 저장한다 — stage는 저장되지 않는 화면 개념이고, status로부터 유도된다 + * ({@link ProductStatus#stage()}). 그래서 상태가 늘어도 그 매핑 한 줄만 고치면 탭·배지·조회가 + * 모두 따라온다. + * + *

URL에는 {@code slug}가 나간다({@code ?stage=ingredient-review}). 화면·컨트롤러가 + * "ingredients" 같은 문자열을 직접 비교하지 않게 하려고 enum으로 둔다. + */ +public enum ProductStage { + + INGREDIENT_REVIEW("ingredient-review", "1 성분 보완", "내가 할 일", + "파이프라인이 성분을 못 가져와 사람이 확인해야 하는 제품입니다."), + + FUNCTIONAL_REVIEW("functional-review", "2 기능성 확인", "내가 할 일", + "전성분이 채워져 식약처 기능성 여부만 확인하면 되는 제품입니다."), + + PIPELINE("pipeline", "파이프라인", "파이프라인", + "파이프라인이 움직일 차례입니다. 수집이 늦어지면 성분을 직접 입력해 앞당길 수 있습니다."), + + COMPLETED("completed", "분석 완료", "파이프라인", + "분석까지 마친 제품입니다."), + + INGREDIENT_FAILED("ingredient-failed", "성분 확보 실패", "", + "파이프라인이 성분을 확보하지 못한 제품입니다. 성분 부족은 크롤링된 성분이 5개 미만," + + " 성분 정보 없음은 성분 정보를 찾지 못한 경우입니다."); + + // group: 탭 묶음 머리말. 빈 문자열이면 구분선만 긋고 이름은 붙이지 않는다. + private final String slug; + private final String label; + private final String group; + private final String description; + + ProductStage(String slug, String label, String group, String description) { + this.slug = slug; + this.label = label; + this.group = group; + this.description = description; + } + + public String slug() { return slug; } + public String label() { return label; } + public String group() { return group; } + public String description() { return description; } + + /** + * 건수 배지의 강조 색. 밀리면 곤란한 단계(어드민 작업 큐, 확보 실패)만 눈에 띄게 한다 — + * 파이프라인이 알아서 굴리는 단계까지 붉게 칠하면 강조가 의미를 잃는다. + */ + public String countTone() { + return switch (this) { + case INGREDIENT_REVIEW, INGREDIENT_FAILED -> "is-alert"; + case FUNCTIONAL_REVIEW -> "is-attention"; + case PIPELINE, COMPLETED -> ""; + }; + } + + /** 이 단계에 속하는 상태들. ProductStatus.stage()에서 유도해 매핑을 한 곳에만 둔다. */ + public List statuses() { + return Arrays.stream(ProductStatus.values()).filter(status -> status.stage() == this).toList(); + } + + /** URL slug로 단계를 찾는다. 모르는 값(손으로 고친 URL, 'all')이면 null. */ + public static ProductStage from(String slug) { + return Arrays.stream(values()) + .filter(stage -> stage.slug.equals(slug)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/seoulection/admin/product/domain/enums/ProductStatus.java b/src/main/java/com/seoulection/admin/product/domain/enums/ProductStatus.java index 03523cc..6c7f244 100644 --- a/src/main/java/com/seoulection/admin/product/domain/enums/ProductStatus.java +++ b/src/main/java/com/seoulection/admin/product/domain/enums/ProductStatus.java @@ -1,10 +1,104 @@ package com.seoulection.admin.product.domain.enums; +/** + * 제품의 파이프라인 상태. + * + *

어드민이 옮기는 상태와 파이프라인이 옮기는 상태가 섞여 있다. 특히 아래 둘은 + * 파이프라인만 만드는 결과라 어드민 화면에서 만들어 내면 안 된다. + *

    + *
  • {@code INSUFFICIENT_INGREDIENTS} — 크롤링은 됐는데 성분이 5개 미만
  • + *
  • {@code NOT_FOUND} — 성분 정보 자체를 찾지 못함
  • + *
+ */ public enum ProductStatus { PENDING, + NEED_MANUAL_REVIEW, NOT_FOUND, INSUFFICIENT_INGREDIENTS, + INGREDIENTS_ADDED, + READY_FOR_INCIAPI, READY_FOR_ANALYSIS, COMPLETE, - SUMMARIZED + SUMMARIZED; + + public String displayName() { + return switch (this) { + case PENDING -> "대기 중"; + case NEED_MANUAL_REVIEW -> "성분 수동 확인 필요"; + case NOT_FOUND -> "성분 정보 없음"; + case INSUFFICIENT_INGREDIENTS -> "성분 부족"; + case INGREDIENTS_ADDED -> "성분 입력 완료"; + case READY_FOR_INCIAPI -> "INCI API 준비"; + case READY_FOR_ANALYSIS -> "분석 준비"; + case COMPLETE -> "분석 완료"; + case SUMMARIZED -> "요약 완료"; + }; + } + + /** + * 목록의 상태 배지 색. displayName()과 같은 결의 표현용 메서드다 — + * 템플릿에서 상태 이름을 일일이 비교하는 th:if 더미를 만들지 않으려고 여기 둔다. + */ + public String tone() { + return switch (this) { + case PENDING -> "neutral"; + case NEED_MANUAL_REVIEW, INSUFFICIENT_INGREDIENTS -> "warning"; + case NOT_FOUND -> "danger"; + case INGREDIENTS_ADDED, READY_FOR_INCIAPI, READY_FOR_ANALYSIS -> "accent"; + case COMPLETE, SUMMARIZED -> "success"; + }; + } + + /** + * 이 상태가 속한 작업 단계(탭). 단계가 답하는 질문은 "지금 누가 움직여야 하는가"다. + * + *

READY_FOR_INCIAPI와 READY_FOR_ANALYSIS를 각각 탭으로 쪼개지 않는 이유: 둘 다 어드민이 + * 손댈 게 없어 눌러도 할 일이 없는 탭만 늘어난다. 세부 상태는 표의 상태 배지와 탭 안 + * 소계 칩으로 드러난다. PENDING(수집 대기)도 같은 이유로 PIPELINE에 둔다. + */ + public ProductStage stage() { + return switch (this) { + case NEED_MANUAL_REVIEW -> ProductStage.INGREDIENT_REVIEW; + case INGREDIENTS_ADDED -> ProductStage.FUNCTIONAL_REVIEW; + case PENDING, READY_FOR_INCIAPI, READY_FOR_ANALYSIS -> ProductStage.PIPELINE; + case COMPLETE, SUMMARIZED -> ProductStage.COMPLETED; + // 성분을 확보하지 못한 두 결말. 다시 시도해 볼 대상이라 한곳에 모아 둔다. + case INSUFFICIENT_INGREDIENTS, NOT_FOUND -> ProductStage.INGREDIENT_FAILED; + }; + } + + /** + * 어드민 작업이 필요한 상태가 열어야 할 workflow 단계. 작업이 없으면 null이다. + * + *

성분과 기능성은 근거 자료가 다른 별개의 작업이라 한 폼에 같이 두지 않는다. 파이프라인이 + * 굴리는 중이거나 이미 끝난 제품은 null을 돌려 상세 화면으로 보낸다 — 할 일이 없는 제품을 + * 작업 화면에 넣으면 "뭘 하라는 거지"가 된다. + * + *

NOT_FOUND는 "성분을 못 찾았다"는 뜻이므로 다시 성분 단계로 돌려보낸다. + */ + public String workflowStep() { + return switch (this) { + case NEED_MANUAL_REVIEW, INSUFFICIENT_INGREDIENTS, NOT_FOUND -> "ingredients"; + case INGREDIENTS_ADDED -> "functional"; + case PENDING, READY_FOR_INCIAPI, READY_FOR_ANALYSIS, COMPLETE, SUMMARIZED -> null; + }; + } + + /** 목록의 "검수" 열 링크 문구. */ + public String actionLabel() { + String step = workflowStep(); + if (step == null) { + return "상세 보기"; + } + return "ingredients".equals(step) ? "성분 보완" : "기능성 확인"; + } + + /** 기능성 검수를 이미 지났는가. 별도 필드 대신 이 판정이 functional_review_status를 대신한다. */ + public boolean functionalReviewDone() { + return switch (this) { + case READY_FOR_INCIAPI, READY_FOR_ANALYSIS, COMPLETE, SUMMARIZED -> true; + default -> false; + }; + } + } diff --git a/src/main/java/com/seoulection/admin/product/domain/repository/ProductRepository.java b/src/main/java/com/seoulection/admin/product/domain/repository/ProductRepository.java index 5ae41f5..9ba8144 100644 --- a/src/main/java/com/seoulection/admin/product/domain/repository/ProductRepository.java +++ b/src/main/java/com/seoulection/admin/product/domain/repository/ProductRepository.java @@ -1,12 +1,32 @@ package com.seoulection.admin.product.domain.repository; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Map; public interface ProductRepository { Product insert(Product product); - List findAll(); + Product findById(String id); + + Product save(Product product); + + void deleteById(String id); + + /** + * 목록 한 페이지. 검색어·상태 필터를 모두 Mongo 쪽에서 적용한다 — + * 전체를 메모리에 올려 자바에서 거르면 제품이 늘어날수록 목록 화면이 먼저 무너진다. + * + * @param keyword 제품명·브랜드 부분 일치. 비어 있으면 전체. + * @param statuses 이 상태들만. 비어 있으면 전체. + */ + Page find(String keyword, List statuses, Pageable pageable); + + /** 검색어를 적용한 상태별 건수. 탭 배지와 소계를 질의 한 번으로 채운다. */ + Map countByStatus(String keyword); } diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java b/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java index 15a8991..897620d 100644 --- a/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java +++ b/src/main/java/com/seoulection/admin/product/infrastructure/document/ProductDocument.java @@ -1,8 +1,10 @@ package com.seoulection.admin.product.infrastructure.document; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductFunctionalCategory; import com.seoulection.admin.product.domain.enums.ProductStatus; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.FieldType; @@ -18,9 +20,23 @@ public class ProductDocument { @Id private String id; + /** 수집 원본(아마존)의 제품 식별자. 같은 제품을 두 번 넣지 않기 위한 자연키다. */ + @Indexed(unique = true, sparse = true) + private String asin; + private String name; private String brand; private String category; + private String description; + + @Field(name = "price", targetType = FieldType.DECIMAL128) + private BigDecimal price; + + @Field("thumbnail_url") + private String thumbnailUrl; + + @Field("product_url") + private String productUrl; @Field("mention_count") private long mentionCount; @@ -28,6 +44,13 @@ public class ProductDocument { @Field(name = "ad_ratio", targetType = FieldType.DECIMAL128) private BigDecimal adRatio; + @Field(name = "ad_likelihood_sum", targetType = FieldType.DECIMAL128) + private BigDecimal adLikelihoodSum; + + /** 성분을 어디서 얻었는지. 어드민이 직접 입력하면 ADMIN, 파이프라인은 자기 값을 넣는다. */ + @Field("ingredient_source") + private String ingredientSource; + private List ingredients; @Field("inciapi_raw_data") @@ -36,6 +59,10 @@ public class ProductDocument { @Field("analyzed_at") private Instant analyzedAt; + /** 식약처 기능성 유형. 비어 있으면 "검수했으나 기능성 아님"이다 — 검수 여부는 status가 안다. */ + private List function; + + @Indexed private ProductStatus status; protected ProductDocument() { @@ -43,14 +70,22 @@ protected ProductDocument() { private ProductDocument(Product product) { this.id = product.id(); + this.asin = product.asin(); this.name = product.name(); this.brand = product.brand(); this.category = product.category(); + this.description = product.description(); + this.price = product.price(); + this.thumbnailUrl = product.thumbnailUrl(); + this.productUrl = product.productUrl(); this.mentionCount = product.mentionCount(); this.adRatio = product.adRatio(); + this.adLikelihoodSum = product.adLikelihoodSum(); + this.ingredientSource = product.ingredientSource(); this.ingredients = product.ingredients(); this.inciapiRawData = product.inciapiRawData(); this.analyzedAt = product.analyzedAt(); + this.function = product.function(); this.status = product.status(); } @@ -59,17 +94,25 @@ public static ProductDocument fromDomain(Product product) { } public Product toDomain() { - return Product.restore( - id, - name, - brand, - category, - mentionCount, - adRatio, - ingredients, - inciapiRawData, - analyzedAt, - status - ); + return Product.builder() + .id(id) + .asin(asin) + .name(name) + .brand(brand) + .category(category) + .description(description) + .price(price) + .thumbnailUrl(thumbnailUrl) + .productUrl(productUrl) + .mentionCount(mentionCount) + .adRatio(adRatio) + .adLikelihoodSum(adLikelihoodSum) + .ingredientSource(ingredientSource) + .ingredients(ingredients) + .inciapiRawData(inciapiRawData) + .analyzedAt(analyzedAt) + .function(function) + .status(status == null ? ProductStatus.PENDING : status) + .build(); } } diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductMongoRepository.java b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductMongoRepository.java deleted file mode 100644 index 460b54b..0000000 --- a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductMongoRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.seoulection.admin.product.infrastructure.repository; - -import com.seoulection.admin.product.infrastructure.document.ProductDocument; -import org.springframework.data.mongodb.repository.MongoRepository; - -interface ProductMongoRepository extends MongoRepository { -} diff --git a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductRepositoryImpl.java b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductRepositoryImpl.java index d74b779..9e648ed 100644 --- a/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductRepositoryImpl.java +++ b/src/main/java/com/seoulection/admin/product/infrastructure/repository/ProductRepositoryImpl.java @@ -1,32 +1,110 @@ package com.seoulection.admin.product.infrastructure.repository; import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductStatus; import com.seoulection.admin.product.domain.repository.ProductRepository; import com.seoulection.admin.product.infrastructure.document.ProductDocument; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Repository; +import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +/** + * 파생 쿼리(findByXxx) 대신 MongoTemplate을 쓰는 이유: 목록은 검색어 × 상태 필터 × 페이지의 + * 조합이라 메서드 이름으로 표현하면 금방 감당이 안 된다. 조건을 Criteria로 조립하면 조합이 + * 늘어도 메서드 하나로 끝난다. + */ @Repository public class ProductRepositoryImpl implements ProductRepository { - private final ProductMongoRepository mongoRepository; + /** 정규식 메타문자. 검색어를 그대로 regex에 넣으면 "("만 쳐도 쿼리가 깨진다. */ + private static final Pattern REGEX_META = Pattern.compile("[\\\\^$.|?*+()\\[\\]{}]"); - public ProductRepositoryImpl(ProductMongoRepository mongoRepository) { - this.mongoRepository = mongoRepository; + private final MongoTemplate mongoTemplate; + + public ProductRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; } @Override public Product insert(Product product) { - return mongoRepository.insert(ProductDocument.fromDomain(product)).toDomain(); + return mongoTemplate.insert(ProductDocument.fromDomain(product)).toDomain(); + } + + @Override + public Product findById(String id) { + ProductDocument found = mongoTemplate.findById(id, ProductDocument.class); + if (found == null) { + throw new IllegalArgumentException("제품을 찾을 수 없습니다: " + id); + } + return found.toDomain(); + } + + @Override + public Product save(Product product) { + return mongoTemplate.save(ProductDocument.fromDomain(product)).toDomain(); + } + + @Override + public void deleteById(String id) { + mongoTemplate.remove(new Query(Criteria.where("_id").is(id)), ProductDocument.class); } @Override - public List findAll() { - return mongoRepository.findAll(Sort.by(Sort.Direction.DESC, "id")) + public Page find(String keyword, List statuses, Pageable pageable) { + Query query = new Query(); + criteria(keyword, statuses).forEach(query::addCriteria); + long total = mongoTemplate.count(query, ProductDocument.class); + List content = mongoTemplate.find(query.with(pageable), ProductDocument.class) .stream() .map(ProductDocument::toDomain) .toList(); + return new PageImpl<>(content, pageable, total); + } + + @Override + public Map countByStatus(String keyword) { + List criteria = criteria(keyword, List.of()); + List stages = new ArrayList<>(); + criteria.forEach(c -> stages.add(Aggregation.match(c))); + stages.add(Aggregation.group("status").count().as("count")); + + Map counts = new EnumMap<>(ProductStatus.class); + mongoTemplate.aggregate(Aggregation.newAggregation(stages), ProductDocument.class, StatusCount.class) + .forEach(row -> { + if (row.id() != null) { + counts.merge(ProductStatus.valueOf(row.id()), row.count(), Long::sum); + } + }); + return counts; + } + + private List criteria(String keyword, List statuses) { + List criteria = new ArrayList<>(); + if (keyword != null && !keyword.isBlank()) { + String escaped = REGEX_META.matcher(keyword.trim()).replaceAll("\\\\$0"); + criteria.add(new Criteria().orOperator( + Criteria.where("name").regex(escaped, "i"), + Criteria.where("brand").regex(escaped, "i"))); + } + if (statuses != null && !statuses.isEmpty()) { + criteria.add(Criteria.where("status").in(statuses)); + } + return criteria; + } + + /** group 단계의 결과 한 줄. _id에 status 문자열이, count에 건수가 들어온다. */ + private record StatusCount(String id, long count) { } } diff --git a/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java b/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java index ad13c53..fd1d7fa 100644 --- a/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java +++ b/src/main/java/com/seoulection/admin/product/presentation/controller/ProductController.java @@ -1,6 +1,8 @@ package com.seoulection.admin.product.presentation.controller; import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductStage; +import com.seoulection.admin.product.domain.enums.ProductStatus; import com.seoulection.admin.product.presentation.dto.ProductRegisterRequest; import jakarta.validation.Valid; import org.springframework.stereotype.Controller; @@ -8,27 +10,117 @@ import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import tools.jackson.databind.ObjectMapper; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; @Controller public class ProductController { + /** 한 페이지에 보여 줄 건수. 표 한 화면에 들어오면서 스크롤이 과하지 않은 값. */ + private static final int PAGE_SIZE = 25; + private final ProductService service; + private final ObjectMapper objectMapper; - public ProductController(ProductService service) { + public ProductController(ProductService service, ObjectMapper objectMapper) { this.service = service; + this.objectMapper = objectMapper; } + /** + * 목록 화면. 기본 단계가 전체가 아니라 성분 보완인 이유: 이 화면을 여는 이유는 대개 + * "지금 내가 처리할 게 뭔가"이지 "전부 몇 개인가"가 아니다. 전체는 맨 끝에 둔다. + * + *

stage는 탭(작업 큐), status는 그 안의 단일 상태다. 함께 쓰면 정밀 조회가 된다 — + * 예: {@code ?stage=ingredient-failed&status=NOT_FOUND}. + */ @GetMapping("/admin/products") - public String page(Model model) { + public String page(@RequestParam(defaultValue = "ingredient-review") String stage, + @RequestParam(required = false) String status, + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "0") int page, + Model model) { if (!model.containsAttribute("request")) { model.addAttribute("request", new ProductRegisterRequest()); } - model.addAttribute("products", service.getProducts()); + populateProductList(model, stage, status, q, page); return "products"; } + /** + * @param stageSlug 탭. 모르는 값이거나 "all"이면 상태 조건 없이 전체를 본다. + * @param status 단계 안에서 한 상태만 보고 싶을 때. stage와 함께 쓴다. + */ + private void populateProductList(Model model, String stageSlug, String status, String query, int page) { + boolean searching = query != null && !query.isBlank(); + String keyword = searching ? query.trim() : null; + ProductStage stage = ProductStage.from(stageSlug); + ProductStatus exact = parseStatus(status); + + model.addAttribute("page", service.getProducts(statusesFor(stage, exact), keyword, + Math.max(page, 0), PAGE_SIZE)); + model.addAttribute("stages", List.of(ProductStage.values())); + model.addAttribute("selectedStage", stage); + model.addAttribute("selectedStageSlug", stage == null ? "all" : stage.slug()); + model.addAttribute("selectedStatus", exact == null ? "" : exact.name()); + model.addAttribute("query", searching ? keyword : ""); + + // 탭 배지·소계를 집계 질의 한 번으로 채운다. 검색 중이면 검색 결과 기준으로 센다 — + // "이 브랜드 중 성분 보완이 몇 건인가"가 검색을 쓰는 이유이므로 전체 건수를 보이면 어긋난다. + Map byStatus = service.countByStatus(keyword); + model.addAttribute("statusCounts", byStatus); + model.addAttribute("stageCounts", stageCounts(byStatus)); + model.addAttribute("substatStatuses", stage == null ? List.of(ProductStatus.values()) : stage.statuses()); + model.addAttribute("productCount", byStatus.values().stream().mapToLong(Long::longValue).sum()); + } + + /** + * 조회할 상태 집합. stage는 여러 상태를 묶은 작업 큐라서, 그 안의 한 상태만 보고 싶을 때가 + * 있다 — 예: 성분 확보 실패 단계에서 NOT_FOUND만. status 파라미터가 그 역할이다. + * 그 단계에 속하지 않는 상태가 들어오면 무시하고 단계 전체를 보여 준다. + */ + private List statusesFor(ProductStage stage, ProductStatus exact) { + List inStage = stage == null ? List.of() : stage.statuses(); + if (exact == null) { + return inStage; + } + if (inStage.isEmpty() || inStage.contains(exact)) { + return List.of(exact); + } + return inStage; + } + + private ProductStatus parseStatus(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return ProductStatus.valueOf(value); + } catch (IllegalArgumentException e) { + return null; // 손으로 URL을 고친 경우다 — 목록을 깨뜨리지 말고 탭 전체를 보여 준다. + } + } + + /** + * 단계별 건수. 키를 enum이 아니라 slug 문자열로 두는 이유: SpEL의 맵 조회가 enum 키를 + * 제대로 잡지 못해 템플릿에서 전부 null로 나온다. slug는 URL에도 그대로 쓰는 값이다. + */ + private Map stageCounts(Map byStatus) { + Map stages = new LinkedHashMap<>(); + Arrays.stream(ProductStage.values()).forEach(stage -> stages.put(stage.slug(), 0L)); + Arrays.stream(ProductStatus.values()) + .forEach(status -> stages.merge(status.stage().slug(), byStatus.getOrDefault(status, 0L), Long::sum)); + return stages; + } + @PostMapping("/admin/products") public String register( @Valid @ModelAttribute("request") ProductRegisterRequest request, @@ -37,12 +129,126 @@ public String register( RedirectAttributes redirectAttributes ) { if (bindingResult.hasErrors()) { - model.addAttribute("products", service.getProducts()); + populateProductList(model, "ingredient-review", null, null, 0); + model.addAttribute("registerFormOpen", true); return "products"; } - - service.register(request.getName(), request.getBrand(), request.getCategory()); + service.register(request.getName(), request.getBrand(), request.getCategory(), + splitIngredients(request.getIngredientsText())); redirectAttributes.addFlashAttribute("successMessage", "제품을 등록했습니다."); return "redirect:/admin/products"; } + + private List splitIngredients(String text) { + if (text == null || text.isBlank()) return List.of(); + return Arrays.stream(text.split("[,\\n]")) + .map(String::trim).filter(value -> !value.isBlank()).distinct().toList(); + } + + /** 제품 상세. 파이프라인이 채운 값(카탈로그·언급·INCI API 원본)까지 전부 보여 준다. */ + @GetMapping("/admin/products/{id}") + public String detail(@PathVariable String id, Model model) { + var product = service.getProduct(id); + model.addAttribute("product", product); + model.addAttribute("inciapiRawJson", prettyJson(product.inciapiRawData())); + return "product-detail"; + } + + private String prettyJson(Map raw) { + if (raw == null || raw.isEmpty()) { + return null; + } + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(raw); + } catch (RuntimeException e) { + // 원본을 못 읽는다고 상세 화면 전체가 죽으면 안 된다 — 사유만 남기고 나머지를 보여 준다. + return "원본 데이터를 표시할 수 없습니다: " + e.getMessage(); + } + } + + @PostMapping("/admin/products/{id}/delete") + public String delete(@PathVariable String id, RedirectAttributes redirectAttributes) { + var product = service.getProduct(id); + service.delete(id); + redirectAttributes.addFlashAttribute("successMessage", "'" + product.name() + "'을(를) 삭제했습니다."); + return "redirect:/admin/products"; + } + + /** + * 1단계 저장 — 전성분만. 저장 후에는 방금까지 보던 성분 보완 큐로 돌려보낸다(다음 건을 + * 이어서 처리하는 흐름). 목록 첫 화면으로 튕기면 처리하던 자리를 잃는다. + */ + @PostMapping("/admin/products/{id}/workflow/ingredients") + public String workflowIngredients(@PathVariable String id, @ModelAttribute ProductRegisterRequest request, + RedirectAttributes redirectAttributes) { + boolean ingredientNotFound = "NOT_FOUND".equals(request.getIngredientResolution()); + List ingredients = splitIngredients(request.getIngredientsText()); + if (ingredientNotFound && !ingredients.isEmpty()) { + redirectAttributes.addFlashAttribute("errorMessage", "성분을 찾지 못함을 선택한 경우 성분 입력란을 비워 주세요."); + return "redirect:/admin/products/" + id + "/workflow"; + } + if (!ingredientNotFound && ingredients.isEmpty()) { + redirectAttributes.addFlashAttribute("errorMessage", + "성분을 입력하거나, 찾지 못한 경우 '성분을 찾지 못함'을 선택해 주세요."); + return "redirect:/admin/products/" + id + "/workflow"; + } + service.reviewIngredients(id, ingredients, ingredientNotFound); + redirectAttributes.addFlashAttribute("successMessage", "전성분을 저장했습니다."); + return "redirect:/admin/products?stage=ingredient-review"; + } + + /** 2단계 저장 — 식약처 기능성만. 저장 후 기능성 확인 큐로 돌아간다. */ + @PostMapping("/admin/products/{id}/workflow/functions") + public String workflowFunctions(@PathVariable String id, @ModelAttribute ProductRegisterRequest request, + RedirectAttributes redirectAttributes) { + String result = request.getFunctionResult(); + if (result == null || result.isBlank()) { + redirectAttributes.addFlashAttribute("errorMessage", "의약품안전나라 조회 결과를 선택해 주세요."); + return "redirect:/admin/products/" + id + "/workflow"; + } + boolean confirmed = "CONFIRMED".equals(result); + boolean hasFunction = !request.getFunction().isEmpty(); + if (confirmed && !hasFunction) { + redirectAttributes.addFlashAttribute("errorMessage", "기능성 확인을 선택한 경우 유형을 최소 1개 선택해 주세요."); + return "redirect:/admin/products/" + id + "/workflow"; + } + // '기능성 아님'을 고르고 유형을 남겨 두면 모순이므로 유형을 버린다. + service.reviewFunction(id, confirmed ? request.getFunction() : List.of()); + redirectAttributes.addFlashAttribute("successMessage", "기능성 검수 정보를 저장했습니다."); + return "redirect:/admin/products?stage=functional-review"; + } + + /** + * 검수 작업 화면. 어느 단계를 열지는 제품 상태가 정한다({@link ProductStatus#workflowStep()}). + * step 파라미터는 이미 지나간 단계를 다시 여는 용도다 — 기능성 화면에서 "성분 수정"으로 + * 넘어가거나, 파이프라인이 늦어 PENDING 제품에 성분을 직접 넣는 경우. + */ + @GetMapping("/admin/products/{id}/workflow") + public String workflowPage(@PathVariable String id, + @RequestParam(required = false) String step, + Model model) { + var product = service.getProduct(id); + ProductRegisterRequest request = new ProductRegisterRequest(); + request.setName(product.name()); + request.setBrand(product.brand()); + request.setCategory(product.category()); + request.setIngredientResolution(product.status() == ProductStatus.NOT_FOUND ? "NOT_FOUND" : "FOUND"); + request.setFunction(product.function().stream().map(Enum::name).toList()); + // 이미 검수한 제품만 현재 값을 찍어 준다. 미검수면 비워 둬서 어드민이 직접 고르게 한다. + if (product.status().functionalReviewDone()) { + request.setFunctionResult(product.hasFunction() ? "CONFIRMED" : "NONE"); + } + request.setIngredientsText(product.ingredients() == null ? "" : String.join(", ", product.ingredients())); + model.addAttribute("product", product); + model.addAttribute("request", request); + String resolved = "ingredients".equals(step) || "functional".equals(step) + ? step + : product.status().workflowStep(); + if (resolved == null) { + // 파이프라인이 굴리는 중이거나 이미 끝난 제품은 어드민이 할 일이 없다 — 상세로 보낸다. + return "redirect:/admin/products/" + id; + } + model.addAttribute("workflowStep", resolved); + return "product-workflow"; + } } diff --git a/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java b/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java index 1df70bc..6ca2110 100644 --- a/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java +++ b/src/main/java/com/seoulection/admin/product/presentation/dto/ProductRegisterRequest.java @@ -4,6 +4,15 @@ import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; + +/** + * 등록 폼과 검수 폼이 함께 쓰는 입력값. + * + *

기능성 "검수 여부"를 받는 필드는 없다 — 그건 status가 담고, 이 폼은 확인된 유형(function)만 + * 받는다. 유형이 비어 있으면 "확인했으나 기능성 아님"이다. + */ public class ProductRegisterRequest { @NotBlank(message = "제품명을 입력해 주세요.") @@ -21,27 +30,38 @@ public class ProductRegisterRequest { ) private String category; - public String getName() { - return name; - } + /** 식약처 기능성 유형. REVIEWED_NONE 같은 별도 상태값 없이 이 목록의 유무가 결과를 말한다. */ + private List function = new ArrayList<>(); + + /** + * 2단계 검수 화면의 라디오. NONE(기능성 아님) 또는 CONFIRMED(기능성 확인). + * + *

기본값을 두지 않는다. 미검수 제품에 "기능성 아님"이 미리 찍혀 있으면 확인 없이 저장만 + * 눌러도 식약처 기능성 아님이 사실로 기록된다 — 규제 정보라 기본값으로 정할 수 없다. + */ + private String functionResult; + + private String ingredientResolution = "FOUND"; + private String ingredientsText; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } - public void setName(String name) { - this.name = name; - } + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } - public String getBrand() { - return brand; - } + public List getFunction() { return function; } + public void setFunction(List value) { this.function = value == null ? new ArrayList<>() : value; } - public void setBrand(String brand) { - this.brand = brand; - } + public String getFunctionResult() { return functionResult; } + public void setFunctionResult(String value) { this.functionResult = value; } - public String getCategory() { - return category; - } + public String getIngredientResolution() { return ingredientResolution; } + public void setIngredientResolution(String value) { this.ingredientResolution = value; } - public void setCategory(String category) { - this.category = category; - } + public String getIngredientsText() { return ingredientsText; } + public void setIngredientsText(String value) { this.ingredientsText = value; } } diff --git a/src/test/java/com/seoulection/admin/product/domain/ProductStatusTransitionTest.java b/src/test/java/com/seoulection/admin/product/domain/ProductStatusTransitionTest.java new file mode 100644 index 0000000..6941071 --- /dev/null +++ b/src/test/java/com/seoulection/admin/product/domain/ProductStatusTransitionTest.java @@ -0,0 +1,67 @@ +package com.seoulection.admin.product.domain; + +import com.seoulection.admin.product.domain.entity.Product; +import com.seoulection.admin.product.domain.enums.ProductStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * INSUFFICIENT_INGREDIENTS(크롤링 성분 5개 미만)와 NOT_FOUND(성분 정보 없음)는 파이프라인의 + * 판정이다. 어드민 저장이 이 둘을 만들어 내지 않는지 고정해 둔다 — NOT_FOUND는 어드민이 + * "찾지 못함"을 명시적으로 고른 경우에만 나온다. + */ +class ProductStatusTransitionTest { + + private Product crawledButTooFew() { + return Product.builder() + .name("시카 세럼").brand("서울렉션").category("treatments") + .ingredients(List.of("Water", "Glycerin")) + .status(ProductStatus.INSUFFICIENT_INGREDIENTS) + .build(); + } + + @Test + @DisplayName("성분을 채워 저장하면 기능성 확인 단계로 넘어간다") + void fillingIngredientsMovesToFunctionalQueue() { + Product reviewed = crawledButTooFew() + .reviewIngredients(List.of("Water", "Glycerin", "Niacinamide", "Panthenol", "Ceramide NP"), false); + + assertThat(reviewed.status()).isEqualTo(ProductStatus.INGREDIENTS_ADDED); + assertThat(reviewed.ingredientSource()).isEqualTo(Product.ADMIN_SOURCE); + } + + @Test + @DisplayName("빈 성분으로 저장해도 상태는 그대로다 — 어드민이 파이프라인 판정을 만들지 않는다") + void emptySaveKeepsStatus() { + assertThat(crawledButTooFew().reviewIngredients(List.of(), false).status()) + .isEqualTo(ProductStatus.INSUFFICIENT_INGREDIENTS); + + Product pending = Product.pending("레티놀 앰플", "토리든", "treatments"); + assertThat(pending.reviewIngredients(List.of(), false).status()) + .isEqualTo(ProductStatus.PENDING); + } + + @Test + @DisplayName("찾지 못함을 고른 경우에만 NOT_FOUND가 된다") + void notFoundOnlyWhenExplicit() { + assertThat(crawledButTooFew().reviewIngredients(List.of(), true).status()) + .isEqualTo(ProductStatus.NOT_FOUND); + } + + @Test + @DisplayName("기능성 검수를 지난 제품은 성분을 고쳐도 앞 단계로 되돌아가지 않는다") + void reviewedProductKeepsPipelineStage() { + Product ready = Product.builder() + .name("선크림").brand("라운드랩").category("sunscreens") + .ingredients(List.of("Water")) + .status(ProductStatus.READY_FOR_ANALYSIS) + .build(); + + assertThat(ready.reviewIngredients(List.of("Water", "Zinc Oxide"), false).status()) + .isEqualTo(ProductStatus.READY_FOR_ANALYSIS); + } +} diff --git a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java index 79b181a..1eff8f7 100644 --- a/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java +++ b/src/test/java/com/seoulection/admin/product/presentation/ProductControllerTest.java @@ -1,7 +1,12 @@ package com.seoulection.admin.product.presentation; +import com.seoulection.admin.product.application.dto.ProductPage; +import com.seoulection.admin.product.application.dto.ProductResult; import com.seoulection.admin.product.application.service.ProductService; +import com.seoulection.admin.product.domain.enums.ProductStage; +import com.seoulection.admin.product.domain.enums.ProductStatus; import com.seoulection.admin.product.presentation.controller.ProductController; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -9,10 +14,16 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.math.BigDecimal; import java.util.List; +import java.util.Map; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; @@ -30,15 +41,29 @@ class ProductControllerTest { @MockitoBean ProductService service; + @BeforeEach + void stubList() { + given(service.getProducts(any(), any(), anyInt(), anyInt())) + .willReturn(new ProductPage(List.of(), 0, 25, 0, 0)); + given(service.countByStatus(any())).willReturn(Map.of()); + } + @Test @DisplayName("제품 등록 화면을 제공한다") void page() throws Exception { - given(service.getProducts()).willReturn(List.of()); - mockMvc.perform(get("/admin/products")) .andExpect(status().isOk()) .andExpect(view().name("products")) - .andExpect(model().attributeExists("request", "products")); + .andExpect(model().attributeExists("request", "page", "stages", "stageCounts")); + } + + @Test + @DisplayName("기본 탭은 성분 보완이다") + void defaultsToIngredientsLane() throws Exception { + mockMvc.perform(get("/admin/products")) + .andExpect(model().attribute("selectedStageSlug", "ingredient-review")); + + then(service).should().getProducts(eq(ProductStage.INGREDIENT_REVIEW.statuses()), eq(null), eq(0), anyInt()); } @Test @@ -52,36 +77,25 @@ void register() throws Exception { .andExpect(redirectedUrl("/admin/products")) .andExpect(flash().attribute("successMessage", "제품을 등록했습니다.")); - then(service).should().register("시카 세럼", "서울렉션", "face masks"); + then(service).should().register("시카 세럼", "서울렉션", "face masks", List.of()); } @Test @DisplayName("제품명, 브랜드, 카테고리는 모두 필수다") void rejectBlankFields() throws Exception { - given(service.getProducts()).willReturn(List.of()); - mockMvc.perform(post("/admin/products") .param("name", " ") .param("brand", " ") .param("category", " ")) .andExpect(status().isOk()) .andExpect(view().name("products")) - .andExpect(model().attributeHasFieldErrors( - "request", - "name", - "brand", - "category" - )); - - then(service).should().getProducts(); - then(service).shouldHaveNoMoreInteractions(); + .andExpect(model().attribute("registerFormOpen", true)) + .andExpect(model().attributeHasFieldErrors("request", "name", "brand", "category")); } @Test @DisplayName("정해진 목록에 없는 카테고리는 등록하지 않는다") void rejectUnsupportedCategory() throws Exception { - given(service.getProducts()).willReturn(List.of()); - mockMvc.perform(post("/admin/products") .param("name", "시카 세럼") .param("brand", "서울렉션") @@ -89,8 +103,261 @@ void rejectUnsupportedCategory() throws Exception { .andExpect(status().isOk()) .andExpect(view().name("products")) .andExpect(model().attributeHasFieldErrors("request", "category")); + } + + @Test + @DisplayName("검색어는 목록 조회와 집계에 모두 전달된다") + void search() throws Exception { + mockMvc.perform(get("/admin/products").param("q", "서울렉션")) + .andExpect(status().isOk()) + .andExpect(model().attribute("query", "서울렉션")); + + then(service).should().getProducts(eq(ProductStage.INGREDIENT_REVIEW.statuses()), eq("서울렉션"), eq(0), anyInt()); + then(service).should().countByStatus("서울렉션"); + } + + @Test + @DisplayName("검색어가 공백뿐이면 검색이 아니다") + void blankQueryIsNotSearch() throws Exception { + mockMvc.perform(get("/admin/products").param("q", " ")) + .andExpect(model().attribute("query", "")); + + then(service).should().getProducts(any(), eq(null), anyInt(), anyInt()); + } + + @Test + @DisplayName("페이지 번호를 조회에 전달한다") + void paging() throws Exception { + mockMvc.perform(get("/admin/products").param("page", "2")) + .andExpect(status().isOk()); + + then(service).should().getProducts(any(), any(), eq(2), anyInt()); + } + + @Test + @DisplayName("음수 페이지는 첫 페이지로 다룬다") + void negativePageIsFirstPage() throws Exception { + mockMvc.perform(get("/admin/products").param("page", "-3")) + .andExpect(status().isOk()); + + then(service).should().getProducts(any(), any(), eq(0), anyInt()); + } + + @Test + @DisplayName("status로 탭 안의 한 상태만 좁혀 본다") + void exactStatusFilter() throws Exception { + mockMvc.perform(get("/admin/products") + .param("stage", "ingredient-failed") + .param("status", "INSUFFICIENT_INGREDIENTS")) + .andExpect(status().isOk()) + .andExpect(model().attribute("selectedStatus", "INSUFFICIENT_INGREDIENTS")); + + then(service).should().getProducts(eq(List.of(ProductStatus.INSUFFICIENT_INGREDIENTS)), + any(), anyInt(), anyInt()); + } + + @Test + @DisplayName("탭에 속하지 않는 status는 무시하고 탭 전체를 보여 준다") + void statusOutsideLaneIsIgnored() throws Exception { + mockMvc.perform(get("/admin/products") + .param("stage", "ingredient-review") + .param("status", "COMPLETE")) + .andExpect(status().isOk()); + + then(service).should().getProducts(eq(ProductStage.INGREDIENT_REVIEW.statuses()), any(), anyInt(), anyInt()); + } + + @Test + @DisplayName("알 수 없는 status가 와도 목록은 깨지지 않는다") + void unknownStatusIsIgnored() throws Exception { + mockMvc.perform(get("/admin/products").param("status", "NOT_A_STATUS")) + .andExpect(status().isOk()) + .andExpect(model().attribute("selectedStatus", "")); + + then(service).should().getProducts(eq(ProductStage.INGREDIENT_REVIEW.statuses()), any(), anyInt(), anyInt()); + } + + @Test + @DisplayName("PENDING은 파이프라인 대기 탭에 있지만 성분 보완이 열려 있다") + void pendingSitsInPipelineLaneButStaysEditable() throws Exception { + org.assertj.core.api.Assertions.assertThat(ProductStatus.PENDING.stage()).isEqualTo(ProductStage.PIPELINE); + // 파이프라인 탭에 있어도 어드민이 성분을 직접 넣을 수 있어야 한다. + org.assertj.core.api.Assertions.assertThat(ProductStatus.PENDING.workflowStep()).isNull(); + org.assertj.core.api.Assertions.assertThat(ProductStatus.NEED_MANUAL_REVIEW.workflowStep()).isEqualTo("ingredients"); + org.assertj.core.api.Assertions.assertThat(ProductStage.INGREDIENT_REVIEW.statuses()) + .containsExactly(ProductStatus.NEED_MANUAL_REVIEW); + } + + @Test + @DisplayName("파이프라인 대기 탭 안에서 PENDING만 좁혀 볼 수 있다") + void pendingIsFilterableInsidePipelineLane() throws Exception { + mockMvc.perform(get("/admin/products").param("stage", "pipeline").param("status", "PENDING")) + .andExpect(status().isOk()) + .andExpect(model().attribute("selectedStatus", "PENDING")); + + then(service).should().getProducts(eq(List.of(ProductStatus.PENDING)), any(), anyInt(), anyInt()); + } + + @Test + @DisplayName("성분 확보 실패 탭은 INSUFFICIENT_INGREDIENTS와 NOT_FOUND를 함께 담는다") + void unavailableLane() throws Exception { + org.assertj.core.api.Assertions.assertThat(ProductStage.INGREDIENT_FAILED.statuses()) + .containsExactlyInAnyOrder(ProductStatus.INSUFFICIENT_INGREDIENTS, ProductStatus.NOT_FOUND); + + mockMvc.perform(get("/admin/products").param("stage", "ingredient-failed")) + .andExpect(status().isOk()); + + then(service).should().getProducts(eq(ProductStage.INGREDIENT_FAILED.statuses()), any(), anyInt(), anyInt()); + } + + @Test + @DisplayName("전체 탭의 소계 칩은 모든 상태를 보여 준다") + void allTabShowsEverySubstat() throws Exception { + mockMvc.perform(get("/admin/products").param("stage", "all")) + .andExpect(status().isOk()) + .andExpect(model().attribute("substatStatuses", List.of(ProductStatus.values()))); + } + + @Test + @DisplayName("제품 상세 화면을 제공한다") + void detail() throws Exception { + given(service.getProduct("abc")).willReturn(product()); + + mockMvc.perform(get("/admin/products/abc")) + .andExpect(status().isOk()) + .andExpect(view().name("product-detail")) + .andExpect(model().attributeExists("product")); + } + + @Test + @DisplayName("제품을 삭제하면 목록으로 돌아간다") + void delete() throws Exception { + given(service.getProduct("abc")).willReturn(product()); + + mockMvc.perform(post("/admin/products/abc/delete")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/products")) + .andExpect(flash().attributeExists("successMessage")); + + then(service).should().delete("abc"); + } + + @Test + @DisplayName("기능성 확인을 골랐는데 유형이 없으면 저장하지 않는다") + void rejectConfirmedWithoutFunction() throws Exception { + mockMvc.perform(post("/admin/products/abc/workflow/functions") + .param("functionResult", "CONFIRMED")) + .andExpect(status().is3xxRedirection()) + .andExpect(flash().attributeExists("errorMessage")); + + then(service).should(never()).reviewFunction(any(), any()); + } + + @Test + @DisplayName("조회 결과를 고르지 않으면 저장하지 않는다") + void rejectMissingFunctionResult() throws Exception { + mockMvc.perform(post("/admin/products/abc/workflow/functions")) + .andExpect(status().is3xxRedirection()) + .andExpect(flash().attributeExists("errorMessage")); + + then(service).should(never()).reviewFunction(any(), any()); + } + + @Test + @DisplayName("미검수 제품은 조회 결과가 선택되지 않은 채로 열린다") + void unreviewedProductHasNoPreselectedResult() throws Exception { + given(service.getProduct("abc")).willReturn(product()); // INGREDIENTS_ADDED + + var request = (com.seoulection.admin.product.presentation.dto.ProductRegisterRequest) + mockMvc.perform(get("/admin/products/abc/workflow")) + .andExpect(status().isOk()) + .andReturn().getModelAndView().getModel().get("request"); + + org.assertj.core.api.Assertions.assertThat(request.getFunctionResult()).isNull(); + } + + @Test + @DisplayName("어드민이 할 일이 없는 제품은 작업 화면 대신 상세로 보낸다") + void productWithoutAdminWorkRedirectsToDetail() throws Exception { + given(service.getProduct("abc")).willReturn(reviewedProduct()); + + mockMvc.perform(get("/admin/products/abc/workflow")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/products/abc")); + } + + @Test + @DisplayName("step을 지정하면 이미 지나간 단계도 다시 열 수 있다") + void explicitStepReopensPastStage() throws Exception { + given(service.getProduct("abc")).willReturn(reviewedProduct()); + + var request = (com.seoulection.admin.product.presentation.dto.ProductRegisterRequest) + mockMvc.perform(get("/admin/products/abc/workflow").param("step", "functional")) + .andExpect(status().isOk()) + .andExpect(view().name("product-workflow")) + .andReturn().getModelAndView().getModel().get("request"); + + // 이미 검수를 지난 제품이라 현재 결과가 찍혀 있어야 한다. + org.assertj.core.api.Assertions.assertThat(request.getFunctionResult()).isEqualTo("NONE"); + } + + private ProductResult reviewedProduct() { + return new ProductResult("abc", null, "시카 세럼", "서울렉션", "treatments", null, null, null, null, + 0L, BigDecimal.ZERO, null, "ADMIN", List.of("Water"), null, null, + List.of(), ProductStatus.READY_FOR_INCIAPI); + } + + @Test + @DisplayName("기능성 아님을 고르면 유형을 남겨 두었어도 빈 목록으로 저장한다") + void noneDiscardsFunction() throws Exception { + mockMvc.perform(post("/admin/products/abc/workflow/functions") + .param("functionResult", "NONE") + .param("function", "WHITENING")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/products?stage=functional-review")); + + then(service).should().reviewFunction("abc", List.of()); + } + + @Test + @DisplayName("성분을 찾지 못함을 고르고 성분을 적으면 저장하지 않는다") + void rejectNotFoundWithIngredients() throws Exception { + mockMvc.perform(post("/admin/products/abc/workflow/ingredients") + .param("ingredientResolution", "NOT_FOUND") + .param("ingredientsText", "Water")) + .andExpect(status().is3xxRedirection()) + .andExpect(flash().attributeExists("errorMessage")); + + then(service).should(never()).reviewIngredients(any(), any(), org.mockito.ArgumentMatchers.anyBoolean()); + } + + @Test + @DisplayName("성분을 비운 채로는 저장하지 않는다 — 파이프라인 판정 상태를 어드민이 만들면 안 된다") + void rejectEmptyIngredientsWhenFound() throws Exception { + mockMvc.perform(post("/admin/products/abc/workflow/ingredients") + .param("ingredientResolution", "FOUND") + .param("ingredientsText", " ")) + .andExpect(status().is3xxRedirection()) + .andExpect(flash().attributeExists("errorMessage")); + + then(service).should(never()).reviewIngredients(any(), any(), org.mockito.ArgumentMatchers.anyBoolean()); + } + + @Test + @DisplayName("성분을 입력하면 저장한다") + void saveIngredients() throws Exception { + mockMvc.perform(post("/admin/products/abc/workflow/ingredients") + .param("ingredientResolution", "FOUND") + .param("ingredientsText", "Water, Glycerin")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/products?stage=ingredient-review")); + + then(service).should().reviewIngredients("abc", List.of("Water", "Glycerin"), false); + } - then(service).should().getProducts(); - then(service).shouldHaveNoMoreInteractions(); + private ProductResult product() { + return new ProductResult("abc", null, "시카 세럼", "서울렉션", "treatments", null, null, null, null, + 0L, BigDecimal.ZERO, null, "ADMIN", List.of("Water"), null, null, + List.of(), ProductStatus.INGREDIENTS_ADDED); } } From da70a0fd121088e0a3c3bf5dec59b0f6f90cb93a Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 2 Sep 2026 14:26:26 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EC=84=B1=EB=B6=84=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EA=B2=80=EC=88=98=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/IngredientService.java | 47 ++++++ .../config/IngredientSeedConfiguration.java | 42 ++++++ .../document/IngredientDocument.java | 41 ++++++ .../repository/IngredientMongoRepository.java | 6 + .../controller/IngredientController.java | 71 +++++++++ .../dto/IngredientCreateRequest.java | 33 +++++ .../templates/ingredient-review.html | 81 +++++++++++ src/main/resources/templates/ingredients.html | 137 ++++++++++++++++++ 8 files changed, 458 insertions(+) create mode 100644 src/main/java/com/seoulection/admin/ingredient/application/service/IngredientService.java create mode 100644 src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java create mode 100644 src/main/java/com/seoulection/admin/ingredient/infrastructure/document/IngredientDocument.java create mode 100644 src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientMongoRepository.java create mode 100644 src/main/java/com/seoulection/admin/ingredient/presentation/controller/IngredientController.java create mode 100644 src/main/java/com/seoulection/admin/ingredient/presentation/dto/IngredientCreateRequest.java create mode 100644 src/main/resources/templates/ingredient-review.html create mode 100644 src/main/resources/templates/ingredients.html diff --git a/src/main/java/com/seoulection/admin/ingredient/application/service/IngredientService.java b/src/main/java/com/seoulection/admin/ingredient/application/service/IngredientService.java new file mode 100644 index 0000000..637f8d2 --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/application/service/IngredientService.java @@ -0,0 +1,47 @@ +package com.seoulection.admin.ingredient.application.service; + +import com.seoulection.admin.ingredient.infrastructure.document.IngredientDocument; +import com.seoulection.admin.ingredient.infrastructure.repository.IngredientMongoRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Arrays; +import java.util.UUID; +import java.util.Map; + +@Service +public class IngredientService { + private final IngredientMongoRepository repository; + + public IngredientService(IngredientMongoRepository repository) { this.repository = repository; } + public List getIngredients() { return repository.findAll(); } + + public IngredientDocument getIngredient(String id) { + return repository.findById(id).orElseThrow(() -> new IllegalArgumentException("성분을 찾을 수 없습니다: " + id)); + } + + public void create(String canonicalName, String inciName, String displayNameKo, String family, + String aliasesText, String searchGroupsText, String effectsText) { + repository.save(new IngredientDocument(UUID.randomUUID().toString(), canonicalName.trim(), inciName.trim(), + displayNameKo.trim(), family.trim(), split(aliasesText), split(searchGroupsText), parseEffects(effectsText), Map.of())); + } + + public void update(String id, String canonicalName, String inciName, String displayNameKo, String family, + String aliasesText, String searchGroupsText, String effectsText) { + repository.save(new IngredientDocument(id, canonicalName.trim(), inciName.trim(), displayNameKo.trim(), family.trim(), + split(aliasesText), split(searchGroupsText), parseEffects(effectsText), Map.of())); + } + + private List split(String value) { + if (value == null || value.isBlank()) return List.of(); + return Arrays.stream(value.split("[,\\n]")).map(String::trim).filter(v -> !v.isBlank()).distinct().toList(); + } + + private Map parseEffects(String value) { + if (value == null || value.isBlank()) return Map.of(); + return Arrays.stream(value.split("[,\\n]")) + .map(String::trim).filter(v -> v.contains("=")) + .map(v -> v.split("=", 2)) + .collect(java.util.stream.Collectors.toMap(v -> v[0].trim(), v -> v[1].trim(), (a, b) -> b)); + } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java b/src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java new file mode 100644 index 0000000..a7b807f --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/config/IngredientSeedConfiguration.java @@ -0,0 +1,42 @@ +package com.seoulection.admin.ingredient.infrastructure.config; + +import com.seoulection.admin.ingredient.infrastructure.document.IngredientDocument; +import com.seoulection.admin.ingredient.infrastructure.repository.IngredientMongoRepository; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; +import java.util.Map; + +@Configuration +public class IngredientSeedConfiguration { + @Bean + ApplicationRunner seedIngredients(IngredientMongoRepository repository) { + return args -> { + if (repository.count() > 0) return; + repository.saveAll(List.of( + i("101","Hyaluronic Acid","Hyaluronic Acid","히알루론산","HYALURONIC_ACID_FAMILY",List.of("HA","히알루론산"),List.of("HYALURONIC_ACID_SEARCH"),Map.of("WATER_SCORE","CORE","ROUGH_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")), + i("102","Sodium DNA","Sodium DNA","PDRN","NUCLEOTIDE_DERIVATIVE",List.of("PDRN","소듐 DNA"),List.of("PDRN_SEARCH"),Map.of("WATER_SCORE","SUPPORT","WRINKLE_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")), + i("103","Panthenol","Panthenol","판테놀","SOOTHING",List.of("D-Panthenol","판테놀"),List.of("SOOTHING_SEARCH"),Map.of("SENSITIVITY_SCORE","CORE","WATER_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")), + i("104","Centella Asiatica","Centella Asiatica","병풀","BOTANICAL_CICA",List.of("Cica","병풀"),List.of("SOOTHING_SEARCH"),Map.of("SENSITIVITY_SCORE","SUPPORT","RED_SPOT_SCORE","SUPPORT"),Map.of()), + i("105","Retinol","Retinol","레티놀","RETINOID",List.of("Vitamin A","레티놀"),List.of("RETINOID_SEARCH"),Map.of("WRINKLE_SCORE","CORE","ROUGH_SCORE","CORE"),Map.of("SOLUBILITY","OIL_SOLUBLE")), + i("106","Retinal","Retinal","레티날","RETINOID",List.of("Retinaldehyde","레티날"),List.of("RETINOID_SEARCH"),Map.of("WRINKLE_SCORE","CORE","ROUGH_SCORE","CORE"),Map.of()), + i("107","Niacinamide","Niacinamide","나이아신아마이드","ACTIVE",List.of("Nicotinamide","나이아신아마이드"),List.of("BRIGHTENING_SEARCH"),Map.of("MELANIN_SCORE","CORE","OILY_INTENSITY_SCORE","SUPPORT"),Map.of("SOLUBILITY","WATER_SOLUBLE")), + i("108","Ascorbic Acid","Ascorbic Acid","비타민 C","ANTIOXIDANT",List.of("Vitamin C","L-Ascorbic Acid"),List.of("BRIGHTENING_SEARCH"),Map.of("MELANIN_SCORE","CORE","ROUGH_SCORE","SUPPORT"),Map.of("STABILITY","OXIDATION_SENSITIVE")), + i("109","Collagen","Collagen","콜라겐","CONDITIONING",List.of("Hydrolyzed Collagen","콜라겐"),List.of("BARRIER_SEARCH"),Map.of("ROUGH_SCORE","SUPPORT"),Map.of()), + i("110","Madecassoside","Madecassoside","마데카소사이드","BOTANICAL_CICA",List.of("마데카소사이드"),List.of("SOOTHING_SEARCH","BARRIER_SEARCH"),Map.of("SENSITIVITY_SCORE","SUPPORT"),Map.of()), + i("111","Ceramide NP","Ceramide NP","세라마이드 NP","CERAMIDE_LIPID",List.of("Ceramide","세라마이드"),List.of("BARRIER_SEARCH"),Map.of("BARRIER_SCORE","CORE","WATER_SCORE","CORE","SENSITIVITY_SCORE","SUPPORT"),Map.of("SOLUBILITY","OIL_DISPERSIBLE")), + i("112","Salicylic Acid","Salicylic Acid","살리실산","EXFOLIANT",List.of("BHA","살리실산"),List.of("EXFOLIATION_SEARCH"),Map.of("BLACKHEAD_SCORE","CORE","ACNE_SCORE","CORE"),Map.of()), + i("113","Kojic Acid","Kojic Acid","코직산","BRIGHTENING",List.of("Kojic","코직산"),List.of("BRIGHTENING_SEARCH"),Map.of("MELANIN_SCORE","CORE"),Map.of("STABILITY","OXIDATION_SENSITIVE")), + i("114","Guaiazulene","Guaiazulene","구아이아줄렌","AZULENE",List.of("Azulene","아줄렌"),List.of("SOOTHING_SEARCH"),Map.of("SENSITIVITY_SCORE","SUPPORT","RED_SPOT_SCORE","SUPPORT"),Map.of("SOLUBILITY","OIL_SOLUBLE")), + i("115","Human Oligopeptide-1","Human Oligopeptide-1","EGF","GROWTH_FACTOR",List.of("EGF","상피세포성장인자"),List.of("EGF_SEARCH"),Map.of("ROUGH_SCORE","SUPPORT","WRINKLE_SCORE","SUPPORT"),Map.of("STABILITY","PROTEIN_STABILITY_SENSITIVE")) + )); + }; + } + + private IngredientDocument i(String id, String name, String inci, String ko, String family, + List aliases, List groups, Map effects, Map properties) { + return new IngredientDocument(id, name, inci, ko, family, aliases, groups, effects, properties); + } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/infrastructure/document/IngredientDocument.java b/src/main/java/com/seoulection/admin/ingredient/infrastructure/document/IngredientDocument.java new file mode 100644 index 0000000..3e70f0f --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/document/IngredientDocument.java @@ -0,0 +1,41 @@ +package com.seoulection.admin.ingredient.infrastructure.document; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.util.List; +import java.util.Map; + +@Document(collection = "ingredients") +public class IngredientDocument { + @Id private String id; + private String canonicalName; + private String inciName; + private String displayNameKo; + private String family; + private List aliases; + private List searchGroups; + private Map effects; + private Map properties; + + protected IngredientDocument() { } + + public IngredientDocument(String id, String canonicalName, String inciName, String displayNameKo, + String family, List aliases, List searchGroups, + Map effects, Map properties) { + this.id = id; this.canonicalName = canonicalName; this.inciName = inciName; + this.displayNameKo = displayNameKo; this.family = family; this.aliases = aliases; + this.searchGroups = searchGroups; this.effects = effects; this.properties = properties; + } + + public String getId() { return id; } + public String getCanonicalName() { return canonicalName; } + public String getInciName() { return inciName; } + public String getDisplayNameKo() { return displayNameKo; } + public String getFamily() { return family; } + public List getAliases() { return aliases == null ? List.of() : aliases; } + public List getSearchGroups() { return searchGroups == null ? List.of() : searchGroups; } + public Map getEffects() { return effects == null ? Map.of() : effects; } + public Map getProperties() { return properties == null ? Map.of() : properties; } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientMongoRepository.java b/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientMongoRepository.java new file mode 100644 index 0000000..1da374b --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/infrastructure/repository/IngredientMongoRepository.java @@ -0,0 +1,6 @@ +package com.seoulection.admin.ingredient.infrastructure.repository; + +import com.seoulection.admin.ingredient.infrastructure.document.IngredientDocument; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface IngredientMongoRepository extends MongoRepository { } diff --git a/src/main/java/com/seoulection/admin/ingredient/presentation/controller/IngredientController.java b/src/main/java/com/seoulection/admin/ingredient/presentation/controller/IngredientController.java new file mode 100644 index 0000000..39e2699 --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/presentation/controller/IngredientController.java @@ -0,0 +1,71 @@ +package com.seoulection.admin.ingredient.presentation.controller; + +import com.seoulection.admin.ingredient.application.service.IngredientService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import com.seoulection.admin.ingredient.presentation.dto.IngredientCreateRequest; +import jakarta.validation.Valid; +import org.springframework.validation.BindingResult; + +@Controller +public class IngredientController { + private final IngredientService service; + public IngredientController(IngredientService service) { this.service = service; } + + @GetMapping("/admin/ingredients") + public String page(Model model) { + if (!model.containsAttribute("request")) model.addAttribute("request", new IngredientCreateRequest()); + model.addAttribute("ingredients", service.getIngredients()); + return "ingredients"; + } + + @PostMapping("/admin/ingredients") + public String create(@Valid @ModelAttribute("request") IngredientCreateRequest request, + BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + // 등록 폼은 기본으로 접혀 있다 — 검증에 실패했으면 펼쳐서 오류를 보여 줘야 한다. + model.addAttribute("ingredients", service.getIngredients()); + model.addAttribute("registerFormOpen", true); + return "ingredients"; + } + service.create(request.getCanonicalName(), request.getInciName(), request.getDisplayNameKo(), request.getFamily(), + request.getAliasesText(), request.getSearchGroupsText(), request.getEffectsText()); + redirectAttributes.addFlashAttribute("successMessage", "성분을 등록했습니다."); + return "redirect:/admin/ingredients"; + } + + @GetMapping("/admin/ingredients/{id}/review") + public String reviewPage(@PathVariable String id, Model model) { + var ingredient = service.getIngredient(id); + IngredientCreateRequest request = new IngredientCreateRequest(); + request.setCanonicalName(ingredient.getCanonicalName()); + request.setInciName(ingredient.getInciName()); + request.setDisplayNameKo(ingredient.getDisplayNameKo()); + request.setFamily(ingredient.getFamily()); + request.setAliasesText(String.join(", ", ingredient.getAliases())); + request.setSearchGroupsText(String.join(", ", ingredient.getSearchGroups())); + request.setEffectsText(ingredient.getEffects().entrySet().stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()).collect(java.util.stream.Collectors.joining(", "))); + model.addAttribute("ingredient", ingredient); + model.addAttribute("request", request); + return "ingredient-review"; + } + + @PostMapping("/admin/ingredients/{id}/review") + public String review(@PathVariable String id, @Valid @ModelAttribute("request") IngredientCreateRequest request, + BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + model.addAttribute("ingredient", service.getIngredient(id)); + return "ingredient-review"; + } + service.update(id, request.getCanonicalName(), request.getInciName(), request.getDisplayNameKo(), request.getFamily(), + request.getAliasesText(), request.getSearchGroupsText(), request.getEffectsText()); + redirectAttributes.addFlashAttribute("successMessage", "성분 검수 정보를 저장했습니다."); + return "redirect:/admin/ingredients"; + } +} diff --git a/src/main/java/com/seoulection/admin/ingredient/presentation/dto/IngredientCreateRequest.java b/src/main/java/com/seoulection/admin/ingredient/presentation/dto/IngredientCreateRequest.java new file mode 100644 index 0000000..3a7e4bd --- /dev/null +++ b/src/main/java/com/seoulection/admin/ingredient/presentation/dto/IngredientCreateRequest.java @@ -0,0 +1,33 @@ +package com.seoulection.admin.ingredient.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class IngredientCreateRequest { + @NotBlank(message = "표준 성분명을 입력해 주세요.") @Size(max = 200) + private String canonicalName; + @NotBlank(message = "INCI명을 입력해 주세요.") @Size(max = 200) + private String inciName; + @NotBlank(message = "한글명을 입력해 주세요.") @Size(max = 100) + private String displayNameKo; + @NotBlank(message = "성분 계열을 입력해 주세요.") @Size(max = 80) + private String family; + private String aliasesText; + private String searchGroupsText; + private String effectsText; + + public String getCanonicalName() { return canonicalName; } + public void setCanonicalName(String value) { canonicalName = value; } + public String getInciName() { return inciName; } + public void setInciName(String value) { inciName = value; } + public String getDisplayNameKo() { return displayNameKo; } + public void setDisplayNameKo(String value) { displayNameKo = value; } + public String getFamily() { return family; } + public void setFamily(String value) { family = value; } + public String getAliasesText() { return aliasesText; } + public void setAliasesText(String value) { aliasesText = value; } + public String getSearchGroupsText() { return searchGroupsText; } + public void setSearchGroupsText(String value) { searchGroupsText = value; } + public String getEffectsText() { return effectsText; } + public void setEffectsText(String value) { effectsText = value; } +} diff --git a/src/main/resources/templates/ingredient-review.html b/src/main/resources/templates/ingredient-review.html new file mode 100644 index 0000000..f68cdcd --- /dev/null +++ b/src/main/resources/templates/ingredient-review.html @@ -0,0 +1,81 @@ + + + + + + 성분 검수 | MySeoulection Admin + + + +

+ +
+
+
+ +

성분 검수·보완

+
+
+
+
+
+
+

+

+
+
+
+
+
성분 이름필수
+
+
+ + +

+
+
+ + +

+
+
+ + +

+
+
+ + +

+
+
+
+
+
검색·추천
+
+
+ + +
+
+ + +
+
+ + +

제품의 식약처 기능성 심사 여부가 아니라, 성분과 피부 지표의 추천 연결입니다.

+
+
+
+ +
+
+
+
+
+ + diff --git a/src/main/resources/templates/ingredients.html b/src/main/resources/templates/ingredients.html new file mode 100644 index 0000000..8810e84 --- /dev/null +++ b/src/main/resources/templates/ingredients.html @@ -0,0 +1,137 @@ + + + + + + 성분 관리 | MySeoulection Admin + + + +
+ +
+
+
+

성분 관리

+

성분의 계열·검색 그룹·추천 지표를 관리합니다.

+
+
+ +
+
+
+
+ + + +
+
+
+

등록된 성분

+

초기 15개 데이터는 MongoDB가 비어 있을 때 자동 생성됩니다.

+
+
+
+ + + + + + + + + + + +
성분성분 계열검색 그룹추천 지표관리
+
+ + +
+
+
+ + 미지정 +
+
+
+ + + 미지정 +
+
+ 검수·보완 +
+
+
+
+
+
+ +
+ + + + + From b6cc7692e7c94830909ef54fa4b166938fdc2e79 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 2 Sep 2026 14:26:33 +0900 Subject: [PATCH 03/11] =?UTF-8?q?ui:=20=EC=A0=9C=ED=92=88=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=ED=81=90=EC=99=80=20=EA=B2=80=EC=88=98=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=ED=99=94=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/css/admin.css | 575 +++++++++++++++++- src/main/resources/static/js/admin.js | 80 +++ .../resources/templates/product-detail.html | 132 ++++ .../resources/templates/product-workflow.html | 148 +++++ src/main/resources/templates/products.html | 231 +++++-- 5 files changed, 1103 insertions(+), 63 deletions(-) create mode 100644 src/main/resources/static/js/admin.js create mode 100644 src/main/resources/templates/product-detail.html create mode 100644 src/main/resources/templates/product-workflow.html diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index e9acc41..7093d26 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -156,7 +156,14 @@ a { color: inherit; } border-bottom: 1px solid var(--border); } +.topbar { + position: sticky; + top: 0; + z-index: 10; +} + .topbar h1 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; } +.topbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .topbar .description { margin-top: 3px; font-size: 13.5px; color: var(--text-secondary); } .content { @@ -165,6 +172,22 @@ a { color: inherit; } padding: 32px 36px 56px; } +/* 폼 한 개만 있는 상세/검수 화면은 좁게 — 넓은 표와 같은 폭이면 필드가 늘어져 읽기 나쁘다. */ +.content-narrow { width: min(760px, 100%); } + +.breadcrumb { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 600; +} + +.breadcrumb a { color: var(--text-secondary); text-decoration: none; } +.breadcrumb a:hover { color: var(--accent); } + /* ── Alerts ── */ .alert { @@ -204,11 +227,12 @@ a { color: inherit; } } .card.is-inactive { background: var(--surface-sunken); } -.card.is-inactive > h2, .card.is-inactive > .title-form { opacity: .55; } +.card.is-inactive > h2, .card.is-inactive > .panel-header .title-form { opacity: .55; } /* ── Forms ── */ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.form-grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .form-grid.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .span-full { grid-column: 1 / -1; } @@ -240,6 +264,465 @@ input:focus-visible { } input::placeholder { color: var(--text-tertiary); } +input:disabled, textarea:disabled { background: var(--surface-sunken); color: var(--text-tertiary); cursor: not-allowed; } + +textarea { + width: 100%; + padding: 12px 13px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font: inherit; + color: var(--text-primary); + background: var(--surface); + resize: vertical; +} + +textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft-strong); } +.form-help { margin-top: 6px; font-size: 12px; color: var(--text-secondary); } +.inline-review-form { min-width: 320px; padding: 12px 0; } +.inline-review-form label { margin-top: 9px; } +.text-link { color: var(--accent); font-size: 12px; font-weight: 700; text-decoration: none; } +.text-link:hover { text-decoration: underline; } +.form-actions { display: flex; gap: 10px; align-items: center; margin-top: 20px; } +.button-secondary { display: inline-flex; align-items: center; height: 40px; margin: 0; padding: 0 14px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-secondary); text-decoration: none; font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; } +.button-secondary:hover { background: var(--surface-sunken); color: var(--text-primary); } +.form-actions button, .form-actions .button-secondary { margin-top: 0; } +.form-actions .button-secondary { margin-right: 4px; } +.data-display { display: flex; flex-direction: column; gap: 5px; } +.data-display strong { font-size: 14px; } +.data-display small { color: var(--text-tertiary); font-size: 11.5px; } +.chip-list { display: flex; flex-wrap: wrap; gap: 5px; } +.data-chip { display: inline-flex; align-items: center; padding: 4px 7px; border-radius: 6px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; } +.data-chip.is-neutral { background: var(--neutral-soft); color: var(--text-secondary); } +.data-chip.is-effect { background: var(--success-soft); color: var(--success); } +.data-chip.is-core { background: #e8f6ee; color: #087443; } +.data-chip.is-support { background: #f1f3f5; color: #667085; } +/* ── Data tables ── */ + +.table-scroll { overflow-x: auto; } +.admin-table { table-layout: fixed; } +.admin-table td { vertical-align: top; } +.admin-table th { position: sticky; top: 0; z-index: 1; background: var(--surface-sunken); white-space: nowrap; } +.admin-table thead th { border-bottom: 1px solid var(--border); } +.admin-table th, .admin-table td { padding-left: 24px; padding-right: 14px; } +.admin-table th:first-child, .admin-table td:first-child { padding-left: 24px; } +.admin-table th:last-child, .admin-table td:last-child { padding-right: 24px; } +.admin-table .cell-strong { font-weight: 600; } +.admin-table .cell-strong a { color: var(--text-primary); font-weight: 600; text-decoration: none; } +.admin-table .cell-strong a:hover { color: var(--accent); text-decoration: underline; } +.admin-table .is-numeric { text-align: right; font-variant-numeric: tabular-nums; } +.admin-table th:last-child, .admin-table td.admin-actions { width: 132px; min-width: 132px; white-space: nowrap; } + +/* 긴 URL이 표를 밀지 않게 한 줄로 잘라 준다(table-layout: fixed 전제). */ +.cell-truncate { max-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.cell-nowrap { white-space: nowrap; } +.cell-truncate a { display: block; overflow: hidden; text-overflow: ellipsis; } + +.ingredient-table th:nth-child(1) { width: 17%; } +.ingredient-table th:nth-child(2) { width: 18%; } +.ingredient-table th:nth-child(3) { width: 22%; } +.ingredient-table th:nth-child(4) { width: auto; } +.admin-actions .text-link { display: block; width: max-content; min-width: 84px; white-space: nowrap !important; word-break: normal !important; overflow-wrap: normal !important; } +.product-table th:nth-child(1) { width: 19%; } +.product-table th:nth-child(2) { width: 12%; } +.product-table th:nth-child(3) { width: 12%; } +.product-table th:nth-child(4) { width: 22%; } +.product-table th:nth-child(5) { width: 6%; } +.product-table th:nth-child(6) { width: 17%; } +.product-table th:last-child { width: 120px; } +.product-table .is-functional { background: var(--accent-soft); color: var(--accent); } + +/* ── Toolbar: 상태 탭 + 화면 액션 ── + 탭 배지가 이 화면의 요약 숫자를 겸한다. 별도 통계 카드 줄을 두면 같은 값이 두 번 나온다. */ + +.toolbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; } + +.tabs-group-label { + padding: 0 8px 0 2px; + color: var(--text-tertiary); + font-size: 10.5px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + white-space: nowrap; +} + +.tabs-divider { width: 1px; height: 16px; margin: 0 8px; background: var(--border); } + +/* ── 툴바 검색 ── */ + +/* 표 카드 헤더 오른쪽에 붙는다. 탭 줄에 두면 탭이 밀려 두 줄이 된다. */ +.toolbar-search { + position: relative; + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.toolbar-search input[type=search] { + width: 240px; + height: 36px; + padding: 0 12px 0 34px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-primary); + font-family: inherit; + font-size: 13.5px; +} + +.toolbar-search input[type=search]:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft-strong); +} + +.search-icon { + position: absolute; + left: 11px; + display: grid; + place-items: center; + width: 15px; + height: 15px; + color: var(--text-tertiary); + pointer-events: none; +} + +.search-icon svg { width: 100%; height: 100%; } + +.tabs a { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 11px 12px; + margin-bottom: -1px; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-size: 13.5px; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + transition: color .12s, border-color .12s; +} + +.tabs a:hover { color: var(--text-primary); } +.tabs a.is-active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 700; } + +.tabs .count { + min-width: 20px; + padding: 2px 6px; + border-radius: 999px; + background: var(--neutral-soft); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + line-height: 1.45; + text-align: center; +} + +.tabs a.is-active .count { background: var(--accent-soft); color: var(--accent); } +.tabs .count.is-alert { background: var(--danger-soft); color: var(--danger); } +.tabs .count.is-attention { background: var(--warning-soft); color: var(--warning); } + +/* ── 카드 헤더 / 접히는 폼 패널 ── */ + +.card.is-flush { padding: 0; overflow: hidden; } + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 24px; + border-bottom: 1px solid var(--border); +} + +.panel-header h2 { display: flex; align-items: center; gap: 8px; font-size: 15.5px; font-weight: 700; } +.panel-header .card-subtitle { margin: 4px 0 0; } +.panel-header-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.panel-header .title-form { flex: 1; min-width: 0; margin: 0; } + +.count-inline { + padding: 2px 8px; + border-radius: 999px; + background: var(--neutral-soft); + color: var(--text-secondary); + font-size: 11.5px; + font-weight: 700; +} + +/* 탭 안의 상태별 소계. 누르면 그 상태만 걸리고, 다시 누르면 해제된다. */ +.substat { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } + +.substat a { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text-secondary); + font-size: 12px; + text-decoration: none; + transition: border-color .12s, background .12s, color .12s; +} + +.substat a:hover { border-color: var(--border-strong); color: var(--text-primary); } +.substat a.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 700; } +.substat b { color: var(--text-primary); font-variant-numeric: tabular-nums; } +.substat a.is-active b { color: var(--accent); } + +/* 검수 화면 단계 표시 — 지금 어느 단계인지, 몇 단계짜리인지 한눈에. */ +.step-rail { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; padding: 0; list-style: none; } + +.step-rail li { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-tertiary); + font-size: 13px; + font-weight: 600; +} + +.step-rail li + li::before { content: ''; width: 20px; height: 1px; margin-right: 3px; background: var(--border-strong); } +.step-rail li span { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 999px; background: var(--neutral-soft); color: var(--text-tertiary); font-size: 11.5px; font-weight: 700; } +.step-rail li.is-current { color: var(--accent); } +.step-rail li.is-current span { background: var(--accent); color: white; } +.step-rail li.is-done { color: var(--text-secondary); } +.step-rail li.is-done span { background: var(--success-soft); color: var(--success); } + +/* 다음 단계에서 앞 단계 결과를 근거로만 보여 주는 영역. */ +.readonly-section { background: var(--surface-sunken); } +.readonly-section .form-section-title { margin-bottom: 10px; } +.readonly-section .form-section-title .text-link { margin-left: auto; letter-spacing: 0; text-transform: none; } + +/* ── Drawer: 화면 오른쪽에서 밀려 나오는 등록·편집 패널 ── + 등록 폼이 800px를 넘어 모달에 넣으면 잘린다(노트북 뷰포트가 약 760px). 드로어는 화면 높이를 + 그대로 쓰므로 넘쳐도 위→아래 한 방향 스크롤이고, 목록이 왼쪽에 남아 대조할 수 있다. */ + +.drawer-scrim { + position: fixed; + inset: 0; + z-index: 90; + /* 목록이 비쳐 보일 정도로만 덮는다 — 가리는 게 목적이 아니라 "지금은 이 작업 중"을 알리는 것. */ + background: rgba(20, 23, 31, .32); + opacity: 0; + visibility: hidden; + transition: opacity .22s ease, visibility .22s ease; +} + +.drawer-scrim.is-open { opacity: 1; visibility: visible; } + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 100; + display: flex; + flex-direction: column; + width: min(600px, 100%); + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: -14px 0 36px rgba(20, 23, 31, .12); + transform: translateX(100%); + visibility: hidden; + transition: transform .22s ease, visibility .22s ease; +} + +.drawer.is-open { transform: translateX(0); visibility: visible; } + +body.is-drawer-open { overflow: hidden; } + +@media (prefers-reduced-motion: reduce) { + .drawer, .drawer-scrim { transition: none; } +} + +.drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-shrink: 0; + padding: 20px 24px; + border-bottom: 1px solid var(--border); +} + +.drawer-header h2 { font-size: 16px; font-weight: 700; } +.drawer-header .card-subtitle { margin: 4px 0 0; } + +/* 헤더와 푸터는 붙박이, 가운데 본문만 스크롤한다 — 저장 버튼이 스크롤에 묻히지 않게. */ +.drawer form { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.drawer-body { flex: 1; min-height: 0; overflow-y: auto; } +.drawer .form-section:last-child { border-bottom: 0; } +.drawer .form-footer { flex-shrink: 0; border-top: 1px solid var(--border); } + +.form-section { padding: 20px 24px; border-bottom: 1px solid var(--border); } + +.form-section-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + color: var(--text-tertiary); + font-size: 11.5px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; +} + +.form-section-title code { text-transform: none; letter-spacing: 0; } + +.required-mark, .optional-mark { + padding: 2px 6px; + border-radius: 4px; + font-size: 10.5px; + letter-spacing: 0; +} + +.required-mark { background: var(--accent-soft); color: var(--accent); } +.optional-mark { background: var(--neutral-soft); color: var(--text-tertiary); } + +.form-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + background: var(--surface-sunken); +} + +.option-table th:nth-child(1) { width: 22%; } +.option-table th:nth-child(2) { width: auto; } +.option-table th:nth-child(3) { width: 90px; } +.option-form { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.option-form input[type=text] { flex: 1; min-width: 180px; } + +/* ── 상세 화면 ── */ + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 4px 0; + margin: 0; + padding: 6px 0 14px; +} + +.detail-grid > div { padding: 16px 24px; } +.detail-grid dt { margin-bottom: 6px; color: var(--text-tertiary); font-size: 11.5px; font-weight: 700; letter-spacing: .03em; } +.detail-grid dt code { padding: 1px 5px; font-size: 10.5px; font-weight: 500; } +.detail-grid dd { margin: 0; font-size: 13.5px; color: var(--text-primary); } +.detail-grid dd.is-numeric { font-variant-numeric: tabular-nums; } + +.code-block { + max-height: 320px; + margin: 0; + padding: 14px 16px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-sunken); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; + white-space: pre; +} + +/* ── 페이지네이션 ── */ + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 24px; + border-top: 1px solid var(--border); + background: var(--surface-sunken); +} + +.pagination-range { color: var(--text-secondary); font-size: 12.5px; font-variant-numeric: tabular-nums; } +.pagination-buttons { display: flex; align-items: center; gap: 6px; } +.pagination-page { padding: 0 8px; color: var(--text-secondary); font-size: 12.5px; font-variant-numeric: tabular-nums; } + +.pagination a, .pagination .is-disabled { + display: inline-flex; + align-items: center; + height: 30px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--surface); + font-size: 12.5px; + font-weight: 600; + text-decoration: none; +} + +.pagination a { color: var(--text-primary); } +.pagination a:hover { border-color: var(--accent); color: var(--accent); } +.pagination .is-disabled { color: var(--text-tertiary); opacity: .55; } + +.thumb { width: 56px; height: 56px; border-radius: 6px; border: 1px solid var(--border); object-fit: cover; } +.detail-grid .span-full { grid-column: 1 / -1; } +.dd-wrap { overflow-wrap: anywhere; } + +.danger-zone { border-color: #f2c6c6; } +.danger-zone .panel-header { align-items: center; border-bottom: 0; } +.danger-zone form { flex-shrink: 0; } + +/* ── 검수 화면의 단계 구분 ── */ + +.review-step { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +.step-label { + color: var(--text-tertiary); + font-size: 11.5px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; +} + +.step-number { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border-radius: 999px; + background: var(--accent); + color: white; + font-size: 11.5px; + font-weight: 700; + flex-shrink: 0; +} + +.step-status { + margin-left: auto; + padding: 3px 9px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 11.5px; + font-weight: 700; +} + +/* review-step은 form-section 맨 위에 오므로 아래 필드와 간격을 준다. */ +.form-section > .review-step { margin-bottom: 18px; } .form-row { display: flex; gap: 10px; } .form-row input { flex: 1; } @@ -251,9 +734,16 @@ input::placeholder { color: var(--text-tertiary); } align-items: center; } +.policy-table th:nth-child(1) { width: 26%; } + +/* td input[type=text]의 min-width: 240px가 그리드 트랙을 밀어 저장 버튼과 겹쳤다. */ +.policy-form input[type=text] { min-width: 0; } + .toggle-group { display: flex; flex-wrap: wrap; gap: 8px; } -.toggle-option { margin: 0; cursor: pointer; } -.toggle-option input { position: absolute; width: 1px; height: 1px; opacity: 0; } +/* position: relative가 없으면 숨긴 input이 먼 조상 기준으로 쌓여, 브라우저 기본 검증 + 말풍선이 화면 엉뚱한 곳에 뜬다. 라벨 기준으로 붙여 칩 옆에 나오게 한다. */ +.toggle-option { position: relative; margin: 0; cursor: pointer; } +.toggle-option input { position: absolute; left: 0; bottom: 0; width: 1px; height: 1px; opacity: 0; } .toggle-option span { display: inline-block; @@ -278,6 +768,13 @@ input::placeholder { color: var(--text-tertiary); } outline-offset: 2px; } +.toggle-option input:disabled + span { + background: var(--surface-sunken); + border-color: var(--border); + color: var(--text-tertiary); + cursor: not-allowed; +} + .error { margin: 7px 0 0; color: var(--danger); font-size: 13px; } /* ── Buttons ── */ @@ -299,6 +796,44 @@ button, .btn { button:hover { background: var(--accent-hover); } +.btn-primary { + display: inline-flex; + align-items: center; + gap: 6px; + height: 40px; + margin: 0; + padding: 0 15px; + border: 0; + border-radius: var(--radius-sm); + background: var(--accent); + color: white; + font-family: inherit; + font-size: 13.5px; + font-weight: 700; + cursor: pointer; +} + +.btn-primary:hover { background: var(--accent-hover); } +a.btn-primary { text-decoration: none; } +.btn-primary[aria-expanded="true"] { background: var(--accent-hover); box-shadow: inset 0 2px 5px rgba(0, 0, 0, .18); } +.btn-icon { font-size: 15px; line-height: 1; } + +.btn-ghost { + height: 32px; + margin: 0; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-family: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} + +.btn-ghost:hover { background: var(--neutral-soft); color: var(--text-primary); } + button.subtle { height: 32px; margin: 0; @@ -366,12 +901,24 @@ td a:hover { text-decoration: underline; } border-radius: 999px; font-size: 11.5px; font-weight: 700; + white-space: nowrap; } .badge-accent { background: var(--accent-soft); color: var(--accent); } .badge-success { background: var(--success-soft); color: var(--success); } .badge-warning { background: var(--warning-soft); color: var(--warning); } .badge-neutral { background: var(--neutral-soft); color: var(--text-secondary); } +.badge-danger { background: var(--danger-soft); color: var(--danger); } + +.badge.is-status::before { + content: ''; + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 999px; + background: currentColor; + flex-shrink: 0; +} /* ── Empty state ── */ @@ -400,18 +947,16 @@ td a:hover { text-decoration: underline; } /* ── Home: quick-link grid ── */ -.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 14px; margin-bottom: 28px; } - -.stat-card { - padding: 18px 20px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-md); - box-shadow: var(--shadow-sm); -} - -.stat-card .stat-label { font-size: 12px; color: var(--text-secondary); font-weight: 600; } -.stat-card .stat-value { margin-top: 6px; font-size: 26px; font-weight: 700; letter-spacing: -.02em; } +.choice-grid { display: grid; grid-template-columns: repeat(3, minmax(150px, 1fr)); gap: 10px; } +.choice-card { margin: 0; cursor: pointer; } +.choice-card input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.choice-card span { display: block; padding: 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); color: var(--text-secondary); transition: border-color .12s, background .12s, color .12s; } +.choice-card strong { display: block; color: var(--text-primary); font-size: 13px; } +.choice-card small { display: block; margin-top: 3px; color: var(--text-tertiary); font-size: 11px; } +.choice-card input:checked + span { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); } +.choice-card input:checked + span strong { color: var(--accent); } +.choice-card input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } +@media (max-width: 600px) { .choice-grid { grid-template-columns: 1fr 1fr; } } .menu { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; } diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js new file mode 100644 index 0000000..434c585 --- /dev/null +++ b/src/main/resources/static/js/admin.js @@ -0,0 +1,80 @@ +/* + * MySeoulection Admin — 등록·편집 드로어. + * + * 등록 폼은 화면 오른쪽에서 밀려 나오는 드로어로 연다. 목록 사이에 끼워 넣으면(예전 방식) 표가 + * 아래로 밀려나고, 모달에 넣으면 800px가 넘는 폼이 잘린다. 드로어는 화면 높이를 그대로 쓰면서 + * 목록을 왼쪽에 남겨 둔다. + * + * 서버가 검증 실패로 화면을 다시 그릴 때는 템플릿이 미리 .is-open을 붙여 보낸다 — + * 그 경우 여기서는 스크림·포커스만 맞춰 주면 된다. + */ +(() => { + const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]),' + + ' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + + const scrim = document.querySelector('[data-drawer-scrim]'); + const shell = document.querySelector('.shell'); + let openDrawer = null; + let lastTrigger = null; + + const togglesFor = (id) => document.querySelectorAll(`[data-panel-toggle="${id}"]`); + + function close({ restoreFocus = true } = {}) { + if (!openDrawer) return; + const drawer = openDrawer; + openDrawer = null; + drawer.classList.remove('is-open'); + scrim?.classList.remove('is-open'); + document.body.classList.remove('is-drawer-open'); + // 배경을 inert로 만들면 포커스도 클릭도 새지 않는다. 아래 Tab 가두기는 미지원 브라우저용 보완. + shell?.removeAttribute('inert'); + togglesFor(drawer.id).forEach(button => button.setAttribute('aria-expanded', 'false')); + if (restoreFocus) lastTrigger?.focus(); + } + + function open(drawer, trigger) { + if (openDrawer && openDrawer !== drawer) close({ restoreFocus: false }); + openDrawer = drawer; + lastTrigger = trigger ?? null; + drawer.classList.add('is-open'); + scrim?.classList.add('is-open'); + document.body.classList.add('is-drawer-open'); + shell?.setAttribute('inert', ''); + togglesFor(drawer.id).forEach(button => button.setAttribute('aria-expanded', 'true')); + (drawer.querySelector('input:not([type=hidden]):not([disabled]), textarea') || drawer).focus(); + } + + document.querySelectorAll('[data-panel-toggle]').forEach(button => { + button.addEventListener('click', () => { + const drawer = document.getElementById(button.dataset.panelToggle); + if (!drawer) return; + if (drawer.classList.contains('is-open')) close(); else open(drawer, button); + }); + }); + + scrim?.addEventListener('click', () => close()); + + document.addEventListener('keydown', (event) => { + if (!openDrawer) return; + if (event.key === 'Escape') { + close(); + return; + } + if (event.key !== 'Tab') return; + const items = [...openDrawer.querySelectorAll(FOCUSABLE)]; + if (!items.length) return; + const first = items[0]; + const last = items[items.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }); + + // 서버가 열린 채로 내려보낸 드로어(검증 실패)의 나머지 상태를 맞춘다. + const serverOpened = document.querySelector('.drawer.is-open'); + if (serverOpened) open(serverOpened, null); +})(); diff --git a/src/main/resources/templates/product-detail.html b/src/main/resources/templates/product-detail.html new file mode 100644 index 0000000..4ca919e --- /dev/null +++ b/src/main/resources/templates/product-detail.html @@ -0,0 +1,132 @@ + + + + + + 제품 상세 | MySeoulection Admin + + + +
+ +
+
+
+ +

+
+
+ + +
+
+
+
+ +
+
+
+

기본 정보

+

등록 시 어드민이 입력하는 값입니다.

+
+
+
+
제품명
+
브랜드
+
카테고리
+
제품 ID
+
상태
+
+
식약처 기능성 function
+
+
+ 미검수 + 확인함 · 기능성 아님 + +
+
+
+
ASIN asin
+
+
없음
+
가격 price
+
+
수집 전
+
제품 링크 product_url
+
+
수집 전
+
썸네일 thumbnail_url
+
+
수집 전
+
설명 description
+
+
수집 전
+
+
+ +
+
+
+

전성분

+

출처 ingredient_source: + + 미지정 +

+
+ 성분 수정 +
+
+
+ +
+
+
+ 아직 등록된 성분이 없습니다. +
+
+ +
+
+
+

파이프라인 데이터

+

어드민이 입력하지 않는, 분석 파이프라인이 채우는 값입니다.

+
+
+
+
언급 수 mention_count
+
광고 비율 ad_ratio
+
광고 가능성 합 ad_likelihood_sum
+
+
분석 전
+
+
분석 시각 analyzed_at
+
+
분석 전
+
+
+
+
INCI API 원본 inciapi_raw_data
+

+                    

아직 수집된 원본 데이터가 없습니다.

+
+
+ + +
+
+
+

제품 삭제

+

문서가 products 컬렉션에서 완전히 지워집니다. 되돌릴 수 없습니다.

+
+
+ +
+
+
+
+
+
+ + diff --git a/src/main/resources/templates/product-workflow.html b/src/main/resources/templates/product-workflow.html new file mode 100644 index 0000000..06310ed --- /dev/null +++ b/src/main/resources/templates/product-workflow.html @@ -0,0 +1,148 @@ + + + + + + 제품 검수 | MySeoulection Admin + + + +
+ +
+
+
+ +

+
+
+ +
+
+
+
+ + +
    +
  1. 1전성분 확인
  2. +
  3. 2식약처 기능성
  4. +
+ +
+
+
+

+

+
+
+ + +
+
+
+
+ 성분 확인 결과 +
+ + +
+

성분 정보를 어디서도 찾지 못한 경우에만 '찾지 못함'을 고르세요 — NOT_FOUND로 저장됩니다.

+
+
+ + +

성분을 입력하거나, 못 찾았으면 위에서 '찾지 못함'을 고르세요. 빈 채로는 저장되지 않습니다. 저장하면 기능성 확인 단계로 넘어갑니다.

+
+
+
+ +
+ + +
+
+
+ 전성분확인됨 + 성분 수정 +
+
+ +
+

등록된 성분이 없습니다.

+
+
+
+
+
+ 의약품안전나라 조회 결과 +
+ + +
+

둘 중 하나를 반드시 고르세요. 기본값을 두지 않는 이유는, 확인 없이 저장했을 때 '기능성 아님'이 사실로 기록되면 안 되기 때문입니다. 저장하면 어느 쪽이든 상태가 다음 단계로 넘어갑니다.

+
+
+ 확인된 기능성 유형 +
+ + + + + + +
+

기능성 확인을 선택한 경우에만 유형을 선택하세요. 저장하면 상태가 READY_FOR_INCIAPI로 바뀌어 파이프라인으로 넘어갑니다.

+
+
+
+ +
+
+
+
+
+
+ + + diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html index 9e00af9..ce063a0 100644 --- a/src/main/resources/templates/products.html +++ b/src/main/resources/templates/products.html @@ -15,67 +15,202 @@

제품 관리

제품 기본 정보를 등록하면 분석 파이프라인이 나머지 정보를 채웁니다.

+
+ +
-
-

제품 등록

-
-
-
- - -

-
-
- - -

-
-
- 카테고리 -
- - - - - - -
-

+ + +
+ +
+
+
+
+

등록된 제품

+

+ +
- - -
- -
-

등록된 제품

- - - - - - - - - - - - - - -
제품명브랜드카테고리언급 수광고 비율상태
-
+ +
+
+ + + + + + + + + + + + + + + +
제품명브랜드카테고리기능성성분상태검수
+
+ 미검수 + 아님 + +
+
+ + + + 상세 보기 +
+
+ +
- 등록된 제품이 없습니다. + 이 상태의 제품이 없습니다. + + 전체 탭에서 검색 결과 보기 + 전체 제품 보기
+ +
+ + + + From 6c3970788d2d058191f2b7d5fd55a8b75409d85b Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 2 Sep 2026 14:26:45 +0900 Subject: [PATCH 04/11] =?UTF-8?q?ui:=20=EC=84=A4=EB=AC=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SurveyAdminController.java | 4 + src/main/resources/templates/survey.html | 282 ++++++++++-------- 2 files changed, 168 insertions(+), 118 deletions(-) diff --git a/src/main/java/com/seoulection/admin/survey/presentation/controller/SurveyAdminController.java b/src/main/java/com/seoulection/admin/survey/presentation/controller/SurveyAdminController.java index a961b26..4d9c1be 100644 --- a/src/main/java/com/seoulection/admin/survey/presentation/controller/SurveyAdminController.java +++ b/src/main/java/com/seoulection/admin/survey/presentation/controller/SurveyAdminController.java @@ -53,6 +53,7 @@ public String createQuestion(@Valid @ModelAttribute("questionRequest") SurveyQue if (bindingResult.hasErrors()) { model.addAttribute("request", new SurveyOptionCreateRequest()); model.addAttribute("questions", service.getQuestions()); + model.addAttribute("questionFormOpen", true); return "survey"; } @@ -62,6 +63,7 @@ public String createQuestion(@Valid @ModelAttribute("questionRequest") SurveyQue bindingResult.rejectValue("questionKey", "invalid", e.getMessage()); model.addAttribute("request", new SurveyOptionCreateRequest()); model.addAttribute("questions", service.getQuestions()); + model.addAttribute("questionFormOpen", true); return "survey"; } redirectAttributes.addFlashAttribute("successMessage", "문항을 추가했습니다."); @@ -76,6 +78,7 @@ public String createOption(@Valid @ModelAttribute("request") SurveyOptionCreateR if (bindingResult.hasErrors()) { model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); model.addAttribute("questions", service.getQuestions()); + model.addAttribute("optionFormOpen", true); return "survey"; } @@ -87,6 +90,7 @@ public String createOption(@Valid @ModelAttribute("request") SurveyOptionCreateR bindingResult.rejectValue("code", "invalid", e.getMessage()); model.addAttribute("questionRequest", new SurveyQuestionCreateRequest()); model.addAttribute("questions", service.getQuestions()); + model.addAttribute("optionFormOpen", true); return "survey"; } redirectAttributes.addFlashAttribute("successMessage", "선택지를 추가했습니다."); diff --git a/src/main/resources/templates/survey.html b/src/main/resources/templates/survey.html index c3e1aba..93a2f19 100644 --- a/src/main/resources/templates/survey.html +++ b/src/main/resources/templates/survey.html @@ -15,6 +15,12 @@

설문 관리

사용자 설문의 질문 문구와 선택지를 관리합니다. 저장 즉시 사용자 화면에 반영됩니다.

+
+ + +
@@ -25,127 +31,63 @@

설문 관리

선택지의 삭제는 '숨김'으로 동작합니다 — 신규 설문에서만 사라지고 기존 응답은 보존됩니다.

-
-

문항 추가

-
-
-
- - -

-
-
- - -

-
-
- - -

-
-
- -
-
-
-

선택지 추가

-
-
-
- 문항 -
- -
-

-
-
- - -

-
-
- - -

-
-
- - -

-
-
- - -

-
-
- 단독 선택 -
- -
-
-
- -
-
-
-
- - - - 노출 - 숨김 -
-
- - -
- - - - - - - - - - - - -
코드노출 문구점수순서단독상태
-
- - - - - -
-
- 노출 - 숨김 - -
- - -
-
+ +
+
+
+ + + +
+
+ 노출 + 숨김 +
+ + +
+
+
+ +
+ + + + + + + + + + + + +
코드선택지 설정상태관리
+
+ + + + + +
+
+ 노출 + 숨김 + +
+ + +
+
+
@@ -156,5 +98,109 @@

선택지 추가

+ +
+ + + + + From e9244b758f74a628db1620b26330a4b723b9a144 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 2 Sep 2026 14:26:52 +0900 Subject: [PATCH 05/11] =?UTF-8?q?ui:=20=EC=9C=A0=ED=8A=9C=EB=B8=8C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/VideoController.java | 2 + .../controller/YoutuberController.java | 2 + src/main/resources/templates/videos.html | 84 ++++++++++----- src/main/resources/templates/youtubers.html | 100 +++++++++++------- 4 files changed, 125 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/seoulection/admin/youtube/presentation/controller/VideoController.java b/src/main/java/com/seoulection/admin/youtube/presentation/controller/VideoController.java index 5684e1a..63cbf72 100644 --- a/src/main/java/com/seoulection/admin/youtube/presentation/controller/VideoController.java +++ b/src/main/java/com/seoulection/admin/youtube/presentation/controller/VideoController.java @@ -39,6 +39,7 @@ public String register( ) { if (bindingResult.hasErrors()) { model.addAttribute("videos", service.getVideos()); + model.addAttribute("registerFormOpen", true); return "videos"; } @@ -49,6 +50,7 @@ public String register( } catch (YoutubeAdminException e) { bindingResult.rejectValue("url", e.reason().name(), e.reason().userMessage()); model.addAttribute("videos", service.getVideos()); + model.addAttribute("registerFormOpen", true); return "videos"; } } diff --git a/src/main/java/com/seoulection/admin/youtube/presentation/controller/YoutuberController.java b/src/main/java/com/seoulection/admin/youtube/presentation/controller/YoutuberController.java index 218676c..7068a2e 100644 --- a/src/main/java/com/seoulection/admin/youtube/presentation/controller/YoutuberController.java +++ b/src/main/java/com/seoulection/admin/youtube/presentation/controller/YoutuberController.java @@ -39,6 +39,7 @@ public String register( ) { if (bindingResult.hasErrors()) { model.addAttribute("youtubers", service.getYoutubers()); + model.addAttribute("registerFormOpen", true); return "youtubers"; } @@ -49,6 +50,7 @@ public String register( } catch (YoutubeAdminException e) { bindingResult.rejectValue("url", e.reason().name(), e.reason().userMessage()); model.addAttribute("youtubers", service.getYoutubers()); + model.addAttribute("registerFormOpen", true); return "youtubers"; } } diff --git a/src/main/resources/templates/videos.html b/src/main/resources/templates/videos.html index 5669539..30a747a 100644 --- a/src/main/resources/templates/videos.html +++ b/src/main/resources/templates/videos.html @@ -15,38 +15,37 @@

YouTube 영상 관리

파이프라인에서 분석할 YouTube 영상 링크를 등록합니다.

+
+ +
-
-

영상 등록

-
- -
- - -
-

-
-
-
-

등록된 영상

- - - - - - - - - - - -
영상 ID제목유튜버 IDURL상태
+ +
+
+

등록된 영상

+
+
+ + + + + + + + + + + +
영상 ID제목유튜버 IDURL상태
+
@@ -57,5 +56,36 @@

등록된 영상

+ +
+ + + diff --git a/src/main/resources/templates/youtubers.html b/src/main/resources/templates/youtubers.html index fef00d8..39897ba 100644 --- a/src/main/resources/templates/youtubers.html +++ b/src/main/resources/templates/youtubers.html @@ -15,49 +15,41 @@

유튜버 채널 관리

파이프라인이 주기적으로 확인할 YouTube 채널 링크를 등록합니다.

+
+ +
-
-

채널 등록

-
-
-
- - -

-
-
- -
- -
-

-
-
- -
-
-
-

등록된 채널

- - - - - - - - - - -
채널명채널 IDURL마지막 확인
+ +
+
+

등록된 채널

+
+
+ + + + + + + + + + +
채널명채널 IDURL마지막 확인
+ + 파이프라인 확인 전 +
+
- + 등록된 채널이 없습니다.
@@ -65,5 +57,41 @@

등록된 채널

+ +
+ + + From da2f83cb58272f62238b7abc9c7c33483d72d98d Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 2 Sep 2026 14:26:58 +0900 Subject: [PATCH 06/11] =?UTF-8?q?ui:=20=EA=B3=B5=ED=86=B5=20=EB=82=B4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=EA=B3=BC=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=ED=99=94=EB=A9=B4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/fragments/sidebar.html | 6 +- .../templates/skin-score-policy.html | 59 ++++++++++++------- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/main/resources/templates/fragments/sidebar.html b/src/main/resources/templates/fragments/sidebar.html index 196baee..c9ef6cd 100644 --- a/src/main/resources/templates/fragments/sidebar.html +++ b/src/main/resources/templates/fragments/sidebar.html @@ -6,7 +6,7 @@ 메뉴 항목이 늘어나도 이 파일만 고치면 된다. 사용법: - active 값은 home / youtubers / videos / products / survey 중 하나. + active 값은 home / youtubers / videos / products / ingredients / survey 중 하나. -->