"use client"; import type { ReactNode } from "react"; import { cx } from "../../utils"; import { FoxPageHeader } from "../fox-page-header"; import type { FoxBreadcrumbItem } from "../fox-breadcrumb"; import { FoxTable } from "../fox-table"; import { FoxTableRow } from "../fox-table-row"; import { FoxTableCell } from "../fox-table-cell"; import type { FoxTableCellAlign } from "../fox-table-cell"; import { FoxPagination } from "../fox-pagination"; import { FoxListCount, FoxListToolbar } from "../fox-list-toolbar"; import { FoxListSearch } from "../fox-list-search"; import type { FoxSelectItem } from "../fox-select"; /** 열 하나의 정의. `render`가 있으면 그 결과가 셀 내용이 된다. */ export interface FoxListColumn { /** 열 식별자. `render`가 없으면 `row[key]`를 그대로 그린다. */ key: string; header: ReactNode; /** 셀을 직접 그린다 — 배지·버튼 무엇이든 넣을 수 있다. */ render?: (row: T, index: number) => ReactNode; width?: string | number; align?: FoxTableCellAlign; /** 참이면 그 열의 본문 글자가 진해진다(시안의 강조 열). */ emphasis?: boolean; } /** * 목록 상태의 변경분. 컨테이너는 **바뀐 것만** 담아 넘긴다 — 호출부가 기존 값과 합쳐 * 쓰라는 뜻이다. 화면마다 필요한 정렬·필터 키가 달라 열려 있는 모양이다. */ export interface FoxListQueryPatch { page?: number; keyword?: string; searchField?: string; [key: string]: unknown; } export interface FoxListContainerProps { // ── 머리말 ──────────────────────────────────────────────────────────────── title?: ReactNode; breadcrumb?: FoxBreadcrumbItem[]; headerActions?: ReactNode; // ── 표 ──────────────────────────────────────────────────────────────────── columns: FoxListColumn[]; rows: T[]; /** 행의 안정적인 키. 인덱스를 쓰면 정렬·페이지 이동에서 어긋난다. */ rowKey: (row: T, index: number) => string; caption?: string; /** 행이 없을 때 표 안에 보여줄 말. */ emptyMessage?: ReactNode; // ── 도구 줄 ─────────────────────────────────────────────────────────────── /** 주면 "총 N건"이 나온다. 없으면 그 자리를 비운다. */ totalCount?: number; toolbarLeading?: ReactNode; sorts?: ReactNode; toolbarActions?: ReactNode; filter?: ReactNode; /** 검색 대상 목록. 주면 검색 상자가 나온다. 대상이 하나뿐이면 빈 배열로 두면 된다. */ searchFields?: FoxSelectItem[]; searchField?: string; keyword?: string; searchPlaceholder?: string; /** 검색 상자를 아예 숨긴다. */ searchHidden?: boolean; // ── 페이지 ──────────────────────────────────────────────────────────────── page?: number; totalPages?: number; // ── 이벤트 ──────────────────────────────────────────────────────────────── /** * 주면 페이지 이동이 **링크**가 된다 — 목록이 Server Component로 남고 새로고침·뒤로가기· * URL 공유가 그대로 동작한다. 이 저장소의 목록 화면들이 쓰는 방식이다. */ buildHref?: (patch: FoxListQueryPatch) => string; /** `buildHref`가 없을 때(모달 안 목록 등) 쓰는 콜백. 둘 다 주면 링크가 이긴다. */ onQueryChange?: (patch: FoxListQueryPatch) => void; hidden?: boolean; className?: string; } /** * @fox 목록 화면 뼈대 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) container (3019:8608) * * 머리말 · 도구 줄 · 표 · 페이지네이션을 한 벌로 묶는다. **데이터를 가져오지도, 상태를 갖지도 * 않는다** — 받은 것을 그리고 바뀐 것을 알릴 뿐이라 어떤 목록에도 그대로 쓸 수 있다. * * 표는 `columns` 정의로 그린다. `render`가 ReactNode를 돌려주므로 셀 안에 배지든 버튼 묶음이든 * 들어간다(시안이 그렇다) — 값의 형식을 컨테이너가 알 필요가 없다. * * 이벤트는 두 갈래다. `buildHref`를 주면 링크로 그려 URL이 상태가 되고, `onQueryChange`만 * 주면 콜백으로 알린다. FoxPagination이 이미 같은 방식이라 그 관례를 따랐다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxListContainer({ title, breadcrumb, headerActions, columns, rows, rowKey, caption, emptyMessage = "조회된 내용이 없습니다.", totalCount, toolbarLeading, sorts, toolbarActions, filter, searchFields, searchField, keyword, searchPlaceholder, searchHidden = false, page = 1, totalPages = 1, buildHref, onQueryChange, hidden = false, className, }: FoxListContainerProps) { if (hidden) { return null; } const hasToolbar = totalCount !== undefined || toolbarLeading !== undefined || sorts !== undefined || toolbarActions !== undefined || filter !== undefined || !searchHidden; return (
{(title !== undefined || breadcrumb !== undefined) && ( )}
{hasToolbar && ( ) : undefined } leading={toolbarLeading} sorts={sorts} search={ searchHidden ? undefined : ( onQueryChange?.({ keyword: nextKeyword, searchField: nextField, // 검색 결과는 항상 첫 장부터 본다 — 남아 있던 페이지 번호를 지운다. page: 1, }) } /> ) } actions={toolbarActions} filter={filter} /> )} {/* 열이 많으면 표가 화면보다 넓어진다. 눌러 담지 않고 이 래퍼가 가로로 스크롤한다. */}
{/* `thead`/`tbody`로 감싸지 않는다 — FoxTable이 스스로 만들고, 머리행은 `head`만 보고 제자리로 보낸다. 감싸면 표 안에 표 구조가 이중으로 생긴다. */} {columns.map((column) => ( {column.header} ))} {rows.length === 0 ? ( {emptyMessage} ) : ( rows.map((row, index) => ( {columns.map((column) => ( {column.render ? column.render(row, index) : String( (row as Record)[column.key] ?? "" )} ))} )) )}
{totalPages > 1 && ( buildHref({ page: next }) : undefined} onPageChange={ buildHref ? undefined : (next) => onQueryChange?.({ page: next }) } /> )}
); }