Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d3db911
feat: connect dashboard stats to APIs and add navigation
hyejj19 Feb 18, 2026
bb593ab
refactor: 배너 목록 UI를 큐레이션 목록과 일치시키고 StatusToggle 컴포넌트 추출
hyejj19 Feb 18, 2026
5b23d48
refactor: 드래그 앤 드롭 순서 변경 로직을 useReorderDrag 훅으로 캡슐화
hyejj19 Feb 18, 2026
770a231
test: useReorderDrag 훅 테스트 코드 추가
hyejj19 Feb 18, 2026
03d2175
fix: 큐레이션 목록 테이블 컬럼 너비를 배너 테이블과 일치시킴
hyejj19 Feb 18, 2026
01160c3
chore: .gitignore에 .omc/ 및 git.environment-variables 추가
hyejj19 Feb 18, 2026
862aa94
chore: GitHub Copilot 코드 리뷰 instruction 파일 추가
hyejj19 Feb 18, 2026
d5ed087
fix: 배너 목록 로딩/빈결과 행의 colSpan을 isReorderMode에 따라 분기
hyejj19 Feb 18, 2026
d7aef4c
fix: StatCard href가 있을 때 div 대신 Link 컴포넌트 사용
hyejj19 Feb 18, 2026
dce1ac3
[feat] 홈 대시보드 API 연동 및 배너/큐레이션 목록 UI 개선
hyejj19 Feb 18, 2026
861ebea
fix: add submodule gitlink and remove gitignore entry for git.environ…
hyejj19 Feb 18, 2026
9f22e0a
[fix] git.environment-variables 서브모듈 gitlink 누락 및 .gitignore 오등록 수정
hyejj19 Feb 18, 2026
14a1d10
[feat] 위스키 목록 삭제 데이터 필터링 및 상세 읽기전용 모드
hyejj19 Feb 23, 2026
2ba8dc3
fix: remove debug console.log statements from WhiskyDetail
hyejj19 Feb 23, 2026
11003a5
fix: guard ImageUpload onImageChange with noop when disabled
hyejj19 Feb 23, 2026
e76d501
Merge pull request #28 from bottle-note/feature/region-crud
hyejj19 Feb 23, 2026
f7feb58
Bump version from 1.0.3 to 1.1.3
hyejj19 Feb 23, 2026
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
149 changes: 149 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Code Review Instructions

You are reviewing a **Vite + React 18 + TypeScript + TanStack Query v5 + Zustand** admin dashboard project.
The app manages whisky tasting platform data (배너, 큐레이션, 위스키, 테이스팅 태그 등).

## Language

- Write all review comments in Korean
- Do not translate technical terms (null check, race condition, stale closure, query key, mutation, refetch 등)

## Review Categories

### 1. Runtime Errors (앱을 중단시키거나 깨뜨리는 버그)

- API 응답 데이터의 null/undefined 접근 처리 누락
- useEffect cleanup 누락으로 인한 메모리 누수 (setTimeout, event listener 등)
- useCallback/useEffect 내 stale closure (outdated state 참조)
- 무한 리렌더 루프 (render 내 setState, 의존성 배열 오류)
- `useSearchParams` 값을 Number 변환 시 NaN 처리 누락

### 2. Data Integrity (잘못된 데이터 표시 또는 유실)

- 잘못된 query key로 인한 stale/incorrect 캐시 데이터
- mutation 후 `queryClient.invalidateQueries` 또는 `refetch` 누락
- TanStack Query 훅의 `data`를 초기값 없이 직접 구조분해 (undefined 가능성)
- URL 파라미터 기반 상태에서 기본값 처리 누락 (`Number(null)` = 0 등 의도치 않은 변환)
- 페이지네이션 `page` 파라미터가 0-based임에도 1-based로 연산

### 3. Auth & Security

- 인증 필요 API 호출 시 auth 토큰 누락
- 사용자 입력을 `dangerouslySetInnerHTML`로 렌더링 (sanitization 없이)
- 토큰/userId 등 민감 정보가 URL 파라미터나 `console.log`에 노출

### 4. Convention Violations (미래 버그를 유발하는 프로젝트 규칙 위반)

**Import & Module:**

- 배럴 파일 import 사용 (`from '@/hooks'` 대신 `from '@/hooks/useBanners'` 직접 import)
- 컴포넌트에서 API를 직접 호출 (fetch/axios) — 반드시 서비스 레이어 경유
- 3계층 API 패턴 순서 위반: `types/api/*.api.ts` → `services/*.service.ts` → `hooks/use*.ts`
- 컴포넌트가 service를 직접 import하거나 types/api를 건너뛰는 경우

