- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
479 lines
16 KiB
TypeScript
479 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 { showErrorToast } from "@/lib/utils/toastUtils";
|
|
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 {
|
|
showErrorToast("그룹 저장에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
} catch (error: any) {
|
|
console.error("그룹 저장 실패:", error);
|
|
showErrorToast("그룹 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
} 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={() => {
|
|
// 상위 그룹의 company_code로 자동 설정
|
|
const parentCompanyCode = parentGroup.company_code || formData.target_company_code;
|
|
setFormData({
|
|
...formData,
|
|
parent_group_id: parentGroup.id,
|
|
target_company_code: parentCompanyCode,
|
|
});
|
|
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>
|
|
);
|
|
}
|
|
|