summaryrefslogtreecommitdiff
path: root/components/knox
diff options
context:
space:
mode:
Diffstat (limited to 'components/knox')
-rw-r--r--components/knox/approval/ApprovalLineSelector.tsx444
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx2230
2 files changed, 1660 insertions, 1014 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;
+
+
diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx
index 8b58aba6..bfe66981 100644
--- a/components/knox/approval/ApprovalSubmit.tsx
+++ b/components/knox/approval/ApprovalSubmit.tsx
@@ -1,1092 +1,1294 @@
-'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'
+"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';
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
// 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier
import {
- restrictToVerticalAxis,
- restrictToParentElement,
-} from '@dnd-kit/modifiers';
+ restrictToVerticalAxis,
+ restrictToParentElement,
+} from "@dnd-kit/modifiers";
import {
- arrayMove,
- SortableContext,
- sortableKeyboardCoordinates,
- verticalListSortingStrategy,
- useSortable,
-} from '@dnd-kit/sortable';
-import {
- CSS,
-} from '@dnd-kit/utilities';
+ 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';
+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<string, string> = {
- '0': '기안',
- '1': '결재',
- '2': '합의',
- '3': '후결',
- '4': '병렬합의',
- '7': '병렬결재',
- '9': '통보',
- };
- return map[role] || role;
+ const map: Record<string, string> = {
+ "0": "기안",
+ "1": "결재",
+ "2": "합의",
+ "3": "후결",
+ "4": "병렬합의",
+ "7": "병렬결재",
+ "9": "통보",
+ };
+ return map[role] || role;
};
// TiptapEditor 컴포넌트
-import RichTextEditor from '@/components/rich-text-editor/RichTextEditor';
+import RichTextEditor from "@/components/rich-text-editor/RichTextEditor";
// UserSelector 컴포넌트
-import { UserSelector, type UserSelectItem } from '@/components/common/user/user-selector';
-import { useSession } from 'next-auth/react';
+import {
+ UserSelector,
+ type UserSelectItem,
+} from "@/components/common/user/user-selector";
+import { useSession } from "next-auth/react";
// UserSelector에서 반환되는 사용자에 epId가 포함될 수 있으므로 확장 타입 정의
interface ExtendedUserSelectItem extends UserSelectItem {
- epId?: string;
+ epId?: string;
}
// 역할 코드 타입 정의
-type ApprovalRole = '0' | '1' | '2' | '3' | '4' | '7' | '9';
+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;
+ 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()
+ 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<typeof formSchema>;
interface ApprovalSubmitProps {
- onSubmitSuccess?: (apInfId: string) => void;
+ onSubmitSuccess?: (apInfId: string) => void;
}
// Sortable한 결재 라인 컴포넌트
interface SortableApprovalLineProps {
- apln: ApprovalLineItem;
- index: number;
- form: ReturnType<typeof useForm<FormData>>;
- onRemove: () => void;
- canRemove: boolean;
- selected: boolean;
- onSelect: () => void;
+ apln: ApprovalLineItem;
+ index: number;
+ form: ReturnType<typeof useForm<FormData>>;
+ 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 (
- <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' : ''}`}
- >
- {/* 드래그 핸들 */}
- <div
- {...attributes}
- {...listeners}
- className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
- >
- <GripVertical className="w-5 h-5" />
- </div>
-
- {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */}
- {index !== 0 ? (
- <Checkbox
- checked={selected}
- onCheckedChange={() => onSelect()}
- onClick={(e) => e.stopPropagation()}
- />
- ) : (
- <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로)
- )}
-
- {/* 실제 seq 기준 표시 */}
- <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge>
-
- <div className="flex-1 grid grid-cols-4 gap-3">
- {/* 사용자 정보 표시 */}
- <div className="flex items-center space-x-2">
- <div>
- <div className="font-medium text-sm">
- {(apln.name || 'Knox 이름 없음')}{apln.deptName ? ` / ${apln.deptName}` : ''}
+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 (
+ <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" : ""}`}
+ >
+ {/* 드래그 핸들 */}
+ <div
+ {...attributes}
+ {...listeners}
+ className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
+ >
+ <GripVertical className="w-5 h-5" />
</div>
- </div>
- <FormField
- control={form.control}
- name={`aplns.${index}.id`}
- render={() => (
- <FormItem className="hidden">
- <FormControl>
- <Input type="hidden" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name={`aplns.${index}.epId`}
- render={() => (
- <FormItem className="hidden">
- <FormControl>
- <Input type="hidden" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name={`aplns.${index}.userId`}
- render={() => (
- <FormItem className="hidden">
- <FormControl>
- <Input type="hidden" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name={`aplns.${index}.emailAddress`}
- render={() => (
- <FormItem className="hidden">
- <FormControl>
- <Input type="hidden" />
- </FormControl>
- <FormMessage />
- </FormItem>
+
+ {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */}
+ {index !== 0 ? (
+ <Checkbox
+ checked={selected}
+ onCheckedChange={() => onSelect()}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+ <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로)
)}
- />
- </div>
- {/* 역할 선택 */}
- {index === 0 ? (
- // 상신자는 역할 선택 대신 고정 표시
- <div className="flex items-center">
- <Badge variant="secondary">기안</Badge>
- </div>
- ) : (
- <FormField
- control={form.control}
- name={`aplns.${index}.role`}
- render={({ field }) => {
- // 병렬 여부 판단
- 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 (
- <FormItem className="w-full">
- <Badge className="w-full justify-center" variant="secondary">
- {getRoleText(field.value)}
- </Badge>
- </FormItem>
- );
- }
-
- return (
- <FormItem>
- <div className="flex flex-col gap-2">
- <ToggleGroup
- type="single"
- value={baseRole}
- onValueChange={handleBaseRoleChange}
- >
- <ToggleGroupItem value="1">결재</ToggleGroupItem>
- <ToggleGroupItem value="2">합의</ToggleGroupItem>
- <ToggleGroupItem value="9">통보</ToggleGroupItem>
- </ToggleGroup>
- </div>
- <FormMessage />
- </FormItem>
- );
- }}
- />
- )}
-
- {/* 의견 입력란 제거됨 */}
-
- {/* 역할 표시 */}
- <div className="flex items-center justify-between">
- <Badge variant="secondary">
- {getRoleText(apln.role)}
- </Badge>
-
- {canRemove && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={onRemove}
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- )}
+ {/* 실제 seq 기준 표시 */}
+ <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge>
+
+ <div className="flex-1 grid grid-cols-4 gap-3">
+ {/* 사용자 정보 표시 */}
+ <div className="flex items-center space-x-2">
+ <div>
+ <div className="font-medium text-sm">
+ {apln.name || "Knox 이름 없음"}
+ {apln.deptName ? ` / ${apln.deptName}` : ""}
+ </div>
+ </div>
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.id`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.epId`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.userId`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.emailAddress`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 역할 선택 */}
+ {index === 0 ? (
+ // 상신자는 역할 선택 대신 고정 표시
+ <div className="flex items-center">
+ <Badge variant="secondary">기안</Badge>
+ </div>
+ ) : (
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.role`}
+ render={({ field }) => {
+ // 병렬 여부 판단
+ 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 (
+ <FormItem className="w-full">
+ <Badge
+ className="w-full justify-center"
+ variant="secondary"
+ >
+ {getRoleText(field.value)}
+ </Badge>
+ </FormItem>
+ );
+ }
+
+ return (
+ <FormItem>
+ <div className="flex flex-col gap-2">
+ <ToggleGroup
+ type="single"
+ value={baseRole}
+ onValueChange={handleBaseRoleChange}
+ >
+ <ToggleGroupItem value="1">
+ 결재
+ </ToggleGroupItem>
+ <ToggleGroupItem value="2">
+ 합의
+ </ToggleGroupItem>
+ <ToggleGroupItem value="9">
+ 통보
+ </ToggleGroupItem>
+ </ToggleGroup>
+ </div>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ )}
+
+ {/* 의견 입력란 제거됨 */}
+
+ {/* 역할 표시 */}
+ <div className="flex items-center justify-between">
+ <Badge variant="secondary">{getRoleText(apln.role)}</Badge>
+
+ {canRemove && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={onRemove}
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </div>
</div>
- </div>
- </div>
- );
+ );
}
// Sortable Approval Group (seq 단위 카드)
interface SortableApprovalGroupProps {
- group: ApprovalLineItem[]; // 동일 seq 항목들
- index: number;
- form: ReturnType<typeof useForm<FormData>>;
- onRemoveGroup: () => void;
- canRemove: boolean;
- selected: boolean;
- onSelect: () => void;
+ group: ApprovalLineItem[]; // 동일 seq 항목들
+ index: number;
+ form: ReturnType<typeof useForm<FormData>>;
+ 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 (
- <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' : ''}`}
- >
- {/* 드래그 핸들 */}
- <div
- {...attributes}
- {...listeners}
- className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
- >
- <GripVertical className="w-5 h-5" />
- </div>
-
- {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */}
- {index !== 0 ? (
- <Checkbox
- checked={selected}
- onCheckedChange={onSelect}
- onClick={(e) => e.stopPropagation()}
- />
- ) : (
- <div className="w-4 h-4" /> // 기안자용 빈 공간
- )}
-
- {/* seq 표시 */}
- <Badge variant="outline">{parseInt(seq) + 1}</Badge>
-
- {/* 그룹 상세 정보 */}
- <div className="flex-1 grid grid-cols-3 gap-3">
- {/* 사용자 목록 */}
- <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}` : ''}
+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 (
+ <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" : ""}`}
+ >
+ {/* 드래그 핸들 */}
+ <div
+ {...attributes}
+ {...listeners}
+ className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
+ >
+ <GripVertical className="w-5 h-5" />
</div>
- ))}
- </div>
- {/* 역할 */}
- <div className="flex items-center">
- {seq === '0' ? (
- <Badge variant="secondary" className="w-full justify-center">
- 기안
- </Badge>
- ) : role === '7' || role === '4' ? (
- <Badge variant="secondary" className="w-full justify-center">
- {getRoleText(role)}
- </Badge>
- ) : (
- // 단일일 때는 기존 토글 재사용 (첫 항목 기준)
- <FormField
- control={form.control}
- name={`aplns.${form.getValues('aplns').findIndex((a) => a.id === group[0].id)}.role`}
- render={({ field }) => (
- <ToggleGroup
- type="single"
- value={field.value}
- onValueChange={field.onChange}
- >
- <ToggleGroupItem value="1">결재</ToggleGroupItem>
- <ToggleGroupItem value="2">합의</ToggleGroupItem>
- <ToggleGroupItem value="9">통보</ToggleGroupItem>
- </ToggleGroup>
- )}
- />
- )}
- </div>
-
- {/* 삭제 버튼 */}
- <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>
- );
-}
+ {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */}
+ {index !== 0 ? (
+ <Checkbox
+ checked={selected}
+ onCheckedChange={onSelect}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+ <div className="w-4 h-4" /> // 기안자용 빈 공간
+ )}
-export default function ApprovalSubmit({ onSubmitSuccess }: ApprovalSubmitProps) {
- const { data: session } = useSession();
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [submitResult, setSubmitResult] = useState<{ apInfId: string } | null>(null);
+ {/* seq 표시 */}
+ <Badge variant="outline">{parseInt(seq) + 1}</Badge>
+
+ {/* 그룹 상세 정보 */}
+ <div className="flex-1 grid grid-cols-3 gap-3">
+ {/* 사용자 목록 */}
+ <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>
- const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]);
+ {/* 역할 */}
+ <div className="flex items-center">
+ {seq === "0" ? (
+ <Badge
+ variant="secondary"
+ className="w-full justify-center"
+ >
+ 기안
+ </Badge>
+ ) : role === "7" || role === "4" ? (
+ <Badge
+ variant="secondary"
+ className="w-full justify-center"
+ >
+ {getRoleText(role)}
+ </Badge>
+ ) : (
+ // 단일일 때는 기존 토글 재사용 (첫 항목 기준)
+ <FormField
+ control={form.control}
+ name={`aplns.${form.getValues("aplns").findIndex((a) => a.id === group[0].id)}.role`}
+ render={({ field }) => (
+ <ToggleGroup
+ type="single"
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <ToggleGroupItem value="1">
+ 결재
+ </ToggleGroupItem>
+ <ToggleGroupItem value="2">
+ 합의
+ </ToggleGroupItem>
+ <ToggleGroupItem value="9">
+ 통보
+ </ToggleGroupItem>
+ </ToggleGroup>
+ )}
+ />
+ )}
+ </div>
- // 그룹 단위 선택/해제
- const toggleSelectGroup = (seq: string) => {
- setSelectedSeqs((prev) =>
- prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq]
+ {/* 삭제 버튼 */}
+ <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>
);
- };
- const clearSelection = () => setSelectedSeqs([]);
-
- const form = useForm<FormData>({
- 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<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)! };
+export default function ApprovalSubmit({
+ onSubmitSuccess,
+}: ApprovalSubmitProps) {
+ const { data: session } = useSession();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitResult, setSubmitResult] = useState<{
+ apInfId: string;
+ } | null>(null);
+
+ const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]);
+
+ // 그룹 단위 선택/해제
+ const toggleSelectGroup = (seq: string) => {
+ setSelectedSeqs((prev) =>
+ prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq],
+ );
+ };
+ const clearSelection = () => setSelectedSeqs([]);
+
+ const form = useForm<FormData>({
+ 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,
+ },
});
- };
-
- // 로그인 사용자를 첫 번째 결재자로 보장하는 effect
- useEffect(() => {
- if (!session?.user) return;
-
- const currentEmail = session.user.email ?? '';
- const currentEpId = (session.user as { epId?: string }).epId;
- const currentUserId = session.user.id ?? undefined;
- let currentAplns = form.getValues('aplns');
+ const aplns = form.watch("aplns");
- // 이미 포함되어 있는지 확인 (epId 또는 email 기준)
- const selfIndex = currentAplns.findIndex(
- (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail
- );
-
- if (selfIndex === -1) {
- // 맨 앞에 상신자 추가
- const newSelf: FormData['aplns'][number] = {
- id: generateUniqueId(),
- epId: currentEpId,
- userId: currentUserId ? currentUserId.toString() : undefined,
- emailAddress: currentEmail,
- name: session.user.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 });
- }, [session, 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<string, string>();
- aplns.forEach((a) => {
- // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑
- idToSeq.set(a.id, a.seq);
- });
+ // 병렬 전환 핸들러
+ const applyParallel = () => {
+ if (selectedSeqs.length < 2) {
+ toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다.");
+ return;
+ }
- const activeSeq = idToSeq.get(activeKey);
- const overSeq = idToSeq.get(overKey);
+ const current = form.getValues("aplns");
+ const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq));
- if (!activeSeq || !overSeq) return;
+ const roles = Array.from(new Set(selectedAplns.map((a) => a.role)));
+ if (roles.length !== 1) {
+ toast.error("선택된 항목의 역할이 동일해야 합니다.");
+ return;
+ }
- if (activeSeq === '0' || overSeq === '0') 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;
+ }
- // 현재 그룹 순서를 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 minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq)));
- const oldIndex = keyOrder.indexOf(activeKey);
- const newIndex = keyOrder.indexOf(overKey);
+ const updated = current.map((a) => {
+ if (selectedSeqs.includes(a.seq)) {
+ return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() };
+ }
+ return a;
+ });
- const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex);
+ form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true });
+ clearSelection();
+ };
- // key → 새 seq 매핑
- const keyToNewSeq = new Map<string, string>();
- newKeyOrder.forEach((k, idx) => {
- keyToNewSeq.set(k, idx.toString());
- });
+ // 후결 전환 핸들러
+ const applyAfter = () => {
+ if (selectedSeqs.length !== 1) {
+ toast.error("후결은 한 명만 지정할 수 있습니다.");
+ return;
+ }
- // 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)! });
- });
- });
+ const targetSeq = selectedSeqs[0];
- form.setValue('aplns', updatedAplns, { shouldValidate: false, shouldDirty: true });
- };
-
- const onSubmit = async (data: FormData) => {
- setIsSubmitting(true);
- setSubmitResult(null);
-
- try {
- // 세션 정보 확인
- if (!session?.user) {
- toast.error('로그인이 필요합니다.');
- return;
- }
-
- const currentEmail = session.user.email ?? '';
- const currentEpId = (session.user as { epId?: string }).epId;
- const currentUserId = session.user.id ?? '';
-
- debugLog('Current User', session.user);
-
- if (!currentEpId) {
- 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
+ // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가
+ const targetRole = form.getValues("aplns").find((a) => a.seq === targetSeq)?.role;
+ if (targetRole === "7" || targetRole === "4") {
+ toast.error("병렬 그룹은 후결로 전환할 수 없습니다.");
+ return;
}
- );
-
- // API 호출 (보안 등급에 따라 분기)
- const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT';
-
- debugLog('Submit Request', submitRequest);
-
- const response = isSecure
- ? await submitSecurityApproval(submitRequest)
- : await submitApproval(submitRequest, {
- userId: currentUserId,
- epId: currentEpId,
- emailAddress: currentEmail
- });
-
- 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 (
- <Card className="w-full max-w-5xl">
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 결재 상신
- </CardTitle>
- <CardDescription>
- 새로운 결재를 상신합니다.
- </CardDescription>
- </CardHeader>
-
- <CardContent className="space-y-6">
- {submitResult && (
- <div className="p-4 bg-green-50 border border-green-200 rounded-lg">
- <div className="flex items-center gap-2 text-green-700">
- <AlertCircle className="w-4 h-4" />
- <span className="font-medium">상신 완료</span>
- </div>
- <p className="text-sm text-green-600 mt-1">
- 결재 ID: {submitResult.apInfId}
- </p>
- </div>
- )}
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 기본 정보 */}
- <div className="space-y-4">
-
- <Separator />
-
- {/* 결재 경로 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">결재 경로</h3>
-
- {/* 상단 제어 버튼 */}
- <div className="flex justify-end gap-2 mb-2">
- <Button variant="outline" size="sm" onClick={applyParallel}>병렬</Button>
- <Button variant="outline" size="sm" onClick={applyAfter}>후결</Button>
- <Button variant="outline" size="sm" onClick={ungroupParallel}>해제</Button>
- </div>
- {/* 결재자 추가 섹션 */}
- <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="결재자를 검색하세요..."
- domainFilter={{ type: "exclude", domains: ["partners"] }}
- maxSelections={10} // 최대 10명까지 추가 가능
- />
- </div>
+ 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;
+ }
- {/* 그룹 기반 렌더링 */}
- {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 (
- <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}
- form={form}
- onRemoveGroup={() => removeApprovalGroup(group[0].seq)}
- canRemove={idx !== 0 && aplns.length > 1}
- selected={selectedSeqs.includes(group[0].seq)}
- onSelect={() => toggleSelectGroup(group[0].seq)}
- />
- ))}
- </div>
- </SortableContext>
- </DndContext>
- );
- })()
- )}
+ 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<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)! };
+ });
+ };
+
+ // 로그인 사용자를 첫 번째 결재자로 보장하는 effect
+ useEffect(() => {
+ if (!session?.user) return;
+
+ const currentEmail = session.user.email ?? "";
+ const currentEpId = (session.user as { epId?: string }).epId;
+ const currentUserId = session.user.id ?? undefined;
+
+ let currentAplns = form.getValues("aplns");
+
+ // 이미 포함되어 있는지 확인 (epId 또는 email 기준)
+ const selfIndex = currentAplns.findIndex(
+ (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail,
+ );
+
+ if (selfIndex === -1) {
+ // 맨 앞에 상신자 추가
+ const newSelf: FormData["aplns"][number] = {
+ id: generateUniqueId(),
+ epId: currentEpId,
+ userId: currentUserId ? currentUserId.toString() : undefined,
+ emailAddress: currentEmail,
+ name: session.user.name ?? undefined,
+ role: "0", // 기안
+ seq: "0",
+ opinion: "",
+ };
+
+ currentAplns = [newSelf, ...currentAplns];
+ }
- {aplns.length === 0 && (
- <div className="text-center py-8 text-gray-500">
- <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
- <p>결재자를 추가해주세요</p>
- </div>
- )}
- </div>
-
- <FormField
- control={form.control}
- name="subject"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제목 *</FormLabel>
- <FormControl>
- <Input placeholder="결재 제목을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="contents"
- render={({ field }) => (
- <FormItem>
- <FormLabel>내용 *</FormLabel>
- <FormControl>
- <RichTextEditor
- value={field.value}
- onChange={field.onChange}
- height="400px"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */}
- <div className="grid grid-cols-3 gap-4">
- {/* 보안 등급 */}
- <FormField
- control={form.control}
- name="docSecuType"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <FormLabel>보안</FormLabel>
- <FormControl>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <SelectTrigger className="w-24">
- <SelectValue placeholder="등급" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="PERSONAL">개인</SelectItem>
- <SelectItem value="CONFIDENTIAL">기밀</SelectItem>
- <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem>
- </SelectContent>
- </Select>
- </FormControl>
- </FormItem>
- )}
- />
+ // seq 재정렬 보장
+ currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() }));
+
+ form.setValue("aplns", currentAplns, { shouldValidate: false, shouldDirty: true });
+ }, [session, form]);
+
+ // dnd-kit sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
- {/* 긴급 여부 */}
- <FormField
- control={form.control}
- name="urgYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <FormLabel>긴급</FormLabel>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
+ // 고유 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<string, string>();
+ 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<string, string>();
+ 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 {
+ // 세션 정보 확인
+ if (!session?.user) {
+ toast.error("로그인이 필요합니다.");
+ return;
+ }
+
+ const currentEmail = session.user.email ?? "";
+ const currentEpId = (session.user as { epId?: string }).epId;
+ const currentUserId = session.user.id ?? "";
+
+ debugLog("Current User", session.user);
+
+ if (!currentEpId) {
+ 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,
+ },
+ );
- {/* 중요 여부 */}
- <FormField
- control={form.control}
- name="importantYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <FormLabel>중요</FormLabel>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- </div>
-
- {/* 첨부 파일 */}
- <FormField
- control={form.control}
- name="attachments"
- render={({ field }) => (
- <FormItem>
- <FormLabel>첨부 파일</FormLabel>
- <FormControl>
- <Input
- type="file"
- multiple
- onChange={(e) => field.onChange(e.target.files)}
- />
- </FormControl>
- <FormDescription>필요 시 파일을 선택하세요. (다중 선택 가능)</FormDescription>
- <FormMessage />
- </FormItem>
+ // API 호출 (보안 등급에 따라 분기)
+ const isSecure =
+ data.docSecuType === "CONFIDENTIAL" ||
+ data.docSecuType === "CONFIDENTIAL_STRICT";
+
+ debugLog("Submit Request", submitRequest);
+
+ const response = isSecure
+ ? await submitSecurityApproval(submitRequest)
+ : await submitApproval(submitRequest, {
+ userId: currentUserId,
+ epId: currentEpId,
+ emailAddress: currentEmail,
+ });
+
+ 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 (
+ <Card className="w-full max-w-5xl">
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 결재 상신
+ </CardTitle>
+ <CardDescription>새로운 결재를 상신합니다.</CardDescription>
+ </CardHeader>
+
+ <CardContent className="space-y-6">
+ {submitResult && (
+ <div className="p-4 bg-green-50 border border-green-200 rounded-lg">
+ <div className="flex items-center gap-2 text-green-700">
+ <AlertCircle className="w-4 h-4" />
+ <span className="font-medium">상신 완료</span>
+ </div>
+ <p className="text-sm text-green-600 mt-1">
+ 결재 ID: {submitResult.apInfId}
+ </p>
+ </div>
)}
- />
- </div>
-
- <Separator />
-
- {/* 제출 버튼 */}
- <div className="flex justify-end space-x-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => form.reset()}
- disabled={isSubmitting}
- >
- 초기화
- </Button>
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting ? (
- <>
- <Loader2 className="w-4 h-4 mr-2 animate-spin" />
- 상신 중...
- </>
- ) : (
- '결재 상신'
- )}
- </Button>
- </div>
- </form>
- </Form>
- </CardContent>
- </Card>
- );
-} \ No newline at end of file
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-6"
+ >
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <Separator />
+
+ {/* 결재 경로 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">
+ 결재 경로
+ </h3>
+
+ {/* 상단 제어 버튼 */}
+ <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>
+
+ {/* 결재자 추가 섹션 */}
+ <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="결재자를 검색하세요..."
+ domainFilter={{
+ type: "exclude",
+ domains: ["partners"],
+ }}
+ maxSelections={10} // 최대 10명까지 추가 가능
+ />
+ </div>
+
+ {/* 그룹 기반 렌더링 */}
+ {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 (
+ <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}
+ form={form}
+ onRemoveGroup={() =>
+ removeApprovalGroup(
+ group[0]
+ .seq,
+ )
+ }
+ canRemove={
+ idx !==
+ 0 &&
+ aplns.length >
+ 1
+ }
+ selected={selectedSeqs.includes(
+ group[0]
+ .seq,
+ )}
+ onSelect={() =>
+ toggleSelectGroup(
+ group[0]
+ .seq,
+ )
+ }
+ />
+ ),
+ )}
+ </div>
+ </SortableContext>
+ </DndContext>
+ );
+ })()}
+
+ {aplns.length === 0 && (
+ <div className="text-center py-8 text-gray-500">
+ <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
+ <p>결재자를 추가해주세요</p>
+ </div>
+ )}
+ </div>
+
+ <FormField
+ control={form.control}
+ name="subject"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제목 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="결재 제목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contents"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>내용 *</FormLabel>
+ <FormControl>
+ <RichTextEditor
+ value={field.value}
+ onChange={field.onChange}
+ height="400px"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */}
+ <div className="grid grid-cols-3 gap-4">
+ {/* 보안 등급 */}
+ <FormField
+ control={form.control}
+ name="docSecuType"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <FormLabel>보안</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={
+ field.onChange
+ }
+ defaultValue={field.value}
+ >
+ <SelectTrigger className="w-24">
+ <SelectValue placeholder="등급" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="PERSONAL">
+ 개인
+ </SelectItem>
+ <SelectItem value="CONFIDENTIAL">
+ 기밀
+ </SelectItem>
+ <SelectItem value="CONFIDENTIAL_STRICT">
+ 극기밀
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* 긴급 여부 */}
+ <FormField
+ control={form.control}
+ name="urgYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <FormLabel>긴급</FormLabel>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={
+ field.onChange
+ }
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* 중요 여부 */}
+ <FormField
+ control={form.control}
+ name="importantYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <FormLabel>중요</FormLabel>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={
+ field.onChange
+ }
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 첨부 파일 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>첨부 파일</FormLabel>
+ <FormControl>
+ <Input
+ type="file"
+ multiple
+ onChange={(e) =>
+ field.onChange(
+ e.target.files,
+ )
+ }
+ />
+ </FormControl>
+ <FormDescription>
+ 필요 시 파일을 선택하세요. (다중
+ 선택 가능)
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end space-x-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => form.reset()}
+ disabled={isSubmitting}
+ >
+ 초기화
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ 상신 중...
+ </>
+ ) : (
+ "결재 상신"
+ )}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+ );
+}