468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useEffect } from "react";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Textarea } from "@/components/ui/textarea";
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from "@/components/ui/select";
|
||
|
|
import { ScreenGroup, createScreenGroup, updateScreenGroup } from "@/lib/api/screenGroup";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { apiClient } from "@/lib/api/client";
|
||
|
|
import {
|
||
|
|
Command,
|
||
|
|
CommandEmpty,
|
||
|
|
CommandGroup,
|
||
|
|
CommandInput,
|
||
|
|
CommandItem,
|
||
|
|
CommandList,
|
||
|
|
} from "@/components/ui/command";
|
||
|
|
import {
|
||
|
|
Popover,
|
||
|
|
PopoverContent,
|
||
|
|
PopoverTrigger,
|
||
|
|
} from "@/components/ui/popover";
|
||
|
|
import { Check, ChevronsUpDown, Folder } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
interface ScreenGroupModalProps {
|
||
|
|
isOpen: boolean;
|
||
|
|
onClose: () => void;
|
||
|
|
onSuccess: () => void;
|
||
|
|
group?: ScreenGroup | null; // 수정 모드일 때 기존 그룹 데이터
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ScreenGroupModal({
|
||
|
|
isOpen,
|
||
|
|
onClose,
|
||
|
|
onSuccess,
|
||
|
|
group,
|
||
|
|
}: ScreenGroupModalProps) {
|
||
|
|
const [currentCompanyCode, setCurrentCompanyCode] = useState<string>("");
|
||
|
|
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
|
||
|
|
|
||
|
|
const [formData, setFormData] = useState({
|
||
|
|
group_name: "",
|
||
|
|
group_code: "",
|
||
|
|
description: "",
|
||
|
|
display_order: 0,
|
||
|
|
target_company_code: "",
|
||
|
|
parent_group_id: null as number | null,
|
||
|
|
});
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [companies, setCompanies] = useState<{ code: string; name: string }[]>([]);
|
||
|
|
const [availableParentGroups, setAvailableParentGroups] = useState<ScreenGroup[]>([]);
|
||
|
|
const [isParentGroupSelectOpen, setIsParentGroupSelectOpen] = useState(false);
|
||
|
|
|
||
|
|
// 그룹 경로 가져오기 (계층 구조 표시용)
|
||
|
|
const getGroupPath = (groupId: number): string => {
|
||
|
|
const grp = availableParentGroups.find((g) => g.id === groupId);
|
||
|
|
if (!grp) return "";
|
||
|
|
|
||
|
|
const path: string[] = [grp.group_name];
|
||
|
|
let currentGroup = grp;
|
||
|
|
|
||
|
|
while ((currentGroup as any).parent_group_id) {
|
||
|
|
const parent = availableParentGroups.find((g) => g.id === (currentGroup as any).parent_group_id);
|
||
|
|
if (parent) {
|
||
|
|
path.unshift(parent.group_name);
|
||
|
|
currentGroup = parent;
|
||
|
|
} else {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return path.join(" > ");
|
||
|
|
};
|
||
|
|
|
||
|
|
// 그룹을 계층 구조로 정렬
|
||
|
|
const getSortedGroups = (): typeof availableParentGroups => {
|
||
|
|
const result: typeof availableParentGroups = [];
|
||
|
|
|
||
|
|
const addChildren = (parentId: number | null, level: number) => {
|
||
|
|
const children = availableParentGroups
|
||
|
|
.filter((g) => (g as any).parent_group_id === parentId)
|
||
|
|
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
|
||
|
|
|
||
|
|
for (const child of children) {
|
||
|
|
result.push({ ...child, group_level: level } as any);
|
||
|
|
addChildren(child.id, level + 1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
addChildren(null, 1);
|
||
|
|
return result;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 현재 사용자 정보 로드
|
||
|
|
useEffect(() => {
|
||
|
|
const loadUserInfo = async () => {
|
||
|
|
try {
|
||
|
|
const response = await apiClient.get("/auth/me");
|
||
|
|
const result = response.data;
|
||
|
|
if (result.success && result.data) {
|
||
|
|
const companyCode = result.data.companyCode || result.data.company_code || "";
|
||
|
|
setCurrentCompanyCode(companyCode);
|
||
|
|
setIsSuperAdmin(companyCode === "*");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("사용자 정보 로드 실패:", error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (isOpen) {
|
||
|
|
loadUserInfo();
|
||
|
|
}
|
||
|
|
}, [isOpen]);
|
||
|
|
|
||
|
|
// 회사 목록 로드 (최고 관리자만)
|
||
|
|
useEffect(() => {
|
||
|
|
if (isSuperAdmin && isOpen) {
|
||
|
|
const loadCompanies = async () => {
|
||
|
|
try {
|
||
|
|
const response = await apiClient.get("/admin/companies");
|
||
|
|
const result = response.data;
|
||
|
|
if (result.success && result.data) {
|
||
|
|
const companyList = result.data.map((c: any) => ({
|
||
|
|
code: c.company_code,
|
||
|
|
name: c.company_name,
|
||
|
|
}));
|
||
|
|
setCompanies(companyList);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("회사 목록 로드 실패:", error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
loadCompanies();
|
||
|
|
}
|
||
|
|
}, [isSuperAdmin, isOpen]);
|
||
|
|
|
||
|
|
// 부모 그룹 목록 로드 (현재 회사의 대분류/중분류 그룹만)
|
||
|
|
useEffect(() => {
|
||
|
|
if (isOpen && currentCompanyCode) {
|
||
|
|
const loadParentGroups = async () => {
|
||
|
|
try {
|
||
|
|
const response = await apiClient.get(`/screen-groups/groups?size=1000`);
|
||
|
|
const result = response.data;
|
||
|
|
if (result.success && result.data) {
|
||
|
|
// 모든 그룹을 상위 그룹으로 선택 가능 (무한 중첩 지원)
|
||
|
|
setAvailableParentGroups(result.data);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("부모 그룹 목록 로드 실패:", error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
loadParentGroups();
|
||
|
|
}
|
||
|
|
}, [isOpen, currentCompanyCode]);
|
||
|
|
|
||
|
|
// 그룹 데이터가 변경되면 폼 초기화
|
||
|
|
useEffect(() => {
|
||
|
|
if (currentCompanyCode) {
|
||
|
|
if (group) {
|
||
|
|
setFormData({
|
||
|
|
group_name: group.group_name || "",
|
||
|
|
group_code: group.group_code || "",
|
||
|
|
description: group.description || "",
|
||
|
|
display_order: group.display_order || 0,
|
||
|
|
target_company_code: group.company_code || currentCompanyCode,
|
||
|
|
parent_group_id: (group as any).parent_group_id || null,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
setFormData({
|
||
|
|
group_name: "",
|
||
|
|
group_code: "",
|
||
|
|
description: "",
|
||
|
|
display_order: 0,
|
||
|
|
target_company_code: currentCompanyCode,
|
||
|
|
parent_group_id: null,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, [group, isOpen, currentCompanyCode]);
|
||
|
|
|
||
|
|
const handleSubmit = async () => {
|
||
|
|
// 필수 필드 검증
|
||
|
|
if (!formData.group_name.trim()) {
|
||
|
|
toast.error("그룹명을 입력하세요");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!formData.group_code.trim()) {
|
||
|
|
toast.error("그룹 코드를 입력하세요");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
let response;
|
||
|
|
if (group) {
|
||
|
|
// 수정 모드
|
||
|
|
response = await updateScreenGroup(group.id, formData);
|
||
|
|
} else {
|
||
|
|
// 추가 모드
|
||
|
|
response = await createScreenGroup({
|
||
|
|
...formData,
|
||
|
|
is_active: "Y",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
toast.success(group ? "그룹이 수정되었습니다" : "그룹이 추가되었습니다");
|
||
|
|
onSuccess();
|
||
|
|
onClose();
|
||
|
|
} else {
|
||
|
|
toast.error(response.message || "작업에 실패했습니다");
|
||
|
|
}
|
||
|
|
} catch (error: any) {
|
||
|
|
console.error("그룹 저장 실패:", error);
|
||
|
|
toast.error("그룹 저장에 실패했습니다");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="text-base sm:text-lg">
|
||
|
|
{group ? "그룹 수정" : "그룹 추가"}
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription className="text-xs sm:text-sm">
|
||
|
|
화면 그룹 정보를 입력하세요
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-3 sm:space-y-4">
|
||
|
|
{/* 회사 선택 (최고 관리자만) */}
|
||
|
|
{isSuperAdmin && (
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="target_company_code" className="text-xs sm:text-sm">
|
||
|
|
회사 선택 *
|
||
|
|
</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.target_company_code}
|
||
|
|
onValueChange={(value) =>
|
||
|
|
setFormData({ ...formData, target_company_code: value })
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||
|
|
<SelectValue placeholder="회사를 선택하세요" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{companies.map((company) => (
|
||
|
|
<SelectItem key={company.code} value={company.code}>
|
||
|
|
{company.name} ({company.code})
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||
|
|
선택한 회사에 그룹이 생성됩니다
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 부모 그룹 선택 (하위 그룹 만들기) - 트리 구조 + 검색 */}
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="parent_group_id" className="text-xs sm:text-sm">
|
||
|
|
상위 그룹 (선택사항)
|
||
|
|
</Label>
|
||
|
|
<Popover open={isParentGroupSelectOpen} onOpenChange={setIsParentGroupSelectOpen}>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
role="combobox"
|
||
|
|
aria-expanded={isParentGroupSelectOpen}
|
||
|
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||
|
|
>
|
||
|
|
{formData.parent_group_id === null
|
||
|
|
? "대분류로 생성"
|
||
|
|
: getGroupPath(formData.parent_group_id) || "그룹 선택"}
|
||
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent
|
||
|
|
className="p-0"
|
||
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||
|
|
align="start"
|
||
|
|
>
|
||
|
|
<Command>
|
||
|
|
<CommandInput
|
||
|
|
placeholder="그룹 검색..."
|
||
|
|
className="text-xs sm:text-sm"
|
||
|
|
/>
|
||
|
|
<CommandList>
|
||
|
|
<CommandEmpty className="text-xs sm:text-sm py-2 text-center">
|
||
|
|
그룹을 찾을 수 없습니다
|
||
|
|
</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{/* 대분류로 생성 옵션 */}
|
||
|
|
<CommandItem
|
||
|
|
value="none"
|
||
|
|
onSelect={() => {
|
||
|
|
setFormData({ ...formData, parent_group_id: null });
|
||
|
|
setIsParentGroupSelectOpen(false);
|
||
|
|
}}
|
||
|
|
className="text-xs sm:text-sm"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-4 w-4",
|
||
|
|
formData.parent_group_id === null ? "opacity-100" : "opacity-0"
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
|
||
|
|
대분류로 생성
|
||
|
|
</CommandItem>
|
||
|
|
{/* 계층 구조로 그룹 표시 */}
|
||
|
|
{getSortedGroups().map((parentGroup) => (
|
||
|
|
<CommandItem
|
||
|
|
key={parentGroup.id}
|
||
|
|
value={`${parentGroup.group_name} ${getGroupPath(parentGroup.id)}`}
|
||
|
|
onSelect={() => {
|
||
|
|
setFormData({ ...formData, parent_group_id: parentGroup.id });
|
||
|
|
setIsParentGroupSelectOpen(false);
|
||
|
|
}}
|
||
|
|
className="text-xs sm:text-sm"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-4 w-4",
|
||
|
|
formData.parent_group_id === parentGroup.id ? "opacity-100" : "opacity-0"
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
{/* 들여쓰기로 계층 표시 */}
|
||
|
|
<span
|
||
|
|
style={{ marginLeft: `${(((parentGroup as any).group_level || 1) - 1) * 16}px` }}
|
||
|
|
className="flex items-center"
|
||
|
|
>
|
||
|
|
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
|
||
|
|
{parentGroup.group_name}
|
||
|
|
</span>
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||
|
|
부모 그룹을 선택하면 하위 그룹으로 생성됩니다
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 그룹명 */}
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="group_name" className="text-xs sm:text-sm">
|
||
|
|
그룹명 *
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="group_name"
|
||
|
|
value={formData.group_name}
|
||
|
|
onChange={(e) =>
|
||
|
|
setFormData({ ...formData, group_name: e.target.value })
|
||
|
|
}
|
||
|
|
placeholder="그룹명을 입력하세요"
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 그룹 코드 */}
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="group_code" className="text-xs sm:text-sm">
|
||
|
|
그룹 코드 *
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="group_code"
|
||
|
|
value={formData.group_code}
|
||
|
|
onChange={(e) =>
|
||
|
|
setFormData({ ...formData, group_code: e.target.value })
|
||
|
|
}
|
||
|
|
placeholder="영문 대문자와 언더스코어로 입력"
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
disabled={!!group} // 수정 모드일 때는 코드 변경 불가
|
||
|
|
/>
|
||
|
|
{group && (
|
||
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||
|
|
그룹 코드는 수정할 수 없습니다
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 설명 */}
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||
|
|
설명
|
||
|
|
</Label>
|
||
|
|
<Textarea
|
||
|
|
id="description"
|
||
|
|
value={formData.description}
|
||
|
|
onChange={(e) =>
|
||
|
|
setFormData({ ...formData, description: e.target.value })
|
||
|
|
}
|
||
|
|
placeholder="그룹에 대한 설명을 입력하세요"
|
||
|
|
className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 정렬 순서 */}
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="display_order" className="text-xs sm:text-sm">
|
||
|
|
정렬 순서
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="display_order"
|
||
|
|
type="number"
|
||
|
|
value={formData.display_order}
|
||
|
|
onChange={(e) =>
|
||
|
|
setFormData({
|
||
|
|
...formData,
|
||
|
|
display_order: parseInt(e.target.value) || 0,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||
|
|
숫자가 작을수록 상단에 표시됩니다
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={onClose}
|
||
|
|
disabled={loading}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={handleSubmit}
|
||
|
|
disabled={loading}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
{loading ? "저장 중..." : "저장"}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|