From bcd462d6e60871b86008e072f4b914138fc5c328 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 11 Aug 2025 09:34:40 +0000 Subject: (김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트), command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../organization/organization-manager-selector.tsx | 338 +++ components/knox/approval/ApprovalLineSelector.tsx | 444 ++++ components/knox/approval/ApprovalSubmit.tsx | 2230 +++++++++++--------- components/layout/command-menu.tsx | 26 +- components/rich-text-editor/BlockquoteButton.tsx | 38 + components/rich-text-editor/BulletListButton.tsx | 46 + components/rich-text-editor/HistoryMenu.tsx | 43 + components/rich-text-editor/InlineStyleMenu.tsx | 67 + components/rich-text-editor/OrderedListButton.tsx | 38 + components/rich-text-editor/RichTextEditor.tsx | 1050 ++------- components/rich-text-editor/StyleMenu.tsx | 65 + components/rich-text-editor/TextAlignMenu.tsx | 46 + components/rich-text-editor/Toolbar.tsx | 350 +++ .../rich-text-editor/extensions/font-size.ts | 31 + 14 files changed, 2876 insertions(+), 1936 deletions(-) create mode 100644 components/common/organization/organization-manager-selector.tsx create mode 100644 components/knox/approval/ApprovalLineSelector.tsx create mode 100644 components/rich-text-editor/BlockquoteButton.tsx create mode 100644 components/rich-text-editor/BulletListButton.tsx create mode 100644 components/rich-text-editor/HistoryMenu.tsx create mode 100644 components/rich-text-editor/InlineStyleMenu.tsx create mode 100644 components/rich-text-editor/OrderedListButton.tsx create mode 100644 components/rich-text-editor/StyleMenu.tsx create mode 100644 components/rich-text-editor/TextAlignMenu.tsx create mode 100644 components/rich-text-editor/Toolbar.tsx create mode 100644 components/rich-text-editor/extensions/font-size.ts (limited to 'components') diff --git a/components/common/organization/organization-manager-selector.tsx b/components/common/organization/organization-manager-selector.tsx new file mode 100644 index 00000000..c715b3a1 --- /dev/null +++ b/components/common/organization/organization-manager-selector.tsx @@ -0,0 +1,338 @@ +"use client" + +import * as React from "react" +import { Search, X, Building2, ChevronLeft, ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { useDebounce } from "@/hooks/use-debounce" +import { cn } from "@/lib/utils" +import { Skeleton } from "@/components/ui/skeleton" +import { searchOrganizationsForManager } from "@/lib/knox-api/organization-service" + +// 조직 관리자 타입 정의 +export interface OrganizationManagerItem { + id: string + departmentCode: string + departmentName: string + managerId: string + managerName: string + managerTitle: string + companyCode: string + companyName: string +} + +// 페이지네이션 정보 타입 +interface PaginationInfo { + page: number + perPage: number + total: number + pageCount: number + hasNextPage: boolean + hasPrevPage: boolean +} + +export interface OrganizationManagerSelectorProps { + /** 선택된 조직 관리자들 */ + selectedManagers?: OrganizationManagerItem[] + /** 조직 관리자 선택 변경 콜백 */ + onManagersChange?: (managers: OrganizationManagerItem[]) => void + /** 단일 선택 모드 여부 */ + singleSelect?: boolean + /** placeholder 텍스트 */ + placeholder?: string + /** 입력 없이 focus 시 표시할 placeholder */ + noValuePlaceHolder?: string + /** 비활성화 여부 */ + disabled?: boolean + /** 최대 선택 가능 조직 관리자 수 */ + maxSelections?: number + /** 컴포넌트 클래스명 */ + className?: string + /** 선택 후 팝오버 닫기 여부 */ + closeOnSelect?: boolean +} + +export function OrganizationManagerSelector({ + selectedManagers = [], + onManagersChange, + singleSelect = false, + placeholder = "조직 관리자를 검색하세요...", + noValuePlaceHolder = "조직명 또는 관리자명으로 검색하세요", + disabled = false, + maxSelections, + className, + closeOnSelect = true +}: OrganizationManagerSelectorProps) { + const [searchQuery, setSearchQuery] = React.useState("") + const [isSearching, setIsSearching] = React.useState(false) + const [searchResults, setSearchResults] = React.useState([]) + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) + const [currentPage, setCurrentPage] = React.useState(1) + const [pagination, setPagination] = React.useState({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + const [searchError, setSearchError] = React.useState(null) + + const inputRef = React.useRef(null) + + // Debounce 적용된 검색어 + const debouncedSearchQuery = useDebounce(searchQuery, 300) + + // 검색 실행 + const performSearch = React.useCallback(async (query: string, page: number = 1) => { + if (!query.trim()) { + setSearchResults([]) + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + return + } + + setIsSearching(true) + setSearchError(null) + + try { + const result = await searchOrganizationsForManager({ + search: query, + page, + perPage: 10, + }) + + setSearchResults(result.data) + setPagination({ + page: result.pageCount, + perPage: 10, + total: result.total, + pageCount: result.pageCount, + hasNextPage: page < result.pageCount, + hasPrevPage: page > 1, + }) + } catch (error) { + setSearchError("검색 중 오류가 발생했습니다.") + setSearchResults([]) + } finally { + setIsSearching(false) + } + }, []) + + // 검색어 변경 시 검색 실행 + React.useEffect(() => { + performSearch(debouncedSearchQuery, 1) + setCurrentPage(1) + }, [debouncedSearchQuery, performSearch]) + + // 페이지 변경 시 검색 실행 + const handlePageChange = React.useCallback((newPage: number) => { + setCurrentPage(newPage) + performSearch(debouncedSearchQuery, newPage) + }, [debouncedSearchQuery, performSearch]) + + // 선택된 관리자 제거 + const removeManager = React.useCallback((managerId: string) => { + const updated = selectedManagers.filter(m => m.id !== managerId) + onManagersChange?.(updated) + }, [selectedManagers, onManagersChange]) + + // 관리자 선택 + const selectManager = React.useCallback((manager: OrganizationManagerItem) => { + if (singleSelect) { + onManagersChange?.([manager]) + if (closeOnSelect) { + setIsPopoverOpen(false) + } + return + } + + // 최대 선택 수 체크 + if (maxSelections && selectedManagers.length >= maxSelections) { + return + } + + // 이미 선택된 관리자인지 확인 + const isAlreadySelected = selectedManagers.some(m => m.id === manager.id) + if (isAlreadySelected) { + return + } + + const updated = [...selectedManagers, manager] + onManagersChange?.(updated) + + if (closeOnSelect) { + setIsPopoverOpen(false) + } + }, [selectedManagers, onManagersChange, singleSelect, maxSelections, closeOnSelect]) + + // 전체 선택 해제 + const clearAll = React.useCallback(() => { + onManagersChange?.([]) + }, [onManagersChange]) + + return ( +
+ {/* 선택된 관리자들 표시 */} + {selectedManagers.length > 0 && ( +
+ {selectedManagers.map((manager) => ( + + + + {manager.departmentName} - {manager.managerName} + + + + ))} + {selectedManagers.length > 1 && ( + + )} +
+ )} + + {/* 검색 입력 */} +
+ setSearchQuery(e.target.value)} + onFocus={() => setIsPopoverOpen(true)} + disabled={disabled} + className="w-full" + /> + + {/* 검색 결과 팝오버 */} + {isPopoverOpen && ( +
+ {/* 검색 중 표시 */} + {isSearching && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ )} + + {/* 검색 결과 */} + {!isSearching && searchResults.length > 0 && ( +
+ {searchResults.map((manager) => { + const isSelected = selectedManagers.some(m => m.id === manager.id) + return ( +
selectManager(manager)} + > +
+
+
+ {manager.departmentName} +
+
+ {manager.managerName} ({manager.managerTitle}) +
+
+ {manager.companyName} +
+
+ {isSelected && ( + + 선택됨 + + )} +
+
+ ) + })} +
+ )} + + {/* 검색 결과 없음 */} + {!isSearching && searchQuery && searchResults.length === 0 && !searchError && ( +
+ 검색 결과가 없습니다. +
+ )} + + {/* 오류 메시지 */} + {searchError && ( +
+ {searchError} +
+ )} + + {/* 페이지네이션 */} + {!isSearching && searchResults.length > 0 && ( +
+ + + {currentPage} / {pagination.pageCount} + + +
+ )} +
+ )} +
+ + {/* 팝오버 외부 클릭 시 닫기 */} + {isPopoverOpen && ( +
setIsPopoverOpen(false)} + /> + )} +
+ ) +} \ No newline at end of file diff --git a/components/knox/approval/ApprovalLineSelector.tsx b/components/knox/approval/ApprovalLineSelector.tsx new file mode 100644 index 00000000..bbe6bd7f --- /dev/null +++ b/components/knox/approval/ApprovalLineSelector.tsx @@ -0,0 +1,444 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { toast } from "sonner"; +import { Trash2, GripVertical } from "lucide-react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + restrictToVerticalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + UserSelector, + type UserSelectItem, +} from "@/components/common/user/user-selector"; + +// ----- Types ----- +export type ApprovalRole = "0" | "1" | "2" | "3" | "4" | "7" | "9"; + +export interface ApprovalLineItem { + id: string; + epId?: string; + userId?: string; + emailAddress?: string; + name?: string; + deptName?: string; + role: ApprovalRole; + seq: string; // 0-based; 동일 seq는 병렬 그룹 의미 + opinion?: string; +} + +export interface ApprovalLineSelectorProps { + value: ApprovalLineItem[]; + onChange: (next: ApprovalLineItem[]) => void; + placeholder?: string; + maxSelections?: number; + domainFilter?: any; // 프로젝트 전역 타입에 맞춰 느슨히 둠 + className?: string; +} + +// 역할 텍스트 매핑 +const getRoleText = (role: string) => { + const map: Record = { + "0": "기안", + "1": "결재", + "2": "합의", + "3": "후결", + "4": "병렬합의", + "7": "병렬결재", + "9": "통보", + }; + return map[role] || role; +}; + +// 고유 ID 생성 +const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + +// Sortable Group Card (seq 단위) +interface SortableApprovalGroupProps { + group: ApprovalLineItem[]; + index: number; + onRemoveGroup: () => void; + canRemove: boolean; + selected: boolean; + onSelect: () => void; + onChangeRole: (role: ApprovalRole) => void; +} + +function SortableApprovalGroup({ + group, + index, + onRemoveGroup, + canRemove, + selected, + onSelect, + onChangeRole, +}: SortableApprovalGroupProps) { + const seq = group[0].seq; + const role = group[0].role; + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: group[0].id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } as React.CSSProperties; + + const isParallel = role === "7" || role === "4"; + + return ( +
+ {/* Drag handle */} +
+ +
+ + {/* Group select (skip for drafter) */} + {index !== 0 ? ( + e.stopPropagation()} /> + ) : ( +
+ )} + + {/* seq index */} + {parseInt(seq) + 1} + + {/* Group details */} +
+ {/* Users in group */} +
+ {group.map((u) => ( +
+ {u.name || "Knox 이름 없음"} + {u.deptName ? ` / ${u.deptName}` : ""} +
+ ))} +
+ + {/* Role UI */} +
+ {seq === "0" ? ( + 기안 + ) : isParallel ? ( + {getRoleText(role)} + ) : ( + val && onChangeRole(val as ApprovalRole)} + > + 결재 + 합의 + 통보 + + )} +
+ + {/* Delete */} +
+ {canRemove && ( + + )} +
+
+
+ ); +} + +export function ApprovalLineSelector({ + value, + onChange, + placeholder = "결재자를 검색하세요...", + maxSelections = 10, + domainFilter, + className, +}: ApprovalLineSelectorProps) { + const aplns = value; + const [selectedSeqs, setSelectedSeqs] = React.useState([]); + + const toggleSelectGroup = (seq: string) => { + setSelectedSeqs((prev) => (prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq])); + }; + const clearSelection = () => setSelectedSeqs([]); + + // drag sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Utilities + const reorderBySeq = (list: ApprovalLineItem[]): ApprovalLineItem[] => { + const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); + const seqMap = new Map(); + let nextSeq = 0; + return sorted.map((apln) => { + if (!seqMap.has(apln.seq)) { + seqMap.set(apln.seq, nextSeq.toString()); + nextSeq += 1; + } + return { ...apln, seq: seqMap.get(apln.seq)! }; + }); + }; + + const addApprovalUsers = (users: UserSelectItem[]) => { + const newAplns = [...aplns]; + users.forEach((user) => { + const exists = newAplns.findIndex((apln) => apln.userId === user.id.toString()); + if (exists === -1) { + // 현재 존재하는 seq 중 최댓값 + 1을 다음 seq로 사용 (0은 상신자) + const uniqueSeqs = Array.from(new Set(newAplns.map((a) => parseInt(a.seq)))); + const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : -1; + const newSeq = (Math.max(1, maxSeq + 1)).toString(); + newAplns.push({ + id: generateUniqueId(), + epId: (user as any).epId, + userId: user.id.toString(), + emailAddress: user.email, + name: user.name, + deptName: (user as any).deptName ?? undefined, + role: "1", + seq: newSeq, + opinion: "", + }); + } + }); + onChange(newAplns); + }; + + const removeApprovalGroup = (seq: string) => { + if (seq === "0") return; // 상신자 삭제 불가 + const remaining = aplns.filter((a) => a.seq !== seq); + onChange(reorderBySeq(remaining)); + }; + + const applyParallel = () => { + if (selectedSeqs.length < 2) { + toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다."); + return; + } + const current = aplns; + const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq)); + const roles = Array.from(new Set(selectedAplns.map((a) => a.role))); + if (roles.length !== 1) { + toast.error("선택된 항목의 역할이 동일해야 합니다."); + return; + } + const role = roles[0]; + let newRole: ApprovalRole; + if (role === "1") newRole = "7"; + else if (role === "2") newRole = "4"; + else if (role === "9") newRole = "9"; + else { + toast.error("결재, 합의 또는 통보만 병렬 지정 가능합니다."); + return; + } + const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq))); + const updated = current.map((a) => + selectedSeqs.includes(a.seq) ? { ...a, role: newRole, seq: minSeq.toString() } : a, + ); + onChange(reorderBySeq(updated)); + clearSelection(); + }; + + const applyAfter = () => { + if (selectedSeqs.length !== 1) { + toast.error("후결은 한 명만 지정할 수 있습니다."); + return; + } + const targetSeq = selectedSeqs[0]; + const targetRole = aplns.find((a) => a.seq === targetSeq)?.role; + if (targetRole === "7" || targetRole === "4") { + toast.error("병렬 그룹은 후결로 전환할 수 없습니다."); + return; + } + const updated = aplns.map((a) => + a.seq === targetSeq ? { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole } : a, + ); + onChange(reorderBySeq(updated)); + clearSelection(); + }; + + const ungroupParallel = () => { + if (selectedSeqs.length === 0) { + toast.error("해제할 결재선을 선택하세요."); + return; + } + let newSeqCounter = 1; // 0은 상신자 유지 + const updated = aplns.map((a) => { + if (selectedSeqs.includes(a.seq)) { + let newRole: ApprovalRole = a.role; + if (a.role === "7") newRole = "1"; + if (a.role === "4") newRole = "2"; + return { ...a, role: newRole, seq: "" }; + } + return { ...a }; + }); + const reassigned = updated + .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0")) + .map((a) => { + if (a.seq === "0") return a; + const next = { ...a, seq: newSeqCounter.toString() }; + newSeqCounter += 1; + return next; + }); + onChange(reassigned); + clearSelection(); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeKey = active.id as string; + const overKey = over.id as string; + + const idToSeq = new Map(); + aplns.forEach((a) => idToSeq.set(a.id, a.seq)); + const activeSeq = idToSeq.get(activeKey); + const overSeq = idToSeq.get(overKey); + if (!activeSeq || !overSeq) return; + if (activeSeq === "0" || overSeq === "0") return; // 상신자 이동 불가 + + const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); + const keyOrder = seqOrder.map((seq) => aplns.find((a) => a.seq === seq)!.id); + const oldIndex = keyOrder.indexOf(activeKey); + const newIndex = keyOrder.indexOf(overKey); + const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); + + const keyToNewSeq = new Map(); + newKeyOrder.forEach((k, idx) => keyToNewSeq.set(k, idx.toString())); + + const updatedAplns: ApprovalLineItem[] = []; + newKeyOrder.forEach((k) => { + const oldSeq = idToSeq.get(k)!; + const groupItems = aplns.filter((a) => a.seq === oldSeq); + groupItems.forEach((item) => { + updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! }); + }); + }); + onChange(updatedAplns); + }; + + // Render groups by seq + const groups = React.useMemo(() => { + const grouped = Object.values( + aplns.reduce>((acc, apln) => { + acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln]; + return acc; + }, {}), + ).sort((a, b) => parseInt(a[0].seq) - parseInt(b[0].seq)); + return grouped; + }, [aplns]); + + return ( +
+

