summaryrefslogtreecommitdiff
path: root/components/knox/approval/ApprovalLineSelector.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/knox/approval/ApprovalLineSelector.tsx')
-rw-r--r--components/knox/approval/ApprovalLineSelector.tsx444
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;
+
+