File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'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_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을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것.
*
* 가맹점 전체가 오고 상세가 붙은 건만 상태·등록일이 있다. 버튼 구성은 시안 그대로다 —
* [+ 업체 등록]과 행의 [수정][비활성][삭제]를 모두 둔다(사용자 확정, 동작보다 시안 우선).
* 상세는 기존 가맹점에만 붙으므로 등록은 행의 [수정]으로 들어간다.
*
* 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
* 않아 대부분 `-`로 보인다.
*/
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<Set<string>>(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<MerchantDetailQuery>) {
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: (
<FoxButtonGroup size="md">
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
<FoxButton
type="primary"
size="md"
label="삭제"
onAction={() => runBulk(deleteMerchantDetailsAction, '삭제했습니다.')}
/>
</FoxButtonGroup>
),
});
}
const columns: FoxListColumn<MerchantDetailSummary>[] = [
{
key: 'select',
header: (
<FoxCheckbox
size="md"
label="전체 선택"
hideLabel
checked={isAllSelected}
disabled={allIds.length === 0}
onChange={toggleAll}
/>
),
width: 56,
align: 'center',
render: (row) => (
<FoxCheckbox
size="md"
label={`${row.name} 선택`}
hideLabel
checked={selectedIds.has(row.id)}
onChange={(checked) => 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) => (
<MerchantThumbnail src={row.thumbnailUrl} alt={`${row.name} 대표이미지`} />
),
},
{ key: 'name', header: '업체명', width: 220, emphasis: true },
{
key: 'category',
header: '카테고리',
width: 140,
render: (row) =>
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: () => EMPTY_FIELD_PLACEHOLDER,
},
{
key: 'status',
header: '상태',
width: 110,
align: 'center',
render: (row) =>
row.hasDetail && row.isVisible !== null ? (
<FoxStatusIndicator
type={row.isVisible ? 'success' : 'danger'}
label={formatMerchantDetailStatusLabel(row)}
/>
) : (
formatMerchantDetailStatusLabel(row)
),
},
{
key: 'registeredAt',
header: '등록일',
width: 120,
align: 'center',
render: (row) => formatOptionalValue(row.registeredAt),
},
{
key: 'actions',
header: '관리',
width: 220,
align: 'center',
render: (row) => <MerchantDetailRowActions row={row} />,
},
];
return (
<FoxListContainer<MerchantDetailSummary>
title="가맹몰 상세 관리"
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '관리자관리' },
{ label: '가맹몰 상세 관리' },
]}
caption="가맹몰 상세 목록"
columns={columns}
rows={items}
rowKey={(row) => row.id}
totalCount={totalCount}
emptyMessage="검색 결과가 없습니다."
sorts={
<FoxSelectText
size="sm"
ariaLabel="페이지 크기"
options={MERCHANT_DETAIL_PAGE_SIZE_OPTIONS.map((size) => ({
value: String(size),
label: `${size}개씩`,
}))}
value={String(query.pageSize)}
onValueChange={(value) =>
go({ pageSize: Number(value) as MerchantDetailPageSize })
}
/>
}
filter={
<div className={styles.filter}>
<div className={styles.select}>
<FoxSelect
size="md"
ariaLabel="카테고리"
options={[
{ value: FILTER_ALL, label: '카테고리: 전체' },
...categories.map((code) => ({ value: code.code, label: code.label })),
]}
value={draft.category}
onValueChange={(value) => setDraft((current) => ({ ...current, category: value }))}
/>
</div>
<div className={styles.select}>
<FoxSelect
size="md"
ariaLabel="노출 상태"
options={[
{ value: FILTER_ALL, label: '노출 상태: 전체' },
...MERCHANT_DETAIL_VISIBILITY_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
})),
]}
value={draft.visibility}
onValueChange={(value) => setDraft((current) => ({ ...current, visibility: value }))}
/>
</div>
{/* 등록일 기간 — 백엔드가 아직 조건에 쓰지 않는다(보고함). 값은 그대로 보낸다. */}
<div className={styles.period}>
<FoxInput
size="md"
type="date"
aria-label="등록일 시작"
max={today}
value={draft.startDate}
onChange={(value) =>
setDraft((current) => ({ ...current, ...clampDateRange(value, current.endDate, today) }))
}
/>
<span aria-hidden="true">~</span>
<FoxInput
size="md"
type="date"
aria-label="등록일 종료"
min={draft.startDate || undefined}
value={draft.endDate}
onChange={(value) =>
setDraft((current) => ({ ...current, ...clampDateRange(current.startDate, value, today) }))
}
/>
</div>
<FoxButtonGroup size="md">
<FoxButton type="secondary" size="md" label="적용" onAction={applyFilters} />
<FoxButton type="default" size="md" label="초기화" onAction={resetFilters} />
</FoxButtonGroup>
</div>
}
searchFields={[]}
keyword={query.keyword}
searchPlaceholder="업체명을 입력하세요."
toolbarActions={
<>
<FoxButton
type="default"
size="md"
label="선택 비활성"
disabled={isBusy || selectedIds.size === 0}
onAction={() => runBulk(deactivateMerchantDetailsAction, '비활성으로 바꿨습니다.')}
/>
<FoxButton
type="default"
size="md"
label="선택 삭제"
disabled={isBusy || selectedIds.size === 0}
onAction={confirmBulkDelete}
/>
{/* 시안 BO-MAL-001의 [+ 업체 등록]. 상세는 기존 가맹점에만 붙어 여기서 업체를 만들 수
없다 — 자리는 시안대로 두고 누르면 가는 곳을 안내한다(사용자 확정). */}
<FoxButton
type="primary"
size="md"
leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
label="업체 등록"
onAction={() =>
showToast({
variant: 'info',
message: '업체(가맹점)는 가맹점 관리에서 등록합니다. 여기서는 등록된 가맹점의 상세를 관리합니다.',
})
}
/>
</>
}
page={currentPage}
totalPages={totalPages}
buildHref={(patch) =>
buildMerchantDetailHref(query, {
...(patch.keyword !== undefined ? { keyword: String(patch.keyword) } : {}),
...(patch.page !== undefined ? { page: patch.page } : {}),
})
}
/>
);
}