**State Management:**

- 서버 데이터(API 응답)를 Zustand에 저장 — TanStack Query로 관리해야 함
- 검색/필터/페이지네이션 상태를 useState로 관리 — URL 파라미터(`useSearchParams`)로 관리해야 함
- Zustand store에서 auth, sidebar 외 도메인 데이터 저장

**Form:**

- React Hook Form + Zod 없이 수동 `onChange` + `useState`로 폼 처리
- Zod 스키마 없이 수동 유효성 검사
- `FormField` 컴포넌트 미사용으로 에러 메시지 표시 방식 불일치

**File Placement:**

- 테스트 파일이 `__tests__/` 서브디렉토리 외 위치에 작성됨
- Hook 테스트가 `hooks/__tests__/` 아닌 다른 위치에 작성됨
- 새 `index.ts` 배럴 파일 생성 (CLAUDE.md 명시 금지)

**Other:**

- `console.log` 사용 (디버그 목적으로 남겨진 경우)
- `src/components/ui/` 내 shadcn/ui 파일 직접 수정

## Severity

모든 코멘트에 다음 중 하나의 접두사를 붙이세요:

- **[P0]** 앱 크래시, 데이터 유실, 보안 취약점, 인증 우회
- **[P1]** 일반 사용자 플로우에서 오작동, 잘못된 데이터 표시, 네비게이션 오류
- **[P2]** 엣지 케이스 버그, 유효성 검사 누락, 유지보수성을 해치는 컨벤션 위반
- **[nit]** 경미한 컨벤션 이탈, 블로킹 아님

### 코멘트 기준: P0, P1, P2만 작성.

- P0/P1: 항상 코멘트. 수정이 명확한 경우 `suggestion` 블록 포함.
- P2: `suggestion` 없이 코멘트. 어떤 규칙을 위반했는지 한 문장으로 설명.
- [nit]: 코멘트하지 않음. 완전히 스킵.

### P1 기준 (하나 이상 해당해야 함):

- 일반 사용자 플로우에서 버그가 발생
- 잘못된 데이터가 표시되거나 제출됨
- 네비게이션 오류 (빈 페이지, 무한 리디렉션)
- 관리자 권한 체크 누락으로 접근 제어 실패
- 장시간 유지되는 페이지에서 메모리 누수

### P1 아닌 경우 (플래그 달지 말 것):

- "서버가 예상치 못한 형태를 반환할 수도 있음" (API 계약은 서버 책임)
- 실제 버그 없는 useEffect 의존성 배열 lint 경고
- 로딩/에러 UI 누락 (UX 문제, 버그 아님)
- 사용자 체감 영향 없는 성능 최적화

## 리뷰하지 않는 항목 (완전히 무시):

- 코드 스타일, 포맷, 들여쓰기 (Prettier가 처리)
- import 순서 (ESLint가 처리)
- 네이밍 컨벤션 (진짜로 혼란스러운 경우 제외)
- 문서, 주석, README
- 테스트 파일 (테스트 로직이 틀린 경우 제외)
- 설정 파일 (빌드/런타임 오류 유발 아닌 경우)
- 리팩토링 제안
- "있으면 좋은" 개선사항
- 사용자 체감 없는 성능 최적화

## 코멘트 형식

```
[P{n}] {한 줄 요약: 무엇이 문제인지}

{재현 조건 또는 영향 범위 (1문장)}
```

- 최대 1단락. 여러 단락 설명 금지.
- P0/P1이고 수정이 명확한 경우에만 `suggestion` 블록 포함.
- Suggestion 코드는 5줄 이하.
- Suggestion 블록에서 아키텍처 변경 제안 금지.

## Examples

**올바른 P1 코멘트:**

[P1] `page` 파라미터가 없을 때 `Number(null)`이 `0`이 되어 올바른 기본값처럼 보이지만, `NaN` 케이스가 별도로 처리되지 않습니다. 필터 파라미터 변경 시 페이지가 의도치 않은 값으로 요청됩니다.

```suggestion
const page = Number(urlParams.get('page')) || 0;
```

**올바른 P2 코멘트:**

[P2] 배럴 파일 import 사용. `@/hooks/useBanners`에서 직접 import 해주세요.

