"use client"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { toast } from "sonner"; import { Loader2, Trash2, FileText, AlertCircle, GripVertical, } from "lucide-react"; import { debugLog, debugError } from "@/lib/debug-utils"; // dnd-kit imports for drag and drop import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; // 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier import { restrictToVerticalAxis, restrictToParentElement, } from "@dnd-kit/modifiers"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; // API 함수 및 타입 import { submitApproval, submitSecurityApproval, createSubmitApprovalRequest, createApprovalLine, } from "@/lib/knox-api/approval/approval"; import type { ApprovalLine, SubmitApprovalRequest, } from "@/lib/knox-api/approval/approval"; // 역할 텍스트 매핑 (기존 mock util 대체) const getRoleText = (role: string) => { const map: Record = { "0": "기안", "1": "결재", "2": "합의", "3": "후결", "4": "병렬합의", "7": "병렬결재", "9": "통보", }; return map[role] || role; }; // TiptapEditor 컴포넌트 import RichTextEditor from "@/components/rich-text-editor/RichTextEditor"; // UserSelector 컴포넌트 import { UserSelector, type UserSelectItem, } from "@/components/common/user/user-selector"; // next-auth 세션 의존 제거 // UserSelector에서 반환되는 사용자에 epId가 포함될 수 있으므로 확장 타입 정의 interface ExtendedUserSelectItem extends UserSelectItem { epId?: string; } // 역할 코드 타입 정의 type ApprovalRole = "0" | "1" | "2" | "3" | "4" | "7" | "9"; // 결재 라인 아이템 타입 정의 (고유 ID 포함) interface ApprovalLineItem { id: string; // 내부 고유 식별자 epId?: string; // Knox 고유 ID (전사 고유) userId?: string; // DB User PK emailAddress?: string; name?: string; // 사용자 이름 deptName?: string; // 부서명 role: ApprovalRole; seq: string; opinion?: string; } const formSchema = z.object({ subject: z.string().min(1, "제목은 필수입니다"), contents: z.string().min(1, "내용은 필수입니다"), contentsType: z.literal("HTML"), docSecuType: z.enum(["PERSONAL", "CONFIDENTIAL", "CONFIDENTIAL_STRICT"]), urgYn: z.boolean(), importantYn: z.boolean(), notifyOption: z.enum(["0", "1", "2", "3"]), docMngSaveCode: z.enum(["0", "1"]), sbmLang: z.enum(["ko", "ja", "zh", "en"]), timeZone: z.string().default("GMT+9"), aplns: z .array( z.object({ id: z.string(), // 고유 식별자 epId: z.string().optional(), userId: z.string().optional(), emailAddress: z .string() .email("유효한 이메일 주소를 입력해주세요") .optional(), name: z.string().optional(), deptName: z.string().optional(), role: z.enum(["0", "1", "2", "3", "4", "7", "9"]), seq: z.string(), opinion: z.string().optional(), }), ) .min(1, "최소 1개의 결재 경로가 필요합니다"), // 첨부파일 (선택) attachments: z.any().optional(), }); type FormData = z.infer; interface ApprovalSubmitProps { onSubmitSuccess?: (apInfId: string) => void; } // Sortable한 결재 라인 컴포넌트 interface SortableApprovalLineProps { apln: ApprovalLineItem; index: number; form: ReturnType>; onRemove: () => void; canRemove: boolean; selected: boolean; onSelect: () => void; } // eslint-disable-next-line @typescript-eslint/no-unused-vars function SortableApprovalLine({ apln, index, form, onRemove, canRemove, selected, onSelect, }: SortableApprovalLineProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: apln.id }); // 고유 ID 사용 const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, // 드래그 중일 때 투명도 조절 }; return (
{/* 드래그 핸들 */}
{/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */} {index !== 0 ? ( onSelect()} onClick={(e) => e.stopPropagation()} /> ) : (
// 기안자용 빈 공간 (체크박스가 없으므로) )} {/* 실제 seq 기준 표시 */} {parseInt(apln.seq) + 1}
{/* 사용자 정보 표시 */}
{apln.name || "Knox 이름 없음"} {apln.deptName ? ` / ${apln.deptName}` : ""}
( )} /> ( )} /> ( )} /> ( )} />
{/* 역할 선택 */} {index === 0 ? ( // 상신자는 역할 선택 대신 고정 표시
기안
) : ( { // 병렬 여부 판단 const isParallel = field.value === "4" || field.value === "7"; // 병렬, 후결 값을 제외한 기본 역할 const baseRole: ApprovalRole = field.value === "7" ? "1" : field.value === "4" ? "2" : field.value === "3" ? "1" : (field.value as ApprovalRole); // 기본 역할 변경 핸들러 const handleBaseRoleChange = (val: string) => { if (!val) return; let newRole = val; if (isParallel) { if (val === "1") newRole = "7"; else if (val === "2") newRole = "4"; } field.onChange(newRole); }; // 병렬인 경우 한 개 버튼으로 표시 if (isParallel) { return ( {getRoleText(field.value)} ); } return (
결재 합의 통보
); }} /> )} {/* 의견 입력란 제거됨 */} {/* 역할 표시 */}
{getRoleText(apln.role)} {canRemove && ( )}
); } // Sortable Approval Group (seq 단위 카드) interface SortableApprovalGroupProps { group: ApprovalLineItem[]; // 동일 seq 항목들 index: number; form: ReturnType>; onRemoveGroup: () => void; canRemove: boolean; selected: boolean; onSelect: () => void; } function SortableApprovalGroup({ group, index, form, onRemoveGroup, canRemove, selected, onSelect, }: SortableApprovalGroupProps) { const seq = group[0].seq; const role = group[0].role; // 그룹을 식별할 안정적인 고유 키(첫 구성원의 id 활용) const groupKey = group[0].id; const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: groupKey }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
{/* 드래그 핸들 */}
{/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */} {index !== 0 ? ( e.stopPropagation()} /> ) : (
// 기안자용 빈 공간 )} {/* seq 표시 */} {parseInt(seq) + 1} {/* 그룹 상세 정보 */}
{/* 사용자 목록 */}
{group.map((u) => (
{u.name || "Knox 이름 없음"} {u.deptName ? ` / ${u.deptName}` : ""}
))}
{/* 역할 */}
{seq === "0" ? ( 기안 ) : role === "7" || role === "4" ? ( {getRoleText(role)} ) : ( // 단일일 때는 기존 토글 재사용 (첫 항목 기준) a.id === group[0].id)}.role`} render={({ field }) => ( 결재 합의 통보 )} /> )}
{/* 삭제 버튼 */}
{canRemove && ( )}
); } export default function ApprovalSubmit({ onSubmitSuccess, currentUser, }: ApprovalSubmitProps & { currentUser?: { id: number | string; name: string | null | undefined; email: string; epId?: string | null } }) { const [isSubmitting, setIsSubmitting] = useState(false); const [submitResult, setSubmitResult] = useState<{ apInfId: string; } | null>(null); const [selectedSeqs, setSelectedSeqs] = useState([]); // 그룹 단위 선택/해제 const toggleSelectGroup = (seq: string) => { setSelectedSeqs((prev) => prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq], ); }; const clearSelection = () => setSelectedSeqs([]); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { subject: "", contents: "", contentsType: "HTML", docSecuType: "PERSONAL", urgYn: false, importantYn: false, notifyOption: "0", docMngSaveCode: "0", sbmLang: "ko", timeZone: "GMT+9", aplns: [], attachments: undefined, }, }); const aplns = form.watch("aplns"); // 병렬 전환 핸들러 const applyParallel = () => { if (selectedSeqs.length < 2) { toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다."); return; } const current = form.getValues("aplns"); const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq)); const roles = Array.from(new Set(selectedAplns.map((a) => a.role))); if (roles.length !== 1) { toast.error("선택된 항목의 역할이 동일해야 합니다."); return; } const role = roles[0]; let newRole: ApprovalRole; if (role === "1") { newRole = "7"; // 병렬 결재 } else if (role === "2") { newRole = "4"; // 병렬 합의 } else if (role === "9") { newRole = "9"; // 병렬 통보(역할 코드 유지) } else { toast.error("결재, 합의 또는 통보만 병렬 지정 가능합니다."); return; } const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq))); const updated = current.map((a) => { if (selectedSeqs.includes(a.seq)) { return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() }; } return a; }); form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true }); clearSelection(); }; // 후결 전환 핸들러 const applyAfter = () => { if (selectedSeqs.length !== 1) { toast.error("후결은 한 명만 지정할 수 있습니다."); return; } const targetSeq = selectedSeqs[0]; // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가 const targetRole = form.getValues("aplns").find((a) => a.seq === targetSeq)?.role; if (targetRole === "7" || targetRole === "4") { toast.error("병렬 그룹은 후결로 전환할 수 없습니다."); return; } const updated = form.getValues("aplns").map((a) => { if (a.seq === targetSeq) { return { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole }; } return a; }); form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true }); clearSelection(); }; // 병렬 해제 핸들러 const ungroupParallel = () => { if (selectedSeqs.length === 0) { toast.error("해제할 결재선을 선택하세요."); return; } let newSeqCounter = 1; // 0은 상신자 유지 const updated = form.getValues("aplns").map((a) => { if (selectedSeqs.includes(a.seq)) { let newRole: ApprovalRole = a.role; if (a.role === "7") newRole = "1"; if (a.role === "4") newRole = "2"; return { ...a, role: newRole, seq: "" }; // seq 임시 비움 } return { ...a }; }); // seq 재할당 (상신자 제외하고 순차) const reassigned = updated .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0")) .map((a) => { if (a.seq === "0") return a; // 상신자 const newItem = { ...a, seq: newSeqCounter.toString() }; newSeqCounter += 1; return newItem; }); form.setValue("aplns", reassigned as FormData["aplns"], { shouldDirty: true }); clearSelection(); }; // seq 기준 정렬 및 재번호 부여 (병렬 그룹은 동일 seq 유지) const reorderBySeq = (list: FormData["aplns"]): FormData["aplns"] => { const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); // 기존 seq -> 새 seq 매핑. 병렬 그룹(동일 seq)은 동일한 새 seq 를 갖도록 처리 const seqMap = new Map(); let nextSeq = 0; return sorted.map((apln) => { if (!seqMap.has(apln.seq)) { seqMap.set(apln.seq, nextSeq.toString()); nextSeq += 1; } return { ...apln, seq: seqMap.get(apln.seq)! }; }); }; // 로그인 사용자를 첫 번째 결재자로 보장하는 effect (prop만 사용) useEffect(() => { if (!currentUser?.email) return; const effectiveEmail = currentUser.email; const effectiveEpId = currentUser.epId ?? undefined; const effectiveUserId = currentUser.id as string | number | undefined; let currentAplns = form.getValues("aplns"); // 이미 포함되어 있는지 확인 (epId 또는 email 기준) const selfIndex = currentAplns.findIndex( (a) => (effectiveEpId && a.epId === effectiveEpId) || a.emailAddress === effectiveEmail, ); if (selfIndex === -1) { // 맨 앞에 상신자 추가 const newSelf: FormData["aplns"][number] = { id: generateUniqueId(), epId: effectiveEpId, userId: effectiveUserId ? effectiveUserId.toString() : undefined, emailAddress: effectiveEmail, name: currentUser?.name ?? undefined, role: "0", // 기안 seq: "0", opinion: "", }; currentAplns = [newSelf, ...currentAplns]; } // seq 재정렬 보장 currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() })); form.setValue("aplns", currentAplns, { shouldValidate: false, shouldDirty: true }); }, [currentUser, form]); // dnd-kit sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); // 고유 ID 생성 함수 const generateUniqueId = () => { return `apln-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; }; // 결재자 추가 (UserSelector를 통해) const addApprovalUsers = (users: UserSelectItem[]) => { const newAplns = [...aplns]; users.forEach((user) => { // 이미 추가된 사용자인지 확인 const existingIndex = newAplns.findIndex(apln => apln.userId === user.id.toString()); if (existingIndex === -1) { // 새 사용자 추가 const newSeq = (newAplns.length).toString(); // 0은 상신자 const newApln: FormData["aplns"][number] = { id: generateUniqueId(), // 고유 ID 생성 epId: (user as ExtendedUserSelectItem).epId, // epId가 전달되면 사용 userId: user.id.toString(), emailAddress: user.email, name: user.name, deptName: (user as ExtendedUserSelectItem).deptName ?? undefined, role: "1", // 기본값: 결재 seq: newSeq, opinion: "", }; newAplns.push(newApln); } }); form.setValue("aplns", newAplns); }; // 그룹 삭제 (seq 기반) const removeApprovalGroup = (seq: string) => { if (seq === "0") return; // 상신자 삭제 불가 const remaining = aplns.filter((a) => a.seq !== seq); // seq 재정렬 (병렬 그룹 유지) const reordered = reorderBySeq(remaining); form.setValue("aplns", reordered); }; // 기존 단일 삭제 로직은 더 이상 사용하지 않음 (호환 위해 남겨둠) // eslint-disable-next-line @typescript-eslint/no-unused-vars const removeApprovalLine = (index: number) => { // 첫 번째(상신자)는 삭제 불가 if (index === 0) return; if (aplns.length > 1) { const newAplns = aplns.filter((_: FormData["aplns"][number], i: number) => i !== index); // 순서 재정렬 (ID는 유지) const reorderedAplns = newAplns.map((apln: FormData["aplns"][number], i: number) => ({ ...apln, seq: (i).toString(), })); form.setValue("aplns", reorderedAplns); } }; // 드래그앤드롭 핸들러 (그룹 이동 지원) const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; // 현재 id는 그룹의 고유 key(첫 라인 id) const activeKey = active.id as string; const overKey = over.id as string; // key → seq 매핑 생성 const idToSeq = new Map(); aplns.forEach((a) => { // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑 idToSeq.set(a.id, a.seq); }); const activeSeq = idToSeq.get(activeKey); const overSeq = idToSeq.get(overKey); if (!activeSeq || !overSeq) return; if (activeSeq === "0" || overSeq === "0") return; // 상신자는 이동 불가 // 현재 그룹 순서를 key 기반으로 계산 const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); const keyOrder = seqOrder.map((seq) => { return aplns.find((a) => a.seq === seq)!.id; }); const oldIndex = keyOrder.indexOf(activeKey); const newIndex = keyOrder.indexOf(overKey); const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); // key → 새 seq 매핑 const keyToNewSeq = new Map(); newKeyOrder.forEach((k, idx) => { keyToNewSeq.set(k, idx.toString()); }); // aplns 재구성 + seq 재할당 const updatedAplns: FormData["aplns"] = []; newKeyOrder.forEach((k) => { const oldSeq = idToSeq.get(k)!; const groupItems = aplns.filter((a) => a.seq === oldSeq); groupItems.forEach((item) => { updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! }); }); }); form.setValue("aplns", updatedAplns, { shouldValidate: false, shouldDirty: true }); }; const onSubmit = async (data: FormData) => { setIsSubmitting(true); setSubmitResult(null); try { // 사용자 정보 (prop 전용) if (!currentUser) { toast.error("사용자 정보를 불러올 수 없습니다."); return; } const effectiveEmail = currentUser.email; const effectiveEpId = currentUser.epId ?? undefined; const effectiveUserId = String(currentUser.id ?? ""); debugLog("Current User", { email: effectiveEmail, epId: effectiveEpId, userId: effectiveUserId }); if (!effectiveEpId) { toast.error("사용자 정보가 올바르지 않습니다."); return; } // 결재 경로 생성 (ID 제거하고 API 호출) const approvalLines: ApprovalLine[] = await Promise.all( data.aplns.map((apln) => createApprovalLine( { epId: apln.epId }, apln.role, apln.seq, { opinion: apln.opinion }, ), ), ); debugLog("Approval Lines", approvalLines); // 상신 요청 생성 const attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined; const submitRequest: SubmitApprovalRequest = await createSubmitApprovalRequest( data.contents, data.subject, approvalLines, { contentsType: data.contentsType, docSecuType: data.docSecuType, urgYn: data.urgYn ? "Y" : "N", importantYn: data.importantYn ? "Y" : "N", notifyOption: data.notifyOption, docMngSaveCode: data.docMngSaveCode, sbmLang: data.sbmLang, timeZone: data.timeZone, attachments: attachmentsArray, }, ); // API 호출 (보안 등급에 따라 분기) const isSecure = data.docSecuType === "CONFIDENTIAL" || data.docSecuType === "CONFIDENTIAL_STRICT"; debugLog("Submit Request", submitRequest); const response = isSecure ? await submitSecurityApproval(submitRequest) : await submitApproval(submitRequest, { userId: effectiveUserId, epId: effectiveEpId, emailAddress: effectiveEmail, }); debugLog("Submit Response", response); if (response.result === "SUCCESS") { setSubmitResult({ apInfId: response.data.apInfId }); toast.success("결재가 성공적으로 상신되었습니다."); onSubmitSuccess?.(response.data.apInfId); form.reset(); } else { toast.error(`결재 상신에 실패했습니다: ${response.result}`); } } catch (error) { debugError("결재 상신 오류", error); toast.error("결재 상신 중 오류가 발생했습니다."); } finally { setIsSubmitting(false); } }; return ( 결재 상신 새로운 결재를 상신합니다. {submitResult && (
상신 완료

결재 ID: {submitResult.apInfId}

)}
{/* 기본 정보 */}
{/* 결재 경로 */}

