- Enhanced the menu management functionality by adding a new `menu_icon` field in the database schema, allowing for the storage of menu icons. - Updated the `saveMenu` and `updateMenu` functions in the admin controller to handle the new `menu_icon` field during menu creation and updates. - Modified the `AdminService` to include `MENU_ICON` in various queries, ensuring that the icon data is retrieved and processed correctly. - Integrated the `MenuIconPicker` component in the frontend to allow users to select and display menu icons in the `MenuFormModal`. - Updated the sidebar and layout components to utilize the new icon data, enhancing the visual representation of menus across the application.
775 lines
29 KiB
TypeScript
775 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import { useState, Suspense, useEffect } from "react";
|
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Shield,
|
|
Menu,
|
|
Home,
|
|
Settings,
|
|
BarChart3,
|
|
FileText,
|
|
Users,
|
|
Package,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
UserCheck,
|
|
LogOut,
|
|
User,
|
|
Building2,
|
|
} from "lucide-react";
|
|
import { useMenu } from "@/contexts/MenuContext";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useProfile } from "@/hooks/useProfile";
|
|
import { MenuItem } from "@/lib/api/menu";
|
|
import { menuScreenApi } from "@/lib/api/screen";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { toast } from "sonner";
|
|
import { ProfileModal } from "./ProfileModal";
|
|
import { Logo } from "./Logo";
|
|
import { SideMenu } from "./SideMenu";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
|
|
import { getIconComponent } from "@/components/admin/MenuIconPicker";
|
|
|
|
// useAuth의 UserInfo 타입을 확장
|
|
interface ExtendedUserInfo {
|
|
userId: string;
|
|
userName: string;
|
|
userNameEng?: string;
|
|
userNameCn?: string;
|
|
deptCode?: string;
|
|
deptName?: string;
|
|
positionCode?: string;
|
|
positionName?: string;
|
|
email?: string;
|
|
tel?: string;
|
|
cellPhone?: string;
|
|
userType?: string;
|
|
userTypeName?: string;
|
|
authName?: string;
|
|
partnerCd?: string;
|
|
isAdmin: boolean;
|
|
sabun?: string;
|
|
photo?: string | null;
|
|
companyCode?: string;
|
|
locale?: string;
|
|
}
|
|
|
|
interface AppLayoutProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
// 메뉴 아이콘 매핑 함수 (DB 아이콘 우선, 없으면 키워드 기반 fallback)
|
|
const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
|
|
if (dbIconName) {
|
|
const DbIcon = getIconComponent(dbIconName);
|
|
if (DbIcon) return <DbIcon className="h-4 w-4" />;
|
|
}
|
|
|
|
const name = menuName.toLowerCase();
|
|
if (name.includes("대시보드") || name.includes("dashboard")) return <Home className="h-4 w-4" />;
|
|
if (name.includes("관리자") || name.includes("admin")) return <Shield className="h-4 w-4" />;
|
|
if (name.includes("사용자") || name.includes("user")) return <Users className="h-4 w-4" />;
|
|
if (name.includes("프로젝트") || name.includes("project")) return <BarChart3 className="h-4 w-4" />;
|
|
if (name.includes("제품") || name.includes("product")) return <Package className="h-4 w-4" />;
|
|
if (name.includes("설정") || name.includes("setting")) return <Settings className="h-4 w-4" />;
|
|
if (name.includes("로그") || name.includes("log")) return <FileText className="h-4 w-4" />;
|
|
if (name.includes("메뉴") || name.includes("menu")) return <Menu className="h-4 w-4" />;
|
|
if (name.includes("화면관리") || name.includes("screen")) return <FileText className="h-4 w-4" />;
|
|
return <FileText className="h-4 w-4" />;
|
|
};
|
|
|
|
// 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외)
|
|
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => {
|
|
const filteredMenus = menus
|
|
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
|
|
.filter((menu) => (menu.status || menu.STATUS) === "active")
|
|
.sort((a, b) => (a.seq || a.SEQ || 0) - (b.seq || b.SEQ || 0));
|
|
|
|
// 최상위 레벨에서 "사용자", "관리자" 카테고리가 있으면 그 하위 메뉴들을 직접 표시
|
|
if (parentId === "0") {
|
|
const allMenus: any[] = [];
|
|
|
|
for (const menu of filteredMenus) {
|
|
const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
|
|
|
|
// "사용자" 또는 "관리자" 카테고리면 하위 메뉴들을 직접 추가
|
|
if (menuName.includes("사용자") || menuName.includes("관리자")) {
|
|
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID);
|
|
allMenus.push(...childMenus);
|
|
} else {
|
|
// 일반 메뉴는 그대로 추가
|
|
allMenus.push(convertSingleMenu(menu, menus, userInfo));
|
|
}
|
|
}
|
|
|
|
return allMenus;
|
|
}
|
|
|
|
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo));
|
|
};
|
|
|
|
// 단일 메뉴 변환 함수
|
|
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null): any => {
|
|
const menuId = menu.objid || menu.OBJID;
|
|
|
|
// 사용자 locale 기준으로 번역 처리
|
|
const getDisplayText = (menu: MenuItem) => {
|
|
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용
|
|
if (menu.translated_name || menu.TRANSLATED_NAME) {
|
|
return menu.translated_name || menu.TRANSLATED_NAME;
|
|
}
|
|
|
|
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음";
|
|
|
|
// 사용자 정보에서 locale 가져오기
|
|
const userLocale = userInfo?.locale || "ko";
|
|
|
|
if (userLocale === "EN") {
|
|
// 영어 번역
|
|
const translations: { [key: string]: string } = {
|
|
관리자: "Administrator",
|
|
사용자: "User Management",
|
|
메뉴: "Menu Management",
|
|
대시보드: "Dashboard",
|
|
권한: "Permission Management",
|
|
코드: "Code Management",
|
|
설정: "Settings",
|
|
로그: "Log Management",
|
|
프로젝트: "Project Management",
|
|
제품: "Product Management",
|
|
};
|
|
|
|
for (const [korean, english] of Object.entries(translations)) {
|
|
if (baseName.includes(korean)) {
|
|
return baseName.replace(korean, english);
|
|
}
|
|
}
|
|
} else if (userLocale === "JA") {
|
|
// 일본어 번역
|
|
const translations: { [key: string]: string } = {
|
|
관리자: "管理者",
|
|
사용자: "ユーザー管理",
|
|
메뉴: "メニュー管理",
|
|
대시보드: "ダッシュボード",
|
|
권한: "権限管理",
|
|
코드: "コード管理",
|
|
설정: "設定",
|
|
로그: "ログ管理",
|
|
프로젝트: "プロジェクト管理",
|
|
제품: "製品管理",
|
|
};
|
|
|
|
for (const [korean, japanese] of Object.entries(translations)) {
|
|
if (baseName.includes(korean)) {
|
|
return baseName.replace(korean, japanese);
|
|
}
|
|
}
|
|
} else if (userLocale === "ZH") {
|
|
// 중국어 번역
|
|
const translations: { [key: string]: string } = {
|
|
관리자: "管理员",
|
|
사용자: "用户管理",
|
|
메뉴: "菜单管理",
|
|
대시보드: "仪表板",
|
|
권한: "权限管理",
|
|
코드: "代码管理",
|
|
설정: "设置",
|
|
로그: "日志管理",
|
|
프로젝트: "项目管理",
|
|
제품: "产品管理",
|
|
};
|
|
|
|
for (const [korean, chinese] of Object.entries(translations)) {
|
|
if (baseName.includes(korean)) {
|
|
return baseName.replace(korean, chinese);
|
|
}
|
|
}
|
|
}
|
|
|
|
return baseName;
|
|
};
|
|
|
|
const children = convertMenuToUI(allMenus, userInfo, menuId);
|
|
|
|
return {
|
|
id: menuId,
|
|
name: getDisplayText(menu),
|
|
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
|
|
url: menu.menu_url || menu.MENU_URL || "#",
|
|
children: children.length > 0 ? children : undefined,
|
|
hasChildren: children.length > 0,
|
|
};
|
|
};
|
|
|
|
function AppLayoutInner({ children }: AppLayoutProps) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const { user, logout, refreshUserData, switchCompany } = useAuth();
|
|
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
|
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
|
|
|
// 현재 회사명 조회 (SUPER_ADMIN 전용)
|
|
useEffect(() => {
|
|
const fetchCurrentCompanyName = async () => {
|
|
if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
|
|
const companyCode = (user as ExtendedUserInfo)?.companyCode;
|
|
|
|
if (companyCode === "*") {
|
|
setCurrentCompanyName("WACE (최고 관리자)");
|
|
} else if (companyCode) {
|
|
try {
|
|
const response = await apiClient.get("/admin/companies/db");
|
|
if (response.data.success) {
|
|
const company = response.data.data.find((c: any) => c.company_code === companyCode);
|
|
setCurrentCompanyName(company?.company_name || companyCode);
|
|
}
|
|
} catch (error) {
|
|
setCurrentCompanyName(companyCode);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchCurrentCompanyName();
|
|
}, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]);
|
|
|
|
// 화면 크기 감지 및 사이드바 초기 상태 설정
|
|
useEffect(() => {
|
|
const checkIsMobile = () => {
|
|
const mobile = window.innerWidth < 1024; // lg 브레이크포인트
|
|
setIsMobile(mobile);
|
|
// 모바일에서만 사이드바를 닫음
|
|
if (mobile) {
|
|
setSidebarOpen(false);
|
|
} else {
|
|
setSidebarOpen(true);
|
|
}
|
|
};
|
|
|
|
checkIsMobile();
|
|
window.addEventListener("resize", checkIsMobile);
|
|
return () => window.removeEventListener("resize", checkIsMobile);
|
|
}, []);
|
|
|
|
// 프로필 관련 로직
|
|
const {
|
|
isModalOpen,
|
|
formData,
|
|
selectedImage,
|
|
isSaving,
|
|
departments,
|
|
alertModal,
|
|
closeAlert,
|
|
openProfileModal,
|
|
closeProfileModal,
|
|
updateFormData,
|
|
selectImage,
|
|
removeImage,
|
|
saveProfile,
|
|
// 운전자 관련
|
|
isDriver,
|
|
hasVehicle,
|
|
driverInfo,
|
|
driverFormData,
|
|
updateDriverFormData,
|
|
handleDriverStatusChange,
|
|
handleDriverAccountDelete,
|
|
handleDeleteVehicle,
|
|
openVehicleRegisterModal,
|
|
closeVehicleRegisterModal,
|
|
isVehicleRegisterModalOpen,
|
|
newVehicleData,
|
|
updateNewVehicleData,
|
|
handleRegisterVehicle,
|
|
} = useProfile(user, refreshUserData, refreshMenus);
|
|
|
|
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
|
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
|
|
|
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
|
|
const isPreviewMode = searchParams.get("preview") === "true";
|
|
|
|
// 현재 모드에 따라 표시할 메뉴 결정
|
|
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
|
|
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
|
|
|
// 메뉴 토글 함수
|
|
const toggleMenu = (menuId: string) => {
|
|
const newExpanded = new Set(expandedMenus);
|
|
if (newExpanded.has(menuId)) {
|
|
newExpanded.delete(menuId);
|
|
} else {
|
|
newExpanded.add(menuId);
|
|
}
|
|
setExpandedMenus(newExpanded);
|
|
};
|
|
|
|
// 메뉴 클릭 핸들러
|
|
const handleMenuClick = async (menu: any) => {
|
|
if (menu.hasChildren) {
|
|
toggleMenu(menu.id);
|
|
} else {
|
|
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
|
|
const menuName = menu.label || menu.name || "메뉴";
|
|
if (typeof window !== "undefined") {
|
|
localStorage.setItem("currentMenuName", menuName);
|
|
}
|
|
|
|
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
|
|
try {
|
|
const menuObjid = menu.objid || menu.id;
|
|
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
|
|
|
if (assignedScreens.length > 0) {
|
|
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
|
const firstScreen = assignedScreens[0];
|
|
|
|
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
|
|
const params = new URLSearchParams();
|
|
if (isAdminMode) {
|
|
params.set("mode", "admin");
|
|
}
|
|
params.set("menuObjid", menuObjid.toString());
|
|
|
|
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
|
|
|
|
router.push(screenPath);
|
|
if (isMobile) {
|
|
setSidebarOpen(false);
|
|
}
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.warn("할당된 화면 조회 실패:", error);
|
|
}
|
|
|
|
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
|
|
if (menu.url && menu.url !== "#") {
|
|
router.push(menu.url);
|
|
if (isMobile) {
|
|
setSidebarOpen(false);
|
|
}
|
|
} else {
|
|
// URL도 없고 할당된 화면도 없으면 경고 메시지
|
|
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// 모드 전환 핸들러
|
|
const handleModeSwitch = () => {
|
|
if (isAdminMode) {
|
|
// 관리자 → 사용자 모드: 선택한 회사 유지
|
|
router.push("/main");
|
|
} else {
|
|
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
|
|
router.push("/admin");
|
|
}
|
|
};
|
|
|
|
// 로그아웃 핸들러
|
|
const handleLogout = async () => {
|
|
try {
|
|
await logout();
|
|
router.push("/login");
|
|
} catch (error) {
|
|
// 로그아웃 실패 시 처리
|
|
}
|
|
};
|
|
|
|
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
|
|
const renderMenu = (menu: any, level: number = 0) => {
|
|
const isExpanded = expandedMenus.has(menu.id);
|
|
|
|
return (
|
|
<div key={menu.id}>
|
|
<div
|
|
className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
|
|
pathname === menu.url
|
|
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
|
: isExpanded
|
|
? "bg-slate-100 text-slate-900"
|
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
|
} ${level > 0 ? "ml-6" : ""}`}
|
|
onClick={() => handleMenuClick(menu)}
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center">
|
|
{menu.icon}
|
|
<span className="ml-3 truncate" title={menu.name}>
|
|
{menu.name}
|
|
</span>
|
|
</div>
|
|
{menu.hasChildren && (
|
|
<div className="ml-auto">
|
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 서브메뉴 */}
|
|
{menu.hasChildren && isExpanded && (
|
|
<div className="mt-1 space-y-1 pl-6">
|
|
{menu.children?.map((child: any) => (
|
|
<div
|
|
key={child.id}
|
|
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
|
|
pathname === child.url
|
|
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
|
}`}
|
|
onClick={() => handleMenuClick(child)}
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center">
|
|
{child.icon}
|
|
<span className="ml-3 truncate" title={child.name}>
|
|
{child.name}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (인증 대기 없이 즉시 렌더링)
|
|
if (isPreviewMode) {
|
|
return (
|
|
<div className="h-screen w-full overflow-auto bg-white p-4">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 사용자 정보가 없으면 로딩 표시
|
|
if (!user) {
|
|
return (
|
|
<div className="flex h-screen items-center justify-center">
|
|
<div className="flex flex-col items-center">
|
|
<div className="border-primary mb-4 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
|
<p>로딩중...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// UI 변환된 메뉴 데이터
|
|
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col bg-white">
|
|
{/* 모바일 헤더 - 모바일에서만 표시 */}
|
|
{isMobile && (
|
|
<header className="fixed top-0 left-0 right-0 z-50 flex h-14 items-center justify-between border-b border-slate-200 bg-white px-4">
|
|
<div className="flex items-center gap-3">
|
|
{/* 햄버거 메뉴 버튼 */}
|
|
<SideMenu onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} />
|
|
<Logo />
|
|
</div>
|
|
{/* 사용자 드롭다운 */}
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-slate-100">
|
|
<div className="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full">
|
|
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
|
<img
|
|
src={user.photo}
|
|
alt={user.userName || "User"}
|
|
className="aspect-square h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700">
|
|
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-56" align="end">
|
|
<DropdownMenuLabel className="font-normal">
|
|
<div className="flex flex-col space-y-1">
|
|
<p className="text-sm leading-none font-medium">
|
|
{user.userName || "사용자"}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs leading-none">
|
|
{user.deptName || user.email || user.userId}
|
|
</p>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={openProfileModal}>
|
|
<User className="mr-2 h-4 w-4" />
|
|
<span>프로필</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span>로그아웃</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</header>
|
|
)}
|
|
|
|
{/* 메인 컨테이너 - 모바일에서는 헤더 높이만큼 패딩 */}
|
|
<div className={`flex flex-1 ${isMobile ? "pt-14" : ""}`}>
|
|
{/* 모바일 사이드바 오버레이 */}
|
|
{sidebarOpen && isMobile && (
|
|
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
|
)}
|
|
|
|
{/* 왼쪽 사이드바 */}
|
|
<aside
|
|
className={`${
|
|
isMobile
|
|
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40 h-[calc(100vh-56px)]"
|
|
: "relative z-auto h-screen translate-x-0"
|
|
} flex w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
|
>
|
|
{/* 사이드바 최상단 - 로고 (데스크톱에서만 표시) */}
|
|
{!isMobile && (
|
|
<div className="flex h-14 items-center justify-between border-b border-slate-200 px-4">
|
|
<Logo />
|
|
</div>
|
|
)}
|
|
|
|
{/* WACE 관리자: 현재 관리 회사 표시 */}
|
|
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
|
|
<div className="mx-3 mt-3 rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 className="h-4 w-4 shrink-0 text-primary" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[10px] text-muted-foreground">현재 관리 회사</p>
|
|
<p className="truncate text-sm font-semibold" title={currentCompanyName || "로딩 중..."}>
|
|
{currentCompanyName || "로딩 중..."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Admin/User 모드 전환 버튼 (관리자만) */}
|
|
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
|
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
|
(user as ExtendedUserInfo)?.userType === "admin") && (
|
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
|
{/* 관리자/사용자 메뉴 전환 */}
|
|
<Button
|
|
onClick={handleModeSwitch}
|
|
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
|
|
isAdminMode
|
|
? "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100"
|
|
: "border-primary/20 bg-accent hover:bg-primary/20 border text-blue-700"
|
|
}`}
|
|
>
|
|
{isAdminMode ? (
|
|
<>
|
|
<UserCheck className="h-4 w-4" />
|
|
사용자 메뉴로 전환
|
|
</>
|
|
) : (
|
|
<>
|
|
<Shield className="h-4 w-4" />
|
|
관리자 메뉴로 전환
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
{/* WACE 관리자 전용: 회사 선택 버튼 */}
|
|
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
|
|
<Button
|
|
onClick={() => { console.log("🔴 회사 선택 버튼 클릭!"); setShowCompanySwitcher(true); }}
|
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-sm font-medium text-purple-700 transition-colors duration-200 hover:cursor-pointer hover:bg-purple-100"
|
|
>
|
|
<Building2 className="h-4 w-4" />
|
|
회사 선택
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 메뉴 영역 */}
|
|
<div className="flex-1 overflow-y-auto py-4">
|
|
<nav className="space-y-1 px-3">
|
|
{loading ? (
|
|
<div className="animate-pulse space-y-2">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="h-8 rounded bg-slate-200"></div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
uiMenus.map((menu) => renderMenu(menu))
|
|
)}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* 사이드바 하단 - 사용자 프로필 */}
|
|
<div className="border-t border-slate-200 p-3">
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-slate-100">
|
|
{/* 프로필 아바타 */}
|
|
<div className="relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full">
|
|
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
|
<img
|
|
src={user.photo}
|
|
alt={user.userName || "User"}
|
|
className="aspect-square h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700">
|
|
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* 사용자 정보 */}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-slate-900">
|
|
{user.userName || "사용자"}
|
|
</p>
|
|
<p className="truncate text-xs text-slate-500">
|
|
{user.deptName || user.email || user.userId}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-56" align="start" side="top">
|
|
<DropdownMenuLabel className="font-normal">
|
|
<div className="flex items-center space-x-3">
|
|
{/* 프로필 사진 표시 */}
|
|
<div className="relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full">
|
|
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
|
<img
|
|
src={user.photo}
|
|
alt={user.userName || "User"}
|
|
className="aspect-square h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-base font-semibold text-slate-700">
|
|
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 사용자 정보 */}
|
|
<div className="flex flex-col space-y-1">
|
|
<p className="text-sm leading-none font-medium">
|
|
{user.userName || "사용자"} ({user.userId || ""})
|
|
</p>
|
|
<p className="text-muted-foreground text-xs leading-none font-semibold">{user.email || ""}</p>
|
|
<p className="text-muted-foreground text-xs leading-none font-semibold">
|
|
{user.deptName && user.positionName
|
|
? `${user.deptName}, ${user.positionName}`
|
|
: user.deptName || user.positionName || "부서 정보 없음"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={openProfileModal}>
|
|
<User className="mr-2 h-4 w-4" />
|
|
<span>프로필</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span>로그아웃</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
|
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
|
{children}
|
|
</main>
|
|
</div>
|
|
|
|
{/* 프로필 수정 모달 */}
|
|
<ProfileModal
|
|
isOpen={isModalOpen}
|
|
user={user}
|
|
formData={formData}
|
|
selectedImage={selectedImage || ""}
|
|
isSaving={isSaving}
|
|
departments={departments}
|
|
alertModal={alertModal}
|
|
isDriver={isDriver}
|
|
hasVehicle={hasVehicle}
|
|
driverInfo={driverInfo}
|
|
driverFormData={driverFormData}
|
|
onDriverFormChange={updateDriverFormData}
|
|
onDriverStatusChange={handleDriverStatusChange}
|
|
onDriverAccountDelete={handleDriverAccountDelete}
|
|
onDeleteVehicle={handleDeleteVehicle}
|
|
onOpenVehicleRegisterModal={openVehicleRegisterModal}
|
|
isVehicleRegisterModalOpen={isVehicleRegisterModalOpen}
|
|
newVehicleData={newVehicleData}
|
|
onCloseVehicleRegisterModal={closeVehicleRegisterModal}
|
|
onNewVehicleDataChange={updateNewVehicleData}
|
|
onRegisterVehicle={handleRegisterVehicle}
|
|
onClose={closeProfileModal}
|
|
onFormChange={updateFormData}
|
|
onImageSelect={selectImage}
|
|
onImageRemove={removeImage}
|
|
onSave={saveProfile}
|
|
onAlertClose={closeAlert}
|
|
/>
|
|
|
|
{/* 회사 전환 모달 (WACE 관리자 전용) */}
|
|
<Dialog open={showCompanySwitcher} onOpenChange={setShowCompanySwitcher}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">회사 선택</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="mt-4">
|
|
<CompanySwitcher onClose={() => setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} />
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AppLayout({ children }: AppLayoutProps) {
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex h-screen items-center justify-center">
|
|
<div className="flex flex-col items-center">
|
|
<div className="border-primary mb-4 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
|
<p>로딩중...</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<AppLayoutInner>{children}</AppLayoutInner>
|
|
</Suspense>
|
|
);
|
|
}
|