feat: 꾸미기 아이템 목록 화면 구현
Co-Authored-By: Claude Opus 5
@4ce26ed4dc1740154152eef234432e746bad603c
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
... | ... | @@ -0,0 +1,73 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useState, type ChangeEvent } from 'react'; | |
| 5 | +import { Button } from '@/components/ui/button'; | |
| 6 | +import { Select } from '@/components/ui/select'; | |
| 7 | +import { | |
| 8 | + DECORATION_ITEM_PAGE_SIZE_OPTIONS, | |
| 9 | + buildDecorationItemHref, | |
| 10 | + type DecorationItemPageSize, | |
| 11 | + type DecorationItemQuery, | |
| 12 | +} from '@/lib/domain/decoration-item-query'; | |
| 13 | +import { DecorationItemCreateModal } from './decoration-item-create-modal'; | |
| 14 | + | |
| 15 | +interface DecorationItemListToolbarProps { | |
| 16 | + query: DecorationItemQuery; | |
| 17 | + /** 현재 유형(개별/셋트)의 검색어 무관 전체 건수 — 등록 팝업의 "(총 등록 N개)" 힌트에 쓴다. */ | |
| 18 | + typeTotalCount: number; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * 목록 툴바(시안 ADM_ITM_101) — 페이지 크기 select는 변경 즉시 URL을 갱신해 서버 데이터를 | |
| 23 | + * 다시 조회하게 하고 1페이지로 되돌린다. 시안에 admins의 "정렬" select·엑셀다운로드에 | |
| 24 | + * 대응하는 항목이 없어 페이지 크기와 등록 버튼만 둔다. | |
| 25 | + * | |
| 26 | + * [+ 신규 등록]은 현재 유형 탭을 기본값으로 들고 등록 팝업을 연다 — 팝업 열림 상태를 이 | |
| 27 | + * 컴포넌트가 소유한다(admin-member-list-toolbar.tsx와 동일한 패턴). | |
| 28 | + */ | |
| 29 | +export function DecorationItemListToolbar({ | |
| 30 | + query, | |
| 31 | + typeTotalCount, | |
| 32 | +}: DecorationItemListToolbarProps) { | |
| 33 | + const router = useRouter(); | |
| 34 | + const [isCreateOpen, setIsCreateOpen] = useState(false); | |
| 35 | + | |
| 36 | + function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) { | |
| 37 | + const pageSize = Number(event.target.value) as DecorationItemPageSize; | |
| 38 | + router.replace(buildDecorationItemHref(query, { pageSize, page: 1 })); | |
| 39 | + } | |
| 40 | + | |
| 41 | + return ( | |
| 42 | + <div className="flex flex-wrap items-center justify-end gap-3"> | |
| 43 | + <Button | |
| 44 | + type="button" | |
| 45 | + variant="primary" | |
| 46 | + onClick={() => setIsCreateOpen(true)} | |
| 47 | + > | |
| 48 | + + 신규 등록 | |
| 49 | + </Button> | |
| 50 | + | |
| 51 | + <Select | |
| 52 | + name="pageSize" | |
| 53 | + aria-label="페이지 크기" | |
| 54 | + defaultValue={query.pageSize} | |
| 55 | + onChange={handlePageSizeChange} | |
| 56 | + > | |
| 57 | + {DECORATION_ITEM_PAGE_SIZE_OPTIONS.map((size) => ( | |
| 58 | + <option key={size} value={size}> | |
| 59 | + {size}개씩 | |
| 60 | + </option> | |
| 61 | + ))} | |
| 62 | + </Select> | |
| 63 | + | |
| 64 | + {isCreateOpen && ( | |
| 65 | + <DecorationItemCreateModal | |
| 66 | + defaultItemType={query.itemType} | |
| 67 | + typeTotalCount={typeTotalCount} | |
| 68 | + onClose={() => setIsCreateOpen(false)} | |
| 69 | + /> | |
| 70 | + )} | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
... | ... | @@ -0,0 +1,74 @@ |
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Button } from '@/components/ui/button'; | |
| 3 | +import { Field } from '@/components/ui/field'; | |
| 4 | +import { Input } from '@/components/ui/input'; | |
| 5 | +import { Select } from '@/components/ui/select'; | |
| 6 | +import { | |
| 7 | + DECORATION_ITEMS_PATH, | |
| 8 | + DECORATION_ITEM_SEARCH_FIELD_OPTIONS, | |
| 9 | + DEFAULT_DECORATION_ITEM_SEARCH_FIELD, | |
| 10 | + buildDecorationItemHref, | |
| 11 | + type DecorationItemQuery, | |
| 12 | +} from '@/lib/domain/decoration-item-query'; | |
| 13 | + | |
| 14 | +interface DecorationItemSearchBarProps { | |
| 15 | + query: DecorationItemQuery; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 검색 영역(시안 ADM_ITM_101) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다 | |
| 20 | + * (자바스크립트 없이도 동작). 현재 `itemType`(유형 탭)·`pageSize`는 hidden input으로 함께 | |
| 21 | + * 제출해 검색 후에도 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 | |
| 22 | + * 두지 않아 `parseDecorationItemQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 | |
| 23 | + * 제출이 아니라 기본값(검색대상·검색어)으로 되돌린 링크라 현재 입력값과 무관하게 동작한다 | |
| 24 | + * (유형 탭은 초기화 대상이 아니다 — 지금 보고 있는 목록은 유지). 상호작용이 전부 네이티브 폼 | |
| 25 | + * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다 | |
| 26 | + * (admin-member-search-bar.tsx와 동일한 설계). | |
| 27 | + */ | |
| 28 | +export function DecorationItemSearchBar({ query }: DecorationItemSearchBarProps) { | |
| 29 | + const resetHref = buildDecorationItemHref(query, { | |
| 30 | + searchField: DEFAULT_DECORATION_ITEM_SEARCH_FIELD, | |
| 31 | + keyword: '', | |
| 32 | + page: 1, | |
| 33 | + }); | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <form | |
| 37 | + method="get" | |
| 38 | + action={DECORATION_ITEMS_PATH} | |
| 39 | + className="flex flex-wrap items-end gap-3" | |
| 40 | + > | |
| 41 | + <input type="hidden" name="itemType" value={query.itemType} /> | |
| 42 | + <input type="hidden" name="pageSize" value={query.pageSize} /> | |
| 43 | + | |
| 44 | + <Field label="검색 대상"> | |
| 45 | + <Select name="searchField" defaultValue={query.searchField}> | |
| 46 | + {DECORATION_ITEM_SEARCH_FIELD_OPTIONS.map((option) => ( | |
| 47 | + <option key={option.value} value={option.value}> | |
| 48 | + {option.label} | |
| 49 | + </option> | |
| 50 | + ))} | |
| 51 | + </Select> | |
| 52 | + </Field> | |
| 53 | + | |
| 54 | + <Field label="검색어"> | |
| 55 | + <Input | |
| 56 | + type="text" | |
| 57 | + name="keyword" | |
| 58 | + defaultValue={query.keyword} | |
| 59 | + placeholder="검색어를 입력하세요." | |
| 60 | + /> | |
| 61 | + </Field> | |
| 62 | + | |
| 63 | + <Button type="submit" variant="secondary"> | |
| 64 | + 검색 | |
| 65 | + </Button> | |
| 66 | + <Link | |
| 67 | + href={resetHref} | |
| 68 | + className="text-body-md text-foreground-muted underline-offset-2 hover:underline" | |
| 69 | + > | |
| 70 | + 초기화 | |
| 71 | + </Link> | |
| 72 | + </form> | |
| 73 | + ); | |
| 74 | +} |
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-type-tabs.tsx
... | ... | @@ -0,0 +1,59 @@ |
| 1 | +import Link from 'next/link'; | |
| 2 | +import { DECORATION_ITEM_TYPE_OPTIONS } from '@/lib/domain/decoration-item'; | |
| 3 | +import { | |
| 4 | + buildDecorationItemHref, | |
| 5 | + type DecorationItemQuery, | |
| 6 | +} from '@/lib/domain/decoration-item-query'; | |
| 7 | + | |
| 8 | +interface DecorationItemTypeTabsProps { | |
| 9 | + query: DecorationItemQuery; | |
| 10 | +} | |
| 11 | + | |
| 12 | +const TAB_BASE_CLASSES = | |
| 13 | + 'inline-flex h-10 items-center justify-center rounded-md px-4 text-body-md transition-colors duration-200'; | |
| 14 | +const ACTIVE_TAB_CLASSES = 'bg-primary text-on-primary'; | |
| 15 | +const INACTIVE_TAB_CLASSES = | |
| 16 | + 'border border-border bg-surface text-foreground-muted hover:bg-surface-muted hover:text-foreground'; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 유형 탭(시안 ADM_ITM_101 — 개별아이템/셋트아이템 전환). **셋트아이템은 별도 화면이 없고 이 | |
| 20 | + * 탭이 같은 목록·같은 폼의 유형만 바꾼다**는 것이 시안 두 곳에 명시된 사양이다 | |
| 21 | + * (`decoration-item-query.ts` 상단 주석 참조). | |
| 22 | + * | |
| 23 | + * 값이 URL(`itemType` 쿼리 파라미터)에 실리므로 순수 `next/link` 내비게이션으로 충분해 | |
| 24 | + * 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다. | |
| 25 | + * | |
| 26 | + * 탭 전환 시 검색어/검색대상/페이지크기는 유지하고 페이지만 1로 되돌린다(admin-member의 | |
| 27 | + * 정렬/페이지크기 전환과 같은 규칙) — 다른 유형으로 넘어가면 전체 건수 자체가 달라지기 | |
| 28 | + * 때문이다. 전용 탭 컴포넌트가 없어(§10.3, 신설은 design 레인 소관) 버튼 형태 Link 2개로 | |
| 29 | + * 구성했다. | |
| 30 | + */ | |
| 31 | +export function DecorationItemTypeTabs({ query }: DecorationItemTypeTabsProps) { | |
| 32 | + return ( | |
| 33 | + <div | |
| 34 | + role="tablist" | |
| 35 | + aria-label="아이템 유형" | |
| 36 | + className="flex items-center gap-2" | |
| 37 | + > | |
| 38 | + {DECORATION_ITEM_TYPE_OPTIONS.map((option) => { | |
| 39 | + const isActive = option.value === query.itemType; | |
| 40 | + return ( | |
| 41 | + <Link | |
| 42 | + key={option.value} | |
| 43 | + href={buildDecorationItemHref(query, { | |
| 44 | + itemType: option.value, | |
| 45 | + page: 1, | |
| 46 | + })} | |
| 47 | + role="tab" | |
| 48 | + aria-selected={isActive} | |
| 49 | + className={`${TAB_BASE_CLASSES} ${ | |
| 50 | + isActive ? ACTIVE_TAB_CLASSES : INACTIVE_TAB_CLASSES | |
| 51 | + }`} | |
| 52 | + > | |
| 53 | + {option.label} | |
| 54 | + </Link> | |
| 55 | + ); | |
| 56 | + })} | |
| 57 | + </div> | |
| 58 | + ); | |
| 59 | +} |
+++ app/(protected)/(basic)/decoration-items/error.tsx
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect } from 'react'; | |
| 4 | + | |
| 5 | +interface ErrorPageProps { | |
| 6 | + error: Error & { digest?: string }; | |
| 7 | + unstable_retry: () => void; | |
| 8 | +} | |
| 9 | + | |
| 10 | +// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는 | |
| 11 | +// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다), | |
| 12 | +// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트 | |
| 13 | +// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다(admins/error.tsx와 동일). | |
| 14 | +export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) { | |
| 15 | + useEffect(() => { | |
| 16 | + console.error(error); | |
| 17 | + }, [error]); | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> | |
| 21 | + <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> | |
| 22 | + <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> | |
| 23 | + <button | |
| 24 | + type="button" | |
| 25 | + onClick={() => unstable_retry()} | |
| 26 | + className="mt-4 bg-foreground px-4 py-2 text-background" | |
| 27 | + > | |
| 28 | + 다시 시도 | |
| 29 | + </button> | |
| 30 | + </section> | |
| 31 | + ); | |
| 32 | +} |
+++ app/(protected)/(basic)/decoration-items/loading.tsx
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +import { Spinner } from '@/components/ui/spinner'; | |
| 2 | + | |
| 3 | +// page.tsx가 스트리밍되는 동안의 폴백 — 같은 section 배치(flex-1 items-center justify-center)를 | |
| 4 | +// 써서 콘텐츠 교체 시 레이아웃이 흔들리지 않게 한다. Server Component로 충분해 'use client'를 | |
| 5 | +// 붙이지 않는다(admins/loading.tsx와 동일한 패턴). | |
| 6 | +export default function Loading() { | |
| 7 | + return ( | |
| 8 | + <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> | |
| 9 | + <Spinner size="lg" /> | |
| 10 | + </section> | |
| 11 | + ); | |
| 12 | +} |
+++ app/(protected)/(basic)/decoration-items/page.tsx
... | ... | @@ -0,0 +1,92 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { Alert } from '@/components/ui/alert'; | |
| 4 | +import { Pagination } from '@/components/ui/pagination'; | |
| 5 | +import { verifySession } from '@/lib/auth/dal'; | |
| 6 | +import { fetchDecorationItems } from '@/lib/data/repositories/decoration-item-repository'; | |
| 7 | +import { | |
| 8 | + buildDecorationItemHref, | |
| 9 | + parseDecorationItemQuery, | |
| 10 | +} from '@/lib/domain/decoration-item-query'; | |
| 11 | +import { DecorationItemListToolbar } from './_components/decoration-item-list-toolbar'; | |
| 12 | +import { DecorationItemSearchBar } from './_components/decoration-item-search-bar'; | |
| 13 | +import { DecorationItemTable } from './_components/decoration-item-table'; | |
| 14 | +import { DecorationItemTypeTabs } from './_components/decoration-item-type-tabs'; | |
| 15 | + | |
| 16 | +export const metadata: Metadata = { | |
| 17 | + title: '꾸미기 아이템', | |
| 18 | +}; | |
| 19 | + | |
| 20 | +interface PageProps { | |
| 21 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * 꾸미기 아이템 목록(시안 ADM_ITM_101). | |
| 26 | + * | |
| 27 | + * **셋트아이템은 별도 화면이 없다** — 유형 탭(개별/셋트)만 바꿔 같은 목록·같은 폼을 재사용 | |
| 28 | + * 한다(시안 두 곳에 명시된 사양). `query.itemType`이 곧 그 탭 상태이고, 다른 검색·페이지 | |
| 29 | + * 상태와 마찬가지로 URL(searchParams)이 단일 진실원천이다(`useState`로 들지 않는다). | |
| 30 | + * | |
| 31 | + * `verifySession()`을 호출만 하고 반환값을 쓰지 않는 이유는 인증 확인 자체가 목적이기 | |
| 32 | + * 때문이다 — admins의 "본인 계정 삭제 방지" 같은 사용자 조건부 로직이 이 화면에는 없다. | |
| 33 | + */ | |
| 34 | +export default async function Page({ searchParams }: PageProps) { | |
| 35 | + await verifySession(); | |
| 36 | + | |
| 37 | + const query = parseDecorationItemQuery(await searchParams); | |
| 38 | + const { items, totalCount, typeTotalCount } = | |
| 39 | + await fetchDecorationItems(query); | |
| 40 | + | |
| 41 | + const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize)); | |
| 42 | + // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐, 또는 유형 탭 전환으로 | |
| 43 | + // 전체 건수가 줄어듦) 마지막 페이지로 맞춘다 — 표의 순번 계산도 이 값을 기준으로 해야 헤더의 | |
| 44 | + // "현재페이지"와 어긋나지 않는다. | |
| 45 | + const currentPage = Math.min(query.page, totalPages); | |
| 46 | + | |
| 47 | + return ( | |
| 48 | + <section className="flex flex-1 flex-col gap-6 p-8"> | |
| 49 | + <h1 className="text-headline-sm text-foreground">꾸미기 아이템 목록</h1> | |
| 50 | + | |
| 51 | + <DecorationItemTypeTabs query={query} /> | |
| 52 | + | |
| 53 | + <DecorationItemSearchBar query={query} /> | |
| 54 | + | |
| 55 | + <DecorationItemListToolbar query={query} typeTotalCount={typeTotalCount} /> | |
| 56 | + | |
| 57 | + <p className="text-body-md text-foreground-muted"> | |
| 58 | + 총 {totalCount}개 | 현재페이지 {currentPage}/{totalPages} | |
| 59 | + </p> | |
| 60 | + | |
| 61 | + {items.length === 0 ? ( | |
| 62 | + <Alert | |
| 63 | + title="등록된 아이템이 없습니다." | |
| 64 | + actions={ | |
| 65 | + <Link | |
| 66 | + href={buildDecorationItemHref(query, { keyword: '', page: 1 })} | |
| 67 | + className="bg-foreground px-4 py-2 text-background" | |
| 68 | + > | |
| 69 | + 확인 | |
| 70 | + </Link> | |
| 71 | + } | |
| 72 | + > | |
| 73 | + 다른 검색어로 다시 시도해 주세요. | |
| 74 | + </Alert> | |
| 75 | + ) : ( | |
| 76 | + <> | |
| 77 | + <DecorationItemTable | |
| 78 | + items={items} | |
| 79 | + page={currentPage} | |
| 80 | + pageSize={query.pageSize} | |
| 81 | + typeTotalCount={typeTotalCount} | |
| 82 | + /> | |
| 83 | + <Pagination | |
| 84 | + currentPage={currentPage} | |
| 85 | + totalPages={totalPages} | |
| 86 | + buildHref={(page: number) => buildDecorationItemHref(query, { page })} | |
| 87 | + /> | |
| 88 | + </> | |
| 89 | + )} | |
| 90 | + </section> | |
| 91 | + ); | |
| 92 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?