결재 경로

{/* 상단 제어 버튼 */}
{/* 결재자 추가 섹션 */}

사용자를 검색하여 결재 라인에 추가하세요

{/* 그룹 기반 렌더링 */} {aplns.length > 0 && (() => { const groups = Object.values( aplns.reduce< Record< string, ApprovalLineItem[] > >((acc, apln) => { acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln]; return acc; }, {}), ).sort( (a, b) => parseInt(a[0].seq) - parseInt(b[0].seq), ); return ( g[0].id, )} strategy={ verticalListSortingStrategy } >
{groups.map( (group, idx) => ( removeApprovalGroup( group[0] .seq, ) } canRemove={ idx !== 0 && aplns.length > 1 } selected={selectedSeqs.includes( group[0] .seq, )} onSelect={() => toggleSelectGroup( group[0] .seq, ) } /> ), )}
); })()} {aplns.length === 0 && (

결재자를 추가해주세요

)}
( 제목 * )} /> ( 내용 * )} /> {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */}
{/* 보안 등급 */} ( 보안 )} /> {/* 긴급 여부 */} ( 긴급 )} /> {/* 중요 여부 */} ( 중요 )} />
{/* 첨부 파일 */} ( 첨부 파일 field.onChange( e.target.files, ) } /> 필요 시 파일을 선택하세요. (다중 선택 가능) )} />
{/* 제출 버튼 */}
); }