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 { useMemo, useState } from 'react';
import {
FoxListContainer,
type FoxListColumn,
} from '@fox/core/components/fox-list-container';
import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
import {
buildMenuTree,
collectNodeIds,
flattenOpenNodes,
formatActiveLabel,
formatOptionalValue,
type SystemMenu,
type SystemMenuNode,
} from '@/lib/domain/system-menu';
import { MenuFormModal } from './menu-form-modal';
import { MenuRowActions } from './menu-row-actions';
import styles from './menu-tree.module.scss';
interface MenuTreeListProps {
items: SystemMenu[];
}
type FormTarget =
| { mode: 'edit'; node: SystemMenuNode }
| { mode: 'create'; parent: SystemMenuNode };
/** 깊이만큼 이름을 밀어 넣는다. 표 칸이라 여백은 인라인으로 준다. */
const INDENT_REM = 2;
export function MenuTreeList({ items }: MenuTreeListProps) {
const tree = useMemo(() => buildMenuTree(items), [items]);
const byId = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
const [closedIds, setClosedIds] = useState<Set<string>>(new Set());
const [target, setTarget] = useState<FormTarget | null>(null);
const isOpen = (id: string) => !closedIds.has(id);
function toggle(id: string) {
setClosedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
const allBranchIds = useMemo(() => collectNodeIds(tree), [tree]);
const isAllOpen = closedIds.size === 0;
function toggleAll() {
setClosedIds(isAllOpen ? new Set(allBranchIds) : new Set());
}
const rows = flattenOpenNodes(tree, isOpen);
const columns: FoxListColumn<SystemMenuNode>[] = [
{ key: 'id', header: '번호', width: 100 },
{
key: 'toggle',
header: (
<button
type="button"
className={styles.toggle}
aria-label={isAllOpen ? '모두 접기' : '모두 펼치기'}
aria-expanded={isAllOpen}
onClick={toggleAll}
>
{isAllOpen ? <i className="fox-ico fox-ico-CaretDown" aria-hidden="true" /> : <i className="fox-ico fox-ico-CaretRight" aria-hidden="true" />}
</button>
),
width: 60,
render: (row) =>
row.children.length > 0 ? (
<button
type="button"
className={styles.toggle}
aria-label={`${row.name} ${isOpen(row.id) ? '접기' : '펼치기'}`}
aria-expanded={isOpen(row.id)}
onClick={() => toggle(row.id)}
>
{isOpen(row.id) ? <i className="fox-ico fox-ico-CaretDown" aria-hidden="true" /> : <i className="fox-ico fox-ico-CaretRight" aria-hidden="true" />}
</button>
) : null,
},
{
key: 'name',
header: '메뉴명',
width: 420,
emphasis: true,
render: (row) => (
<span
className={styles.name}
style={{ paddingInlineStart: `${row.depth * INDENT_REM}rem` }}
>
<span className={styles.icon}>
{row.children.length > 0 ? <i className="fox-ico fox-ico-Folder" aria-hidden="true" /> : <i className="fox-ico fox-ico-File" aria-hidden="true" />}
</span>
{row.name}
</span>
),
},
{ key: 'sortOrder', header: '메뉴순번', width: 120, align: 'center' },
{
key: 'isActive',
header: '활성여부',
width: 120,
render: (row) => (
<FoxStatusIndicator
type={row.isActive ? 'success' : 'danger'}
label={formatActiveLabel(row.isActive)}
/>
),
},
{
key: 'roleName',
header: '권한',
width: 160,
render: (row) => formatOptionalValue(row.roleName),
},
{
key: 'actions',
header: '관리',
width: 140,
render: (row) => (
<MenuRowActions
node={row}
onEdit={(node) => setTarget({ mode: 'edit', node })}
onAddChild={(node) => setTarget({ mode: 'create', parent: node })}
/>
),
},
];
return (
<>
<FoxListContainer<SystemMenuNode>
title="앱 메뉴관리"
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '시스템관리' },
{ label: '앱 메뉴관리' },
]}
caption="메뉴 목록"
columns={columns}
rows={rows}
rowKey={(row) => row.id}
totalCount={items.length}
emptyMessage="등록된 메뉴가 없습니다."
searchHidden
/>
{target && (
<MenuFormModal
mode={target.mode}
menu={target.mode === 'edit' ? target.node : undefined}
parent={
target.mode === 'edit'
? (target.node.parentId ? (byId.get(target.node.parentId) ?? null) : null)
: target.parent
}
onClose={() => setTarget(null)}
/>
)}
</>
);
}