summaryrefslogtreecommitdiff
path: root/components/knox/approval/ApprovalSubmit.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-07-28 12:10:39 +0000
committerjoonhoekim <26rote@gmail.com>2025-07-28 12:10:39 +0000
commit75249e6fa46864f49d4eb91bd755171b6b65eaae (patch)
treef2c021f0fe10b3513d29f05ca15b82e460d79d20 /components/knox/approval/ApprovalSubmit.tsx
parentc228a89c2834ee63b209bad608837c39643f350e (diff)
(김준회) 공통모듈 - Knox 결재 모듈 구현, 유저 선택기 구현, 상신 결재 저장을 위한 DB 스키마 및 서비스 추가, spreadjs 라이센스 환경변수 통일, 유저 테이블에 epId 컬럼 추가
Diffstat (limited to 'components/knox/approval/ApprovalSubmit.tsx')
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx1063
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