diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-28 12:10:39 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-28 12:10:39 +0000 |
| commit | 75249e6fa46864f49d4eb91bd755171b6b65eaae (patch) | |
| tree | f2c021f0fe10b3513d29f05ca15b82e460d79d20 /components/knox/approval/ApprovalSubmit.tsx | |
| parent | c228a89c2834ee63b209bad608837c39643f350e (diff) | |
(김준회) 공통모듈 - Knox 결재 모듈 구현, 유저 선택기 구현, 상신 결재 저장을 위한 DB 스키마 및 서비스 추가, spreadjs 라이센스 환경변수 통일, 유저 테이블에 epId 컬럼 추가
Diffstat (limited to 'components/knox/approval/ApprovalSubmit.tsx')
| -rw-r--r-- | components/knox/approval/ApprovalSubmit.tsx | 1063 |
1 files changed, 811 insertions, 252 deletions
diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx index 526a87f3..f3c1fa3d 100644 --- a/components/knox/approval/ApprovalSubmit.tsx +++ b/components/knox/approval/ApprovalSubmit.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -8,25 +8,93 @@ 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 { Textarea } from '@/components/ui/textarea'; + 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, Plus, Trash2, FileText, AlertCircle } from 'lucide-react'; +import { Loader2, Trash2, FileText, AlertCircle, GripVertical } from 'lucide-react'; + +// dnd-kit imports for drag and drop +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; + +// 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier +import { + restrictToVerticalAxis, + restrictToParentElement, +} from '@dnd-kit/modifiers'; +import { + 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'; -// Mock 데이터 -import { mockApprovalAPI, createMockApprovalLine, getRoleText } from './mocks/approval-mock'; +// 역할 텍스트 매핑 (기존 mock util 대체) +const getRoleText = (role: string) => { + 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'; + +// UserSelector 컴포넌트 +import { UserSelector, type UserSelectItem } from '@/components/common/user/user-selector'; +import { useSession } from 'next-auth/react'; + +// UserSelector에서 반환되는 사용자에 epId가 포함될 수 있으므로 확장 타입 정의 +interface ExtendedUserSelectItem extends UserSelectItem { + epId?: string; +} + +// 역할 코드 타입 정의 +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; +} const formSchema = z.object({ subject: z.string().min(1, '제목은 필수입니다'), contents: z.string().min(1, '내용은 필수입니다'), - contentsType: z.enum(['TEXT', 'HTML', 'MIME']), + contentsType: z.literal('HTML'), docSecuType: z.enum(['PERSONAL', 'CONFIDENTIAL', 'CONFIDENTIAL_STRICT']), urgYn: z.boolean(), importantYn: z.boolean(), @@ -35,8 +103,12 @@ const formSchema = z.object({ sbmLang: z.enum(['ko', 'ja', 'zh', 'en']), timeZone: z.string().default('GMT+9'), aplns: z.array(z.object({ - userId: z.string().min(1, '사용자 ID는 필수입니다'), + 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() @@ -48,25 +120,344 @@ const formSchema = z.object({ type FormData = z.infer<typeof formSchema>; interface ApprovalSubmitProps { - useFakeData?: boolean; - systemId?: string; onSubmitSuccess?: (apInfId: string) => void; } -export default function ApprovalSubmit({ - useFakeData = false, - systemId = 'EVCP_SYSTEM', - onSubmitSuccess -}: ApprovalSubmitProps) { +// Sortable한 결재 라인 컴포넌트 +interface SortableApprovalLineProps { + 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}` : ''} + </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> + ); +} + +// Sortable Approval Group (seq 단위 카드) +interface SortableApprovalGroupProps { + 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}` : ''} + </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> + ); +} + +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: 'TEXT', + contentsType: 'HTML', docSecuType: 'PERSONAL', urgYn: false, importantYn: false, @@ -74,109 +465,342 @@ export default function ApprovalSubmit({ docMngSaveCode: '0', sbmLang: 'ko', timeZone: 'GMT+9', - aplns: [ - { - userId: '', - emailAddress: '', - role: '0', - seq: '1', - opinion: '' - } - ], + aplns: [], attachments: undefined } }); const aplns = form.watch('aplns'); - const addApprovalLine = () => { - const newSeq = (aplns.length + 1).toString(); - form.setValue('aplns', [...aplns, { - userId: '', - emailAddress: '', - role: '1', - seq: newSeq, - opinion: '' - }]); + // 병렬 전환 핸들러 + 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)! }; + }); + }; + + // 로그인 사용자를 첫 번째 결재자로 보장하는 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]; + } + + // 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((_, i) => i !== index); - // 순서 재정렬 - const reorderedAplns = newAplns.map((apln, i) => ({ + 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 + 1).toString() + 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 { - // 결재 경로 생성 + // 결재 경로 생성 (ID 제거하고 API 호출) const approvalLines: ApprovalLine[] = await Promise.all( - data.aplns.map(async (apln) => { - if (useFakeData) { - return createMockApprovalLine({ - userId: apln.userId, - emailAddress: apln.emailAddress, - role: apln.role, - seq: apln.seq, - opinion: apln.opinion - }); - } else { - return createApprovalLine( - { userId: apln.userId, emailAddress: apln.emailAddress }, - apln.role, - apln.seq, - { opinion: apln.opinion } - ); - } - }) + data.aplns.map((apln) => + createApprovalLine( + // userId: apln.userId 는 불필요하므로 제거 + { epId: apln.epId, emailAddress: apln.emailAddress }, + apln.role, + apln.seq, + { opinion: apln.opinion } + ) + ) ); // 상신 요청 생성 const attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined; - const submitRequest: SubmitApprovalRequest = useFakeData - ? { - ...data, - urgYn: data.urgYn ? 'Y' : 'N', - importantYn: data.importantYn ? 'Y' : 'N', - sbmDt: new Date().toISOString().replace(/-|:|T/g, '').slice(0, 14), - apInfId: 'test-ap-inf-id-' + Date.now(), - aplns: approvalLines, - attachments: attachmentsArray - } - : 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 - } - ); + const submitRequest: SubmitApprovalRequest = await createSubmitApprovalRequest( + data.contents, + data.subject, + approvalLines, + { + contentsType: 'HTML', + 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 + } + ); // API 호출 (보안 등급에 따라 분기) const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT'; - const response = useFakeData - ? await mockApprovalAPI.submitApproval(submitRequest) - : isSecure - ? await submitSecurityApproval(submitRequest, systemId) - : await submitApproval(submitRequest, systemId); + console.log(submitRequest); + + const response = isSecure + ? await submitSecurityApproval(submitRequest) + : await submitApproval(submitRequest); if (response.result === 'SUCCESS') { setSubmitResult({ apInfId: response.data.apInfId }); @@ -195,14 +819,14 @@ export default function ApprovalSubmit({ }; return ( - <Card className="w-full max-w-4xl"> + <Card className="w-full max-w-5xl"> <CardHeader> <CardTitle className="flex items-center gap-2"> <FileText className="w-5 h-5" /> 결재 상신 </CardTitle> <CardDescription> - 새로운 결재를 상신합니다. {useFakeData && '(테스트 모드)'} + 새로운 결재를 상신합니다. </CardDescription> </CardHeader> @@ -223,7 +847,80 @@ export default function ApprovalSubmit({ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> {/* 기본 정보 */} <div className="space-y-4"> - <h3 className="text-lg font-semibold">기본 정보</h3> + + <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> + + {/* 그룹 기반 렌더링 */} + {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} @@ -246,10 +943,10 @@ export default function ApprovalSubmit({ <FormItem> <FormLabel>내용 *</FormLabel> <FormControl> - <Textarea - placeholder="결재 내용을 입력하세요" - rows={8} - {...field} + <RichTextEditor + value={field.value} + onChange={field.onChange} + height="400px" /> </FormControl> <FormMessage /> @@ -257,64 +954,38 @@ export default function ApprovalSubmit({ )} /> - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="contentsType" - render={({ field }) => ( - <FormItem> - <FormLabel>내용 형식</FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="내용 형식 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="TEXT">TEXT</SelectItem> - <SelectItem value="HTML">HTML</SelectItem> - <SelectItem value="MIME">MIME</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - + {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */} + <div className="grid grid-cols-3 gap-4"> + {/* 보안 등급 */} <FormField control={form.control} name="docSecuType" render={({ field }) => ( - <FormItem> - <FormLabel>보안 등급</FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="보안 등급 선택" /> + <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> - </FormControl> - <SelectContent> - <SelectItem value="PERSONAL">개인</SelectItem> - <SelectItem value="CONFIDENTIAL">기밀</SelectItem> - <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem> - </SelectContent> - </Select> - <FormMessage /> + <SelectContent> + <SelectItem value="PERSONAL">개인</SelectItem> + <SelectItem value="CONFIDENTIAL">기밀</SelectItem> + <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem> + </SelectContent> + </Select> + </FormControl> </FormItem> )} /> - </div> - <div className="grid grid-cols-2 gap-4"> + {/* 긴급 여부 */} <FormField control={form.control} name="urgYn" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <div className="space-y-0.5"> - <FormLabel>긴급 여부</FormLabel> - <FormDescription>긴급 결재로 처리</FormDescription> - </div> + <FormLabel>긴급</FormLabel> <FormControl> <Switch checked={field.value} @@ -325,15 +996,13 @@ export default function ApprovalSubmit({ )} /> + {/* 중요 여부 */} <FormField control={form.control} name="importantYn" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <div className="space-y-0.5"> - <FormLabel>중요 여부</FormLabel> - <FormDescription>중요 결재로 분류</FormDescription> - </div> + <FormLabel>중요</FormLabel> <FormControl> <Switch checked={field.value} @@ -369,116 +1038,6 @@ export default function ApprovalSubmit({ <Separator /> - {/* 결재 경로 */} - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <h3 className="text-lg font-semibold">결재 경로</h3> - <Button - type="button" - variant="outline" - size="sm" - onClick={addApprovalLine} - > - <Plus className="w-4 h-4 mr-2" /> - 결재자 추가 - </Button> - </div> - - <div className="space-y-3"> - {aplns.map((apln, index) => ( - <div key={index} className="flex items-center gap-3 p-3 border rounded-lg"> - <Badge variant="outline">{index + 1}</Badge> - - <div className="flex-1 grid grid-cols-4 gap-3"> - <FormField - control={form.control} - name={`aplns.${index}.userId`} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input placeholder="사용자 ID" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name={`aplns.${index}.emailAddress`} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input placeholder="이메일 (선택)" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name={`aplns.${index}.role`} - render={({ field }) => ( - <FormItem> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="0">기안</SelectItem> - <SelectItem value="1">결재</SelectItem> - <SelectItem value="2">합의</SelectItem> - <SelectItem value="3">후결</SelectItem> - <SelectItem value="4">병렬합의</SelectItem> - <SelectItem value="7">병렬결재</SelectItem> - <SelectItem value="9">통보</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name={`aplns.${index}.opinion`} - render={({ field }) => ( - <FormItem> - <FormControl> - <Input placeholder="의견 (선택)" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="flex items-center gap-2"> - <Badge variant="secondary"> - {getRoleText(apln.role)} - </Badge> - - {aplns.length > 1 && ( - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeApprovalLine(index)} - > - <Trash2 className="w-4 h-4" /> - </Button> - )} - </div> - </div> - ))} - </div> - </div> - - <Separator /> - {/* 제출 버튼 */} <div className="flex justify-end space-x-3"> <Button |