결재 경로

+ + {/* Controls */} +
+ + + +
+ + {/* User add */} +
+
+ +

사용자를 검색하여 결재 라인에 추가하세요

+
+ +
+ + {/* Groups */} +
+ {aplns.length > 0 ? ( + + g[0].id)} strategy={verticalListSortingStrategy}> +
+ {groups.map((group, idx) => ( + removeApprovalGroup(group[0].seq)} + canRemove={idx !== 0 && aplns.length > 1} + selected={selectedSeqs.includes(group[0].seq)} + onSelect={() => toggleSelectGroup(group[0].seq)} + onChangeRole={(role) => { + // 단일 그룹일 때만 역할 변경 허용 + if (group.length > 1) return; + const gid = group[0].id; + const updated = aplns.map((a) => (a.id === gid ? { ...a, role } : a)); + onChange(updated); + }} + /> + ))} +
+
+
+ ) : ( +
+

결재자를 추가해주세요

+
+ )} +
+ + +
+ ); +} + +export default ApprovalLineSelector; + + diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx index 8b58aba6..bfe66981 100644 --- a/components/knox/approval/ApprovalSubmit.tsx +++ b/components/knox/approval/ApprovalSubmit.tsx @@ -1,1092 +1,1294 @@ -'use client' - -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; - -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { Checkbox } from '@/components/ui/checkbox'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import { toast } from 'sonner'; -import { Loader2, Trash2, FileText, AlertCircle, GripVertical } from 'lucide-react'; -import { debugLog, debugError } from '@/lib/debug-utils' +"use client"; + +import { useState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { toast } from "sonner"; +import { + Loader2, + Trash2, + FileText, + AlertCircle, + GripVertical, +} from "lucide-react"; +import { debugLog, debugError } from "@/lib/debug-utils"; // dnd-kit imports for drag and drop import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; // 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier import { - restrictToVerticalAxis, - restrictToParentElement, -} from '@dnd-kit/modifiers'; + restrictToVerticalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - useSortable, -} from '@dnd-kit/sortable'; -import { - CSS, -} from '@dnd-kit/utilities'; + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; // API 함수 및 타입 -import { submitApproval, submitSecurityApproval, createSubmitApprovalRequest, createApprovalLine } from '@/lib/knox-api/approval/approval'; -import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval'; +import { + submitApproval, + submitSecurityApproval, + createSubmitApprovalRequest, + createApprovalLine, +} from "@/lib/knox-api/approval/approval"; +import type { + ApprovalLine, + SubmitApprovalRequest, +} from "@/lib/knox-api/approval/approval"; // 역할 텍스트 매핑 (기존 mock util 대체) const getRoleText = (role: string) => { - const map: Record = { - '0': '기안', - '1': '결재', - '2': '합의', - '3': '후결', - '4': '병렬합의', - '7': '병렬결재', - '9': '통보', - }; - return map[role] || role; + const map: Record = { + "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; interface ApprovalSubmitProps { - onSubmitSuccess?: (apInfId: string) => void; + onSubmitSuccess?: (apInfId: string) => void; } // Sortable한 결재 라인 컴포넌트 interface SortableApprovalLineProps { - apln: ApprovalLineItem; - index: number; - form: ReturnType>; - onRemove: () => void; - canRemove: boolean; - selected: boolean; - onSelect: () => void; + apln: ApprovalLineItem; + index: number; + form: ReturnType>; + 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 ( -
- {/* 드래그 핸들 */} -
- -
- - {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */} - {index !== 0 ? ( - onSelect()} - onClick={(e) => e.stopPropagation()} - /> - ) : ( -
// 기안자용 빈 공간 (체크박스가 없으므로) - )} - - {/* 실제 seq 기준 표시 */} - {parseInt(apln.seq) + 1} - -
- {/* 사용자 정보 표시 */} -
-
-
- {(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 ( +
+ {/* 드래그 핸들 */} +
+
-
- ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - ( - - - - - - + + {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */} + {index !== 0 ? ( + onSelect()} + onClick={(e) => e.stopPropagation()} + /> + ) : ( +
// 기안자용 빈 공간 (체크박스가 없으므로) )} - /> -
- {/* 역할 선택 */} - {index === 0 ? ( - // 상신자는 역할 선택 대신 고정 표시 -
- 기안 -
- ) : ( - { - // 병렬 여부 판단 - 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 ( - - - {getRoleText(field.value)} - - - ); - } - - return ( - -
- - 결재 - 합의 - 통보 - -
- -
- ); - }} - /> - )} - - {/* 의견 입력란 제거됨 */} - - {/* 역할 표시 */} -
- - {getRoleText(apln.role)} - - - {canRemove && ( - - )} + {/* 실제 seq 기준 표시 */} + {parseInt(apln.seq) + 1} + +
+ {/* 사용자 정보 표시 */} +
+
+
+ {apln.name || "Knox 이름 없음"} + {apln.deptName ? ` / ${apln.deptName}` : ""} +
+
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> +
+ + {/* 역할 선택 */} + {index === 0 ? ( + // 상신자는 역할 선택 대신 고정 표시 +
+ 기안 +
+ ) : ( + { + // 병렬 여부 판단 + 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 ( + + + {getRoleText(field.value)} + + + ); + } + + return ( + +
+ + + 결재 + + + 합의 + + + 통보 + + +
+ +
+ ); + }} + /> + )} + + {/* 의견 입력란 제거됨 */} + + {/* 역할 표시 */} +
+ {getRoleText(apln.role)} + + {canRemove && ( + + )} +
+
-
-
- ); + ); } // Sortable Approval Group (seq 단위 카드) interface SortableApprovalGroupProps { - group: ApprovalLineItem[]; // 동일 seq 항목들 - index: number; - form: ReturnType>; - onRemoveGroup: () => void; - canRemove: boolean; - selected: boolean; - onSelect: () => void; + group: ApprovalLineItem[]; // 동일 seq 항목들 + index: number; + form: ReturnType>; + 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 ( -
- {/* 드래그 핸들 */} -
- -
- - {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */} - {index !== 0 ? ( - e.stopPropagation()} - /> - ) : ( -
// 기안자용 빈 공간 - )} - - {/* seq 표시 */} - {parseInt(seq) + 1} - - {/* 그룹 상세 정보 */} -
- {/* 사용자 목록 */} -
- {group.map((u) => ( -
- {(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 ( +
+ {/* 드래그 핸들 */} +
+
- ))} -
- {/* 역할 */} -
- {seq === '0' ? ( - - 기안 - - ) : role === '7' || role === '4' ? ( - - {getRoleText(role)} - - ) : ( - // 단일일 때는 기존 토글 재사용 (첫 항목 기준) - a.id === group[0].id)}.role`} - render={({ field }) => ( - - 결재 - 합의 - 통보 - - )} - /> - )} -
- - {/* 삭제 버튼 */} -
- {canRemove && ( - - )} -
-
-
- ); -} + {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */} + {index !== 0 ? ( + e.stopPropagation()} + /> + ) : ( +
// 기안자용 빈 공간 + )} -export default function ApprovalSubmit({ onSubmitSuccess }: ApprovalSubmitProps) { - const { data: session } = useSession(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitResult, setSubmitResult] = useState<{ apInfId: string } | null>(null); + {/* seq 표시 */} + {parseInt(seq) + 1} + + {/* 그룹 상세 정보 */} +
+ {/* 사용자 목록 */} +
+ {group.map((u) => ( +
+ {u.name || "Knox 이름 없음"} + {u.deptName ? ` / ${u.deptName}` : ""} +
+ ))} +
- const [selectedSeqs, setSelectedSeqs] = useState([]); + {/* 역할 */} +
+ {seq === "0" ? ( + + 기안 + + ) : role === "7" || role === "4" ? ( + + {getRoleText(role)} + + ) : ( + // 단일일 때는 기존 토글 재사용 (첫 항목 기준) + a.id === group[0].id)}.role`} + render={({ field }) => ( + + + 결재 + + + 합의 + + + 통보 + + + )} + /> + )} +
- // 그룹 단위 선택/해제 - const toggleSelectGroup = (seq: string) => { - setSelectedSeqs((prev) => - prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq] + {/* 삭제 버튼 */} +
+ {canRemove && ( + + )} +
+
+
); - }; - const clearSelection = () => setSelectedSeqs([]); - - const form = useForm({ - 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(); - 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([]); + + // 그룹 단위 선택/해제 + const toggleSelectGroup = (seq: string) => { + setSelectedSeqs((prev) => + prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq], + ); + }; + const clearSelection = () => setSelectedSeqs([]); + + const form = useForm({ + 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(); - 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(); - 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 ( - - - - - 결재 상신 - - - 새로운 결재를 상신합니다. - - - - - {submitResult && ( -
-
- - 상신 완료 -
-

- 결재 ID: {submitResult.apInfId} -

-
- )} - -
- - {/* 기본 정보 */} -
- - - - {/* 결재 경로 */} -
-

결재 경로

- - {/* 상단 제어 버튼 */} -
- - - -
- {/* 결재자 추가 섹션 */} -
-
- -

사용자를 검색하여 결재 라인에 추가하세요

-
- -
+ 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>((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 ( - - g[0].id)} strategy={verticalListSortingStrategy}> -
- {groups.map((group, idx) => ( - removeApprovalGroup(group[0].seq)} - canRemove={idx !== 0 && aplns.length > 1} - selected={selectedSeqs.includes(group[0].seq)} - onSelect={() => toggleSelectGroup(group[0].seq)} - /> - ))} -
-
-
- ); - })() - )} + 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(); + 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 && ( -
- -

결재자를 추가해주세요

-
- )} -
- - ( - - 제목 * - - - - - - )} - /> - - ( - - 내용 * - - - - - - )} - /> - - {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */} -
- {/* 보안 등급 */} - ( - - 보안 - - - - - )} - /> + // 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(); + 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(); + 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, + }, + ); - {/* 중요 여부 */} - ( - - 중요 - - - - - )} - /> -
- - {/* 첨부 파일 */} - ( - - 첨부 파일 - - field.onChange(e.target.files)} - /> - - 필요 시 파일을 선택하세요. (다중 선택 가능) - - + // 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 ( + + + + + 결재 상신 + + 새로운 결재를 상신합니다. + + + + {submitResult && ( +
+
+ + 상신 완료 +
+

