Phase 6 마이그레이션 (41개 컴포넌트 완료): - 건설/시공: 협력업체, 시공관리, 기성관리, 발주관리, 계약관리 등 - 영업: 견적관리(V2), 고객관리(V2), 수주관리 - 회계: 청구관리, 매입관리, 매출관리, 거래처관리, 악성채권 등 - 생산: 작업지시, 검수관리 - 출고: 출하관리 - 자재: 입고관리, 재고현황 - 고객센터: 문의관리, 이벤트관리, 공지관리 - 인사: 직원관리 - 설정: 권한관리 주요 변경사항: - 34개 xxxConfig.ts 파일 생성 (설정 기반 페이지 구성) - PageLayout/PageHeader → IntegratedDetailTemplate 통합 - 일관된 타이틀/버튼 영역 (목록, 상세, 수정, 삭제) - 1112줄 코드 감소 (중복 제거) 프로젝트 공통화 현황 분석 문서 추가: - 상세 페이지 62%, 목록 페이지 82% 공통화 달성 - 추가 공통화 기회 및 로드맵 정리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
|
import { permissionConfig } from './permissionConfig';
|
|
import type { Permission, MenuPermission, PermissionType } from './types';
|
|
|
|
interface PermissionDetailProps {
|
|
permission: Permission;
|
|
onBack: () => void;
|
|
onSave: (permission: Permission) => void;
|
|
onDelete?: (permission: Permission) => void;
|
|
}
|
|
|
|
// 메뉴 구조 타입 (localStorage user.menu와 동일한 구조)
|
|
interface SerializableMenuItem {
|
|
id: string;
|
|
label: string;
|
|
iconName: string;
|
|
path: string;
|
|
children?: SerializableMenuItem[];
|
|
}
|
|
|
|
// 권한 타입 (기획서 기준: 전체 제외)
|
|
const PERMISSION_TYPES: PermissionType[] = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage'];
|
|
const PERMISSION_LABELS_MAP: Record<PermissionType, string> = {
|
|
view: '조회',
|
|
create: '생성',
|
|
update: '수정',
|
|
delete: '삭제',
|
|
approve: '승인',
|
|
export: '내보내기',
|
|
manage: '관리',
|
|
};
|
|
|
|
// 제외할 메뉴 ID 목록 (시스템 설정 하위 메뉴)
|
|
const EXCLUDED_MENU_IDS = ['database', 'system-monitoring', 'security-management', 'system-settings'];
|
|
|
|
// 메뉴 필터링 함수 (제외 목록에 있는 메뉴 제거)
|
|
const filterExcludedMenus = (menus: SerializableMenuItem[]): SerializableMenuItem[] => {
|
|
return menus
|
|
.filter(menu => !EXCLUDED_MENU_IDS.includes(menu.id))
|
|
.map(menu => {
|
|
if (menu.children && menu.children.length > 0) {
|
|
const filteredChildren = menu.children.filter(
|
|
child => !EXCLUDED_MENU_IDS.includes(child.id)
|
|
);
|
|
// 자식이 모두 필터링되면 부모도 제거
|
|
if (filteredChildren.length === 0) {
|
|
return null;
|
|
}
|
|
return { ...menu, children: filteredChildren };
|
|
}
|
|
return menu;
|
|
})
|
|
.filter((menu): menu is SerializableMenuItem => menu !== null);
|
|
};
|
|
|
|
// localStorage에서 메뉴 데이터 가져오기
|
|
const getMenuFromLocalStorage = (): SerializableMenuItem[] => {
|
|
if (typeof window === 'undefined') return [];
|
|
|
|
try {
|
|
const userDataStr = localStorage.getItem('user');
|
|
if (userDataStr) {
|
|
const userData = JSON.parse(userDataStr);
|
|
if (userData.menu && Array.isArray(userData.menu)) {
|
|
// 제외 목록 필터링 적용
|
|
return filterExcludedMenus(userData.menu);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load menu from localStorage:', error);
|
|
}
|
|
|
|
// 기본 메뉴 (user.menu가 없는 경우)
|
|
return [
|
|
{ id: 'dashboard', label: '대시보드', iconName: 'layout-dashboard', path: '/dashboard' },
|
|
{
|
|
id: 'sales',
|
|
label: '판매관리',
|
|
iconName: 'shopping-cart',
|
|
path: '#',
|
|
children: [
|
|
{ id: 'customer-management', label: '거래처관리', iconName: 'building-2', path: '/sales/client-management-sales-admin' },
|
|
{ id: 'quote-management', label: '견적관리', iconName: 'receipt', path: '/sales/quote-management' },
|
|
{ id: 'pricing-management', label: '단가관리', iconName: 'dollar-sign', path: '/sales/pricing-management' },
|
|
],
|
|
},
|
|
{
|
|
id: 'master-data',
|
|
label: '기준정보',
|
|
iconName: 'settings',
|
|
path: '#',
|
|
children: [
|
|
{ id: 'item-master', label: '품목기준관리', iconName: 'package', path: '/master-data/item-master-data-management' },
|
|
],
|
|
},
|
|
{
|
|
id: 'hr',
|
|
label: '인사관리',
|
|
iconName: 'users',
|
|
path: '#',
|
|
children: [
|
|
{ id: 'vacation-management', label: '휴가관리', iconName: 'calendar', path: '/hr/vacation-management' },
|
|
{ id: 'salary-management', label: '급여관리', iconName: 'wallet', path: '/hr/salary-management' },
|
|
],
|
|
},
|
|
{
|
|
id: 'settings',
|
|
label: '기준정보 설정',
|
|
iconName: 'settings-2',
|
|
path: '#',
|
|
children: [
|
|
{ id: 'ranks', label: '직급관리', iconName: 'badge', path: '/settings/ranks' },
|
|
{ id: 'titles', label: '직책관리', iconName: 'user-cog', path: '/settings/titles' },
|
|
{ id: 'permissions', label: '권한관리', iconName: 'shield', path: '/settings/permissions' },
|
|
],
|
|
},
|
|
];
|
|
};
|
|
|
|
// 메뉴 구조를 flat한 MenuPermission 배열로 변환
|
|
const convertMenuToPermissions = (
|
|
menus: SerializableMenuItem[],
|
|
existingPermissions: MenuPermission[]
|
|
): MenuPermission[] => {
|
|
const result: MenuPermission[] = [];
|
|
|
|
const processMenu = (menu: SerializableMenuItem, parentId?: string) => {
|
|
// 기존 권한 데이터 찾기
|
|
const existing = existingPermissions.find(ep => ep.menuId === menu.id);
|
|
|
|
result.push({
|
|
menuId: menu.id,
|
|
menuName: menu.label,
|
|
parentMenuId: parentId,
|
|
permissions: existing?.permissions || {},
|
|
});
|
|
|
|
// 자식 메뉴 처리
|
|
if (menu.children && menu.children.length > 0) {
|
|
menu.children.forEach(child => processMenu(child, menu.id));
|
|
}
|
|
};
|
|
|
|
menus.forEach(menu => processMenu(menu));
|
|
return result;
|
|
};
|
|
|
|
export function PermissionDetail({ permission, onBack, onSave, onDelete }: PermissionDetailProps) {
|
|
const [name, setName] = useState(permission.name);
|
|
const [status, setStatus] = useState(permission.status);
|
|
const [menuStructure, setMenuStructure] = useState<SerializableMenuItem[]>([]);
|
|
const [menuPermissions, setMenuPermissions] = useState<MenuPermission[]>([]);
|
|
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
|
|
// 메뉴 구조 로드 (localStorage에서)
|
|
useEffect(() => {
|
|
const menus = getMenuFromLocalStorage();
|
|
setMenuStructure(menus);
|
|
|
|
// 기존 권한 데이터와 메뉴 구조 병합
|
|
const permissions = convertMenuToPermissions(menus, permission.menuPermissions);
|
|
setMenuPermissions(permissions);
|
|
|
|
// 기본적으로 모든 부모 메뉴 접힌 상태로 시작
|
|
setExpandedMenus(new Set());
|
|
}, [permission.menuPermissions]);
|
|
|
|
// 부모 메뉴 접기/펼치기
|
|
const toggleMenuExpand = (menuId: string) => {
|
|
setExpandedMenus(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(menuId)) {
|
|
newSet.delete(menuId);
|
|
} else {
|
|
newSet.add(menuId);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
// 권한 토글 (자동 저장)
|
|
const handlePermissionToggle = (menuId: string, permType: PermissionType) => {
|
|
const newMenuPermissions = menuPermissions.map(mp =>
|
|
mp.menuId === menuId
|
|
? {
|
|
...mp,
|
|
permissions: {
|
|
...mp.permissions,
|
|
[permType]: !mp.permissions[permType],
|
|
},
|
|
}
|
|
: mp
|
|
);
|
|
setMenuPermissions(newMenuPermissions);
|
|
|
|
onSave({
|
|
...permission,
|
|
name,
|
|
status,
|
|
menuPermissions: newMenuPermissions,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
};
|
|
|
|
// 전체 선택/해제 (열 기준) - 헤더 체크박스
|
|
const handleColumnSelectAll = (permType: PermissionType, checked: boolean) => {
|
|
const newMenuPermissions = menuPermissions.map(mp => ({
|
|
...mp,
|
|
permissions: {
|
|
...mp.permissions,
|
|
[permType]: checked,
|
|
},
|
|
}));
|
|
setMenuPermissions(newMenuPermissions);
|
|
|
|
onSave({
|
|
...permission,
|
|
name,
|
|
status,
|
|
menuPermissions: newMenuPermissions,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
};
|
|
|
|
// 기본 정보 변경
|
|
const handleNameChange = (newName: string) => setName(newName);
|
|
|
|
const handleNameBlur = () => {
|
|
if (name !== permission.name) {
|
|
onSave({
|
|
...permission,
|
|
name,
|
|
status,
|
|
menuPermissions,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = (newStatus: 'active' | 'hidden') => {
|
|
setStatus(newStatus);
|
|
onSave({
|
|
...permission,
|
|
name,
|
|
status: newStatus,
|
|
menuPermissions,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
};
|
|
|
|
// 삭제 확인
|
|
const confirmDelete = () => {
|
|
onDelete?.(permission);
|
|
setDeleteDialogOpen(false);
|
|
onBack();
|
|
};
|
|
|
|
// IntegratedDetailTemplate용 삭제 핸들러
|
|
const handleFormDelete = useCallback(async () => {
|
|
setDeleteDialogOpen(true);
|
|
return { success: true };
|
|
}, []);
|
|
|
|
// 폼 콘텐츠 렌더링
|
|
const renderFormContent = useCallback(() => (
|
|
<div className="space-y-6">
|
|
{/* 기본 정보 */}
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<h3 className="text-lg font-semibold mb-4">기본 정보</h3>
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="perm-name">권한명</Label>
|
|
<Input
|
|
id="perm-name"
|
|
value={name}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
onBlur={handleNameBlur}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="perm-status">상태</Label>
|
|
<Select value={status} onValueChange={handleStatusChange}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">공개</SelectItem>
|
|
<SelectItem value="hidden">숨김</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 메뉴별 권한 설정 테이블 */}
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<h3 className="text-lg font-semibold mb-4">메뉴</h3>
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-b">
|
|
<TableHead className="w-64 py-4">메뉴</TableHead>
|
|
{PERMISSION_TYPES.map(pt => (
|
|
<TableHead key={pt} className="text-center w-24 py-4">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<span className="text-sm font-medium">{PERMISSION_LABELS_MAP[pt]}</span>
|
|
<Checkbox
|
|
checked={menuPermissions.length > 0 && menuPermissions.every(mp => mp.permissions[pt])}
|
|
onCheckedChange={(checked) => handleColumnSelectAll(pt, !!checked)}
|
|
/>
|
|
</div>
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{renderMenuRows()}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
), [name, status, menuPermissions, handleNameChange, handleNameBlur, handleStatusChange, handleColumnSelectAll, renderMenuRows]);
|
|
|
|
// 메뉴 행 렌더링 (재귀적으로 부모-자식 처리)
|
|
const renderMenuRows = () => {
|
|
const rows: React.ReactElement[] = [];
|
|
|
|
menuStructure.forEach(menu => {
|
|
const mp = menuPermissions.find(p => p.menuId === menu.id);
|
|
if (!mp) return;
|
|
|
|
const hasChildren = menu.children && menu.children.length > 0;
|
|
const isExpanded = expandedMenus.has(menu.id);
|
|
|
|
// 부모 메뉴 행
|
|
rows.push(
|
|
<TableRow key={menu.id} className="hover:bg-muted/50">
|
|
<TableCell className="font-medium">
|
|
<div className="flex items-center gap-2">
|
|
{hasChildren && (
|
|
<button
|
|
onClick={() => toggleMenuExpand(menu.id)}
|
|
className="p-0.5 hover:bg-accent rounded"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
)}
|
|
<span>{menu.label}</span>
|
|
</div>
|
|
</TableCell>
|
|
{PERMISSION_TYPES.map(pt => (
|
|
<TableCell key={pt} className="text-center">
|
|
<Checkbox
|
|
checked={mp.permissions[pt] || false}
|
|
onCheckedChange={() => handlePermissionToggle(menu.id, pt)}
|
|
/>
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
);
|
|
|
|
// 자식 메뉴 행 (펼쳐진 경우에만)
|
|
if (hasChildren && isExpanded) {
|
|
menu.children?.forEach(child => {
|
|
const childMp = menuPermissions.find(p => p.menuId === child.id);
|
|
if (!childMp) return;
|
|
|
|
rows.push(
|
|
<TableRow key={child.id} className="hover:bg-muted/50">
|
|
<TableCell className="pl-10 text-muted-foreground">
|
|
- {child.label}
|
|
</TableCell>
|
|
{PERMISSION_TYPES.map(pt => (
|
|
<TableCell key={pt} className="text-center">
|
|
<Checkbox
|
|
checked={childMp.permissions[pt] || false}
|
|
onCheckedChange={() => handlePermissionToggle(child.id, pt)}
|
|
/>
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
return rows;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<IntegratedDetailTemplate
|
|
config={permissionConfig}
|
|
mode="view"
|
|
initialData={permission}
|
|
itemId={permission.id}
|
|
onBack={onBack}
|
|
onDelete={handleFormDelete}
|
|
renderView={() => renderFormContent()}
|
|
renderForm={() => renderFormContent()}
|
|
/>
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>권한 삭제</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
"{permission.name}" 권한을 삭제하시겠습니까?
|
|
<br />
|
|
<span className="text-destructive">
|
|
이 권한을 사용 중인 사원이 있으면 해당 사원의 권한이 초기화됩니다.
|
|
</span>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmDelete}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
삭제
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|