화면 저장기능 구현

This commit is contained in:
kjs
2025-09-01 18:42:59 +09:00
parent 31d25268ce
commit 3bd5a2fa14
12 changed files with 1599 additions and 256 deletions

View File

@@ -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}