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 { useState } from 'react';
import { FoxButton } from '@fox/core/components/fox-button';
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
import {
FoxListContainer,
type FoxListColumn,
} from '@fox/core/components/fox-list-container';
import { useFeedback } from '@/app/_hooks/use-feedback';
import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
import {
flattenAdminMenuTree,
formatActiveLabel,
type AdminMenu,
type AdminMenuNode,
} from '@/lib/domain/admin-menu';
import { deleteAdminMenuAction } from '../_actions';
import { AdminMenuModal, type AdminMenuRole } from './admin-menu-modal';
import styles from './admin-menu-list.module.scss';
interface AdminMenuListProps {
roots: AdminMenuNode[];
totalCount: number;
roles: AdminMenuRole[];
}
type ModalState =
| { mode: 'create'; parent: AdminMenu; nextSortOrder: number }
| { mode: 'edit'; menu: AdminMenuNode; parent: AdminMenu | null };
function findParent(
roots: AdminMenuNode[],
parentId: string | null
): AdminMenu | null {
if (!parentId) return null;
const stack = [...roots];
while (stack.length > 0) {
const node = stack.pop();
if (!node) continue;
if (node.id === parentId) return node;
stack.push(...node.children);
}
return null;
}
export function AdminMenuList({ roots, totalCount, roles }: AdminMenuListProps) {
const { showAlert, hideAlert, showToast } = useFeedback();
const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(
new Set()
);
const [modal, setModal] = useState<ModalState | null>(null);
const [isDeleting, startDeleting] = useBlockingAction();
const rows = flattenAdminMenuTree(roots, collapsedIds);
function toggle(id: string) {
setCollapsedIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function confirmDelete(menu: AdminMenuNode) {
showAlert({
variant: 'danger',
title: '메뉴를 삭제하시겠습니까?',
message: `${menu.name} 메뉴를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
actions: (
<FoxButtonGroup size="md">
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
<FoxButton
type="error"
size="md"
label="삭제"
onAction={() => {
hideAlert();
startDeleting(async () => {
const result = await deleteAdminMenuAction(menu.id);
showToast(
result.status === 'error'
? {
variant: 'danger',
message: result.message ?? '삭제하지 못했습니다.',
}
: { variant: 'success', message: '메뉴를 삭제했습니다.' }
);
});
}}
/>
</FoxButtonGroup>
),
});
}
const columns: FoxListColumn<AdminMenuNode>[] = [
{ key: 'id', header: '메뉴번호', width: 120 },
{
key: 'isActive',
header: '활성여부',
width: 120,
render: (row) => (
<span
className={`${styles.state} ${row.isActive ? styles.active : styles.inactive}`}
title={formatActiveLabel(row.isActive)}
>
{row.isActive ? <i className="fox-ico fox-ico-Check" aria-hidden="true" /> : <i className="fox-ico fox-ico-Prohibit" aria-hidden="true" />}
</span>
),
},
{
key: 'name',
header: '메뉴명',
render: (row) => (
<span
className={styles.name}
style={{ paddingInlineStart: `${row.depth * 1.6}rem` }}
>
{row.children.length > 0 ? (
<button
type="button"
className={styles.toggle}
aria-expanded={!collapsedIds.has(row.id)}
aria-label={`${row.name} 하위 메뉴 ${collapsedIds.has(row.id) ? '펼치기' : '접기'}`}
onClick={() => toggle(row.id)}
>
{collapsedIds.has(row.id) ? (
<i className="fox-ico fox-ico-CaretRight" aria-hidden="true" />
) : (
<i className="fox-ico fox-ico-CaretDown" aria-hidden="true" />
)}
</button>
) : (
<span className={styles.togglePlaceholder} aria-hidden="true" />
)}
{row.name}
</span>
),
},
{ key: 'sortOrder', header: '메뉴순번', align: 'end', width: 120 },
{
key: 'roleName',
header: '권한',
width: 160,
render: (row) => row.roleName || row.roleId || '-',
},
{
key: 'actions',
header: '관리',
align: 'center',
width: 160,
render: (row) => (
<FoxButtonGroup size="sm" align="center">
<FoxIconButton
type="default"
size="sm"
icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
label="수정"
onAction={() =>
setModal({
mode: 'edit',
menu: row,
parent: findParent(roots, row.parentId),
})
}
/>
<FoxIconButton
type="default"
size="sm"
icon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
label="하위 메뉴 추가"
onAction={() =>
setModal({
mode: 'create',
parent: row,
nextSortOrder: row.children.length + 1,
})
}
/>
<FoxIconButton
type="default"
size="sm"
icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
label={isDeleting ? '삭제 중' : '삭제'}
disabled={isDeleting}
onAction={() => confirmDelete(row)}
/>
</FoxButtonGroup>
),
},
];
return (
<>
<FoxListContainer<AdminMenuNode>
title="메뉴관리"
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '관리자관리' },
{ label: '메뉴관리' },
]}
caption="메뉴 목록"
columns={columns}
rows={rows}
rowKey={(row) => row.id}
totalCount={totalCount}
emptyMessage="등록된 메뉴가 없습니다."
searchHidden
/>
{modal && (
<AdminMenuModal
parent={modal.parent}
menu={modal.mode === 'edit' ? modal.menu : undefined}
roles={roles}
nextSortOrder={modal.mode === 'create' ? modal.nextSortOrder : 1}
onClose={() => setModal(null)}
/>
)}
</>
);
}