+ 결재 ID: {submitResult.apInfId} +

+
)} - /> -
- - - - {/* 제출 버튼 */} -
- - -
- - -
-
- ); -} \ No newline at end of file +
+ + {/* 기본 정보 */} +
+ + + {/* 결재 경로 */} +
+

+ 결재 경로 +

+ + {/* 상단 제어 버튼 */} +
+ + + +
+ + {/* 결재자 추가 섹션 */} +
+
+ +

+ 사용자를 검색하여 결재 라인에 + 추가하세요 +

+
+ +
+ + {/* 그룹 기반 렌더링 */} + {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 ( + + g[0].id, + )} + strategy={ + verticalListSortingStrategy + } + > +
+ {groups.map( + (group, idx) => ( + + removeApprovalGroup( + group[0] + .seq, + ) + } + canRemove={ + idx !== + 0 && + aplns.length > + 1 + } + selected={selectedSeqs.includes( + group[0] + .seq, + )} + onSelect={() => + toggleSelectGroup( + group[0] + .seq, + ) + } + /> + ), + )} +
+
+
+ ); + })()} + + {aplns.length === 0 && ( +
+ +

결재자를 추가해주세요

+
+ )} +
+ + ( + + 제목 * + + + + + + )} + /> + + ( + + 내용 * + + + + + + )} + /> + + {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */} +
+ {/* 보안 등급 */} + ( + + 보안 + + + + + )} + /> + + {/* 긴급 여부 */} + ( + + 긴급 + + + + + )} + /> + + {/* 중요 여부 */} + ( + + 중요 + + + + + )} + /> +
+ + {/* 첨부 파일 */} + ( + + 첨부 파일 + + + field.onChange( + e.target.files, + ) + } + /> + + + 필요 시 파일을 선택하세요. (다중 + 선택 가능) + + + + )} + /> +
+ + + + {/* 제출 버튼 */} +
+ + +
+ + + + + ); +} diff --git a/components/layout/command-menu.tsx b/components/layout/command-menu.tsx index 5537a042..d0c9c49a 100644 --- a/components/layout/command-menu.tsx +++ b/components/layout/command-menu.tsx @@ -1,12 +1,13 @@ "use client" import * as React from "react" -import { useRouter,usePathname } from "next/navigation" +import { useRouter, usePathname, useParams } from "next/navigation" import { type DialogProps } from "@radix-ui/react-dialog" import { Circle, File, Laptop, Moon, Sun } from "lucide-react" import { useTheme } from "next-themes" import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; +import { useTranslation } from "@/i18n/client" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { @@ -24,6 +25,9 @@ export function CommandMenu({ ...props }: DialogProps) { const router = useRouter() const [open, setOpen] = React.useState(false) const { setTheme } = useTheme() + const params = useParams() + const lng = (params?.lng as string) || "evcp" + const { t } = useTranslation(lng, "menu") React.useEffect(() => { const down = (e: KeyboardEvent) => { @@ -81,20 +85,20 @@ const isPartnerRoute = pathname.includes("/partners"); No results found. - {main.map((group) => ( - + {main.map((group: MenuSection) => ( + {group.items.map((navItem) => ( { - runCommand(() => router.push(navItem.href as string)) + runCommand(() => router.push(`/${lng}${navItem.href}`)) }} >
- {navItem.title} + {t(navItem.titleKey)}
))}
@@ -104,14 +108,14 @@ const isPartnerRoute = pathname.includes("/partners"); // .filter((navitem) => !navitem.external) .map((navItem) => ( { - runCommand(() => router.push(navItem.href as string)) + runCommand(() => router.push(`/${lng}${navItem.href}`)) }} > - {navItem.title} + {t(navItem.titleKey)} ))}
diff --git a/components/rich-text-editor/BlockquoteButton.tsx b/components/rich-text-editor/BlockquoteButton.tsx new file mode 100644 index 00000000..be9a342b --- /dev/null +++ b/components/rich-text-editor/BlockquoteButton.tsx @@ -0,0 +1,38 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Quote as QuoteIcon } from 'lucide-react' + +interface BlockquoteButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function BlockquoteButton({ editor, disabled, isActive, executeCommand }: BlockquoteButtonProps) { + if (!editor) return null + return ( + + + e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBlockquote().run())} + disabled={disabled} + > + + + + +

인용문

+
+
+ ) +} + + diff --git a/components/rich-text-editor/BulletListButton.tsx b/components/rich-text-editor/BulletListButton.tsx new file mode 100644 index 00000000..bf5b833c --- /dev/null +++ b/components/rich-text-editor/BulletListButton.tsx @@ -0,0 +1,46 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { List as ListIcon } from 'lucide-react' + +interface BulletListButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function BulletListButton({ editor, disabled, isActive, executeCommand }: BulletListButtonProps) { + + + if (!editor) return null + + const handleToggleBulletList = () => { + console.log('toggleBulletList') + executeCommand(() => editor.chain().focus().toggleBulletList().run()) + } + + return ( + + + e.preventDefault()} + onPressedChange={handleToggleBulletList} + disabled={disabled} + > + + + + +

글머리 기호

+
+
+ ) +} + + diff --git a/components/rich-text-editor/HistoryMenu.tsx b/components/rich-text-editor/HistoryMenu.tsx new file mode 100644 index 00000000..e5bb819c --- /dev/null +++ b/components/rich-text-editor/HistoryMenu.tsx @@ -0,0 +1,43 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Undo, Redo } from 'lucide-react' + +interface HistoryMenuProps { + editor: Editor | null + disabled?: boolean + executeCommand: (command: () => void) => void +} + +export function HistoryMenu({ editor, disabled, executeCommand }: HistoryMenuProps) { + if (!editor) return null + return ( + <> + + + executeCommand(() => editor.chain().focus().undo().run())} disabled={!editor.can().undo() || disabled}> + + + + +

실행 취소 (Ctrl+Z)

+
+
+ + + executeCommand(() => editor.chain().focus().redo().run())} disabled={!editor.can().redo() || disabled}> + + + + +

다시 실행 (Ctrl+Y)

+
+
+ + ) +} + + diff --git a/components/rich-text-editor/InlineStyleMenu.tsx b/components/rich-text-editor/InlineStyleMenu.tsx new file mode 100644 index 00000000..02eac252 --- /dev/null +++ b/components/rich-text-editor/InlineStyleMenu.tsx @@ -0,0 +1,67 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Bold, Italic, Underline as UnderlineIcon, Strikethrough } from 'lucide-react' + +interface InlineStyleMenuProps { + editor: Editor | null + disabled?: boolean + isBold: boolean + isItalic: boolean + isUnderline: boolean + isStrike: boolean + executeCommand: (command: () => void) => void +} + +export function InlineStyleMenu({ editor, disabled, isBold, isItalic, isUnderline, isStrike, executeCommand }: InlineStyleMenuProps) { + if (!editor) return null + return ( + <> + + + executeCommand(() => editor.chain().focus().toggleBold().run())} disabled={disabled}> + + + + +

굵게 (Ctrl+B)

+
+
+ + + executeCommand(() => editor.chain().focus().toggleItalic().run())} disabled={disabled}> + + + + +

기울임 (Ctrl+I)

+
+
+ + + executeCommand(() => editor.chain().focus().toggleUnderline().run())} disabled={disabled}> + + + + +

밑줄

+
+
+ + + executeCommand(() => editor.chain().focus().toggleStrike().run())} disabled={disabled}> + + + + +

