'use client'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { FoxButton } from '@fox/core/components/fox-button'; import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; import { FoxCheckbox } from '@fox/core/components/fox-checkbox'; import { FoxInput } from '@fox/core/components/fox-input'; import { FoxListContainer, type FoxListColumn, } from '@fox/core/components/fox-list-container'; import { FoxSelect } from '@fox/core/components/fox-select'; import { FoxSelectText } from '@fox/core/components/fox-select-text'; import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator'; import { useBlockingAction } from '@/app/_hooks/use-blocking-action'; import { useFeedback } from '@/app/_hooks/use-feedback'; import { formatCommonCode, type CommonCode } from '@/lib/domain/common-code'; import { EMPTY_FIELD_PLACEHOLDER, MERCHANT_DETAIL_VISIBILITY_OPTIONS, formatMerchantDetailStatusLabel, formatOptionalValue, type MerchantDetailSummary, } from '@/lib/domain/merchant-detail'; import { FILTER_ALL, MERCHANT_DETAILS_PATH, MERCHANT_DETAIL_NEW_PATH, MERCHANT_DETAIL_PAGE_SIZE_OPTIONS, buildMerchantDetailHref, clampDateRange, type MerchantDetailPageSize, type MerchantDetailQuery, } from '@/lib/domain/merchant-detail-query'; import { formatPhoneNumber } from '@/lib/domain/phone-number'; import { deactivateMerchantDetailsAction, deleteMerchantDetailsAction, } from '../_actions'; import { MerchantDetailRowActions } from './merchant-detail-row-actions'; import { MerchantThumbnail } from './merchant-thumbnail'; import styles from './merchant-detail-list.module.scss'; interface MerchantDetailListProps { items: MerchantDetailSummary[]; categories: CommonCode[]; query: MerchantDetailQuery; currentPage: number; totalPages: number; totalCount: number; /** 서버가 정한 오늘(YYYY-MM-DD). 여기서 계산하면 서버·브라우저 시간대 차이로 hydration이 어긋난다. */ today: string; } /** * 가맹몰 상세 목록 — 기획 BO-MAL-001을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것. * * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정) — Repository가 가맹점 목록에 * 상세를 덧입혀 준다. [+ 업체 등록]은 가맹점을 고르는 등록 화면으로, 행의 [수정]은 그 가맹점의 * 상세 화면으로 간다(상세가 없으면 거기서 처음 만든다). * */ export function MerchantDetailList({ items, categories, query, currentPage, totalPages, totalCount, today, }: MerchantDetailListProps) { const router = useRouter(); const { showAlert, hideAlert, showToast } = useFeedback(); const [isBusy, startAction] = useBlockingAction(); const [selectedIds, setSelectedIds] = useState>(new Set()); const [draft, setDraft] = useState({ category: query.category, visibility: query.visibility, startDate: query.startDate, endDate: query.endDate, }); const allIds = items.map((row) => row.id); const isAllSelected = allIds.length > 0 && allIds.every((id) => selectedIds.has(id)); function go(patch: Partial) { router.replace(buildMerchantDetailHref(query, { ...patch, page: 1 })); } /** 업체명 검색은 검색 상자가 바로 반영하고, 나머지 필터는 [적용]을 눌러야 간다(사용자 확정). */ function applyFilters() { go({ ...draft, ...clampDateRange(draft.startDate, draft.endDate, today) }); } function toggleAll(checked: boolean) { setSelectedIds(checked ? new Set(allIds) : new Set()); } /** 모든 필터를 비우고 첫 페이지로 — 초안과 URL을 함께 되돌린다. */ function resetFilters() { setDraft({ category: FILTER_ALL, visibility: FILTER_ALL, startDate: '', endDate: '' }); router.replace(MERCHANT_DETAILS_PATH); } function toggleOne(id: string, checked: boolean) { setSelectedIds((current) => { const next = new Set(current); if (checked) next.add(id); else next.delete(id); return next; }); } function runBulk( run: (ids: string[]) => Promise<{ status: 'success' } | { status: 'error'; message: string }>, successMessage: string ) { hideAlert(); const ids = items .filter((row) => selectedIds.has(row.id) && row.hasDetail) .map((row) => row.id); if (ids.length === 0) { showToast({ variant: 'warning', message: '상세가 등록된 행만 처리할 수 있습니다.' }); return; } startAction(async () => { const result = await run(ids); showToast( result.status === 'error' ? { variant: 'danger', message: result.message } : { variant: 'success', message: successMessage } ); if (result.status === 'success') setSelectedIds(new Set()); }); } const selectedDetailCount = items.filter( (row) => selectedIds.has(row.id) && row.hasDetail ).length; function confirmBulkDelete() { showAlert({ variant: 'danger', title: '선택한 상세를 삭제하시겠습니까?', message: `${selectedDetailCount}건의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, actions: ( runBulk(deleteMerchantDetailsAction, '삭제했습니다.')} /> ), }); } const columns: FoxListColumn[] = [ { key: 'select', header: ( ), width: 56, align: 'center', render: (row) => ( toggleOne(row.id, checked)} /> ), }, { key: 'no', header: 'No', width: 72, align: 'center', render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1, }, { key: 'thumbnail', header: '대표이미지', width: 140, align: 'center', render: (row) => ( ), }, { key: 'name', header: '업체명', width: 220, emphasis: true }, { key: 'category', header: '카테고리', width: 140, render: (row) => row.categoryLabel ?? (row.categoryCode ? formatCommonCode(categories, row.categoryCode) : EMPTY_FIELD_PLACEHOLDER), }, { key: 'phoneNumber', header: '연락처', width: 150, render: (row) => formatOptionalValue(formatPhoneNumber(row.phoneNumber)), }, { key: 'address', header: '주소', width: 280, render: (row) => formatOptionalValue(row.address), }, { key: 'productCount', header: '메뉴', width: 72, align: 'center', render: (row) => (row.productCount === null ? EMPTY_FIELD_PLACEHOLDER : String(row.productCount)), }, { key: 'status', header: '상태', width: 110, align: 'center', render: (row) => row.hasDetail && row.isVisible !== null ? ( ) : ( formatMerchantDetailStatusLabel(row) ), }, { key: 'registeredAt', header: '등록일', width: 120, align: 'center', render: (row) => formatOptionalValue(row.registeredAt), }, { key: 'actions', header: '관리', width: 220, align: 'center', render: (row) => , }, ]; return ( title="가맹몰 상세 관리" description="지역상생몰(바우처 사용처)에 노출되는 업체를 등록·관리한다" breadcrumb={[ { label: '홈', href: '/' }, { label: '관리자관리' }, { label: '가맹몰 상세 관리' }, ]} caption="가맹몰 상세 목록" columns={columns} rows={items} rowKey={(row) => row.id} totalCount={totalCount} emptyMessage="검색 결과가 없습니다." sorts={ ({ value: String(size), label: `${size}개씩`, }))} value={String(query.pageSize)} onValueChange={(value) => go({ pageSize: Number(value) as MerchantDetailPageSize }) } /> } filter={
({ value: code.code, label: code.label })), ]} value={draft.category} onValueChange={(value) => setDraft((current) => ({ ...current, category: value }))} />
({ value: option.value, label: option.label, })), ]} value={draft.visibility} onValueChange={(value) => setDraft((current) => ({ ...current, visibility: value }))} />
{/* 등록일 기간 — 백엔드가 조건에 쓰지 않아 Repository가 등록일로 거른다. */}
setDraft((current) => ({ ...current, ...clampDateRange(value, current.endDate, today) })) } /> setDraft((current) => ({ ...current, ...clampDateRange(current.startDate, value, today) })) } />
} searchFields={[]} keyword={query.keyword} searchPlaceholder="업체명을 입력하세요." toolbarActions={ <> runBulk(deactivateMerchantDetailsAction, '비활성으로 바꿨습니다.')} />