diff --git a/src/hooks/__tests__/useAdminAlcohols.test.ts b/src/hooks/__tests__/useAdminAlcohols.test.ts index 3166081..843dbab 100644 --- a/src/hooks/__tests__/useAdminAlcohols.test.ts +++ b/src/hooks/__tests__/useAdminAlcohols.test.ts @@ -5,7 +5,7 @@ import { server } from '@/test/mocks/server'; import { renderHook } from '@/test/test-utils'; import { wrapApiError } from '@/test/mocks/data'; import { useAdminAlcoholList, useCategoryReferences } from '../useAdminAlcohols'; -import { getCategoryGroup } from '@/types/api/alcohol.api'; +import { useCategoryGroupMap } from '../useCategoryGroupMap'; const BASE = '/admin/api/v1/alcohols'; @@ -89,10 +89,13 @@ describe('useAdminAlcohols hooks', () => { }); // ========================================== - // getCategoryGroup (프론트엔드 매핑) + // useCategoryGroupMap (카테고리 그룹 매핑 훅) // ========================================== - describe('getCategoryGroup', () => { + describe('useCategoryGroupMap', () => { it('메인 카테고리를 올바른 그룹으로 매핑한다', () => { + const { result } = renderHook(() => useCategoryGroupMap()); + const { getCategoryGroup } = result.current; + expect(getCategoryGroup('싱글 몰트')).toBe('SINGLE_MALT'); expect(getCategoryGroup('블렌디드')).toBe('BLEND'); expect(getCategoryGroup('블렌디드 몰트')).toBe('BLENDED_MALT'); @@ -100,11 +103,10 @@ describe('useAdminAlcohols hooks', () => { expect(getCategoryGroup('라이')).toBe('RYE'); }); - it('싱글몰트 알코올 변형도 SINGLE_MALT로 매핑한다', () => { - expect(getCategoryGroup('싱글몰트 알코올')).toBe('SINGLE_MALT'); - }); - it('기타 하위 카테고리는 OTHER로 매핑한다', () => { + const { result } = renderHook(() => useCategoryGroupMap()); + const { getCategoryGroup } = result.current; + expect(getCategoryGroup('테네시')).toBe('OTHER'); expect(getCategoryGroup('싱글 그레인')).toBe('OTHER'); expect(getCategoryGroup('싱글 팟 스틸')).toBe('OTHER'); @@ -114,7 +116,22 @@ describe('useAdminAlcohols hooks', () => { }); it('알 수 없는 카테고리도 OTHER로 매핑한다', () => { - expect(getCategoryGroup('새로운카테고리')).toBe('OTHER'); + const { result } = renderHook(() => useCategoryGroupMap()); + expect(result.current.getCategoryGroup('새로운카테고리')).toBe('OTHER'); + }); + + it('그룹의 대표 카테고리를 반환한다', () => { + const { result } = renderHook(() => useCategoryGroupMap()); + const { getGroupDefaultCategory } = result.current; + + expect(getGroupDefaultCategory('SINGLE_MALT')).toEqual({ + korCategory: '싱글 몰트', + engCategory: 'Single Malt', + }); + expect(getGroupDefaultCategory('BLEND')).toEqual({ + korCategory: '블렌디드', + engCategory: 'Blend', + }); }); }); }); diff --git a/src/hooks/useCategoryGroupMap.ts b/src/hooks/useCategoryGroupMap.ts new file mode 100644 index 0000000..5f7f27e --- /dev/null +++ b/src/hooks/useCategoryGroupMap.ts @@ -0,0 +1,37 @@ +/** + * 카테고리 그룹 매핑 훅 + * GROUPED_CATEGORY_REFERENCES(단일 소스)에서 카테고리 ↔ 그룹 매핑을 파생. + * TODO: #221 API 반영 시 내부 데이터 소스를 React Query로 교체 + */ + +import { + GROUPED_CATEGORY_REFERENCES, + type AlcoholCategory, + type CategoryReference, +} from '@/types/api'; + +const categoryToGroupMap = new Map( + Object.entries(GROUPED_CATEGORY_REFERENCES).flatMap(([group, refs]) => + refs.map((ref) => [ref.korCategory, group as AlcoholCategory] as const) + ) +); + +export function useCategoryGroupMap() { + // TODO: #221 반영 시 useGroupedCategoryReferences() React Query 데이터로 교체 + const categoryReferencesByGroup = GROUPED_CATEGORY_REFERENCES; + + const getCategoryGroup = (korCategory: string): AlcoholCategory => + categoryToGroupMap.get(korCategory) ?? 'OTHER'; + + const getGroupDefaultCategory = ( + group: Exclude + ): CategoryReference => { + const ref = categoryReferencesByGroup[group][0]; + if (!ref) { + throw new Error(`No category reference found for group: ${group}`); + } + return ref; + }; + + return { getCategoryGroup, getGroupDefaultCategory, categoryReferencesByGroup }; +} diff --git a/src/pages/whisky/WhiskyDetail.tsx b/src/pages/whisky/WhiskyDetail.tsx index ee4414d..a087758 100644 --- a/src/pages/whisky/WhiskyDetail.tsx +++ b/src/pages/whisky/WhiskyDetail.tsx @@ -23,6 +23,7 @@ import { import { useWhiskyDetailForm } from './useWhiskyDetailForm'; import { useImageUpload, S3UploadPath } from '@/hooks/useImageUpload'; import { useTastingTagList } from '@/hooks/useTastingTags'; +import { useToast } from '@/hooks/useToast'; import type { AlcoholTastingTag } from '@/types/api'; @@ -45,6 +46,9 @@ export function WhiskyDetailPage() { handleDelete, } = useWhiskyDetailForm(id); + // Toast 알림 + const { showToast } = useToast(); + // 이미지 업로드 훅 const { upload: uploadImage, isUploading: isImageUploading } = useImageUpload({ rootPath: S3UploadPath.ALCOHOL, @@ -94,6 +98,9 @@ export function WhiskyDetailPage() { (data) => { onSubmit(data, { tastingTags, relatedKeywords, imagePreviewUrl }); }, + () => { + showToast({ type: 'warning', message: '입력 정보를 확인해주세요.' }); + }, ); const handleDeleteConfirm = () => { diff --git a/src/pages/whisky/WhiskyList.tsx b/src/pages/whisky/WhiskyList.tsx index 15586f6..839017f 100644 --- a/src/pages/whisky/WhiskyList.tsx +++ b/src/pages/whisky/WhiskyList.tsx @@ -31,7 +31,7 @@ import { Badge } from '@/components/ui/badge'; import { Pagination } from '@/components/common/Pagination'; import { useAdminAlcoholList } from '@/hooks/useAdminAlcohols'; import type { AlcoholSearchParams, AlcoholCategory } from '@/types/api'; -import { ALCOHOL_CATEGORIES, CATEGORY_GROUP_LABELS, getCategoryGroup } from '@/types/api'; +import { ALCOHOL_CATEGORIES, CATEGORY_GROUP_LABELS } from '@/types/api'; const CATEGORY_OPTIONS: { value: AlcoholCategory | 'ALL'; label: string }[] = [ { value: 'ALL', label: '전체' }, @@ -245,14 +245,7 @@ export function WhiskyListPage() { {item.engName} - -
- {item.korCategoryName} - - {CATEGORY_GROUP_LABELS[getCategoryGroup(item.korCategoryName)]} - -
-
+ {item.korCategoryName} {new Date(item.modifiedAt).toLocaleDateString('ko-KR')} diff --git a/src/pages/whisky/components/WhiskyBasicInfoCard.tsx b/src/pages/whisky/components/WhiskyBasicInfoCard.tsx index 60b8e74..00230fe 100644 --- a/src/pages/whisky/components/WhiskyBasicInfoCard.tsx +++ b/src/pages/whisky/components/WhiskyBasicInfoCard.tsx @@ -20,7 +20,8 @@ import { SearchableSelect } from '@/components/common/SearchableSelect'; import type { WhiskyFormValues } from '../whisky.schema'; import type { AlcoholCategory, CategoryReference } from '@/types/api'; -import { ALCOHOL_CATEGORIES, CATEGORY_GROUP_LABELS, GROUP_TO_CATEGORY } from '@/types/api'; +import { ALCOHOL_CATEGORIES, CATEGORY_GROUP_LABELS } from '@/types/api'; +import { useCategoryGroupMap } from '@/hooks/useCategoryGroupMap'; /** * WhiskyBasicInfoCard 컴포넌트의 props @@ -46,6 +47,7 @@ export function WhiskyBasicInfoCard({ }: WhiskyBasicInfoCardProps) { const { register, watch, setValue, formState } = form; const { errors } = formState; + const { getGroupDefaultCategory, categoryReferencesByGroup } = useCategoryGroupMap(); const regionOptions = regions.map((region) => ({ value: String(region.id), label: region.korName })); const distilleryOptions = distilleries.map((distillery) => ({ value: String(distillery.id), label: distillery.korName })); @@ -54,7 +56,11 @@ export function WhiskyBasicInfoCard({ const isOtherCategory = currentCategoryGroup === 'OTHER'; // OTHER일 때 기존 서브카테고리 옵션 (CategoryReference API에서 메인 그룹 제외) - const mainKorCategories = new Set(Object.values(GROUP_TO_CATEGORY).map((c) => c.korCategory)); + const mainKorCategories = new Set( + Object.entries(categoryReferencesByGroup) + .filter(([group]) => group !== 'OTHER') + .flatMap(([, refs]) => refs.map((r) => r.korCategory)) + ); const otherCategoryOptions = categories .filter((cat) => !mainKorCategories.has(cat.korCategory)) .map((cat) => ({ value: cat.korCategory, label: `${cat.korCategory} (${cat.engCategory})` })); @@ -63,7 +69,7 @@ export function WhiskyBasicInfoCard({ const handleCategoryGroupChange = (group: AlcoholCategory) => { setValue('categoryGroup', group); if (group !== 'OTHER') { - const mapped = GROUP_TO_CATEGORY[group]; + const mapped = getGroupDefaultCategory(group); setValue('korCategory', mapped.korCategory); setValue('engCategory', mapped.engCategory); } else { diff --git a/src/pages/whisky/useWhiskyDetailForm.ts b/src/pages/whisky/useWhiskyDetailForm.ts index 2ab7ad0..69689ba 100644 --- a/src/pages/whisky/useWhiskyDetailForm.ts +++ b/src/pages/whisky/useWhiskyDetailForm.ts @@ -22,13 +22,15 @@ import { useDistilleryList } from '@/hooks/useDistilleries'; import { whiskyFormSchema } from './whisky.schema'; import type { WhiskyFormValues } from './whisky.schema'; import type { AlcoholCreateRequest, AlcoholUpdateRequest, AlcoholTastingTag, CategoryReference } from '@/types/api'; +import { GROUPED_CATEGORY_REFERENCES } from '@/types/api'; /** 신규 등록용 폼 기본값 */ +const defaultCategory = GROUPED_CATEGORY_REFERENCES.SINGLE_MALT[0]!; const DEFAULT_WHISKY_FORM: WhiskyFormValues = { korName: '', engName: '', - korCategory: '', - engCategory: '', + korCategory: defaultCategory.korCategory, + engCategory: defaultCategory.engCategory, categoryGroup: 'SINGLE_MALT', regionId: 0, distilleryId: 0, diff --git a/src/types/api/alcohol.api.ts b/src/types/api/alcohol.api.ts index ff6459c..46b2a00 100644 --- a/src/types/api/alcohol.api.ts +++ b/src/types/api/alcohol.api.ts @@ -365,37 +365,18 @@ export const CATEGORY_GROUP_LABELS: Record = { export const ALCOHOL_CATEGORIES: AlcoholCategory[] = Object.keys(CATEGORY_GROUP_LABELS) as AlcoholCategory[]; /** - * korCategory → categoryGroup 매핑 - * 메인 5개 그룹에 해당하는 카테고리만 명시, 나머지는 모두 OTHER + * 그룹별 카테고리 레퍼런스 (단일 소스) + * 현재는 하드코딩이지만, #221 API 반영 시 서버 응답으로 교체 예정. + * 서버 응답 형태: Record */ -const CATEGORY_TO_GROUP_MAP: Record = { - '싱글 몰트': 'SINGLE_MALT', - '싱글몰트 알코올': 'SINGLE_MALT', - '블렌디드': 'BLEND', - '블렌디드 몰트': 'BLENDED_MALT', - '버번': 'BOURBON', - '라이': 'RYE', -}; - -/** - * korCategory 문자열로부터 categoryGroup을 결정한다. - * CategoryReference API가 categoryGroup을 내려주지 않으므로 프론트에서 매핑. - * 메인 5개 그룹에 해당하지 않으면 OTHER로 분류. - */ -export function getCategoryGroup(korCategory: string): AlcoholCategory { - return CATEGORY_TO_GROUP_MAP[korCategory] ?? 'OTHER'; -} - -/** - * categoryGroup → 고정 카테고리 이름 매핑 (메인 5개 그룹용) - * OTHER는 자유 입력이므로 포함하지 않음 - */ -export const GROUP_TO_CATEGORY: Record, { korCategory: string; engCategory: string }> = { - SINGLE_MALT: { korCategory: '싱글 몰트', engCategory: 'Single Malt' }, - BLEND: { korCategory: '블렌디드', engCategory: 'Blend' }, - BLENDED_MALT: { korCategory: '블렌디드 몰트', engCategory: 'Blended Malt' }, - BOURBON: { korCategory: '버번', engCategory: 'Bourbon' }, - RYE: { korCategory: '라이', engCategory: 'Rye' }, +// TODO: #221 API 반영 시 서버 응답으로 교체 +export const GROUPED_CATEGORY_REFERENCES: Record = { + SINGLE_MALT: [{ korCategory: '싱글 몰트', engCategory: 'Single Malt' }], + BLEND: [{ korCategory: '블렌디드', engCategory: 'Blend' }], + BLENDED_MALT: [{ korCategory: '블렌디드 몰트', engCategory: 'Blended Malt' }], + BOURBON: [{ korCategory: '버번', engCategory: 'Bourbon' }], + RYE: [{ korCategory: '라이', engCategory: 'Rye' }], + OTHER: [], }; // ============================================