취소선

+
+
+ + ) +} + + diff --git a/components/rich-text-editor/OrderedListButton.tsx b/components/rich-text-editor/OrderedListButton.tsx new file mode 100644 index 00000000..f4f68729 --- /dev/null +++ b/components/rich-text-editor/OrderedListButton.tsx @@ -0,0 +1,38 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ListOrdered as ListOrderedIcon } from 'lucide-react' + +interface OrderedListButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function OrderedListButton({ editor, disabled, isActive, executeCommand }: OrderedListButtonProps) { + if (!editor) return null + return ( + + + e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleOrderedList().run())} + disabled={disabled} + > + + + + +

번호 매기기

+
+
+ ) +} + + diff --git a/components/rich-text-editor/RichTextEditor.tsx b/components/rich-text-editor/RichTextEditor.tsx index ceb76665..1360a5f8 100644 --- a/components/rich-text-editor/RichTextEditor.tsx +++ b/components/rich-text-editor/RichTextEditor.tsx @@ -1,17 +1,15 @@ 'use client' -import React, { useCallback, useRef, useState, useEffect } from 'react' +import React, { useRef, useEffect } from 'react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' -import { Image as TiptapImage } from '@tiptap/extension-image' -import Link from '@tiptap/extension-link' import TextAlign from '@tiptap/extension-text-align' import TextStyle from '@tiptap/extension-text-style' import Subscript from '@tiptap/extension-subscript' import Superscript from '@tiptap/extension-superscript' -import { Extension } from '@tiptap/core' +import Placeholder from '@tiptap/extension-placeholder' import Highlight from '@tiptap/extension-highlight' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' @@ -23,95 +21,23 @@ import Table from '@tiptap/extension-table' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' +import { toast } from 'sonner' -// shadcn/ui & lucide -import { - Bold, - Italic, - Underline as UnderlineIcon, - Strikethrough, - ListOrdered, - List, - Quote, - Undo, - Redo, - Link as LinkIcon, - Image as ImageIcon, - AlignLeft, - AlignCenter, - AlignRight, - AlignJustify, - Subscript as SubscriptIcon, - Superscript as SuperscriptIcon, - Table as TableIcon, - Highlighter, - CheckSquare, - Type, -} from 'lucide-react' -import { Toggle } from '@/components/ui/toggle' -import { Separator } from '@/components/ui/separator' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' +import { FontSize } from './extensions/font-size' +import ImageResize from 'tiptap-extension-resize-image' +import { Toolbar } from './Toolbar' -/* ------------------------------------------------------------------------------------------------- - * FontSize extension (wraps TextStyle) - * -------------------------------------------------------------------------------------------------*/ -const FontSize = Extension.create({ - name: 'fontSize', - addGlobalAttributes() { - return [ - { - types: ['textStyle'], - attributes: { - fontSize: { - default: null, - parseHTML: element => { - const size = element.style.fontSize - return size ? size.replace(/[^0-9]/g, '') : null - }, - renderHTML: attributes => { - if (!attributes.fontSize) return {} - return { - style: `font-size: ${attributes.fontSize}`, - } - }, - }, - }, - }, - ] - }, -}) - -/* ------------------------------------------------------------------------------------------------- - * Props & component - * -------------------------------------------------------------------------------------------------*/ interface RichTextEditorProps { value: string onChange: (val: string) => void disabled?: boolean - height?: string // e.g. "400px" or "100%" + height?: string + className?: string + placeholder?: string + debounceMs?: number + onReady?: (editor: Editor) => void + onFocus?: () => void + onBlur?: () => void } export default function RichTextEditor({ @@ -119,51 +45,75 @@ export default function RichTextEditor({ onChange, disabled, height = '300px', + className, + placeholder, + debounceMs = 200, + onReady, + onFocus, + onBlur, }: RichTextEditorProps) { - // --------------------------------------------------------------------------- - // Editor instance - // --------------------------------------------------------------------------- + const updateTimerRef = useRef(undefined) + + const computedExtensions: unknown[] = [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + blockquote: false, + codeBlock: false, + code: false, + heading: { levels: [1, 2, 3] }, + horizontalRule: false, + }), + Underline, + ImageResize, + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + defaultAlignment: 'left', + }), + Subscript, + Superscript, + TextStyle, + FontSize, + Table.configure({ resizable: true }), + TableRow, + TableCell, + TableHeader, + Highlight.configure({ multicolor: true }), + TaskList, + TaskItem.configure({ nested: true }), + BulletList.configure({ + HTMLAttributes: { + class: 'list-disc ml-5', + }, + }), + ListItem.configure({ + HTMLAttributes: { + class: 'list-item my-0.5', + }, + }), + OrderedList.configure({ + HTMLAttributes: { + class: 'list-decimal ml-5', + }, + }), + Blockquote.configure({ + HTMLAttributes: { + class: 'border-l-4 pl-4 my-3 italic', + }, + }), + ] + + if (placeholder) { + computedExtensions.push(Placeholder.configure({ placeholder })) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const extensionsForEditor = computedExtensions as any + const editor = useEditor({ - extensions: [ - StarterKit.configure({ - bulletList: false, - orderedList: false, - listItem: false, - blockquote: false, - codeBlock: false, - code: false, - heading: { levels: [1, 2, 3] }, - horizontalRule: false, - }), - Underline, - TiptapImage.configure({ - HTMLAttributes: { - class: 'max-w-full h-auto', - style: 'max-width: 600px; height: auto;', - }, - }), - Link.configure({ openOnClick: true, linkOnPaste: true }), - TextAlign.configure({ - types: ['heading', 'paragraph'], - alignments: ['left', 'center', 'right', 'justify'], - defaultAlignment: 'left', - }), - Subscript, - Superscript, - TextStyle, - FontSize, - Table.configure({ resizable: true }), - TableRow, - TableCell, - TableHeader, - Highlight.configure({ multicolor: true }), - TaskList, - TaskItem.configure({ nested: true }), - BulletList, - ListItem, - OrderedList, - Blockquote, - ], + extensions: extensionsForEditor, content: value, editable: !disabled, enablePasteRules: false, @@ -172,7 +122,7 @@ export default function RichTextEditor({ editorProps: { attributes: { class: - 'w-full h-full min-h-full bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 prose prose-sm max-w-none', + 'w-full h-full min-h-full bg-background px-3 py-2 text-sm leading-[1.6] ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 prose prose-sm max-w-none', }, handleDrop: (view, event, slice, moved) => { if (!moved && event.dataTransfer?.files.length) { @@ -194,805 +144,83 @@ export default function RichTextEditor({ } return false }, + handleDOMEvents: { + focus: () => { + onFocus?.() + return false + }, + blur: () => { + onBlur?.() + return false + }, + }, }, onUpdate: ({ editor }) => { - onChange(editor.getHTML()) + if (updateTimerRef.current) window.clearTimeout(updateTimerRef.current) + updateTimerRef.current = window.setTimeout(() => { + onChange(editor.getHTML()) + }, debounceMs) as unknown as number }, }) - // --------------------------------------------------------------------------- - // Image handling (base64) - // --------------------------------------------------------------------------- + useEffect(() => { + if (!editor) return + const current = editor.getHTML() + if (value !== current) { + editor.commands.setContent(value, false) + } + }, [editor, value]) + + useEffect(() => { + if (!editor) return + editor.setEditable(!disabled) + }, [editor, disabled]) + + const readyCalledRef = useRef(false) + useEffect(() => { + if (!editor || readyCalledRef.current) return + readyCalledRef.current = true + onReady?.(editor) + }, [editor, onReady]) + + const readFileAsDataURL = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = e => resolve(String(e.target?.result)) + reader.onerror = reject + reader.readAsDataURL(file) + }) + const handleImageUpload = async (file: File) => { if (file.size > 3 * 1024 * 1024) { - alert('이미지 크기는 3 MB 이하만 지원됩니다.') + toast.error('이미지 크기는 3 MB 이하만 지원됩니다.') return } if (!file.type.startsWith('image/')) { - alert('이미지 파일만 업로드 가능합니다.') + toast.error('이미지 파일만 업로드 가능합니다.') return } - const reader = new FileReader() - reader.onload = e => { - const base64 = e.target?.result as string - editor?.chain().focus().setImage({ src: base64, alt: file.name }).run() + try { + const dataUrl = await readFileAsDataURL(file) + editor?.chain().focus().setImage({ src: dataUrl, alt: file.name }).run() + } catch (error) { + console.error(error) + toast.error('이미지 읽기에 실패했습니다.') } - reader.onerror = () => alert('이미지 읽기에 실패했습니다.') - reader.readAsDataURL(file) } - // --------------------------------------------------------------------------- - // Toolbar (internal component) - // --------------------------------------------------------------------------- - const Toolbar: React.FC<{ editor: Editor | null; disabled?: boolean }> = ({ - editor, - disabled, - }) => { - const [fontSize, setFontSize] = useState('16') - const [isTableDialogOpen, setIsTableDialogOpen] = useState(false) - const [tableRows, setTableRows] = useState('3') - const [tableCols, setTableCols] = useState('3') - - // 간단한 툴바 상태 계산 - 실시간으로 계산하여 상태 동기화 문제 해결 - const getToolbarState = useCallback(() => { - if (!editor) return { - bold: false, - italic: false, - underline: false, - strike: false, - bulletList: false, - orderedList: false, - blockquote: false, - link: false, - highlight: false, - taskList: false, - table: false, - subscript: false, - superscript: false, - heading: false, - textAlign: 'left' as 'left' | 'center' | 'right' | 'justify', - } - - const textAlign = editor.isActive({ textAlign: 'center' }) - ? 'center' - : editor.isActive({ textAlign: 'right' }) - ? 'right' - : editor.isActive({ textAlign: 'justify' }) - ? 'justify' - : 'left' - - return { - bold: editor.isActive('bold'), - italic: editor.isActive('italic'), - underline: editor.isActive('underline'), - strike: editor.isActive('strike'), - bulletList: editor.isActive('bulletList'), - orderedList: editor.isActive('orderedList'), - blockquote: editor.isActive('blockquote'), - link: editor.isActive('link'), - highlight: editor.isActive('highlight'), - taskList: editor.isActive('taskList'), - table: editor.isActive('table'), - subscript: editor.isActive('subscript'), - superscript: editor.isActive('superscript'), - heading: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })), - textAlign: textAlign as 'left' | 'center' | 'right' | 'justify', - } - }, [editor]) - - const toolbarState = getToolbarState() - - // 폰트 사이즈 업데이트 - 복잡한 timeout 로직 제거 - useEffect(() => { - if (!editor) return - - const updateFontSize = () => { - const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize - if (typeof currentFontSizeAttr === 'string') { - const sizeValue = currentFontSizeAttr.replace('px', '') - setFontSize(sizeValue) - } else { - setFontSize('16') - } - } - - updateFontSize() - editor.on('selectionUpdate', updateFontSize) - editor.on('transaction', updateFontSize) - - return () => { - editor.off('selectionUpdate', updateFontSize) - editor.off('transaction', updateFontSize) - } - }, [editor]) - - // 개선된 executeCommand - 포커스 문제 해결 및 단순화 - const executeCommand = useCallback( - (command: () => void) => { - if (!editor || disabled) return - - // 명령 실행 전 포커스 확보 - if (!editor.isFocused) { - editor.commands.focus() - } - - // 명령 실행 - command() - - // 명령 실행 후 포커스 유지 - setTimeout(() => { - if (editor && !editor.isFocused) { - editor.commands.focus() - } - }, 10) - }, - [editor, disabled] - ) - - // 폰트 사이즈 입력 필드의 동적 width 계산 - const getFontSizeInputWidth = useCallback((size: string) => { - const length = size.length - return Math.max(length * 8 + 16, 40) // 최소 40px, 글자 수에 따라 증가 - }, []) - - if (!editor) return null - - // --- Render toolbar UI --- - return ( - -
-
- {/* 텍스트 스타일 */} - - - - executeCommand(() => editor.chain().focus().toggleBold().run()) - } - disabled={disabled} - > - - - - -

