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_DETAIL_PAGE_SIZE_OPTIONS,
buildMerchantDetailHref,
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 styles from './merchant-detail-list.module.scss';
interface MerchantDetailListProps {
items: MerchantDetailSummary[];
categories: CommonCode[];
query: MerchantDetailQuery;
currentPage: number;
totalPages: number;
totalCount: number;
}
/**
* 가맹몰 상세 목록 — 기획 BO-MAL-001을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것.
*
* 가맹점 전체가 오고 상세가 붙은 건만 상태·등록일이 있다. 기획의 [+ 업체 등록]은 없다 —
* 상세는 기존 가맹점에만 붙어 행의 [상세 등록]으로 들어간다(사용자 확정).
*
* 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
* 않아 대부분 `-`로 보인다.
*/
export function MerchantDetailList({
items,
categories,
query,
currentPage,
totalPages,
totalCount,
}: 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 selectableIds = items.filter((row) => row.hasDetail).map((row) => row.id);
const isAllSelected =
selectableIds.length > 0 && selectableIds.every((id) => selectedIds.has(id));
function go(patch: Partial<MerchantDetailQuery>) {
router.replace(buildMerchantDetailHref(query, { ...patch, page: 1 }));
}
function toggleAll(checked: boolean) {
setSelectedIds(checked ? new Set(selectableIds) : new Set());
}
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 = [...selectedIds];
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());
});
}
function confirmBulkDelete() {
showAlert({
variant: 'danger',
title: '선택한 상세를 삭제하시겠습니까?',
message: `${selectedIds.size}건의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
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={selectableIds.length === 0}
onChange={toggleAll}
/>
),
width: 56,
align: 'center',
render: (row) =>
row.hasDetail ? (
<FoxCheckbox
size="md"
label={`${row.name} 선택`}
hideLabel
checked={selectedIds.has(row.id)}
onChange={(checked) => toggleOne(row.id, checked)}
/>
) : null,
},
{
key: 'no',
header: 'No',
width: 72,
align: 'center',
render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
},
{
key: 'thumbnail',
header: '대표이미지',
width: 110,
align: 'center',
render: (row) =>
row.thumbnailUrl ? (
// 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`는 백엔드 호스트를
// next.config에 등록해야 해 여기서는 <img>로 둔다(꾸미기아이템과 같은 판단).
// eslint-disable-next-line @next/next/no-img-element
<img className={styles.thumbnail} src={row.thumbnailUrl} alt={`${row.name} 대표이미지`} />
) : (
EMPTY_FIELD_PLACEHOLDER
),
},
{ 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 }));
go({ 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 }));
go({ visibility: value });
}}
/>
</div>
{/* 등록일 기간 — 백엔드가 아직 조건에 쓰지 않는다(보고함). 값은 그대로 보낸다. */}
<div className={styles.period}>
<FoxInput
size="md"
type="date"
aria-label="등록일 시작"
value={draft.startDate}
onChange={(value) => setDraft((current) => ({ ...current, startDate: value }))}
/>
<span aria-hidden="true">~</span>
<FoxInput
size="md"
type="date"
aria-label="등록일 종료"
value={draft.endDate}
onChange={(value) => setDraft((current) => ({ ...current, endDate: value }))}
/>
<FoxButton
type="default"
size="md"
label="기간 적용"
onAction={() => go({ startDate: draft.startDate, endDate: draft.endDate })}
/>
</div>
</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}
/>
</>
}
page={currentPage}
totalPages={totalPages}
buildHref={(patch) =>
buildMerchantDetailHref(query, {
...(patch.keyword !== undefined ? { keyword: String(patch.keyword) } : {}),
...(patch.page !== undefined ? { page: patch.page } : {}),
})
}
/>
);
}