diff --git a/.agents/skills/bottlenote-admin-api/SKILL.md b/.agents/skills/bottlenote-admin-api/SKILL.md new file mode 100644 index 0000000..0027285 --- /dev/null +++ b/.agents/skills/bottlenote-admin-api/SKILL.md @@ -0,0 +1,119 @@ +--- +name: bottlenote-admin-api +description: > + Check Bottlenote admin API documentation and apply frontend API contract + updates for this dashboard. Use when Codex needs to inspect admin API specs, + compare docs with code, add or update endpoints, fix request/response type + mismatches, or support feature spec/plan work with API field analysis. + Trigger on requests mentioning Bottlenote API, admin API docs, endpoint + updates, response shape changes, or code/API inconsistencies. +--- + +# Bottlenote Admin API Contract + +Use this skill to inspect Bottlenote admin API documentation and keep frontend +API contracts aligned with the dashboard code. + +## Workflow + +1. Confirm the target domain and operation. + - If the user names a feature or bug, identify the relevant domain files in + `src/types/api`, `src/services`, and `src/hooks`. + - If the user asks for a broad API audit, scan all API domains and report + changes by domain before editing. + +2. Check the API source. + - Treat the published admin API documentation as the source of truth when the + user says the API was deployed, the spec changed, or a backend response + shape is available. + - Prefer the latest published API documentation: + `https://bottle-note.github.io/bottle-note-api-server/bottle-note/admin-api/admin-api.html` + - If the request specifically mentions dev, development, or behavior that is + missing from the published docs, check the dev API server from + `.env.local` (`VITE_API_BASE_URL`) before falling back to the local + snapshot. + - If dev documentation paths return 403/404 but API endpoints respond, use + non-destructive endpoint probing to confirm the contract: + - Authenticate with available local dev credentials when present. + - Use `OPTIONS` to discover allowed methods. + - Use safe `GET` requests for list/detail shapes. + - Use malformed or empty-body mutation requests to discover validation, + required fields, duplicate errors, and route existence. + - For delete/update checks, prefer impossible IDs or invalid payloads. Do + not create, update, or delete real records just to inspect a contract + unless the user explicitly approves it. + - Clearly label findings as "dev API behavior" when they come from probing + instead of a published document. + - If published docs and dev API behavior differ, report the difference and + prefer the source the user asked for. For feature docs targeting imminent + dev work, record dev API behavior and note that published docs may be + stale. + - If neither live docs nor dev API behavior can be checked, use + `references/api-spec.md` as the local snapshot and explicitly say it may be + stale. + - When live docs differ from `references/api-spec.md`, update the snapshot + only when the user explicitly wants the repo to record the new API + contract snapshot. + +3. Compare documentation with code. + - `src/types/api/*.api.ts`: endpoint constants and request/response types. + - `src/services/*.service.ts`: service functions, endpoint interpolation, + query keys, response normalization. + - `src/hooks/use*.ts`: TanStack Query hooks, mutation variables, cache + invalidation, Korean toast messages. + +4. Produce a concise diff report when the scope is not already obvious. + - Missing endpoint: method and path exist in docs but not code. + - Missing field: request/response field exists in docs but not types. + - Changed field: type, requiredness, or name differs. + - Removed endpoint: code has an endpoint that no longer exists in docs. + - UI implication: list/detail/form/filter behavior needed by the frontend. + +5. Apply changes in the project order. + - Update `src/types/api/{domain}.api.ts`. + - Update `src/types/api/index.ts` only when an existing export pattern + requires it. + - Update `src/services/{domain}.service.ts`. + - Update `src/hooks/use{Domain}.ts`. + - Add or update focused tests when behavior or contracts changed. + +6. Record API findings in the feature docs when this supports feature work. + - Put user-facing requirements and field meanings in + `docs/features//spec.md`. + - Put concrete frontend type/service/hook mapping in + `docs/features//plan.md`. + - Do not create a standalone API report unless the user explicitly asks for a + broad API audit. + +7. Verify with the narrowest useful commands. + - Prefer targeted `pnpm test:run ...` when supported. + - Run `pnpm lint` or `pnpm test:run` when changes touch shared contracts or + multiple domains. + +## Output Contract + +For API contract work, leave the user with: + +- API source used: published docs, dev API behavior, or local snapshot. +- Contract changes found: endpoint and field-level summary. +- Code changes applied: types, services, hooks, tests. +- Feature docs updated, when applicable. +- Verification result: exact commands run and pass/fail status. +- Follow-up needed: missing backend docs, ambiguous fields, or manual UI checks. + +## References + +- `references/api-spec.md`: Local snapshot of the admin API documentation. Use + only as fallback or for quick orientation when live docs are not needed. +- `references/code-patterns.md`: Dashboard API layer examples and conventions. + +## Rules + +- Keep API types in `src/types/api`; do not duplicate them in components. +- Follow the three-layer order: types -> service -> hook. +- Do not edit `src/components/ui`. +- Do not create new barrel files unless the repo already requires one at that + boundary. +- Treat pagination as 0-based. +- Preserve TanStack Query ownership of server data. +- Use Korean toast and validation messages for admin-facing UI. diff --git a/.agents/skills/bottlenote-admin-api/agents/openai.yaml b/.agents/skills/bottlenote-admin-api/agents/openai.yaml new file mode 100644 index 0000000..abe9b96 --- /dev/null +++ b/.agents/skills/bottlenote-admin-api/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bottlenote Admin API" + short_description: "Sync Bottlenote API contracts" + default_prompt: "Use $bottlenote-admin-api to compare the admin API docs with this dashboard code." diff --git a/.agents/skills/bottlenote-admin-api/references/api-spec.md b/.agents/skills/bottlenote-admin-api/references/api-spec.md new file mode 100644 index 0000000..fa6e80d --- /dev/null +++ b/.agents/skills/bottlenote-admin-api/references/api-spec.md @@ -0,0 +1,181 @@ +# Bottlenote Admin API Specification + +Source: https://bottle-note.github.io/bottle-note-api-server/bottle-note/admin-api/admin-api.html + +## Common Response Wrapper + +All responses follow this structure: +```json +{ "success": boolean, "code": string, "data": T, "errors": [], "meta": {} } +``` + +Paginated responses include `meta`: +```json +{ "page": number, "size": number, "totalElements": number, "totalPages": number, "hasNext": boolean } +``` + +Mutation responses (create/update/delete) return: +```json +{ "code": string, "message": string, "targetId": number, "responseAt": string } +``` + +All endpoints except login require `Authorization: Bearer `. + +--- + +## 1. Auth API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| POST | `/admin/api/v1/auth/login` | `{ email, password }` | `{ accessToken, refreshToken }` | +| POST | `/admin/api/v1/auth/refresh` | `{ refreshToken }` | `{ accessToken, refreshToken }` | +| POST | `/admin/api/v1/auth/signup` | `{ email, password, name, roles[] }` | `{ adminId, email, name, roles[] }` | +| DELETE | `/admin/api/v1/auth/withdraw` | - | `{ message }` | + +## 2. Alcohol API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| GET | `/admin/api/v1/alcohols` | Query: `keyword`, `category`, `regionId`, `sortType`, `sortOrder`, `page`, `size`, `includeDeleted` | Paginated `AlcoholListItem[]` | +| GET | `/admin/api/v1/alcohols/{alcoholId}` | - | `AlcoholDetail` (includes `tastingTags[]`) | +| POST | `/admin/api/v1/alcohols` | See create request below | Mutation response | +| PUT | `/admin/api/v1/alcohols/{alcoholId}` | Same as create | Mutation response | +| DELETE | `/admin/api/v1/alcohols/{alcoholId}` | - | Mutation response | +| GET | `/admin/api/v1/alcohols/categories/reference` | - | `CategoryReference[]` | + +### Alcohol Create/Update Request +```typescript +{ + korName: string; + engName: string; + abv: string; // e.g. "40%" + type: AlcoholType; // WHISKY | RUM | VODKA | GIN | TEQUILA | BRANDY | BEER | WINE | ETC + korCategory: string; + engCategory: string; + categoryGroup: AlcoholCategory; // SINGLE_MALT | BLEND | BLENDED_MALT | BOURBON | RYE | OTHER + regionId: number; + distilleryId: number; + age: string; + cask: string; + imageUrl: string; + description: string; + volume: string; + tastingTagIds: number[]; // IMPORTANT: tasting tag IDs to connect +} +``` + +### Alcohol Detail Response +```typescript +{ + alcoholId: number; + korName: string; + engName: string; + imageUrl: string | null; + type: string; + korCategory: string; + engCategory: string; + categoryGroup: AlcoholCategory; + abv: string | null; + age: string | null; + cask: string | null; + volume: string | null; + description: string | null; + regionId: number | null; + korRegion: string | null; + engRegion: string | null; + distilleryId: number | null; + korDistillery: string | null; + engDistillery: string | null; + tastingTags: { id: number; korName: string; engName: string; }[]; + avgRating: number; + totalRatingsCount: number; + reviewCount: number; + pickCount: number; + createdAt: string; + modifiedAt: string; + deletedAt: string | null; +} +``` + +## 3. Tasting Tag API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| GET | `/admin/api/v1/tasting-tags` | Query: `keyword`, `page`, `size`, `sortOrder` | Paginated `TastingTagListItem[]` | +| GET | `/admin/api/v1/tasting-tags/{tagId}` | - | `{ tag: TastingTag, alcohols: TastingTagAlcohol[] }` | +| POST | `/admin/api/v1/tasting-tags` | `{ korName, engName, icon?, description?, parentId? }` | Mutation response | +| PUT | `/admin/api/v1/tasting-tags/{tagId}` | Same as create | Mutation response | +| DELETE | `/admin/api/v1/tasting-tags/{tagId}` | - | Mutation response | +| POST | `/admin/api/v1/tasting-tags/{tagId}/alcohols` | `{ alcoholIds: number[] }` | Mutation response | +| DELETE | `/admin/api/v1/tasting-tags/{tagId}/alcohols` | `{ alcoholIds: number[] }` | Mutation response | + +## 4. Help (Inquiry) API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| GET | `/admin/api/v1/helps` | Query: `status`, `type`, `cursor`, `pageSize` | Cursor-paginated help list | +| GET | `/admin/api/v1/helps/{helpId}` | - | Help detail with images | +| POST | `/admin/api/v1/helps/{helpId}/answer` | `{ responseContent, status }` | `{ helpId, status, message }` | + +## 5. File (S3) API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| GET | `/admin/api/v1/s3/presign-url` | Query: `rootPath`, `uploadSize` | `{ bucketName, expiryTime, imageUploadInfo[] }` | + +## 6. Region API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| GET | `/admin/api/v1/regions` | Query: `keyword`, `page`, `size`, `sortOrder` | Paginated region array | + +## 7. Distillery API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| GET | `/admin/api/v1/distilleries` | Query: `keyword`, `page`, `size`, `sortOrder` | Paginated distillery array | + +## 8. Curation API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| GET | `/admin/api/v1/curations` | Query: `keyword`, `isActive`, `page`, `size` | Paginated curation array | +| GET | `/admin/api/v1/curations/{curationId}` | - | Curation with alcohols | +| POST | `/admin/api/v1/curations` | `{ name, description, coverImageUrl, displayOrder, alcoholIds[] }` | Mutation response | +| PUT | `/admin/api/v1/curations/{curationId}` | `{ name, description, coverImageUrl, displayOrder, isActive, alcoholIds[] }` | Mutation response | +| DELETE | `/admin/api/v1/curations/{curationId}` | - | Mutation response | +| PATCH | `/admin/api/v1/curations/{curationId}/status` | `{ isActive }` | Mutation response | +| PATCH | `/admin/api/v1/curations/{curationId}/display-order` | `{ displayOrder }` | Mutation response | +| POST | `/admin/api/v1/curations/{curationId}/alcohols` | `{ alcoholIds[] }` | Mutation response | +| DELETE | `/admin/api/v1/curations/{curationId}/alcohols/{alcoholId}` | - | Mutation response | + +## 9. Banner API + +| Method | Path | Request | Response | +|--------|------|---------|----------| +| GET | `/admin/api/v1/banners` | Query: `keyword`, `isActive`, `bannerType`, `page`, `size` | Paginated banner array | +| GET | `/admin/api/v1/banners/{bannerId}` | - | Banner detail | +| POST | `/admin/api/v1/banners` | See banner create request below | Mutation response | +| PUT | `/admin/api/v1/banners/{bannerId}` | Same as create + `isActive` | Mutation response | +| DELETE | `/admin/api/v1/banners/{bannerId}` | - | Mutation response | +| PATCH | `/admin/api/v1/banners/{bannerId}/status` | `{ isActive }` | Mutation response | +| PATCH | `/admin/api/v1/banners/{bannerId}/sort-order` | `{ sortOrder }` | Mutation response | + +### Banner Create Request +```typescript +{ + name: string; + nameFontColor: string; + descriptionA: string; + descriptionB: string; + descriptionFontColor: string; + imageUrl: string; + textPosition: string; + isExternalUrl: boolean; + targetUrl: string; + bannerType: string; + sortOrder: number; + startDate: string; + endDate: string; +} +``` diff --git a/.agents/skills/bottlenote-admin-api/references/code-patterns.md b/.agents/skills/bottlenote-admin-api/references/code-patterns.md new file mode 100644 index 0000000..3a3bf08 --- /dev/null +++ b/.agents/skills/bottlenote-admin-api/references/code-patterns.md @@ -0,0 +1,101 @@ +# Bottlenote Admin Dashboard - Code Patterns + +## 3-Layer API Pattern + +New API: **types -> service -> hook** + +### Layer 1: Types (`src/types/api/{domain}.api.ts`) + +```typescript +// 1. API endpoint constants +export const ExampleApi = { + list: { endpoint: '/admin/api/v1/examples', method: 'GET' }, + detail: { endpoint: '/admin/api/v1/examples/:id', method: 'GET' }, + create: { endpoint: '/admin/api/v1/examples', method: 'POST' }, + update: { endpoint: '/admin/api/v1/examples/:id', method: 'PUT' }, + delete: { endpoint: '/admin/api/v1/examples/:id', method: 'DELETE' }, +} as const; + +// 2. API types interface (namespaced) +export interface ExampleApiTypes { + list: { + params: { keyword?: string; page?: number; size?: number; }; + response: { id: number; korName: string; }; + meta: { page: number; size: number; totalElements: number; totalPages: number; hasNext: boolean; }; + }; + detail: { response: { /* fields */ }; }; + create: { + request: { /* fields */ }; + response: { code: string; message: string; targetId: number; responseAt: string; }; + }; + update: { + request: { /* fields */ }; + response: { code: string; message: string; targetId: number; responseAt: string; }; + }; + delete: { + response: { code: string; message: string; targetId: number; responseAt: string; }; + }; +} + +// 3. Helper types +export type ExampleSearchParams = ExampleApiTypes['list']['params']; +export type ExampleListItem = ExampleApiTypes['list']['response']; +export type ExamplePageMeta = ExampleApiTypes['list']['meta']; +export type ExampleDetail = ExampleApiTypes['detail']['response']; +export type ExampleCreateRequest = ExampleApiTypes['create']['request']; +// ... etc +``` + +### Layer 2: Service (`src/services/{domain}.service.ts`) + +```typescript +import { apiClient } from '@/lib/api-client'; +import { createQueryKeys } from '@/hooks/useApiQuery'; + +export const exampleKeys = createQueryKeys('examples'); + +export interface ExampleListResponse { + items: ExampleListItem[]; + meta: ExamplePageMeta; +} + +export const exampleService = { + list: async (params?) => { + const response = await apiClient.getWithMeta( + ExampleApi.list.endpoint, { params } + ); + return { items: response.data ?? [], meta: { /* map fields */ } }; + }, + detail: async (id: number) => { + const endpoint = ExampleApi.detail.endpoint.replace(':id', String(id)); + return apiClient.get(endpoint); + }, + create: async (data) => apiClient.post(ExampleApi.create.endpoint, data), + update: async (id, data) => { + const endpoint = ExampleApi.update.endpoint.replace(':id', String(id)); + return apiClient.put(endpoint, data); + }, + delete: async (id) => { + const endpoint = ExampleApi.delete.endpoint.replace(':id', String(id)); + return apiClient.delete(endpoint); + }, +}; +``` + +### Layer 3: Hook (`src/hooks/use{Domain}.ts`) + +```typescript +// Query hooks: useApiQuery(key, fn, options) +// Mutation hooks: useApiMutation(fn, { successMessage, onSuccess: invalidateQueries }) +// Update variables: interface { id: number; data: UpdateRequest } +// Delete: mutationFn takes number (id) +``` + +## Key Conventions + +- Pagination: 0-based +- URL params: `:param` pattern, replaced with `String(value)` +- Mutations always invalidate query caches +- Toast messages in Korean +- Types re-exported from `src/types/api/index.ts` +- DO NOT modify `src/components/ui/` diff --git a/.agents/skills/bottlenote-admin-feature-builder/SKILL.md b/.agents/skills/bottlenote-admin-feature-builder/SKILL.md new file mode 100644 index 0000000..0f0c28e --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-builder/SKILL.md @@ -0,0 +1,58 @@ +--- +name: bottlenote-admin-feature-builder +description: > + Route Bottlenote admin dashboard feature, bug, API, implementation, + verification, and PR requests to the correct project-local skill workflow. + Use when the user gives a vague or multi-stage admin request and Codex needs + to decide whether to create a spec, design, implementation plan, implement + from an existing plan, inspect API docs, or prepare a Korean PR. Trigger on + "기능 개발", "버그 수정", "구체화", "어드민에 추가", "구현해줘", + "PR 만들어줘", or similar admin workflow requests. +--- + +# Bottlenote Admin Feature Router + +Use this skill only to choose the correct project-local workflow. Do not use it +to create feature docs or edit code directly. + +## Workflow + +1. Read `AGENTS.md` and classify the request. + - New feature or page without docs: use + `bottlenote-admin-feature-spec`. + - Feature with `spec.md` but no `design.md`: use + `bottlenote-admin-feature-design`. + - Feature with `spec.md` and `design.md` but no `plan.md`: use + `bottlenote-admin-feature-plan`. + - Feature with a ready `plan.md`: use + `bottlenote-admin-feature-implement`. + - API docs, response shape, endpoint, or contract mismatch: use + `bottlenote-admin-api` as a supporting skill. + - Completed work that needs a Korean PR: use + `bottlenote-admin-korean-pr`. + +2. Preserve the stage order for new feature work. + - Spec first: `docs/features//spec.md`. + - Design second: `docs/features//design.md`. + - Plan third: `docs/features//plan.md`. + - Implementation last: code changes based on `plan.md`. + +3. Keep outputs in repo docs by default. + - Use `.context/` only for temporary scratch notes or handoff details that + should not be committed. + - Do not create feature briefs in `.context`. + +## Routing Examples + +- "어드민에 공지사항 관리 추가해줘" -> spec -> design -> plan -> implement. +- "이 `docs/features/notices/plan.md`대로 구현해줘" -> implement. +- "API 문서가 바뀌었는데 타입 맞춰줘" -> admin-api. +- "작업 끝났으니 PR 만들어줘" -> korean-pr. + +## Guardrails + +- Do not skip directly to implementation when feature docs are missing unless + the user explicitly asks for a fast one-shot change. +- If multiple stage skills apply, use the earliest missing stage first. +- Keep API analysis inside the relevant feature docs unless the user explicitly + asks for a broad API audit. diff --git a/.agents/skills/bottlenote-admin-feature-builder/agents/openai.yaml b/.agents/skills/bottlenote-admin-feature-builder/agents/openai.yaml new file mode 100644 index 0000000..603a67d --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-builder/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bottlenote Feature Router" + short_description: "Route admin feature workflows" + default_prompt: "Use $bottlenote-admin-feature-builder to route this admin request to the right spec, design, plan, implementation, API, or PR workflow." diff --git a/.agents/skills/bottlenote-admin-feature-builder/references/admin-dashboard-patterns.md b/.agents/skills/bottlenote-admin-feature-builder/references/admin-dashboard-patterns.md new file mode 100644 index 0000000..79508dc --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-builder/references/admin-dashboard-patterns.md @@ -0,0 +1,113 @@ +# Bottlenote Admin Dashboard Patterns + +Use this reference after a Bottlenote admin feature skill triggers and before +writing `spec.md`, `design.md`, `plan.md`, or implementation changes. + +## Stack + +- Vite + React 18 + TypeScript +- React Router 7 +- TanStack Query 5 +- React Hook Form + Zod +- Zustand for auth/sidebar UI state only +- shadcn/ui primitives in `src/components/ui` +- Vitest + Testing Library + MSW +- Playwright E2E with Page Object classes + +## API-Backed Domain Work + +Follow this order: + +1. `src/types/api/{domain}.api.ts` + - Endpoint constants. + - Namespaced API type interface. + - Helper type aliases. +2. `src/services/{domain}.service.ts` + - Query keys via `createQueryKeys`. + - API calls through `apiClient`. + - Response normalization into `{ items, meta }` for paginated lists. +3. `src/hooks/use{Domain}.ts` + - Query hooks with stable keys. + - Mutation hooks with invalidation and Korean toast messages. +4. `src/pages/{domain}` + - List/detail pages. + - Schema files for forms. + - Domain-specific form hooks when state is complex. +5. `src/routes/index.tsx` and `src/config/menu.config.ts` + - Add routes and sidebar entries only when the feature introduces navigation. + +## Feature Definition From API Docs + +When the backend documentation or response shape is the starting point: + +1. Extract available list/detail/create/update/delete/status/reorder endpoints. +2. Map list response fields to table columns and filters. +3. Map detail response fields to read-only fields, editable inputs, associated + entity selectors, images, status controls, and validation rules. +4. Map mutation request fields to Zod schemas and submit payloads. +5. Identify gaps that need user decisions: labels, sidebar placement, destructive + confirmation copy, required fields, or missing backend semantics. + +## Feature Docs + +Feature docs are committed repo artifacts under: + +```text +docs/features// +├── spec.md +├── design.md +└── plan.md +``` + +- `spec.md`: product/API requirements and acceptance criteria. +- `design.md`: admin UI structure using existing components and tokens. +- `plan.md`: decision-complete implementation instructions. +- `.context/`: temporary scratch notes only. + +## Existing Anchors + +- Banners: `src/pages/banners`, `src/hooks/useBanners.ts`, + `src/services/banner.service.ts`, `src/types/api/banner.api.ts` +- Curations: `src/pages/curations`, `src/hooks/useCurations.ts`, + `src/services/curation.service.ts`, `src/types/api/curation.api.ts` +- Tasting tags: `src/pages/tasting-tags`, `src/hooks/useTastingTags.ts`, + `src/services/tasting-tag.service.ts`, + `src/types/api/tasting-tag.api.ts` +- Whisky: `src/pages/whisky`, `src/hooks/useAdminAlcohols.ts`, + `src/services/admin-alcohol.service.ts`, + `src/types/api/alcohol.api.ts` + +## Forms + +- Use React Hook Form and Zod. +- Keep schemas in `src/pages/{domain}/*.schema.ts`. +- Use `FormField` for labels, required markers, and errors. +- Fields are required by default unless optionality is explicit. +- For associated entity editing, keep local state and send bulk diffs on save. + +## Lists + +- Keep search, filter, sort, and pagination in URL params. +- Treat page numbers as 0-based. +- Use project pagination components and existing table styling. +- Invalidate list/detail query keys after mutations. + +## UI Verification + +- Check that the page fits the existing admin dashboard visual language. +- Verify loading, empty, error, and mutation states when practical. +- For new routes, verify sidebar navigation and direct URL access. +- For forms, verify required markers, validation messages, submit disabled or + error behavior, and success navigation. +- For table/list pages, verify search/filter/page params remain in the URL. + +## Tests + +- Hook tests: `src/hooks/__tests__/use*.test.ts` +- Service tests: `src/services/__tests__/*.service.test.ts` +- Page/component tests: `src/pages/{domain}/__tests__/*.test.tsx` +- E2E specs: `e2e/specs/*.spec.ts` +- E2E Page Objects: `e2e/pages/*.page.ts` + +Prefer a focused regression test for every bug fix when the behavior can be +tested without excessive setup. diff --git a/.agents/skills/bottlenote-admin-feature-design/SKILL.md b/.agents/skills/bottlenote-admin-feature-design/SKILL.md new file mode 100644 index 0000000..329634f --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-design/SKILL.md @@ -0,0 +1,86 @@ +--- +name: bottlenote-admin-feature-design +description: > + Create or update Bottlenote admin feature UI design docs in + docs/features/{feature-slug}/design.md from an approved spec.md and existing + shadcn/Tailwind admin dashboard patterns. Use when Codex needs to define + route/sidebar placement, list/detail/form UI, states, validation copy, and + manual UI review points before implementation planning. Trigger on design, + UI structure, 화면 설계, 디자인 문서, or admin page layout requests. +--- + +# Bottlenote Admin Feature Design + +Use this skill to write the committed UI design document for a feature. The +design must follow the existing admin dashboard, not invent a new visual system. + +## Workflow + +1. Read the source spec. + - Require `docs/features//spec.md`. + - If the spec is missing, route back to `bottlenote-admin-feature-spec`. + +2. Inspect local UI patterns. + - Read `AGENTS.md`. + - Review similar pages in `src/pages/banners`, `src/pages/curations`, + `src/pages/tasting-tags`, or `src/pages/whisky`. + - Check shared components such as `DetailPageHeader`, `FormField`, + `Pagination`, `StatusToggle`, `ImageUpload`, and shadcn `Table`, `Card`, + `Button`, `Select`, `Input`, `Dialog`. + - Use `tailwind.config.js`, `src/index.css`, and `components.json` for token + and component style constraints. + +3. Write or update `docs/features//design.md`: + +```markdown +# Design + +## UI Summary + + + +## Navigation + +- Route: +- Sidebar/menu placement: +- Entry actions: + +## List View + +- Columns: +- Filters/search: +- Row actions: +- Empty/loading/error states: + +## Detail/Create/Edit View + +- Sections/cards: +- Fields: +- Validation messages: +- Primary/secondary actions: + +## State and Feedback + +- Loading: +- Empty: +- Error: +- Success: +- Destructive confirmation: + +## Design System Usage + +- Components: +- Tokens/classes: +- Existing pages to mirror: + +## Manual UI Review Points + +- [ ] +``` + +## Guardrails + +- Do not write implementation order or API service details. +- Do not modify `src/components/ui`. +- Prefer existing dense admin layouts over marketing-style composition. +- Use Korean for labels, validation, and destructive confirmation copy. diff --git a/.agents/skills/bottlenote-admin-feature-design/agents/openai.yaml b/.agents/skills/bottlenote-admin-feature-design/agents/openai.yaml new file mode 100644 index 0000000..e26b54a --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-design/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bottlenote Feature Design" + short_description: "Design admin feature UI" + default_prompt: "Use $bottlenote-admin-feature-design to write docs/features/{feature-slug}/design.md from the approved spec." diff --git a/.agents/skills/bottlenote-admin-feature-implement/SKILL.md b/.agents/skills/bottlenote-admin-feature-implement/SKILL.md new file mode 100644 index 0000000..ef17be2 --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-implement/SKILL.md @@ -0,0 +1,54 @@ +--- +name: bottlenote-admin-feature-implement +description: > + Implement Bottlenote admin dashboard features strictly from an existing + docs/features/{feature-slug}/plan.md. Use when spec.md, design.md, and plan.md + already exist and Codex should make code changes, update tests/mocks, run + verification, and hand off manual UI review points without redefining the + feature. Trigger on "plan.md대로 구현", "구현해줘", "implement this plan", or + requests that provide a docs/features/{feature-slug}/plan.md path. +--- + +# Bottlenote Admin Feature Implement + +Use this skill to execute an existing feature plan. The implementation source of +truth is `docs/features//plan.md`. + +## Workflow + +1. Read the feature docs. + - Read `plan.md` first. + - Read linked `spec.md` and `design.md` only to clarify plan references. + - Do not reinterpret product scope or UI design beyond the plan. + +2. Validate plan completeness before editing. + - If route placement, API mapping, required fields, destructive behavior, or + test expectations are missing, stop and ask for a plan update. + - If the plan is complete, implement it in the listed order. + +3. Implement using repository patterns. + - API-backed work: types -> service -> hook -> schema/form -> page -> + route/menu -> tests. + - Keep server data in TanStack Query. + - Keep list search/filter/pagination in URL params. + - Do not modify `src/components/ui`. + - Do not add barrel files unless required by an existing boundary. + +4. Verify. + - Run the commands from the plan's verification checklist when feasible. + - For UI changes, start the dev server if needed and provide the local URL. + - Use Playwright or manual inspection guidance when route-level behavior or + visual layout risk exists. + +5. Handoff. + - Summarize implemented behavior, changed areas, tests run, skipped checks, + and manual UI review points. + - Do not create a PR unless the user asks; then use + `bottlenote-admin-korean-pr`. + +## Guardrails + +- Do not change feature scope during implementation. +- Do not silently fill major gaps in `plan.md`. +- Preserve unrelated worktree changes. +- Never claim verification passed unless it was actually run successfully. diff --git a/.agents/skills/bottlenote-admin-feature-implement/agents/openai.yaml b/.agents/skills/bottlenote-admin-feature-implement/agents/openai.yaml new file mode 100644 index 0000000..f0959e4 --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-implement/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bottlenote Feature Implement" + short_description: "Implement planned admin work" + default_prompt: "Use $bottlenote-admin-feature-implement to implement the existing docs/features/{feature-slug}/plan.md." diff --git a/.agents/skills/bottlenote-admin-feature-plan/SKILL.md b/.agents/skills/bottlenote-admin-feature-plan/SKILL.md new file mode 100644 index 0000000..14cc8ff --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-plan/SKILL.md @@ -0,0 +1,79 @@ +--- +name: bottlenote-admin-feature-plan +description: > + Create or update decision-complete implementation plans in + docs/features/{feature-slug}/plan.md from Bottlenote admin feature spec.md + and design.md. Use before code implementation so another agent can implement + without reinterpreting product requirements or UI design. Trigger on + implementation plan, 개발 계획, 구현 계획, plan.md, or requests to prepare a + feature for implementation. +--- + +# Bottlenote Admin Feature Plan + +Use this skill to write an implementation plan that an implementation agent can +execute without making product, design, or architecture decisions. + +## Workflow + +1. Read required inputs. + - Require `docs/features//spec.md`. + - Require `docs/features//design.md`. + - If either is missing, route to the missing stage skill first. + +2. Inspect implementation anchors. + - Read `AGENTS.md`. + - Inspect matching API types, services, hooks, pages, schemas, routes, menu, + tests, and MSW mocks. + - Use `bottlenote-admin-api` if endpoint or field mapping needs contract + confirmation. + +3. Write or update `docs/features//plan.md`: + +```markdown +# Implementation Plan + +## Summary + + + +## Inputs + +- Spec: `docs/features//spec.md` +- Design: `docs/features//design.md` +- API source: + +## Data and API Mapping + +- frontend type/service/hook/form/table mapping.> + +## Implementation Steps + +1. +2. +3. +4. +5. +6. + +## Edge Cases + +- + +## Verification Checklist + +- [ ] `` +- [ ] + +## Implementation Notes + +- +``` + +## Guardrails + +- Make the plan decision-complete. Do not leave "decide later" items unless + they are listed as blockers. +- Include API findings in this plan when they affect implementation. +- Do not implement code in this stage. +- Do not create separate verification docs by default. diff --git a/.agents/skills/bottlenote-admin-feature-plan/agents/openai.yaml b/.agents/skills/bottlenote-admin-feature-plan/agents/openai.yaml new file mode 100644 index 0000000..5fed94b --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-plan/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bottlenote Feature Plan" + short_description: "Plan admin implementation" + default_prompt: "Use $bottlenote-admin-feature-plan to write docs/features/{feature-slug}/plan.md from spec.md and design.md." diff --git a/.agents/skills/bottlenote-admin-feature-spec/SKILL.md b/.agents/skills/bottlenote-admin-feature-spec/SKILL.md new file mode 100644 index 0000000..4a84a78 --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-spec/SKILL.md @@ -0,0 +1,85 @@ +--- +name: bottlenote-admin-feature-spec +description: > + Create or update Bottlenote admin feature specifications in + docs/features/{feature-slug}/spec.md from user intent, API documentation, + response shapes, and existing dashboard behavior. Use before design, + planning, or implementation for new admin pages, workflows, API-backed + features, or substantial feature changes. Trigger on requests for feature + definition, spec writing, requirements, acceptance criteria, or Korean + requests like "기능 정의", "스펙 작성", "요구사항 정리". +--- + +# Bottlenote Admin Feature Spec + +Use this skill to write the committed feature specification for a Bottlenote +admin feature. Do not design UI details or implementation steps here. + +## Workflow + +1. Choose the feature slug. + - Use lowercase hyphen-case English, e.g. `notice-management`. + - If the user supplies an existing feature doc path, use that path. + - Create or update `docs/features//spec.md`. + +2. Gather requirements. + - Read `AGENTS.md`. + - Inspect similar existing pages and API layers with `rg`. + - If API docs or response shapes are involved, use `bottlenote-admin-api` to + identify endpoints, fields, and contract assumptions. + - Keep feature-related API analysis inside this `spec.md`; do not create a + standalone API report by default. + +3. Ask only blocker questions. + - Ask if the target admin workflow, destructive behavior, user-visible + labels, or backend semantics cannot be inferred. + - Otherwise state assumptions in the spec. + +4. Write `spec.md` with this shape: + +```markdown +# Spec + +## Summary + + + +## Source Inputs + +- API docs/response shape: +- Existing UI/code references: +- User request: + +## Admin Workflow + +- + +## Data Requirements + +- + +## Acceptance Criteria + +- [ ] +- [ ] +- [ ] + +## In Scope + +- + +## Out of Scope + +- + +## Open Questions + +- +``` + +## Guardrails + +- Do not include component-level layout or Tailwind class decisions. +- Do not include implementation ordering. +- Keep speculative assumptions explicit. +- Use Korean for admin-facing labels and copy when known. diff --git a/.agents/skills/bottlenote-admin-feature-spec/agents/openai.yaml b/.agents/skills/bottlenote-admin-feature-spec/agents/openai.yaml new file mode 100644 index 0000000..77dd332 --- /dev/null +++ b/.agents/skills/bottlenote-admin-feature-spec/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bottlenote Feature Spec" + short_description: "Write admin feature specs" + default_prompt: "Use $bottlenote-admin-feature-spec to write docs/features/{feature-slug}/spec.md for this admin feature." diff --git a/.agents/skills/bottlenote-admin-korean-pr/SKILL.md b/.agents/skills/bottlenote-admin-korean-pr/SKILL.md new file mode 100644 index 0000000..20fca20 --- /dev/null +++ b/.agents/skills/bottlenote-admin-korean-pr/SKILL.md @@ -0,0 +1,83 @@ +--- +name: bottlenote-admin-korean-pr +description: > + Create Korean pull requests for Bottlenote admin dashboard changes. Use when + Codex needs to prepare or create a PR after feature work, bug fixes, API + contract work, tests, or documentation changes in this repository. Trigger on + requests like "PR 만들어줘", "PR 생성", "pull request 만들어줘", "한글 PR", + "작업 올려줘", or when implementation is complete and the user asks to open a + PR. +--- + +# Bottlenote Admin Korean PR + +Use this skill to prepare a concise Korean PR that matches this repository's +template and accurately reflects the actual diff. + +## Workflow + +1. Inspect the branch and diff. + - Run `git status --short --untracked-files=all`. + - Use `git diff origin/main...` for the change summary unless the user + specifies another base. + - Do not include unrelated existing worktree changes in the PR summary. + - If unrelated changes are present, explicitly separate them from this work. + +2. Check repository PR template. + - Prefer `.github/PULL_REQUEST_TEMPLATE.md`. + - Preserve its sections: + `PR 제목`, `변경 사항`, `변경 이유`, `테스트 방법`, `참고 사항`. + +3. Write the title. + - Format: `[type] Korean summary`. + - Use one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`. + - Keep it specific to the shipped behavior. + +4. Write the body in Korean. + - Keep technical terms such as API, React, TanStack Query, MSW, Playwright, + route, hook, service in English when clearer. + - In `변경 사항`, list concrete code/user-facing changes. + - In `변경 이유`, explain why the change is needed. + - In `테스트 방법`, include commands actually run and any manual checks. + - In `참고 사항`, mention residual risks, skipped checks, required env vars, + or user verification steps. Use `특이사항 없음` only when true. + +5. Create the PR only when requested. + - Use `gh pr create --base main` unless the user specifies a different base. + - If the branch has not been pushed, push the current branch first only when + the user asked to create the PR and the remote branch is missing. + - Do not add AI attribution footers unless the user asks for them. + +## PR Body Shape + +```markdown +### PR 제목 (Title) + +[type] <한글 요약> + +### 변경 사항 (Changes) + +- [ ] <구체적인 변경 사항> +- [ ] <구체적인 변경 사항> + +### 변경 이유 (Reason for Changes) + +<왜 필요한 변경인지> + +### 테스트 방법 (Test Procedure) + +- [ ] `<실행한 명령>` +- [ ] <수동 검증 내용> + +### 참고 사항 (Additional Information) + +<리뷰어가 알아야 할 내용> +``` + +## Guardrails + +- Never claim tests passed unless the command was run successfully. +- Mention tests that could not be run. +- Keep the PR focused on changes in the current branch. +- Respect the target branch from the workspace instructions: `origin/main` for + diffing and `main` as the PR base. diff --git a/.agents/skills/bottlenote-admin-korean-pr/agents/openai.yaml b/.agents/skills/bottlenote-admin-korean-pr/agents/openai.yaml new file mode 100644 index 0000000..a512f97 --- /dev/null +++ b/.agents/skills/bottlenote-admin-korean-pr/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bottlenote Korean PR" + short_description: "Create Korean dashboard PRs" + default_prompt: "Use $bottlenote-admin-korean-pr to create a Korean PR for this dashboard change." diff --git a/.omc/autopilot/spec.md b/.omc/autopilot/spec.md deleted file mode 100644 index d77b9ec..0000000 --- a/.omc/autopilot/spec.md +++ /dev/null @@ -1,100 +0,0 @@ -# Curation Admin Feature - Specification - -## Overview -큐레이션 관리 기능 구현 - 위스키 컬렉션 관리를 위한 어드민 CRUD 기능 - -## File Structure - -``` -src/ -├── types/api/ -│ └── curation.api.ts # API 타입 정의 -├── services/ -│ └── curation.service.ts # API 서비스 레이어 -├── hooks/ -│ └── useCurations.ts # TanStack Query 훅 -├── pages/curations/ -│ ├── CurationList.tsx # 목록 페이지 -│ ├── CurationDetail.tsx # 상세/수정 페이지 -│ ├── curation.schema.ts # Zod 폼 스키마 -│ └── useCurationDetailForm.ts # 폼 관리 훅 -├── config/ -│ └── menu.config.ts # 메뉴 설정 업데이트 -└── routes/ - └── index.tsx # 라우트 업데이트 -``` - -## API Endpoints - -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | /admin/api/v1/curations | 목록 조회 (검색, 필터, 페이지네이션) | -| GET | /admin/api/v1/curations/{id} | 상세 조회 | -| POST | /admin/api/v1/curations | 생성 | -| PUT | /admin/api/v1/curations/{id} | 수정 | -| DELETE | /admin/api/v1/curations/{id} | 삭제 | -| PATCH | /admin/api/v1/curations/{id}/status | 상태 토글 | -| PATCH | /admin/api/v1/curations/{id}/display-order | 순서 변경 | -| POST | /admin/api/v1/curations/{id}/alcohols | 위스키 추가 | -| DELETE | /admin/api/v1/curations/{id}/alcohols/{alcoholId} | 위스키 제거 | - -## Implementation Tasks - -### Task 1: API Types (curation.api.ts) -- Endpoint 상수 정의 -- CurationApiTypes 인터페이스 -- Helper 타입 export -- types/api/index.ts 업데이트 - -### Task 2: Service Layer (curation.service.ts) -- Query keys 정의 (createQueryKeys) -- Mock 데이터 구현 -- 모든 API 함수 구현 - -### Task 3: Hooks (useCurations.ts) -- useCurationList -- useCurationDetail -- useCurationCreate -- useCurationUpdate -- useCurationDelete -- useCurationToggleStatus -- useCurationAddAlcohols -- useCurationRemoveAlcohol - -### Task 4: Form Schema (curation.schema.ts) -- Zod 스키마 정의 -- 기본값 상수 - -### Task 5: Form Hook (useCurationDetailForm.ts) -- 폼 상태 관리 -- 생성/수정 모드 처리 -- 위스키 선택 관리 - -### Task 6: List Page (CurationList.tsx) -- URL 기반 검색/필터/페이지네이션 -- 테이블 렌더링 -- 상태 토글 스위치 -- 삭제 확인 다이얼로그 - -### Task 7: Detail Page (CurationDetail.tsx) -- 기본 정보 폼 -- 커버 이미지 업로드 -- 위스키 선택/관리 UI -- 생성/수정 모드 분기 - -### Task 8: Routes & Menu -- routes/index.tsx 업데이트 -- menu.config.ts 업데이트 - -## Patterns to Follow -- Banner: BannerList.tsx, BannerDetail.tsx, useBanners.ts -- TastingTag: tasting-tag.api.ts, useTastingTags.ts -- WhiskySearchSelect: 기존 컴포넌트 재사용 - -## Acceptance Criteria -- [ ] 목록 페이지: 검색, 필터, 페이지네이션 동작 -- [ ] 상세 페이지: 생성/수정/삭제 동작 -- [ ] 위스키 선택: 추가/제거 동작 -- [ ] 상태 토글: 목록에서 빠른 토글 동작 -- [ ] 메뉴: 사이드바에 큐레이션 메뉴 표시 -- [ ] 라우트: 모든 경로 접근 가능 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6fb8f73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,168 @@ +# Bottlenote Admin Dashboard Agent Guide + +This is the shared instruction file for coding agents working in this +repository. Keep tool-specific instructions in their own files only when they +are not useful to other agents. + +## Project + +Bottlenote Admin Dashboard is a Vite + React 18 + TypeScript admin app for +managing whisky tasting platform data such as banners, curations, whisky, +tasting tags, users, inquiries, policies, and distilleries. + +## Project-Local Skills + +Project-specific agent skills live in `.agents/skills` and belong to this +repository. Do not rely on user-global skills for Bottlenote admin workflow. + +When a user request matches one of these project skills, open the skill's +`SKILL.md` and follow it before planning or editing: + +- `.agents/skills/bottlenote-admin-feature-builder/SKILL.md`: use as the + router for vague admin feature, bug, API, implementation, verification, or PR + requests. +- `.agents/skills/bottlenote-admin-feature-spec/SKILL.md`: use to turn API + docs, response shapes, and product intent into + `docs/features//spec.md`. +- `.agents/skills/bottlenote-admin-feature-design/SKILL.md`: use to turn an + approved `spec.md` into `docs/features//design.md` using this + app's existing shadcn/Tailwind admin UI patterns. +- `.agents/skills/bottlenote-admin-feature-plan/SKILL.md`: use to turn + `spec.md` and `design.md` into a decision-complete + `docs/features//plan.md`. +- `.agents/skills/bottlenote-admin-feature-implement/SKILL.md`: use to + implement from `docs/features//plan.md` without reinterpreting + the spec/design. +- `.agents/skills/bottlenote-admin-api/SKILL.md`: use for API documentation + checks, endpoint additions or changes, request/response type mismatches, and + types/services/hooks API contract work. For feature work, include API + findings in that feature's `spec.md` and `plan.md`. +- `.agents/skills/bottlenote-admin-korean-pr/SKILL.md`: use when the user asks + to prepare or create a Korean PR for completed work. + +Use `.context/` only for temporary scratch notes, unresolved investigation +state, or Conductor handoff details that should not be committed. Feature +requirements and implementation decisions should live under +`docs/features//`. + +Typical delivery flow: + +1. Read the latest API docs or provided response shape. +2. Write or update `docs/features//spec.md`. +3. Write or update `docs/features//design.md`. +4. Write or update `docs/features//plan.md`. +5. Implement only from `plan.md` following existing project structure. +6. Verify with code tests and UI/manual checks. +7. Let the user inspect the result. +8. Create a Korean PR when requested. + +## Commands + +```bash +pnpm dev # development server, dev environment +pnpm dev:local # development server, local environment +pnpm build # production build +pnpm test # unit/hook tests in watch mode +pnpm test:run # unit/hook tests once +pnpm test:e2e # Playwright E2E tests +pnpm test:e2e:ui # Playwright UI mode +pnpm test:all # unit + E2E tests +pnpm lint # ESLint +``` + +## Architecture + +```text +src/ +├── components/ +│ ├── common/ # reusable components +│ ├── layout/ # admin layout, sidebar, header +│ └── ui/ # shadcn/ui primitives; do not edit directly +├── pages/ # route-level page components +├── hooks/ # custom hooks +├── services/ # API service layer +├── stores/ # Zustand stores +├── types/api/ # API type definitions +└── lib/ # utilities and API client setup +``` + +## API Pattern + +New API work follows the 3-layer order: + +1. `src/types/api/*.api.ts` defines endpoints and request/response types. +2. `src/services/*.service.ts` implements API calls and query keys. +3. `src/hooks/use*.ts` wraps TanStack Query hooks. + +Do not define duplicate API types outside `src/types/api/`. Components should +not call `fetch`, `axios`, or service functions directly when a project hook +should own the data flow. + +## Forms + +Use React Hook Form + Zod schemas for forms. Place schemas in +`src/pages/{domain}/*.schema.ts`. + +All fields are required by default unless there is a clear product or API +reason for optional input. Use `FormField` from `src/components/common` to show +labels, required markers, and validation errors consistently. + +## Related Entities + +For related entity management, follow the tasting tag pattern: + +1. Keep the initial IDs for diffing. +2. Manage add/remove interactions in local state. +3. On save, calculate `toAdd` and `toRemove`. +4. Call bulk add/remove APIs after the main save. + +## Tests + +For new hooks, services, and page behavior, write tests first unless the change +is styling-only, type-only, or configuration-only. + +Test locations: + +| Target | Location | +| --- | --- | +| `hooks/use*.ts` | `src/hooks/__tests__/use*.test.ts` | +| `pages/domain/*.tsx` | `src/pages/domain/__tests__/*.test.tsx` | +| `services/*.service.ts` | `src/services/__tests__/*.service.test.ts` | + +Use MSW mocks from `src/test/mocks/` and test utilities from +`src/test/test-utils.tsx`. + +## E2E + +Playwright specs live in `e2e/specs/` and use Page Object classes from +`e2e/pages/`. Auth uses `e2e/fixtures/auth.fixture.ts`. + +E2E tests require `VITE_E2E_TEST_ID` and `VITE_E2E_TEST_PW`. + +## Conventions + +- Do not edit `src/components/ui/`; those files are generated shadcn/ui + primitives. +- Do not create new barrel files such as `index.ts` unless there is an existing + local pattern that requires one. +- Import modules directly instead of through broad barrels. +- Keep server data in TanStack Query, not Zustand. Zustand should stay limited + to UI/auth state such as sidebar and auth. +- Keep search, filters, and pagination in URL parameters when they affect list + pages. +- Pagination is 0-based because that is the backend API contract. +- Toast messages and user-facing admin copy should be Korean unless nearby UI + establishes otherwise. +- Use `useMemo` and `useCallback` only when they solve a real referential + stability or measured performance problem. +- Leave unrelated worktree changes alone. + +## Reference Files + +- API type pattern: `src/types/api/tasting-tag.api.ts` +- Service pattern: `src/services/tasting-tag.service.ts` +- Hook pattern: `src/hooks/useTastingTags.ts` +- Hook test pattern: `src/hooks/__tests__/useTastingTags.test.ts` +- List page pattern: `src/pages/banners/BannerList.tsx` +- Detail page pattern: `src/pages/banners/BannerDetail.tsx` +- Page Object pattern: `e2e/pages/tasting-tag-list.page.ts` diff --git a/VERSION b/VERSION index 347f583..1c99cf0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.1 +1.4.4 diff --git a/docs/features/region-management/design.md b/docs/features/region-management/design.md new file mode 100644 index 0000000..1f8614f --- /dev/null +++ b/docs/features/region-management/design.md @@ -0,0 +1,233 @@ +# Region Management Design + +## UI Summary + +지역 관리는 위스키 기준정보를 다루는 운영 화면으로 구성한다. 증류소 관리와 같은 테이블 기반 목록, `DetailPageHeader` 기반 등록/상세 화면, `Card` 기반 기본 정보 폼을 사용해 기존 어드민 화면과 같은 밀도와 흐름을 유지한다. + +목록은 빠른 검색, 페이지 이동, 드래그 앤 드롭 순서 변경에 집중한다. 등록/상세 화면은 지역의 이름, 대륙, 상위 지역, 설명을 편집하는 단일 폼 중심으로 구성하고, 정렬 순서는 목록에서만 변경한다. 상세 화면에서는 현재 순서, 하위 지역 존재 여부, 연결 위스키 수를 읽기 전용 참고 정보로 보여 삭제 판단에 도움을 준다. + +## Navigation + +- Route: + - `/regions`: 지역 목록 + - `/regions/new`: 지역 등록 + - `/regions/:id`: 지역 상세/수정 +- Sidebar/menu placement: + - `위스키/테이스팅 태그` 그룹 하위에 `지역 관리` 섹션을 추가한다. + - `지역 관리` 하위 메뉴는 `지역 목록`, `지역 추가`를 둔다. + - 아이콘은 지역/지도 의미의 lucide 아이콘이 있으면 `MapPinned` 또는 `Map`; 없으면 기존 관리 섹션과 맞춰 `List`, `Plus`를 사용한다. +- Entry actions: + - 목록 우측 상단의 `순서 변경` 버튼으로 드래그 앤 드롭 순서 변경 모드에 진입한다. + - 목록 우측 상단의 `지역 추가` 버튼으로 `/regions/new`에 진입한다. + - 목록 행 클릭으로 `/regions/:id`에 진입한다. + - 상세/등록 화면의 뒤로가기 버튼은 `/regions`로 이동한다. + +## List View + +- Header: + - 제목: `지역 목록` + - 설명: `위스키 원산지로 사용되는 지역을 관리합니다.` + - 우측 액션: + - 일반 모드: `순서 변경` 버튼, `ArrowUpDown` 아이콘 포함 + - 일반 모드: `지역 추가` 버튼, `Plus` 아이콘 포함 + - 순서 변경 모드: `취소` outline 버튼 + - 순서 변경 모드: `순서 변경 완료` primary 버튼, 저장 중에는 `순서 저장 중...` +- Columns: + - 순서 변경 모드일 때 첫 컬럼 `순서`: `sortOrder + 1` 또는 `sortOrder` 표시. 기존 배너/큐레이션 화면과 같은 표시 규칙을 따른다. + - `ID`: 고정 폭, monospaced small text + - `한글명`: primary text, bold + - `영문명`: muted text + - `대륙`: 값이 없으면 `-` + - `상위 지역`: `parentId`가 있으면 `#`, 없으면 `-` + - `순서`: 일반 모드에서 숫자, 고정 폭 + - `수정일`: `ko-KR` 날짜 + - 순서 변경 모드일 때 마지막 빈 헤더: 드래그 핸들 영역 +- Filters/search: + - 증류소 목록과 같은 필터 바를 사용한다. + - 검색 input placeholder: `지역 이름으로 검색...` + - 검색 아이콘: `Search` + - 검색 실행: 검색 버튼 클릭 또는 Enter + - 정렬 select: + - `순서 낮은순` = `ASC` + - `순서 높은순` = `DESC` + - 검색어, 정렬, 페이지, 페이지 크기는 URL 파라미터에 유지한다. + - 순서 변경 모드에서는 현재 화면에 표시된 목록 기준으로만 드래그한다. +- Row actions: + - 별도 액션 메뉴는 두지 않는다. + - 행 hover는 `hover:bg-muted/50`, 행 전체 클릭으로 상세 진입. + - `id=0`처럼 특수 데이터도 목록에서는 동일하게 보여주되, 삭제 제한 여부는 상세/삭제 응답으로 처리한다. + - 순서 변경 모드에서는 행 클릭으로 상세 진입하지 않는다. + - 순서 변경 모드에서는 마지막 컬럼에 `GripVertical` 핸들을 표시하고 `cursor-grab active:cursor-grabbing`을 적용한다. + - 드래그 오버 대상 행은 `bg-primary/10`로 강조한다. + - `parentId`는 순서 변경 제약으로 사용하지 않는다. 서로 다른 상위 지역을 가진 row 사이 드래그도 허용한다. +- Empty/loading/error states: + - Loading: 테이블 한 줄 전체 폭에 `로딩 중...` + - Empty without keyword: `등록된 지역이 없습니다.` + - Empty with keyword: `검색 결과가 없습니다.` + - Error: 기존 프로젝트 에러 처리 패턴을 따르되, 화면 내 표시가 필요하면 테이블 영역에 `지역 목록을 불러오지 못했습니다.` +- Pagination: + - 공용 `Pagination` 컴포넌트를 사용한다. + - 페이지 번호는 0-based API 값을 그대로 전달하고, 표시 문구는 공용 컴포넌트에 맡긴다. +- Reorder mode: + - 기존 `BannerList`/`CurationList`의 순서 변경 UI 패턴을 따르되, 저장 시점은 드롭이 아니라 완료 버튼 클릭으로 둔다. + - `sortOrder=ASC` 기준에서만 순서 변경 모드를 연다. + - `sortOrder=DESC` 상태에서 `순서 변경`을 누르면 `sortOrder=ASC`, `page=0`으로 전환한 뒤 순서 변경 모드에 진입한다. + - 안내 배너: + - `순서 변경 모드 - 우측의 핸들을 드래그하여 지역 순서를 변경할 수 있습니다.` + - 로컬 변경이 있으면 `(저장되지 않은 순서 변경이 있습니다.)`를 덧붙인다. + - 완료 저장 중에는 `(순서 저장 중...)`을 덧붙인다. + - 드래그앤드롭은 API를 호출하지 않고 로컬 row 배열만 재정렬한다. + - 드롭할 때마다 staged move를 기록한다. + - staged move: `{ regionId, targetSortOrder }` + - `targetSortOrder`는 드롭된 위치의 순서 값이다. + - `순서 변경 완료` 클릭 시 staged move를 저장한다. + - 백엔드 `PATCH /regions/{id}/sort-order`는 단일 지역 이동 API다. 프론트가 영향받은 모든 row를 직접 저장하지 않는다. + - staged move가 1개면 `PATCH /regions/{regionId}/sort-order`를 한 번 호출한다. + - staged move가 여러 개면 완료 시 staged move queue를 순서대로 재생한다. + - 각 요청 body는 `{ sortOrder: targetSortOrder }`다. + - 저장 후에는 목록을 다시 조회해 백엔드가 자동 reorder한 최종 순서를 화면에 반영한다. + - 실패 시 저장 전 로컬 순서로 되돌리고 목록을 다시 조회한다. + - `취소`를 누르면 로컬 변경을 버리고 진입 시점의 원본 목록 순서로 되돌린 뒤 일반 모드로 나온다. + - `parentId`가 다른 대상 위에 드롭해도 일반 row reorder로 처리한다. + - 검색 또는 페이지네이션이 적용된 상태에서도 현재 페이지에 표시된 flat 목록 기준으로 순서 변경을 허용한다. + - 저장되지 않은 순서 변경이 있을 때 검색/정렬/page size/페이지 이동은 비활성화한다. + - 관리자는 `순서 변경 완료`로 저장하거나 `취소`로 변경을 버린 뒤 다른 목록 조작을 할 수 있다. + +## Detail/Create/Edit View + +- Page header: + - 등록 제목: `지역 등록` + - 상세 제목: `지역 상세` + - 상세 subtitle: `ID: {id}` + - 좌측: 뒤로가기 icon button + - 우측: + - 상세 모드: `삭제` destructive 버튼, `저장` 버튼 + - 등록 모드: `등록` 버튼 + - 저장 중: `저장 중...` +- Sections/cards: + - `기본 정보` 카드: + - 지역의 기본 이름과 분류 정보를 입력한다. + - 페이지 폭 전체 또는 `max-w` 없이 기존 상세 화면의 content width를 따른다. + - `참고 정보` 카드: + - 상세 모드에서만 표시한다. + - 하위 지역 여부, 연결 위스키 수, 생성일, 수정일을 읽기 전용으로 보여준다. + - 등록 모드에서는 표시하지 않는다. +- Fields: + - `한글명`: + - Required + - Input placeholder: `예: 스코틀랜드` + - `영문명`: + - Required + - Input placeholder: `예: Scotland` + - `대륙`: + - Optional + - Input placeholder: `예: 유럽` + - enum이 확정되지 않았으므로 1차 UI는 자유 입력으로 둔다. + - `상위 지역`: + - Optional + - 기존 지역 목록 API를 사용한 select/combobox. + - Placeholder: `상위 지역 없음` + - Search placeholder: `상위 지역 검색...` + - Empty: `지역을 찾을 수 없습니다.` + - 상세 모드에서는 현재 지역 자신은 선택 목록에서 제외한다. + - `정렬 순서`: + - 입력 필드로 제공하지 않는다. + - 상세 모드의 `참고 정보` 카드에서 읽기 전용 값으로만 표시한다. + - 순서 변경은 목록의 `순서 변경` 모드에서만 제공한다. + - `설명`: + - Optional + - Textarea + - Placeholder: `지역 설명을 입력하세요.` + - 3-5줄 높이 +- Validation messages: + - `한글명은 필수입니다` + - `영문명은 필수입니다` + - `상위 지역은 자기 자신으로 설정할 수 없습니다` +- Primary/secondary actions: + - Primary: `등록` 또는 `저장` + - Secondary/destructive: 상세 모드에서 `삭제` + - Back: 헤더의 icon button + - 삭제 성공 후 `/regions`로 이동한다. + - 생성 성공 후 `/regions` 또는 생성된 상세 페이지 중 하나로 이동할 수 있으나, 기존 증류소 패턴에 맞춰 목록으로 이동한다. + +## State and Feedback + +- Loading: + - 목록: 테이블 body에서 `로딩 중...` + - 상세: 본문 영역에서 `로딩 중...` + - 저장 버튼 disabled, 문구 `저장 중...` + - 삭제 버튼 disabled 또는 다이얼로그 confirm pending, 문구는 공용 다이얼로그 pending 상태를 따른다. +- Empty: + - 목록 빈 상태는 검색어 유무에 따라 `등록된 지역이 없습니다.` 또는 `검색 결과가 없습니다.` + - 상위 지역 select 빈 상태는 `지역을 찾을 수 없습니다.` +- Error: + - API 공통 toast/error handling을 따른다. + - 상세 조회 실패 시 화면 본문에 `지역 정보를 불러오지 못했습니다.` + - 중복 한글명 등 API validation 에러는 toast로 표시하고, 필드 매핑이 가능하면 해당 필드 에러로도 보여준다. +- Success: + - 등록 성공 toast: `지역이 등록되었습니다.` + - 수정 성공 toast: `지역이 수정되었습니다.` + - 삭제 성공 toast: `지역이 삭제되었습니다.` + - 순서 변경 성공 toast: `지역 순서가 변경되었습니다.` +- Reorder failure: + - API 실패 시 공통 에러 toast를 표시하고 저장 전 순서로 되돌린 뒤 목록을 다시 조회한다. +- Destructive confirmation: + - Dialog title: `지역 삭제` + - Dialog description: `정말 이 지역을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.` + - `hasChildren` 또는 `alcoholCount`가 있을 때 삭제 버튼을 숨기지는 않는다. 백엔드가 제한하면 에러 toast로 보여준다. + +## Design System Usage + +- Components: + - `Button` + - `Input` + - `Textarea` + - `Select` + - `Table` + - `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent` + - `DetailPageHeader` + - `FormField` + - `Pagination` + - `DeleteConfirmDialog` + - `useReorderDrag` 또는 동일한 로컬 재정렬 로직 + - 지역 선택에 기존 searchable select 컴포넌트가 있으면 재사용하고, 없으면 기존 `WhiskyBasicInfoCard`의 검색 가능 select 패턴을 따른다. +- Tokens/classes: + - Page spacing: `space-y-6` + - Header: `flex items-center justify-between` + - Filter bar: `flex flex-col gap-4 sm:flex-row` + - Search icon positioning: `absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground` + - Table wrapper: `rounded-lg border` + - Clickable rows: `cursor-pointer hover:bg-muted/50` + - Reorder banner: `rounded-lg border border-primary/50 bg-primary/5 p-4` + - Drag-over row: `bg-primary/10` + - Drag handle: `cursor-grab active:cursor-grabbing` + - Muted values: `text-muted-foreground` + - ID values: `font-mono text-sm` + - Required marker comes from `FormField` +- Existing pages to mirror: + - `src/pages/distilleries/DistilleryList.tsx` + - `src/pages/distilleries/DistilleryDetail.tsx` + - `src/pages/banners/BannerList.tsx` for reorder mode, drag handle, 안내 배너 + - `src/pages/curations/CurationList.tsx` for reorder mode visuals + - `src/hooks/useReorderDrag.ts` for drag state and affected range calculation, adjusted so API save is deferred until completion + - `src/pages/tasting-tags/TastingTagDetail.tsx` for delete dialog and detailed form flow + +## Manual UI Review Points + +- [ ] 사이드바에서 `위스키/테이스팅 태그 > 지역 관리 > 지역 목록/지역 추가`가 자연스럽게 보이는지 확인한다. +- [ ] `/regions` 목록에서 검색 input, 정렬 select, 검색 버튼이 한 줄에 안정적으로 배치되는지 확인한다. +- [ ] `순서 변경` 버튼을 누르면 안내 배너, 드래그 핸들, `순서 변경 완료` 문구가 기존 배너/큐레이션 목록과 같은 밀도로 보이는지 확인한다. +- [ ] 순서 변경 모드에서 행 클릭으로 상세 이동이 발생하지 않는지 확인한다. +- [ ] `parentId`가 다른 지역 사이에서도 드래그 순서 변경이 로컬에서 동일하게 동작하는지 확인한다. +- [ ] 드래그 직후에는 API가 호출되지 않고, `순서 변경 완료` 클릭 시 staged move의 sort-order API가 호출되는지 확인한다. +- [ ] sort-order API 저장 후 백엔드 자동 reorder 결과가 refetch로 반영되는지 확인한다. +- [ ] `취소`를 누르면 저장되지 않은 순서 변경이 버려지고 일반 목록 모드로 돌아오는지 확인한다. +- [ ] 순서 저장 실패 시 저장 전 순서로 되돌아가는지 확인한다. +- [ ] 순서 변경 중 검색/정렬/페이지 변경을 시도할 때 UI가 어색하게 꼬이지 않는지 확인한다. +- [ ] 목록 테이블이 좁은 화면에서 텍스트가 겹치지 않고 필요한 경우 가로 스크롤 또는 줄임표 처리가 되는지 확인한다. +- [ ] 빈 목록, 검색 결과 없음, 로딩 상태 문구가 테이블 중앙에 보이는지 확인한다. +- [ ] `/regions/new`에서 필수값 없이 저장 시 한국어 validation 메시지가 표시되는지 확인한다. +- [ ] `/regions/:id`에서 기본 정보와 참고 정보가 구분되어 보이고, 순서/날짜/숫자 값이 읽기 쉬운지 확인한다. +- [ ] 상위 지역 선택에서 현재 지역 자신이 제외되는지 확인한다. +- [ ] 저장/삭제 pending 중 버튼 중복 클릭이 막히는지 확인한다. +- [ ] 삭제 확인 다이얼로그 문구와 destructive 버튼 스타일이 기존 증류소/태그 삭제 흐름과 맞는지 확인한다. diff --git a/docs/features/region-management/plan.md b/docs/features/region-management/plan.md new file mode 100644 index 0000000..81edf0a --- /dev/null +++ b/docs/features/region-management/plan.md @@ -0,0 +1,97 @@ +# Region Management Implementation Plan + +## Summary + +Add region management under the whisky/tasting tag admin section. The implementation includes region list/search/pagination, create/detail/edit/delete forms, and list-only drag-and-drop order changes using the current dev API contract. + +## Inputs + +- Spec: `docs/features/region-management/spec.md` +- Design: `docs/features/region-management/design.md` +- API source: + - Dev API behavior from `https://admin-api.development.bottle-note.com` + - GitHub PR: `https://github.com/bottle-note/bottle-note-api-server/pull/586` + +## Data and API Mapping + +- `GET /admin/api/v1/regions` + - Query: `keyword`, `page`, `size`, `sortOrder` + - Frontend: `RegionListPage`, `RegionSearchParams`, `regionService.list`, `useRegionList` + - List fields: `id`, `korName`, `engName`, `continent`, `description`, `createdAt`, `modifiedAt`, `parentId`, `sortOrder` +- `GET /admin/api/v1/regions/{regionId}` + - Frontend: `RegionDetailPage`, `regionService.detail`, `useRegionDetail` + - Detail fields: `id`, `korName`, `engName`, `continent`, `description`, `sortOrder`, `parentId`, `parentKorName`, `hasChildren`, `alcoholCount`, `createAt`, `lastModifyAt` +- `POST /admin/api/v1/regions` + - Request: `korName`, `engName`, optional `continent`, `description`, `parentId`, `sortOrder` + - Frontend: create form. Use `sortOrder: 9999` by default because backend defaults missing values to `9999`. +- `PUT /admin/api/v1/regions/{regionId}` + - Request same as create. + - Frontend: edit form. Keep `sortOrder` from detail as hidden form data, but do not expose it as an editable field. +- `DELETE /admin/api/v1/regions/{regionId}` + - Frontend: destructive dialog. Backend may reject `REGION_HAS_CHILDREN` or `REGION_HAS_ALCOHOLS`. +- `PATCH /admin/api/v1/regions/{regionId}/sort-order` + - Request: `{ sortOrder: number }` + - Backend is Banner-style single item move, not bulk reorder. It inserts one region at the new order and pushes conflicting rows. + - Frontend drag mode records local staged moves and calls this API only when `순서 변경 완료` is clicked. + - Multiple staged moves are replayed sequentially on save. After save, refetch the list for backend final order. + +## Implementation Steps + +1. Update `src/types/api/region.api.ts`. + - Add endpoint constants for detail/create/update/delete/updateSortOrder. + - Add request/response/helper types for detail, form, delete, and sort-order mutation. + - Add missing list fields `parentId` and `sortOrder`; keep `continent` nullable. +2. Update `src/services/region.service.ts`. + - Add `detail`, `create`, `update`, `delete`, `updateSortOrder`. + - Keep list meta normalization. +3. Update `src/hooks/useRegions.ts`. + - Add detail/create/update/delete/sort-order hooks. + - Invalidate region list/detail caches after mutations. + - Use Korean success toasts. +4. Add `src/pages/regions/region.schema.ts`. + - Required: `korName`, `engName`. + - Optional nullable: `continent`, `description`, `parentId`. + - Hidden `sortOrder` number with default `9999`. +5. Add `src/pages/regions/RegionList.tsx`. + - Mirror dense table/list patterns from distillery list. + - Keep search/sort/page/size in URL params. + - Add reorder mode actions: `순서 변경`, `취소`, `순서 변경 완료`. + - Reorder mode uses local rows and staged move queue; drag does not call API. + - Disable search/sort/page/pageSize while reorder mode has unsaved changes. +6. Add `src/pages/regions/RegionDetail.tsx`. + - Use `DetailPageHeader`, `Card`, `FormField`, `SearchableSelect`, `DeleteConfirmDialog`. + - Show sort order, child 여부, alcohol count, created/modified dates in readonly reference card. + - Exclude current region from parent select. +7. Add route and menu integration. + - Routes: `/regions`, `/regions/new`, `/regions/:id`. + - Menu: `위스키/테이스팅 태그 > 지역 관리 > 지역 목록/지역 추가`. +8. Update MSW mock data and handlers. + - Add region list/detail/form/delete/sort-order fixtures. + - Include handlers in exported handler list. +9. Add focused tests. + - `src/hooks/__tests__/useRegions.test.ts` for list/detail/create/update/delete/sort-order hooks. + - Keep page tests out of first pass unless hook/API tests expose risk. + +## Edge Cases + +- `parentId` is a relationship field, not reorder scope. +- `id=0` region is rendered like any other row; backend decides whether operations are allowed. +- Detail response date fields are `createAt` and `lastModifyAt`; list fields are `createdAt` and `modifiedAt`. +- Parent select excludes the current region in edit mode. +- Search/sort/pagination changes are disabled while reorder mode has unsaved changes. +- If reorder save fails, restore local rows to the original order and refetch. +- Bulk reorder API is not available. Track a follow-up issue for `PATCH /admin/api/v1/regions/reorder` with `regionIds` if product wants atomic full-order saves. + +## Verification Checklist + +- [ ] `pnpm test:run src/hooks/__tests__/useRegions.test.ts` +- [ ] `pnpm lint` +- [ ] Manual UI check: route/menu visibility, list search/pagination, create/edit/delete form, reorder local drag then save/cancel. + +## Implementation Notes + +- Do not edit `src/components/ui`. +- Do not add a barrel file for `src/pages/regions`. +- Do not call API directly from page components except through region hooks. +- Do not expose `sortOrder` as an editable input in detail/create forms. +- Keep all admin-facing copy in Korean. diff --git a/docs/features/region-management/spec.md b/docs/features/region-management/spec.md new file mode 100644 index 0000000..35b9b2a --- /dev/null +++ b/docs/features/region-management/spec.md @@ -0,0 +1,189 @@ +# Region Management Spec + +## Summary + +위스키 원산지로 사용되는 지역(국가) 데이터를 어드민에서 조회, 등록, 수정, 삭제할 수 있는 관리 페이지를 추가한다. + +초기 확인에 사용한 공개 GitHub Pages admin API 문서에는 지역 목록 조회만 노출되어 있었지만, dev 기준 API 서버에는 지역 상세, 생성, 수정, 삭제, 정렬 순서 변경 엔드포인트가 존재한다. 이 스펙은 dev API 계약을 기준으로 지역 관리 전체 CRUD 화면을 정의한다. + +## Source Inputs + +- API docs/response shape: + - Dev API base: `.env.local`의 `VITE_API_BASE_URL` (`https://admin-api.development.bottle-note.com`) + - Dev API 문서 후보 경로(`/admin-api/admin-api.html`, `/docs/index.html`, `/swagger-ui/index.html`, `/v3/api-docs`)는 인증 토큰을 붙여도 `403`을 반환했다. + - Dev API 엔드포인트는 인증 토큰으로 `OPTIONS`, 안전한 `GET`, 검증 실패 요청을 통해 확인했다. + - GitHub PR source: `https://github.com/bottle-note/bottle-note-api-server/pull/586` + - PR summary confirms `sort-order` is Banner-style reorder. + - `AdminRegionSortOrderRequest` is `{ sortOrder: number }`. + - Backend behavior: changing one region's `sortOrder` moves that region and pushes all regions with `sortOrder >= newSortOrder` by `+1`; remaining ties are sorted by `korName ASC`. + - `OPTIONS /admin/api/v1/regions`: `POST,GET,HEAD,OPTIONS` + - `OPTIONS /admin/api/v1/regions/{regionId}`: `DELETE,GET,HEAD,PUT,OPTIONS` + - `OPTIONS /admin/api/v1/regions/{regionId}/sort-order`: `PATCH,OPTIONS` +- Existing UI/code references: + - `src/pages/distilleries/DistilleryList.tsx`: URL query 기반 검색, 정렬, 페이지네이션 테이블 패턴 + - `src/pages/distilleries/DistilleryDetail.tsx`: 단순 기준정보 상세/생성/수정/삭제 폼 패턴 + - `src/pages/banners/BannerList.tsx`: 순서 변경 모드, 드래그 핸들, 안내 배너 패턴 + - `src/pages/curations/CurationList.tsx`: 드래그 앤 드롭 순서 변경 UI 패턴 + - `src/hooks/useReorderDrag.ts`: 드래그 상태와 영향 범위 계산 참고 + - `src/pages/tasting-tags/TastingTagList.tsx`: 위스키/테이스팅 태그 메뉴 하위 관리 페이지 패턴 + - `src/config/menu.config.ts`: 위스키/테이스팅 태그 사이드바 그룹 + - `src/routes/index.tsx`: `ROOT_ADMIN` 보호 라우트 패턴 + - `src/types/api/region.api.ts`, `src/services/region.service.ts`, `src/hooks/useRegions.ts`: 현재는 지역 목록 API 레이어만 존재 +- User request: + - 위스키/테이스팅 태그 관리 영역에 지역 관리 페이지를 추가하기 전에, dev 기준 백엔드 API 스펙을 확인하고 화면 구성 기준이 되는 스펙을 작성한다. + +## Admin Workflow + +- 관리자는 사이드바의 `위스키/테이스팅 태그` 그룹에서 `지역 관리` 메뉴로 진입한다. +- 지역 관리의 기본 화면은 `지역 목록`이다. +- 목록 화면은 지역 데이터를 테이블로 보여준다. +- 관리자는 지역 한글명/영문명 기준으로 검색할 수 있다. +- 검색어, 페이지 번호, 페이지 크기, 정렬 방향은 URL 파라미터로 유지한다. +- 관리자는 순서 정렬 방향을 `ASC` 또는 `DESC`로 변경할 수 있다. +- 관리자는 페이지네이션으로 지역 목록을 탐색할 수 있다. +- 관리자는 `지역 추가` 버튼으로 신규 등록 화면에 진입한다. +- 관리자는 목록 행을 클릭해 지역 상세 화면에 진입한다. +- 상세 화면에서 관리자는 지역 한글명, 영문명, 대륙, 설명, 상위 지역을 확인하고 수정할 수 있다. +- 상세 화면에서 관리자는 지역을 삭제할 수 있다. +- 삭제는 확인 다이얼로그를 거친다. +- 지역 순서는 상세 폼이 아니라 목록의 순서 변경 모드에서만 변경한다. +- 순서 변경 모드는 기존 배너/큐레이션 목록의 드래그 앤 드롭 패턴을 따른다. +- 순서 변경 모드는 `sortOrder=ASC` 기준 목록에서 동작한다. 다른 정렬 방향에서 진입하면 `sortOrder=ASC`, `page=0`으로 전환한다. +- 순서 변경 모드에서 드래그 앤 드롭은 로컬 목록 순서만 변경한다. +- `순서 변경 완료` 버튼을 클릭하면 staged move를 `PATCH /sort-order`로 반영한다. +- `PATCH /sort-order`는 bulk API가 아니라 단일 지역 이동 API다. 한 지역의 `regionId`와 새 `sortOrder`를 보내면 백엔드가 충돌하는 기존 지역들을 자동으로 밀어낸다. +- `취소` 버튼을 클릭하면 저장하지 않은 로컬 순서 변경을 버리고 일반 목록 모드로 돌아간다. +- `parentId`는 지역 관계를 나타내는 참고 정보이며, 순서 변경 제약으로 사용하지 않는다. +- 드래그 앤 드롭은 목록에 표시된 flat 지역 row 사이에서 허용한다. +- 상위 지역 자체를 바꾸는 동작은 상세 화면의 `상위 지역` 필드에서 처리한다. + +## Data Requirements + +- `GET /admin/api/v1/regions` + - `keyword?: string`: 검색어. 입력값이 비어 있으면 요청에서 제외한다. + - `page?: number`: 0-based 페이지 번호. 기본값은 `0`. + - `size?: number`: 페이지 크기. 지역 목록 화면 기본 조회 개수는 `100`. + - `sortOrder?: 'ASC' | 'DESC'`: 정렬 방향. 기본값은 `ASC`. + - Response list item: + - `id: number` + - `korName: string` + - `engName: string` + - `continent: string | null` + - `description: string | null` + - `createdAt: string` + - `modifiedAt: string` + - `parentId: number | null` + - `sortOrder: number` + - Response meta: + - `page: number` + - `size: number` + - `totalElements: number` + - `totalPages: number` + - `hasNext: boolean` +- `GET /admin/api/v1/regions/{regionId}` + - Response detail: + - `id: number` + - `korName: string` + - `engName: string` + - `continent: string | null` + - `description: string | null` + - `sortOrder: number` + - `parentId: number | null` + - `parentKorName: string | null` + - `hasChildren: boolean` + - `alcoholCount: number` + - `createAt: string` + - `lastModifyAt: string` + - Dev 상세 응답은 목록 응답과 달리 날짜 필드명이 `createAt`, `lastModifyAt`이다. +- `POST /admin/api/v1/regions` + - Request: + - `korName: string` + - `engName: string` + - `continent?: string | null` + - `description?: string | null` + - `parentId?: number | null` + - `sortOrder?: number | null` + - Empty object 검증 결과 `korName`, `engName`은 필수다. + - 중복 한글명은 `REGION_DUPLICATE_KOR_NAME`으로 실패한다. + - Response는 프로젝트의 일반 mutation 응답 형식인 `{ code, message, targetId, responseAt }`로 본다. +- `PUT /admin/api/v1/regions/{regionId}` + - Request는 생성과 동일하게 본다. + - Empty object 검증 결과 `korName`, `engName`은 필수다. + - 존재하지 않는 ID는 `REGION_NOT_FOUND`로 실패한다. + - Response는 프로젝트의 일반 mutation 응답 형식인 `{ code, message, targetId, responseAt }`로 본다. +- `DELETE /admin/api/v1/regions/{regionId}` + - 존재하지 않는 ID는 `REGION_NOT_FOUND`로 실패한다. + - Response는 프로젝트의 일반 mutation 응답 형식인 `{ code, message, targetId, responseAt }`로 본다. +- `PATCH /admin/api/v1/regions/{regionId}/sort-order` + - Request: + - `sortOrder: number` + - Empty object 검증 결과 `sortOrder`는 필수다. + - Response는 프로젝트의 일반 mutation 응답 형식인 `{ code, message, targetId, responseAt }`로 본다. + - UI에서는 `순서 변경 완료` 클릭 시에만 호출한다. + - 드래그 중에는 API를 호출하지 않고 로컬 목록 순서만 변경한다. + - 완료 시 마지막 staged move의 `regionId`와 target `sortOrder`를 전송한다. + - 여러 번 드래그한 상태를 모두 지원해야 하면, 완료 시 staged move queue를 순서대로 재생한다. 각 요청은 `{ regionId, sortOrder }` 단건 이동이며, 변경된 모든 row를 직접 PATCH하지 않는다. + - 백엔드는 해당 지역을 새 `sortOrder`에 삽입하고 `sortOrder >= newSortOrder`인 다른 지역들을 `+1` 밀어낸다. + - 실패하면 저장 전 순서로 로컬 상태를 되돌리고 목록을 다시 조회한다. + +## Acceptance Criteria + +- [ ] `ROOT_ADMIN` 관리자는 사이드바의 `위스키/테이스팅 태그` 하위에서 `지역 관리 > 지역 목록` 메뉴를 볼 수 있다. +- [ ] `ROOT_ADMIN` 관리자는 `/regions` 경로에서 지역 목록 페이지를 볼 수 있다. +- [ ] 지역 목록은 `GET /admin/api/v1/regions`를 호출하고, 응답의 `data`와 `meta`를 사용해 테이블과 페이지네이션을 렌더링한다. +- [ ] 목록 테이블은 ID, 한글명, 영문명, 대륙, 상위 지역, 순서, 수정일을 표시한다. +- [ ] 검색어 입력 후 검색하면 `keyword`와 `page=0`이 URL에 반영되고 목록이 다시 조회된다. +- [ ] 정렬 방향을 변경하면 순서 기준 `sortOrder`와 `page=0`이 URL에 반영되고 목록이 다시 조회된다. +- [ ] 페이지 번호와 페이지 크기를 변경하면 URL 파라미터와 목록 조회 파라미터가 함께 변경된다. +- [ ] 목록 우측 상단의 `순서 변경` 버튼을 클릭하면 순서 변경 모드로 진입한다. +- [ ] `sortOrder=DESC` 상태에서 순서 변경 모드에 진입하면 `sortOrder=ASC`, `page=0` 기준 목록으로 전환된다. +- [ ] 순서 변경 모드에서는 안내 배너와 드래그 핸들이 표시되고, 버튼 문구는 `순서 변경 완료`로 바뀐다. +- [ ] 순서 변경 모드에서는 행 클릭으로 상세 화면에 이동하지 않는다. +- [ ] 지역 row를 드래그하면 `parentId`와 무관하게 화면 내 로컬 순서만 변경되고 API는 호출되지 않는다. +- [ ] 순서 변경 완료 버튼을 클릭하면 staged move에 대해 `PATCH /admin/api/v1/regions/{regionId}/sort-order`가 호출된다. +- [ ] 순서 변경 PATCH body는 `{ sortOrder: number }`이며, 프론트가 영향받은 모든 row를 직접 저장하지 않는다. +- [ ] 순서 변경 완료가 성공하면 일반 목록 모드로 돌아가고 목록을 다시 조회한다. +- [ ] 순서 변경 완료가 실패하면 저장 전 순서로 되돌리고 목록을 다시 조회한다. +- [ ] 취소 버튼을 클릭하면 로컬 순서 변경을 버리고 일반 목록 모드로 돌아간다. +- [ ] `지역 추가` 버튼은 `/regions/new` 등록 화면으로 이동한다. +- [ ] 목록 행 클릭은 `/regions/{id}` 상세 화면으로 이동한다. +- [ ] 등록 화면은 한글명, 영문명, 대륙, 설명, 상위 지역을 입력받고 `POST /admin/api/v1/regions`를 호출한다. +- [ ] 상세 화면은 `GET /admin/api/v1/regions/{regionId}`를 호출해 폼 초기값과 참조 정보를 표시한다. +- [ ] 수정 저장은 `PUT /admin/api/v1/regions/{regionId}`를 호출한다. +- [ ] 삭제는 확인 다이얼로그 이후 `DELETE /admin/api/v1/regions/{regionId}`를 호출하고 목록으로 돌아간다. +- [ ] 로딩, 빈 목록, 검색 결과 없음, 저장 중, 삭제 중 상태가 한국어 문구로 표시된다. +- [ ] API 성공 후 목록/상세 캐시가 적절히 무효화되고 한국어 toast가 표시된다. + +## In Scope + +- 지역 목록 페이지 추가 +- 지역 등록/상세/수정 페이지 추가 +- 지역 삭제 확인 다이얼로그 +- 지역 목록 드래그 앤 드롭 순서 변경 모드 +- 위스키/테이스팅 태그 사이드바 하위 메뉴 추가 +- `/regions`, `/regions/new`, `/regions/:id` 라우트 추가 +- 지역 API 타입, 서비스, 훅을 dev API 기준으로 보강 +- 목록 페이지 검색, 정렬, 페이지네이션 상태의 URL 파라미터 관리 +- React Hook Form + Zod 기반 지역 폼 스키마 +- 상위 지역 선택은 기존 지역 목록 API를 재사용한다. +- `sortOrder` 변경은 상세 폼이 아니라 목록 드래그 앤 드롭으로만 제공한다. +- 순서 변경 API 호출은 드롭 시점이 아니라 `순서 변경 완료` 클릭 시점에만 수행한다. +- 순서 변경은 `parentId`와 무관한 flat 목록 기준으로 제공한다. +- 목록/상세/생성/수정/삭제/정렬 변경에 대한 focused 테스트 + +## Out of Scope + +- 지역 계층 구조를 드래그 앤 드롭 트리로 편집하는 기능 +- 드래그 앤 드롭으로 `parentId`를 변경하는 기능 +- 지역과 위스키의 연결 관계 직접 편집 +- 특정 지역에 연결된 위스키 목록 상세 관리 +- 기존 위스키 등록/수정 폼의 지역 선택 UX 변경 +- dev API 문서 HTML 접근 권한 문제 해결 + +## Open Questions + +- `keyword` 검색 대상은 한글명과 영문명 모두인지, 대륙/설명까지 포함하는지 확인이 필요한가? +- `continent` 값은 자유 문자열인지, 고정 enum인지 확인이 필요한가? +- `parentId` 선택 시 자기 자신 또는 하위 지역 선택 방지는 백엔드 검증만으로 충분한가, 프론트에서도 제외해야 하는가? +- `hasChildren` 또는 `alcoholCount > 0`인 지역 삭제가 백엔드에서 제한되는지, 제한된다면 어떤 에러 코드와 문구가 내려오는가? +- 상세 응답의 `createAt`, `lastModifyAt` 필드명은 의도된 계약인가, `createdAt`, `modifiedAt`으로 맞춰질 예정인가? diff --git a/src/config/menu.config.ts b/src/config/menu.config.ts index 2194ce8..a4189a7 100644 --- a/src/config/menu.config.ts +++ b/src/config/menu.config.ts @@ -14,6 +14,7 @@ import { LayoutDashboard, Layers, Factory, + MapPinned, } from 'lucide-react'; import type { MenuGroup } from '@/types/menu'; @@ -76,6 +77,25 @@ export const menuConfig: MenuGroup[] = [ }, ], }, + { + id: 'region-management', + label: '지역 관리', + icon: MapPinned, + children: [ + { + id: 'region-list', + label: '지역 목록', + icon: List, + path: '/regions', + }, + { + id: 'region-create', + label: '지역 추가', + icon: Plus, + path: '/regions/new', + }, + ], + }, { id: 'distillery-management', label: '증류소 관리', diff --git a/src/hooks/__tests__/useRegions.test.ts b/src/hooks/__tests__/useRegions.test.ts new file mode 100644 index 0000000..4140983 --- /dev/null +++ b/src/hooks/__tests__/useRegions.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi } 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, wrapApiResponse } from '@/test/mocks/data'; +import { + useRegionCreate, + useRegionDelete, + useRegionDetail, + useRegionList, + useRegionSortOrderUpdate, + useRegionUpdate, +} from '../useRegions'; + +const BASE = '/admin/api/v1/regions'; + +describe('useRegions hooks', () => { + describe('useRegionList', () => { + it('목록 데이터를 반환한다', async () => { + const { result } = renderHook(() => useRegionList()); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items.length).toBeGreaterThan(0); + expect(result.current.data!.meta.totalElements).toBeGreaterThan(0); + }); + + it('keyword로 필터링한다', async () => { + const { result } = renderHook(() => useRegionList({ keyword: '스페이사이드' })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items).toHaveLength(1); + expect(result.current.data!.items[0]!.korName).toBe('스페이사이드'); + }); + + it('sortOrder DESC로 정렬한다', async () => { + const { result } = renderHook(() => useRegionList({ sortOrder: 'DESC' })); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.items[0]!.korName).toBe('미국'); + }); + + it('API 에러 시 에러 상태가 된다', async () => { + server.use( + http.get(BASE, () => { + return HttpResponse.json(wrapApiError(500, 'SERVER_ERROR', '서버 오류'), { + status: 500, + }); + }) + ); + + const { result } = renderHook(() => useRegionList()); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + describe('useRegionDetail', () => { + it('id가 undefined이면 쿼리가 비활성화된다', () => { + const { result } = renderHook(() => useRegionDetail(undefined)); + expect(result.current.fetchStatus).toBe('idle'); + }); + + it('상세 데이터를 반환한다', async () => { + const { result } = renderHook(() => useRegionDetail(1)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data!.id).toBe(1); + expect(result.current.data!.korName).toBe('스코틀랜드'); + expect(result.current.data!.sortOrder).toBe(0); + expect(result.current.data!.hasChildren).toBe(true); + }); + + it('id가 0이어도 상세 쿼리를 실행한다', async () => { + server.use( + http.get(`${BASE}/0`, () => { + return HttpResponse.json( + wrapApiResponse({ + id: 0, + korName: '-', + engName: '-', + continent: null, + description: null, + sortOrder: 21, + parentId: null, + parentKorName: null, + hasChildren: false, + alcoholCount: 0, + createAt: '2024-01-01T00:00:00', + lastModifyAt: '2024-06-01T00:00:00', + }) + ); + }) + ); + + const { result } = renderHook(() => useRegionDetail(0)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data!.id).toBe(0); + }); + + it('존재하지 않는 ID는 에러 상태가 된다', async () => { + const { result } = renderHook(() => useRegionDetail(9999)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + describe('useRegionCreate', () => { + it('생성 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useRegionCreate({ onSuccess })); + + result.current.mutate({ + korName: '일본', + engName: 'Japan', + continent: '아시아', + description: null, + parentId: null, + sortOrder: 9999, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('에러 시 에러 상태가 된다', async () => { + server.use( + http.post(BASE, () => { + return HttpResponse.json( + wrapApiError(400, 'DUPLICATE_NAME', '이미 존재하는 지역명입니다.'), + { status: 400 } + ); + }) + ); + + const { result } = renderHook(() => useRegionCreate()); + + result.current.mutate({ + korName: '스코틀랜드', + engName: 'Scotland', + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + describe('useRegionUpdate', () => { + it('수정 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useRegionUpdate({ onSuccess })); + + result.current.mutate({ + id: 1, + data: { + korName: '스코틀랜드 수정', + engName: 'Scotland Updated', + sortOrder: 0, + }, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + describe('useRegionDelete', () => { + it('삭제 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useRegionDelete({ onSuccess })); + + result.current.mutate(1); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('에러 시 에러 상태가 된다', async () => { + server.use( + http.delete(`${BASE}/:id`, () => { + return HttpResponse.json( + wrapApiError(400, 'REGION_HAS_CHILDREN', '하위 지역이 있어 삭제할 수 없습니다.'), + { status: 400 } + ); + }) + ); + + const { result } = renderHook(() => useRegionDelete()); + + result.current.mutate(1); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + describe('useRegionSortOrderUpdate', () => { + it('정렬 순서 변경 mutation이 성공한다', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useRegionSortOrderUpdate({ onSuccess })); + + result.current.mutate({ + id: 1, + data: { sortOrder: 2 }, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(onSuccess).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/hooks/useRegions.ts b/src/hooks/useRegions.ts index 15ac516..0ef801c 100644 --- a/src/hooks/useRegions.ts +++ b/src/hooks/useRegions.ts @@ -2,13 +2,23 @@ * Region API 커스텀 훅 */ +import { useQueryClient } from '@tanstack/react-query'; import { useApiQuery } from './useApiQuery'; +import { useApiMutation, type UseApiMutationOptions } from './useApiMutation'; import { regionService, regionKeys, type RegionListResponse, } from '@/services/region.service'; -import type { RegionSearchParams } from '@/types/api'; +import type { + RegionSearchParams, + RegionDetail, + RegionFormData, + RegionFormResponse, + RegionDeleteResponse, + RegionSortOrderRequest, + RegionSortOrderResponse, +} from '@/types/api'; /** * 지역 목록 조회 훅 @@ -32,3 +42,132 @@ export function useRegionList(params?: RegionSearchParams) { } ); } + +/** + * 지역 상세 조회 훅 + */ +export function useRegionDetail(id: number | undefined) { + return useApiQuery( + regionKeys.detail(id ?? 0), + () => regionService.detail(id!), + { + enabled: id !== undefined, + staleTime: 1000 * 60 * 5, + } + ); +} + +/** + * 지역 생성 훅 + */ +export function useRegionCreate( + options?: Omit, 'successMessage'> +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + regionService.create, + { + successMessage: '지역이 등록되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: regionKeys.lists() }); + if (onSuccess) { + (onSuccess as (data: RegionFormResponse, variables: RegionFormData, context: unknown) => void)(data, variables, context); + } + }, + } + ); +} + +export interface RegionUpdateVariables { + id: number; + data: RegionFormData; +} + +/** + * 지역 수정 훅 + */ +export function useRegionUpdate( + options?: Omit, 'successMessage'> +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + ({ id, data }) => regionService.update(id, data), + { + successMessage: '지역이 수정되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: regionKeys.lists() }); + queryClient.invalidateQueries({ queryKey: regionKeys.details() }); + if (onSuccess) { + (onSuccess as (data: RegionFormResponse, variables: RegionUpdateVariables, context: unknown) => void)(data, variables, context); + } + }, + } + ); +} + +/** + * 지역 삭제 훅 + */ +export function useRegionDelete( + options?: Omit, 'successMessage'> +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + regionService.delete, + { + successMessage: '지역이 삭제되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: regionKeys.lists() }); + if (onSuccess) { + (onSuccess as (data: RegionDeleteResponse, variables: number, context: unknown) => void)(data, variables, context); + } + }, + } + ); +} + +export interface RegionSortOrderVariables { + id: number; + data: RegionSortOrderRequest; +} + +/** + * 지역 정렬 순서 변경 훅 + */ +export function useRegionSortOrderUpdate( + options?: Omit< + UseApiMutationOptions, + 'successMessage' + > +) { + const queryClient = useQueryClient(); + const { onSuccess, ...restOptions } = options ?? {}; + + return useApiMutation( + ({ id, data }) => regionService.updateSortOrder(id, data), + { + successMessage: '지역 순서가 변경되었습니다.', + ...restOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: regionKeys.lists() }); + queryClient.invalidateQueries({ queryKey: regionKeys.detail(variables.id) }); + if (onSuccess) { + (onSuccess as ( + data: RegionSortOrderResponse, + variables: RegionSortOrderVariables, + context: unknown + ) => void)(data, variables, context); + } + }, + } + ); +} diff --git a/src/pages/regions/RegionDetail.tsx b/src/pages/regions/RegionDetail.tsx new file mode 100644 index 0000000..4a7f65d --- /dev/null +++ b/src/pages/regions/RegionDetail.tsx @@ -0,0 +1,285 @@ +/** + * 지역 상세 페이지 + * - 신규 등록 + * - 상세 조회 및 수정 + */ + +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Save, Trash2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { DeleteConfirmDialog } from '@/components/common/DeleteConfirmDialog'; +import { FormField } from '@/components/common/FormField'; +import { SearchableSelect } from '@/components/common/SearchableSelect'; +import { + useRegionCreate, + useRegionDelete, + useRegionDetail, + useRegionList, + useRegionUpdate, +} from '@/hooks/useRegions'; +import { + regionDefaultValues, + regionFormSchema, + type RegionFormValues, +} from './region.schema'; + +function toNullableText(value: string | null | undefined) { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function formatDateTime(value: string | undefined) { + if (!value) return '-'; + return new Date(value).toLocaleString('ko-KR'); +} + +export function RegionDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const isNewMode = !id || id === 'new'; + const regionId = isNewMode ? undefined : Number(id); + + const { data: detailData, isLoading } = useRegionDetail(regionId); + const { data: parentRegionData } = useRegionList({ size: 100, sortOrder: 'ASC' }); + + const createMutation = useRegionCreate({ + onSuccess: () => { + navigate('/regions'); + }, + }); + const updateMutation = useRegionUpdate(); + const deleteMutation = useRegionDelete({ + onSuccess: () => { + navigate('/regions'); + }, + }); + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(regionFormSchema), + defaultValues: regionDefaultValues, + }); + + useEffect(() => { + if (isNewMode) { + form.reset(regionDefaultValues); + } else if (detailData) { + form.reset({ + korName: detailData.korName, + engName: detailData.engName, + continent: detailData.continent, + description: detailData.description, + parentId: detailData.parentId, + sortOrder: detailData.sortOrder, + }); + } + }, [detailData, form, isNewMode]); + + const parentOptions = [ + { value: 'NONE', label: '상위 지역 없음' }, + ...(parentRegionData?.items ?? []) + .filter((region) => region.id !== regionId) + .map((region) => ({ + value: String(region.id), + label: `${region.korName} (${region.engName})`, + })), + ]; + + const selectedParentValue = form.watch('parentId'); + const nextCreateSortOrder = + parentRegionData?.items.length + ? Math.max(...parentRegionData.items.map((region) => region.sortOrder)) + 1 + : regionDefaultValues.sortOrder; + + const onSubmit = (data: RegionFormValues) => { + const formData = { + korName: data.korName, + engName: data.engName, + continent: toNullableText(data.continent), + description: toNullableText(data.description), + parentId: data.parentId, + sortOrder: isNewMode ? nextCreateSortOrder : data.sortOrder, + }; + + if (isNewMode) { + createMutation.mutate(formData); + } else if (regionId !== undefined) { + updateMutation.mutate({ id: regionId, data: formData }); + } + }; + + const handleDeleteConfirm = () => { + if (regionId !== undefined) { + deleteMutation.mutate(regionId); + } + }; + + const handleBack = () => navigate('/regions'); + + const isMutating = createMutation.isPending || updateMutation.isPending; + + return ( +
+ + {detailData && ( + + )} + + + } + /> + + {isLoading ? ( +
로딩 중...
+ ) : ( +
+ + + 기본 정보 + 지역의 기본 정보를 입력합니다. + + +
+ + + + + + +
+ +
+ + form.setValue('continent', e.target.value)} + placeholder="예: 유럽" + /> + + + + form.setValue( + 'parentId', + value === 'NONE' ? null : Number(value), + { shouldValidate: true } + ) + } + options={parentOptions} + placeholder="상위 지역 없음" + searchPlaceholder="상위 지역 검색..." + emptyMessage="지역을 찾을 수 없습니다." + /> + +
+ + +