굵게 (Ctrl+B)

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleItalic().run()) - } - disabled={disabled} - > - - - - -

기울임 (Ctrl+I)

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleUnderline().run()) - } - disabled={disabled} - > - - - - -

밑줄

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleStrike().run()) - } - disabled={disabled} - > - - - - -

취소선

-
-
- - - - {/* 제목 및 단락 */} - - - - - - - - {([1, 2, 3] as Array<1 | 2 | 3>).map((level) => ( - - executeCommand(() => - editor.chain().focus().toggleHeading({ level }).run() - ) - } - className="flex items-center" - > - - 제목 {level} - - - ))} - - executeCommand(() => editor.chain().focus().setParagraph().run()) - } - className="flex items-center" - > - 본문 - - - - - {/* 글자 크기 - 동적 width 적용 */} -
- { - const size = e.target.value - setFontSize(size) - if (size && parseInt(size) >= 8 && parseInt(size) <= 72) { - executeCommand(() => - editor - .chain() - .focus() - .setMark('textStyle', { fontSize: `${size}px` }) - .run() - ) - } - }} - style={{ width: `${getFontSizeInputWidth(fontSize)}px` }} - className="h-8 text-xs" - disabled={disabled} - /> - - - - - - {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map((size) => ( - { - setFontSize(size.toString()) - executeCommand(() => - editor - .chain() - .focus() - .setMark('textStyle', { fontSize: `${size}px` }) - .run() - ) - }} - className="flex items-center" - > - {size}px - - ))} - - -
- - - - {/* 리스트 */} - - - - executeCommand(() => editor.chain().focus().toggleBulletList().run()) - } - disabled={disabled} - > - - - - -

