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_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<Set<string>>(new Set());
const [draft, setDraft] = useState({
category: query.category,
visibility: query.visibility,
startDate: query.startDate,
endDate: query.endDate,
});
// 선택은 일괄 비활성·삭제를 위한 것이라 상세가 있는 행만 고를 수 있다(사용자 확정).
const allIds = items.filter((row) => row.hasDetail).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)}
disabled={!row.hasDetail}
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: '업체명', align: 'start', 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: '연락처',
align: 'start',
width: 150,
render: (row) => formatOptionalValue(formatPhoneNumber(row.phoneNumber)),
},
{
key: 'address',
header: '주소',
align: 'start',
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 ? (
<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="가맹몰 상세 관리"
description="지역상생몰(바우처 사용처)에 노출되는 업체를 등록·관리한다"
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>
{/* 등록일 기간 — 백엔드가 조건에 쓰지 않아 Repository가 등록일로 거른다. */}
<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}
/>
<FoxButton
type="primary"
size="md"
leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
label="업체 등록"
onAction={() => router.push(MERCHANT_DETAIL_NEW_PATH)}
/>
</>
}
page={currentPage}
totalPages={totalPages}
buildHref={(patch) =>
buildMerchantDetailHref(query, {
...(patch.keyword !== undefined ? { keyword: String(patch.keyword) } : {}),
...(patch.page !== undefined ? { page: patch.page } : {}),
})
}
// 검색 상자는 링크가 아니라 콜백으로만 알린다(FoxListSearch.onSearch) — 페이지는 위 링크가 맡는다.
onQueryChange={(patch) => {
if (patch.keyword !== undefined) go({ keyword: String(patch.keyword) });
}}
/>
);
}