From bcd462d6e60871b86008e072f4b914138fc5c328 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Mon, 11 Aug 2025 09:34:40 +0000
Subject: (김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트),
 command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시
 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/knox/approval/ApprovalLineSelector.tsx | 444 ++++++++++++++++++++++
 1 file changed, 444 insertions(+)
 create mode 100644 components/knox/approval/ApprovalLineSelector.tsx

(limited to 'components/knox/approval/ApprovalLineSelector.tsx')

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;
+
+
-- 
cgit v1.2.3