글머리 기호

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleOrderedList().run()) - } - disabled={disabled} - > - - - - -

번호 매기기

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleBlockquote().run()) - } - disabled={disabled} - > - - - - -

인용문

-
-
- - - - {/* 텍스트 정렬 */} - - - - - - {toolbarState.textAlign === 'center' ? ( - - ) : toolbarState.textAlign === 'right' ? ( - - ) : toolbarState.textAlign === 'justify' ? ( - - ) : ( - - )} - - - -

텍스트 정렬

-
-
-
- - - executeCommand(() => editor.chain().focus().setTextAlign('left').run()) - } - className="flex items-center" - > - - 왼쪽 정렬 - - - executeCommand(() => editor.chain().focus().setTextAlign('center').run()) - } - className="flex items-center" - > - - 가운데 정렬 - - - executeCommand(() => editor.chain().focus().setTextAlign('right').run()) - } - className="flex items-center" - > - - 오른쪽 정렬 - - - executeCommand(() => editor.chain().focus().setTextAlign('justify').run()) - } - className="flex items-center" - > - - 양쪽 정렬 - - -
- - - - {/* 링크 */} - - - { - if (toolbarState.link) { - executeCommand(() => editor.chain().focus().unsetLink().run()) - } else { - const url = window.prompt('URL을 입력하세요:') - if (url) { - executeCommand(() => editor.chain().focus().setLink({ href: url }).run()) - } - } - }} - disabled={disabled} - > - - - - -

