"use client"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Separator } from "@/components/ui/separator"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { toast } from "sonner"; import { Trash2, GripVertical } from "lucide-react"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { restrictToVerticalAxis, restrictToParentElement, } from "@dnd-kit/modifiers"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { UserSelector, type UserSelectItem, } from "@/components/common/user/user-selector"; // ----- Types ----- export type ApprovalRole = "0" | "1" | "2" | "3" | "4" | "7" | "9"; export interface ApprovalLineItem { id: string; epId?: string; userId?: string; emailAddress?: string; name?: string; deptName?: string; role: ApprovalRole; seq: string; // 0-based; 동일 seq는 병렬 그룹 의미 opinion?: string; } export interface ApprovalLineSelectorProps { value: ApprovalLineItem[]; onChange: (next: ApprovalLineItem[]) => void; placeholder?: string; maxSelections?: number; domainFilter?: any; // 프로젝트 전역 타입에 맞춰 느슨히 둠 className?: string; } // 역할 텍스트 매핑 const getRoleText = (role: string) => { const map: Record = { "0": "기안", "1": "결재", "2": "합의", "3": "후결", "4": "병렬합의", "7": "병렬결재", "9": "통보", }; return map[role] || role; }; // 고유 ID 생성 const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; // Sortable Group Card (seq 단위) interface SortableApprovalGroupProps { group: ApprovalLineItem[]; index: number; onRemoveGroup: () => void; canRemove: boolean; selected: boolean; onSelect: () => void; onChangeRole: (role: ApprovalRole) => void; } function SortableApprovalGroup({ group, index, onRemoveGroup, canRemove, selected, onSelect, onChangeRole, }: SortableApprovalGroupProps) { const seq = group[0].seq; const role = group[0].role; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group[0].id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, } as React.CSSProperties; const isParallel = role === "7" || role === "4"; return (
{/* Drag handle */}
{/* Group select (skip for drafter) */} {index !== 0 ? ( e.stopPropagation()} /> ) : (
)} {/* seq index */} {parseInt(seq) + 1} {/* Group details */}
{/* Users in group */}
{group.map((u) => (
{u.name || "Knox 이름 없음"} {u.deptName ? ` / ${u.deptName}` : ""}
))}
{/* Role UI */}
{seq === "0" ? ( 기안 ) : isParallel ? ( {getRoleText(role)} ) : ( val && onChangeRole(val as ApprovalRole)} > 결재 합의 통보 )}
{/* Delete */}
{canRemove && ( )}
); } export function ApprovalLineSelector({ value, onChange, placeholder = "결재자를 검색하세요...", maxSelections = 10, domainFilter, className, }: ApprovalLineSelectorProps) { const aplns = value; const [selectedSeqs, setSelectedSeqs] = React.useState([]); const toggleSelectGroup = (seq: string) => { setSelectedSeqs((prev) => (prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq])); }; const clearSelection = () => setSelectedSeqs([]); // drag sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); // Utilities const reorderBySeq = (list: ApprovalLineItem[]): ApprovalLineItem[] => { const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.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)! }; }); }; const addApprovalUsers = (users: UserSelectItem[]) => { const newAplns = [...aplns]; users.forEach((user) => { const exists = newAplns.findIndex((apln) => apln.userId === user.id.toString()); if (exists === -1) { // 현재 존재하는 seq 중 최댓값 + 1을 다음 seq로 사용 (0은 상신자) const uniqueSeqs = Array.from(new Set(newAplns.map((a) => parseInt(a.seq)))); const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : -1; const newSeq = (Math.max(1, maxSeq + 1)).toString(); newAplns.push({ id: generateUniqueId(), epId: (user as any).epId, userId: user.id.toString(), emailAddress: user.email, name: user.name, deptName: (user as any).deptName ?? undefined, role: "1", seq: newSeq, opinion: "", }); } }); onChange(newAplns); }; const removeApprovalGroup = (seq: string) => { if (seq === "0") return; // 상신자 삭제 불가 const remaining = aplns.filter((a) => a.seq !== seq); onChange(reorderBySeq(remaining)); }; const applyParallel = () => { if (selectedSeqs.length < 2) { toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다."); return; } const current = 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) => selectedSeqs.includes(a.seq) ? { ...a, role: newRole, seq: minSeq.toString() } : a, ); onChange(reorderBySeq(updated)); clearSelection(); }; const applyAfter = () => { if (selectedSeqs.length !== 1) { toast.error("후결은 한 명만 지정할 수 있습니다."); return; } const targetSeq = selectedSeqs[0]; const targetRole = aplns.find((a) => a.seq === targetSeq)?.role; if (targetRole === "7" || targetRole === "4") { toast.error("병렬 그룹은 후결로 전환할 수 없습니다."); return; } const updated = aplns.map((a) => a.seq === targetSeq ? { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole } : a, ); onChange(reorderBySeq(updated)); clearSelection(); }; const ungroupParallel = () => { if (selectedSeqs.length === 0) { toast.error("해제할 결재선을 선택하세요."); return; } let newSeqCounter = 1; // 0은 상신자 유지 const updated = 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: "" }; } return { ...a }; }); const reassigned = updated .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0")) .map((a) => { if (a.seq === "0") return a; const next = { ...a, seq: newSeqCounter.toString() }; newSeqCounter += 1; return next; }); onChange(reassigned); clearSelection(); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const activeKey = active.id as string; const overKey = over.id as string; const idToSeq = new Map(); aplns.forEach((a) => 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; // 상신자 이동 불가 const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); const keyOrder = seqOrder.map((seq) => aplns.find((a) => a.seq === seq)!.id); const oldIndex = keyOrder.indexOf(activeKey); const newIndex = keyOrder.indexOf(overKey); const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); const keyToNewSeq = new Map(); newKeyOrder.forEach((k, idx) => keyToNewSeq.set(k, idx.toString())); const updatedAplns: ApprovalLineItem[] = []; 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)! }); }); }); onChange(updatedAplns); }; // Render groups by seq const groups = React.useMemo(() => { const grouped = Object.values( aplns.reduce>((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 grouped; }, [aplns]); return (

결재 경로

{/* Controls */}
{/* User add */}

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

{/* Groups */}
{aplns.length > 0 ? ( 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)} onChangeRole={(role) => { // 단일 그룹일 때만 역할 변경 허용 if (group.length > 1) return; const gid = group[0].id; const updated = aplns.map((a) => (a.id === gid ? { ...a, role } : a)); onChange(updated); }} /> ))}
) : (

결재자를 추가해주세요

)}
); } export default ApprovalLineSelector;