diff options
Diffstat (limited to 'components/knox/approval/ApprovalLineSelector.tsx')
| -rw-r--r-- | components/knox/approval/ApprovalLineSelector.tsx | 444 |
1 files changed, 444 insertions, 0 deletions
diff --git a/components/knox/approval/ApprovalLineSelector.tsx b/components/knox/approval/ApprovalLineSelector.tsx new file mode 100644 index 00000000..bbe6bd7f --- /dev/null +++ b/components/knox/approval/ApprovalLineSelector.tsx @@ -0,0 +1,444 @@ +"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<string, string> = { + "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 ( + <div + ref={setNodeRef} + style={style} + className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`} + > + {/* Drag handle */} + <div + {...attributes} + {...listeners} + className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" + > + <GripVertical className="w-5 h-5" /> + </div> + + {/* Group select (skip for drafter) */} + {index !== 0 ? ( + <Checkbox checked={selected} onCheckedChange={onSelect} onClick={(e) => e.stopPropagation()} /> + ) : ( + <div className="w-4 h-4" /> + )} + + {/* seq index */} + <Badge variant="outline">{parseInt(seq) + 1}</Badge> + + {/* Group details */} + <div className="flex-1 grid grid-cols-3 gap-3"> + {/* Users in group */} + <div className="flex flex-col justify-center gap-1"> + {group.map((u) => ( + <div key={u.id} className="text-sm"> + {u.name || "Knox 이름 없음"} + {u.deptName ? ` / ${u.deptName}` : ""} + </div> + ))} + </div> + + {/* Role UI */} + <div className="flex items-center"> + {seq === "0" ? ( + <Badge variant="secondary" className="w-full justify-center">기안</Badge> + ) : isParallel ? ( + <Badge variant="secondary" className="w-full justify-center">{getRoleText(role)}</Badge> + ) : ( + <ToggleGroup + type="single" + value={role} + onValueChange={(val) => val && onChangeRole(val as ApprovalRole)} + > + <ToggleGroupItem value="1">결재</ToggleGroupItem> + <ToggleGroupItem value="2">합의</ToggleGroupItem> + <ToggleGroupItem value="9">통보</ToggleGroupItem> + </ToggleGroup> + )} + </div> + + {/* Delete */} + <div className="flex items-center justify-end"> + {canRemove && ( + <Button type="button" variant="ghost" size="sm" onClick={onRemoveGroup}> + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </div> + </div> + ); +} + +export function ApprovalLineSelector({ + value, + onChange, + placeholder = "결재자를 검색하세요...", + maxSelections = 10, + domainFilter, + className, +}: ApprovalLineSelectorProps) { + const aplns = value; + const [selectedSeqs, setSelectedSeqs] = React.useState<string[]>([]); + + 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<string, string>(); + 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<string, string>(); + 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<string, string>(); + 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<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 grouped; + }, [aplns]); + + return ( + <div className={className}> + <h3 className="text-lg font-semibold">결재 경로</h3> + + {/* Controls */} + <div className="flex justify-end gap-2 mb-2"> + <Button type="button" variant="outline" size="sm" onClick={applyParallel}> + 병렬 + </Button> + <Button type="button" variant="outline" size="sm" onClick={applyAfter}> + 후결 + </Button> + <Button type="button" variant="outline" size="sm" onClick={ungroupParallel}> + 해제 + </Button> + </div> + + {/* User add */} + <div className="p-4 border border-dashed border-gray-300 rounded-lg"> + <div className="mb-2"> + <label className="text-sm font-medium text-gray-700">결재자 추가</label> + <p className="text-xs text-gray-500">사용자를 검색하여 결재 라인에 추가하세요</p> + </div> + <UserSelector + selectedUsers={[]} + onUsersChange={addApprovalUsers} + placeholder={placeholder} + domainFilter={domainFilter} + maxSelections={maxSelections} + /> + </div> + + {/* Groups */} + <div className="mt-4"> + {aplns.length > 0 ? ( + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + modifiers={[restrictToVerticalAxis, restrictToParentElement]} + onDragEnd={handleDragEnd} + > + <SortableContext items={groups.map((g) => g[0].id)} strategy={verticalListSortingStrategy}> + <div className="space-y-3"> + {groups.map((group, idx) => ( + <SortableApprovalGroup + key={group[0].id} + group={group} + index={idx} + onRemoveGroup={() => 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); + }} + /> + ))} + </div> + </SortableContext> + </DndContext> + ) : ( + <div className="text-center py-8 text-gray-500"> + <p>결재자를 추가해주세요</p> + </div> + )} + </div> + + <Separator className="my-4" /> + </div> + ); +} + +export default ApprovalLineSelector; + + |