링크 {toolbarState.link ? '제거' : '삽입'}

-
-
- - {/* 이미지 업로드 */} - - -
- { - const file = e.target.files?.[0] - if (file) handleImageUpload(file) - }} - /> - { - document.getElementById('image-upload-rt')?.click() - }} - disabled={disabled} - > - - -
-
- -

이미지 삽입

-
-
- - - - {/* 첨자 */} - - - - executeCommand(() => editor.chain().focus().toggleSubscript().run()) - } - disabled={disabled} - > - - - - -

아래 첨자

-
-
- - - - - executeCommand(() => editor.chain().focus().toggleSuperscript().run()) - } - disabled={disabled} - > - - - - -

위 첨자

-
-
- - - - {/* 하이라이트 */} - - - - executeCommand(() => editor.chain().focus().toggleHighlight().run()) - } - disabled={disabled} - > - - - - -

하이라이트

-
-
- - {/* 체크리스트 */} - - - - executeCommand(() => editor.chain().focus().toggleTaskList().run()) - } - disabled={disabled} - > - - - - -

체크리스트

-
-
- - - - {/* 테이블 */} - {!toolbarState.table ? ( - - - - - { - if (editor && editor.isActive('table')) { - alert('커서를 테이블 밖으로 이동시키세요') - return - } - setIsTableDialogOpen(true) - }} - disabled={disabled} - > - - - - -

테이블 삽입

-
-
-
- - - 테이블 크기 설정 - - 생성할 테이블의 행과 열 수를 입력하세요 (1-20) - - -
-
- - setTableRows(e.target.value)} - placeholder="3" - /> -
-
- - setTableCols(e.target.value)} - placeholder="3" - /> -
-
- - - - -
-
- ) : ( - - - - - - - - - -

테이블 편집

-
-
-
- - - executeCommand(() => editor.chain().focus().addRowBefore().run()) - } - className="flex items-center" - > - 위에 행 추가 - - - executeCommand(() => editor.chain().focus().addRowAfter().run()) - } - className="flex items-center" - > - 아래에 행 추가 - - - executeCommand(() => editor.chain().focus().addColumnBefore().run()) - } - className="flex items-center" - > - 왼쪽에 열 추가 - - - executeCommand(() => editor.chain().focus().addColumnAfter().run()) - } - className="flex items-center" - > - 오른쪽에 열 추가 - - - executeCommand(() => editor.chain().focus().deleteRow().run()) - } - className="flex items-center" - > - 행 삭제 - - - executeCommand(() => editor.chain().focus().deleteColumn().run()) - } - className="flex items-center" - > - 열 삭제 - - - executeCommand(() => editor.chain().focus().deleteTable().run()) - } - className="flex items-center text-red-600" - > - 테이블 삭제 - - -
- )} - - - - {/* 실행 취소/다시 실행 */} - - - - executeCommand(() => editor.chain().focus().undo().run()) - } - disabled={!editor.can().undo() || disabled} - > - - - - -

실행 취소 (Ctrl+Z)

-
-
- - - - - executeCommand(() => editor.chain().focus().redo().run()) - } - disabled={!editor.can().redo() || disabled} - > - - - - -

다시 실행 (Ctrl+Y)

