화면 저장기능 구현
This commit is contained in:
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -27,6 +28,7 @@ import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { ScreenAssignmentTab } from "./ScreenAssignmentTab";
|
||||
|
||||
type MenuType = "admin" | "user";
|
||||
|
||||
@@ -804,234 +806,254 @@ export const MenuManagement: React.FC = () => {
|
||||
return (
|
||||
<LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 메인 컨텐츠 - 2:8 비율 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
|
||||
<div className="w-[20%] border-r bg-gray-50">
|
||||
<div className="p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">{getUITextSync("menu.type.title")}</h2>
|
||||
<div className="space-y-3">
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedMenuType === "admin" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleMenuTypeChange("admin")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{getUITextSync("menu.management.admin.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
|
||||
{adminMenus.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 탭 컨테이너 */}
|
||||
<Tabs defaultValue="menus" className="flex flex-1 flex-col">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="menus">메뉴 관리</TabsTrigger>
|
||||
<TabsTrigger value="screen-assignment">화면 할당</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedMenuType === "user" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleMenuTypeChange("user")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{getUITextSync("menu.management.user.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>{userMenus.length}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 메인 영역 - 메뉴 목록 (80%) */}
|
||||
<div className="w-[80%] overflow-hidden">
|
||||
<div className="flex h-full flex-col p-6">
|
||||
<div className="mb-6 flex-shrink-0">
|
||||
<h2 className="mb-2 text-xl font-semibold">
|
||||
{getMenuTypeString()} {getUITextSync("menu.list.title")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 영역 */}
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<Label htmlFor="company">{getUITextSync("filter.company")}</Label>
|
||||
<div className="company-dropdown relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{/* 메뉴 관리 탭 */}
|
||||
<TabsContent value="menus" className="flex-1 overflow-hidden">
|
||||
<div className="flex h-full">
|
||||
{/* 메인 컨텐츠 - 2:8 비율 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
|
||||
<div className="w-[20%] border-r bg-gray-50">
|
||||
<div className="p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">{getUITextSync("menu.type.title")}</h2>
|
||||
<div className="space-y-3">
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedMenuType === "admin" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleMenuTypeChange("admin")}
|
||||
>
|
||||
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
|
||||
{selectedCompany === "all"
|
||||
? getUITextSync("filter.company.all")
|
||||
: selectedCompany === "*"
|
||||
? getUITextSync("filter.company.common")
|
||||
: companies.find((c) => c.code === selectedCompany)?.name ||
|
||||
getUITextSync("filter.company.all")}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isCompanyDropdownOpen && (
|
||||
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md">
|
||||
{/* 검색 입력 */}
|
||||
<div className="border-b p-2">
|
||||
<Input
|
||||
placeholder={getUITextSync("filter.company.search")}
|
||||
value={companySearchText}
|
||||
onChange={(e) => setCompanySearchText(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{getUITextSync("menu.management.admin.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
|
||||
{adminMenus.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 회사 목록 */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div
|
||||
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||
onClick={() => {
|
||||
setSelectedCompany("all");
|
||||
setIsCompanyDropdownOpen(false);
|
||||
setCompanySearchText("");
|
||||
}}
|
||||
>
|
||||
{getUITextSync("filter.company.all")}
|
||||
</div>
|
||||
<div
|
||||
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||
onClick={() => {
|
||||
setSelectedCompany("*");
|
||||
setIsCompanyDropdownOpen(false);
|
||||
setCompanySearchText("");
|
||||
}}
|
||||
>
|
||||
{getUITextSync("filter.company.common")}
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedMenuType === "user" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleMenuTypeChange("user")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{getUITextSync("menu.management.user.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>
|
||||
{userMenus.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{companies
|
||||
.filter((company) => company.code && company.code.trim() !== "")
|
||||
.filter(
|
||||
(company) =>
|
||||
company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
|
||||
company.code.toLowerCase().includes(companySearchText.toLowerCase()),
|
||||
)
|
||||
.map((company, index) => (
|
||||
<div
|
||||
key={company.code || `company-${index}`}
|
||||
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||
onClick={() => {
|
||||
setSelectedCompany(company.code);
|
||||
setIsCompanyDropdownOpen(false);
|
||||
setCompanySearchText("");
|
||||
}}
|
||||
>
|
||||
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
|
||||
{/* 우측 메인 영역 - 메뉴 목록 (80%) */}
|
||||
<div className="w-[80%] overflow-hidden">
|
||||
<div className="flex h-full flex-col p-6">
|
||||
<div className="mb-6 flex-shrink-0">
|
||||
<h2 className="mb-2 text-xl font-semibold">
|
||||
{getMenuTypeString()} {getUITextSync("menu.list.title")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 영역 */}
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<Label htmlFor="company">{getUITextSync("filter.company")}</Label>
|
||||
<div className="company-dropdown relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
|
||||
{selectedCompany === "all"
|
||||
? getUITextSync("filter.company.all")
|
||||
: selectedCompany === "*"
|
||||
? getUITextSync("filter.company.common")
|
||||
: companies.find((c) => c.code === selectedCompany)?.name ||
|
||||
getUITextSync("filter.company.all")}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isCompanyDropdownOpen && (
|
||||
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md">
|
||||
{/* 검색 입력 */}
|
||||
<div className="border-b p-2">
|
||||
<Input
|
||||
placeholder={getUITextSync("filter.company.search")}
|
||||
value={companySearchText}
|
||||
onChange={(e) => setCompanySearchText(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 회사 목록 */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div
|
||||
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||
onClick={() => {
|
||||
setSelectedCompany("all");
|
||||
setIsCompanyDropdownOpen(false);
|
||||
setCompanySearchText("");
|
||||
}}
|
||||
>
|
||||
{getUITextSync("filter.company.all")}
|
||||
</div>
|
||||
<div
|
||||
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||
onClick={() => {
|
||||
setSelectedCompany("*");
|
||||
setIsCompanyDropdownOpen(false);
|
||||
setCompanySearchText("");
|
||||
}}
|
||||
>
|
||||
{getUITextSync("filter.company.common")}
|
||||
</div>
|
||||
|
||||
{companies
|
||||
.filter((company) => company.code && company.code.trim() !== "")
|
||||
.filter(
|
||||
(company) =>
|
||||
company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
|
||||
company.code.toLowerCase().includes(companySearchText.toLowerCase()),
|
||||
)
|
||||
.map((company, index) => (
|
||||
<div
|
||||
key={company.code || `company-${index}`}
|
||||
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
|
||||
onClick={() => {
|
||||
setSelectedCompany(company.code);
|
||||
setIsCompanyDropdownOpen(false);
|
||||
setCompanySearchText("");
|
||||
}}
|
||||
>
|
||||
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="search">{getUITextSync("filter.search")}</Label>
|
||||
<Input
|
||||
placeholder={getUITextSync("filter.search.placeholder")}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchText("");
|
||||
setSelectedCompany("all");
|
||||
setCompanySearchText("");
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{getUITextSync("filter.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="text-sm text-gray-600">
|
||||
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="search">{getUITextSync("filter.search")}</Label>
|
||||
<Input
|
||||
placeholder={getUITextSync("filter.search.placeholder")}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchText("");
|
||||
setSelectedCompany("all");
|
||||
setCompanySearchText("");
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{getUITextSync("filter.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="text-sm text-gray-600">
|
||||
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
|
||||
{getUITextSync("button.add.top.level")}
|
||||
</Button>
|
||||
{selectedMenus.size > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelectedMenus}
|
||||
disabled={deleting}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
{getUITextSync("button.delete.processing")}
|
||||
</>
|
||||
) : (
|
||||
getUITextSync("button.delete.selected.count", {
|
||||
count: selectedMenus.size,
|
||||
})
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MenuTable
|
||||
menus={getCurrentMenus()}
|
||||
title=""
|
||||
onAddMenu={handleAddMenu}
|
||||
onEditMenu={handleEditMenu}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
selectedMenus={selectedMenus}
|
||||
onMenuSelectionChange={handleMenuSelectionChange}
|
||||
onSelectAllMenus={handleSelectAllMenus}
|
||||
expandedMenus={expandedMenus}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
uiTexts={uiTexts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
|
||||
{getUITextSync("button.add.top.level")}
|
||||
</Button>
|
||||
{selectedMenus.size > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelectedMenus}
|
||||
disabled={deleting}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
{getUITextSync("button.delete.processing")}
|
||||
</>
|
||||
) : (
|
||||
getUITextSync("button.delete.selected.count", {
|
||||
count: selectedMenus.size,
|
||||
})
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MenuTable
|
||||
menus={getCurrentMenus()}
|
||||
title=""
|
||||
onAddMenu={handleAddMenu}
|
||||
onEditMenu={handleEditMenu}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
selectedMenus={selectedMenus}
|
||||
onMenuSelectionChange={handleMenuSelectionChange}
|
||||
onSelectAllMenus={handleSelectAllMenus}
|
||||
expandedMenus={expandedMenus}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
uiTexts={uiTexts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 화면 할당 탭 */}
|
||||
<TabsContent value="screen-assignment" className="flex-1 overflow-hidden">
|
||||
<ScreenAssignmentTab menus={[...adminMenus, ...userMenus]} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<MenuFormModal
|
||||
isOpen={formModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user