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