-
-
-
-
-
- ) - } - - // --------------------------------------------------------------------------- - // Layout & rendering - // --------------------------------------------------------------------------- - const containerStyle = height === '100%' ? { height: '100%' } : { height } - const editorContentStyle = - height === '100%' ? { flex: 1, minHeight: 0 } : { height: `calc(${height} - 60px)` } + const containerStyle = { height } return ( -
-
- +
+
+
-
+
) -} \ No newline at end of file +} + + diff --git a/components/rich-text-editor/StyleMenu.tsx b/components/rich-text-editor/StyleMenu.tsx new file mode 100644 index 00000000..a919e639 --- /dev/null +++ b/components/rich-text-editor/StyleMenu.tsx @@ -0,0 +1,65 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +interface StyleMenuProps { + editor: Editor | null + disabled?: boolean + executeCommand: (command: () => void) => void +} + +export function StyleMenu({ editor, disabled, executeCommand }: StyleMenuProps) { + if (!editor) return null + return ( + + e.preventDefault()}> + + + + + executeCommand(() => + editor + .chain() + .focus() + .setMark('textStyle', { fontSize: '32px' }) + .setBold() + .run() + ) + } + > + 제목 (굵게 + 32pt) + + + executeCommand(() => + editor + .chain() + .focus() + .unsetBold() + .setParagraph() + .setMark('textStyle', { fontSize: null as unknown as string }) + .run() + ) + } + > + 본문 (기본) + + + + ) +} + + diff --git a/components/rich-text-editor/TextAlignMenu.tsx b/components/rich-text-editor/TextAlignMenu.tsx new file mode 100644 index 00000000..98cc0d4c --- /dev/null +++ b/components/rich-text-editor/TextAlignMenu.tsx @@ -0,0 +1,46 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { AlignCenter, AlignJustify, AlignLeft, AlignRight } from 'lucide-react' + +interface TextAlignMenuProps { + editor: Editor + disabled?: boolean + currentAlign?: 'left' | 'center' | 'right' | 'justify' + executeCommand: (command: () => void) => void +} + +export function TextAlignMenu({ editor, disabled, executeCommand }: TextAlignMenuProps) { + return ( + + e.preventDefault()}> + + + + executeCommand(() => editor.chain().focus().setTextAlign('left').run())} className="flex items-center"> + + 왼쪽 정렬 + + executeCommand(() => editor.chain().focus().setTextAlign('center').run())} className="flex items-center"> + + 가운데 정렬 + + executeCommand(() => editor.chain().focus().setTextAlign('right').run())} className="flex items-center"> + + 오른쪽 정렬 + + executeCommand(() => editor.chain().focus().setTextAlign('justify').run())} className="flex items-center"> + + 양쪽 정렬 + + + + ) +} + + diff --git a/components/rich-text-editor/Toolbar.tsx b/components/rich-text-editor/Toolbar.tsx new file mode 100644 index 00000000..13e31c24 --- /dev/null +++ b/components/rich-text-editor/Toolbar.tsx @@ -0,0 +1,350 @@ +'use client' + +import React, { useCallback, useEffect, useId, useReducer, useState } from 'react' +import type { Editor } from '@tiptap/react' + +import { Image as ImageIcon, Type } from 'lucide-react' + +import { Toggle } from '@/components/ui/toggle' +import { Separator } from '@/components/ui/separator' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { TextAlignMenu } from './TextAlignMenu' +import { StyleMenu } from './StyleMenu' +import { InlineStyleMenu } from './InlineStyleMenu' +import { BulletListButton } from './BulletListButton' +import { OrderedListButton } from './OrderedListButton' +import { BlockquoteButton } from './BlockquoteButton' +import { HistoryMenu } from './HistoryMenu' + +interface ToolbarProps { + editor: Editor | null + disabled?: boolean + onSelectImageFile?: (file: File) => void +} + +export function Toolbar({ editor, disabled, onSelectImageFile }: ToolbarProps) { + const [fontSize, setFontSize] = useState('16') + const [imageWidthPct, setImageWidthPct] = useState('100') + + const imageInputId = useId() + + const getToolbarState = useCallback(() => { + if (!editor) + return { + bold: false, + italic: false, + underline: false, + strike: false, + bulletList: false, + orderedList: false, + blockquote: false, + highlight: false, + taskList: false, + table: false, + subscript: false, + superscript: false, + heading: false, + textAlign: 'left' as 'left' | 'center' | 'right' | 'justify', + } + + const textAlign = editor.isActive({ textAlign: 'center' }) + ? 'center' + : editor.isActive({ textAlign: 'right' }) + ? 'right' + : editor.isActive({ textAlign: 'justify' }) + ? 'justify' + : 'left' + + return { + bold: editor.isActive('bold'), + italic: editor.isActive('italic'), + underline: editor.isActive('underline'), + strike: editor.isActive('strike'), + bulletList: editor.isActive('bulletList'), + orderedList: editor.isActive('orderedList'), + blockquote: editor.isActive('blockquote'), + highlight: editor.isActive('highlight'), + taskList: editor.isActive('taskList'), + table: editor.isActive('table'), + subscript: editor.isActive('subscript'), + superscript: editor.isActive('superscript'), + heading: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })), + textAlign: textAlign as 'left' | 'center' | 'right' | 'justify', + } + }, [editor]) + + const toolbarState = getToolbarState() + + useEffect(() => { + if (!editor) return + const updateFontSize = () => { + const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize + if (typeof currentFontSizeAttr === 'string') { + const sizeValue = currentFontSizeAttr.replace('px', '') + setFontSize(sizeValue) + } else { + try { + const from = editor.state.selection.from + const dom = editor.view.domAtPos(from).node as HTMLElement + const el = dom.nodeType === 3 ? (dom.parentElement as HTMLElement) : (dom as HTMLElement) + const computed = window.getComputedStyle(el) + const val = computed.fontSize.replace('px', '') + setFontSize(val || '16') + } catch { + setFontSize('16') + } + } + } + updateFontSize() + editor.on('selectionUpdate', updateFontSize) + editor.on('transaction', updateFontSize) + return () => { + editor.off('selectionUpdate', updateFontSize) + editor.off('transaction', updateFontSize) + } + }, [editor]) + + const [, forceRender] = useReducer((x: number) => x + 1, 0) + const executeCommand = useCallback( + (command: () => void) => { + if (!editor || disabled) return + if (!editor.isFocused) editor.commands.focus() + command() + forceRender() + setTimeout(() => { + if (editor && !editor.isFocused) editor.commands.focus() + forceRender() + }, 0) + }, + [editor, disabled] + ) + + useEffect(() => { + if (!editor) return + const updateImageWidth = () => { + if (editor.isActive('image')) { + const width = editor.getAttributes('image').width as string | undefined + if (typeof width === 'string') { + const pct = width.endsWith('%') ? width.replace('%', '') : width.replace('px', '') + setImageWidthPct(pct) + } else { + setImageWidthPct('100') + } + } + } + updateImageWidth() + editor.on('selectionUpdate', updateImageWidth) + editor.on('transaction', updateImageWidth) + return () => { + editor.off('selectionUpdate', updateImageWidth) + editor.off('transaction', updateImageWidth) + } + }, [editor]) + + if (!editor) return null + + return ( + +
+
+ + + + + + + + + + + + + + {([1, 2, 3] as Array<1 | 2 | 3>).map(level => ( + executeCommand(() => editor.chain().focus().toggleHeading({ level }).run())} + className="flex items-center" + > + + 제목 {level} + + + ))} + executeCommand(() => editor.chain().focus().setParagraph().run())} className="flex items-center"> + 본문 + + + + +
+ { + const size = e.target.value + setFontSize(size) + if (size && parseInt(size) >= 8 && parseInt(size) <= 72) { + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()) + } + }} + style={{ width: `64px` }} + className="h-8 text-xs text-right" + disabled={disabled} + /> +
+ + +
+ + + + + + {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map(size => ( + { + setFontSize(size.toString()) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()) + }} + className="flex items-center" + > + {size}px + + ))} + + +
+ + + +
+ + + +
+ + + + + + + + + +
+ { + const file = e.target.files?.[0] + if (file) onSelectImageFile?.(file) + }} + /> + { + const el = document.getElementById(`image-upload-rt-${imageInputId}`) as HTMLInputElement | null + el?.click() + }} + disabled={disabled} + aria-label="이미지 삽입" + > + + +
+
+ +

이미지 삽입

+
+
+ + {editor.isActive('image') && ( +
+ + { + const val = e.target.value + setImageWidthPct(val) + const pct = Math.min(100, Math.max(5, parseInt(val || '0', 10))) + executeCommand(() => editor.chain().focus().updateAttributes('image', { width: `${pct}%` }).run()) + }} + className="h-8 w-16 text-xs" + disabled={disabled} + /> + % +
+ )} + + + + +
+
+
+ ) +} + + diff --git a/components/rich-text-editor/extensions/font-size.ts b/components/rich-text-editor/extensions/font-size.ts new file mode 100644 index 00000000..1b7e2700 --- /dev/null +++ b/components/rich-text-editor/extensions/font-size.ts @@ -0,0 +1,31 @@ +import { Extension } from '@tiptap/core' + +export const FontSize = Extension.create({ + name: 'fontSize', + addGlobalAttributes() { + return [ + { + types: ['textStyle'], + attributes: { + fontSize: { + default: null, + parseHTML: element => { + const sizeWithUnit = (element as HTMLElement).style.fontSize + return sizeWithUnit || null + }, + renderHTML: attributes => { + if (!attributes.fontSize) return {} + const value = String(attributes.fontSize) + const withUnit = /(px|em|rem|%)$/i.test(value) ? value : `${value}px` + return { + style: `font-size: ${withUnit}`, + } + }, + }, + }, + }, + ] + }, +}) + + -- cgit v1.2.3