Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions src/hooks/__tests__/useAdminAlcohols.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -89,22 +89,24 @@ 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');
expect(getCategoryGroup('버번')).toBe('BOURBON');
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');
Expand All @@ -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',
});
});
});
});
37 changes: 37 additions & 0 deletions src/hooks/useCategoryGroupMap.ts
Original file line number Diff line number Diff line change
@@ -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<string, AlcoholCategory>(
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<AlcoholCategory, 'OTHER'>
): CategoryReference => {
const ref = categoryReferencesByGroup[group][0];
if (!ref) {
throw new Error(`No category reference found for group: ${group}`);
}
return ref;
};

return { getCategoryGroup, getGroupDefaultCategory, categoryReferencesByGroup };
}
7 changes: 7 additions & 0 deletions src/pages/whisky/WhiskyDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -45,6 +46,9 @@ export function WhiskyDetailPage() {
handleDelete,
} = useWhiskyDetailForm(id);

// Toast 알림
const { showToast } = useToast();

// 이미지 업로드 훅
const { upload: uploadImage, isUploading: isImageUploading } = useImageUpload({
rootPath: S3UploadPath.ALCOHOL,
Expand Down Expand Up @@ -94,6 +98,9 @@ export function WhiskyDetailPage() {
(data) => {
onSubmit(data, { tastingTags, relatedKeywords, imagePreviewUrl });
},
() => {
showToast({ type: 'warning', message: '입력 정보를 확인해주세요.' });
},
);

const handleDeleteConfirm = () => {
Expand Down
11 changes: 2 additions & 9 deletions src/pages/whisky/WhiskyList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '전체' },
Expand Down Expand Up @@ -245,14 +245,7 @@ export function WhiskyListPage() {
<TableCell className="text-muted-foreground">
{item.engName}
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span>{item.korCategoryName}</span>
<span className="text-xs text-muted-foreground">
{CATEGORY_GROUP_LABELS[getCategoryGroup(item.korCategoryName)]}
</span>
</div>
</TableCell>
<TableCell>{item.korCategoryName}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(item.modifiedAt).toLocaleDateString('ko-KR')}
</TableCell>
Expand Down
12 changes: 9 additions & 3 deletions src/pages/whisky/components/WhiskyBasicInfoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }));
Expand All @@ -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})` }));
Expand All @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions src/pages/whisky/useWhiskyDetailForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 11 additions & 30 deletions src/types/api/alcohol.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,37 +365,18 @@ export const CATEGORY_GROUP_LABELS: Record<AlcoholCategory, string> = {
export const ALCOHOL_CATEGORIES: AlcoholCategory[] = Object.keys(CATEGORY_GROUP_LABELS) as AlcoholCategory[];

/**
* korCategory → categoryGroup 매핑
* 메인 5개 그룹에 해당하는 카테고리만 명시, 나머지는 모두 OTHER
* 그룹별 카테고리 레퍼런스 (단일 소스)
* 현재는 하드코딩이지만, #221 API 반영 시 서버 응답으로 교체 예정.
* 서버 응답 형태: Record<AlcoholCategory, CategoryReference[]>
*/
const CATEGORY_TO_GROUP_MAP: Record<string, AlcoholCategory> = {
'싱글 몰트': '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<Exclude<AlcoholCategory, 'OTHER'>, { 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<AlcoholCategory, CategoryReference[]> = {
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: [],
};

// ============================================
Expand Down
Loading