diff options
Diffstat (limited to 'components/knox/approval/ApprovalSubmit.tsx')
| -rw-r--r-- | components/knox/approval/ApprovalSubmit.tsx | 2230 |
1 files changed, 1216 insertions, 1014 deletions
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> + ); +} |