**나쁜 코멘트 (이렇게 쓰지 말 것):**

~~[P1] 이 코드는 개선이 필요합니다. useEffect cleanup이 없는데 React best practice에 위배됩니다. cleanup을 추가하면 메모리 누수를 방지할 수 있고...~~

(너무 길고, 재현 조건 없음. "Best practice"는 버그가 아님.)

## PR Overview Guidelines

- 최대 3문장
- 구성:
- 변경 요약: 무엇이 왜 바뀌었는지 (1문장)
- 우려 사항: 발견된 P0/P1, 없으면 "특이사항 없음" (1~2문장)
- 인라인 코멘트에서 이미 언급한 내용 반복 금지
- 칭찬 생략 ("좋은 PR", "잘 작성" 등 금지)
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ CLAUDE.personal.md
.secrets
.claude/

# OMC (oh-my-claudecode) internal state
.omc/


# Playwright
e2e/.auth/
e2e/reports/
Expand Down
4 changes: 0 additions & 4 deletions .omc/continuation-count.json

This file was deleted.

2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.3
1.1.3
2 changes: 1 addition & 1 deletion git.environment-variables
40 changes: 40 additions & 0 deletions src/components/common/StatusToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* 활성/비활성 상태 토글 컴포넌트
* Switch와 Badge를 조합하여 인라인 상태 변경 UI 제공
*/

import { Check, X } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';

interface StatusToggleProps {
isActive: boolean;
onToggle: () => void;
disabled?: boolean;
}

export function StatusToggle({ isActive, onToggle, disabled }: StatusToggleProps) {
return (
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Switch
checked={isActive}
onCheckedChange={onToggle}
disabled={disabled}
/>
{isActive ? (
<Badge variant="default" className="whitespace-nowrap bg-green-500">
<Check className="mr-1 h-3 w-3" />
활성
</Badge>
) : (
<Badge variant="secondary" className="whitespace-nowrap">
<X className="mr-1 h-3 w-3" />
비활성
</Badge>
)}
</div>
);
}
75 changes: 75 additions & 0 deletions src/hooks/__tests__/useAdminAlcohols.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
import { renderHook } from '@/test/test-utils';
import { wrapApiError } from '@/test/mocks/data';
import { useAdminAlcoholList } from '../useAdminAlcohols';

const BASE = '/admin/api/v1/alcohols';

describe('useAdminAlcohols hooks', () => {
// ==========================================
// useAdminAlcoholList
// ==========================================
describe('useAdminAlcoholList', () => {
it('목록 데이터를 반환한다 (삭제 데이터 제외)', async () => {
const { result } = renderHook(() => useAdminAlcoholList());

await waitFor(() => expect(result.current.isSuccess).toBe(true));

// 기본 조회: 삭제된 위스키(ID 30)는 제외
expect(result.current.data!.items.length).toBe(2);
expect(result.current.data!.items.every((item) => item.deletedAt === null)).toBe(true);
});

it('includeDeleted=true 시 삭제된 데이터도 포함한다', async () => {
const { result } = renderHook(() =>
useAdminAlcoholList({ includeDeleted: true })
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

// 삭제 포함: 3개 모두 반환
expect(result.current.data!.items.length).toBe(3);
const deletedItem = result.current.data!.items.find((item) => item.alcoholId === 30);
expect(deletedItem).toBeDefined();
expect(deletedItem!.deletedAt).not.toBeNull();
});

it('keyword로 필터링한다', async () => {
const { result } = renderHook(() =>
useAdminAlcoholList({ keyword: '글렌피딕' })
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data!.items).toHaveLength(1);
expect(result.current.data!.items[0]!.korName).toBe('글렌피딕 12년');
});

it('API 에러 시 에러 상태가 된다', async () => {
server.use(
http.get(BASE, () => {
return HttpResponse.json(wrapApiError(500, 'SERVER_ERROR', '서버 오류'), {
status: 500,
});
})
);

const { result } = renderHook(() => useAdminAlcoholList());

await waitFor(() => expect(result.current.isError).toBe(true));
});

it('페이지네이션 메타 정보를 반환한다', async () => {
const { result } = renderHook(() => useAdminAlcoholList({ page: 0, size: 10 }));

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data!.meta).toBeDefined();
expect(result.current.data!.meta.page).toBe(0);
expect(result.current.data!.meta.totalElements).toBeGreaterThan(0);
});
});
});
Loading