diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/common/organization/organization-manager-selector.tsx | 338 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalLineSelector.tsx | 444 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalSubmit.tsx | 2230 | ||||
| -rw-r--r-- | components/layout/command-menu.tsx | 26 | ||||
| -rw-r--r-- | components/rich-text-editor/BlockquoteButton.tsx | 38 | ||||
| -rw-r--r-- | components/rich-text-editor/BulletListButton.tsx | 46 | ||||
| -rw-r--r-- | components/rich-text-editor/HistoryMenu.tsx | 43 | ||||
| -rw-r--r-- | components/rich-text-editor/InlineStyleMenu.tsx | 67 | ||||
| -rw-r--r-- | components/rich-text-editor/OrderedListButton.tsx | 38 | ||||
| -rw-r--r-- | components/rich-text-editor/RichTextEditor.tsx | 1050 | ||||
| -rw-r--r-- | components/rich-text-editor/StyleMenu.tsx | 65 | ||||
| -rw-r--r-- | components/rich-text-editor/TextAlignMenu.tsx | 46 | ||||
| -rw-r--r-- | components/rich-text-editor/Toolbar.tsx | 350 | ||||
| -rw-r--r-- | components/rich-text-editor/extensions/font-size.ts | 31 |
14 files changed, 2876 insertions, 1936 deletions
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<OrganizationManagerItem[]>([]) + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) + const [currentPage, setCurrentPage] = React.useState(1) + const [pagination, setPagination] = React.useState<PaginationInfo>({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + const [searchError, setSearchError] = React.useState<string | null>(null) + + const inputRef = React.useRef<HTMLInputElement>(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 ( + <div className={cn("w-full", className)}> + {/* 선택된 관리자들 표시 */} + {selectedManagers.length > 0 && ( + <div className="mb-3 flex flex-wrap gap-2"> + {selectedManagers.map((manager) => ( + <Badge + key={manager.id} + variant="secondary" + className="flex items-center gap-1" + > + <Building2 className="w-3 h-3" /> + <span className="text-xs"> + {manager.departmentName} - {manager.managerName} + </span> + <Button + variant="ghost" + size="sm" + className="h-auto p-0 ml-1 hover:bg-transparent" + onClick={() => removeManager(manager.id)} + > + <X className="w-3 h-3" /> + </Button> + </Badge> + ))} + {selectedManagers.length > 1 && ( + <Button + variant="ghost" + size="sm" + onClick={clearAll} + className="h-6 px-2 text-xs" + > + 전체 해제 + </Button> + )} + </div> + )} + + {/* 검색 입력 */} + <div className="relative"> + <Input + ref={inputRef} + placeholder={selectedManagers.length === 0 ? placeholder : noValuePlaceHolder} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onFocus={() => setIsPopoverOpen(true)} + disabled={disabled} + className="w-full" + /> + + {/* 검색 결과 팝오버 */} + {isPopoverOpen && ( + <div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-80 overflow-hidden"> + {/* 검색 중 표시 */} + {isSearching && ( + <div className="p-4 space-y-2"> + {Array.from({ length: 3 }).map((_, i) => ( + <div key={i} className="flex items-center space-x-3"> + <Skeleton className="h-4 w-4" /> + <div className="space-y-2 flex-1"> + <Skeleton className="h-4 w-3/4" /> + <Skeleton className="h-3 w-1/2" /> + </div> + </div> + ))} + </div> + )} + + {/* 검색 결과 */} + {!isSearching && searchResults.length > 0 && ( + <div className="max-h-60 overflow-y-auto"> + {searchResults.map((manager) => { + const isSelected = selectedManagers.some(m => m.id === manager.id) + return ( + <div + key={manager.id} + className={cn( + "p-3 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0", + isSelected && "bg-blue-50" + )} + onClick={() => selectManager(manager)} + > + <div className="flex items-center justify-between"> + <div className="flex-1"> + <div className="font-medium text-sm"> + {manager.departmentName} + </div> + <div className="text-xs text-gray-500"> + {manager.managerName} ({manager.managerTitle}) + </div> + <div className="text-xs text-gray-400"> + {manager.companyName} + </div> + </div> + {isSelected && ( + <Badge variant="secondary" className="text-xs"> + 선택됨 + </Badge> + )} + </div> + </div> + ) + })} + </div> + )} + + {/* 검색 결과 없음 */} + {!isSearching && searchQuery && searchResults.length === 0 && !searchError && ( + <div className="p-4 text-center text-gray-500"> + 검색 결과가 없습니다. + </div> + )} + + {/* 오류 메시지 */} + {searchError && ( + <div className="p-4 text-center text-red-500"> + {searchError} + </div> + )} + + {/* 페이지네이션 */} + {!isSearching && searchResults.length > 0 && ( + <div className="flex items-center justify-between p-3 border-t border-gray-200"> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage - 1)} + disabled={!pagination.hasPrevPage} + > + <ChevronLeft className="w-4 h-4" /> + </Button> + <span className="text-sm text-gray-500"> + {currentPage} / {pagination.pageCount} + </span> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage + 1)} + disabled={!pagination.hasNextPage} + > + <ChevronRight className="w-4 h-4" /> + </Button> + </div> + )} + </div> + )} + </div> + + {/* 팝오버 외부 클릭 시 닫기 */} + {isPopoverOpen && ( + <div + className="fixed inset-0 z-40" + onClick={() => setIsPopoverOpen(false)} + /> + )} + </div> + ) +}
\ 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<string, string> = { + "0": "기안", + "1": "결재", + "2": "합의", + "3": "후결", + "4": "병렬합의", + "7": "병렬결재", + "9": "통보", + }; + return map[role] || role; +}; + +// 고유 ID 생성 +const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + +// Sortable Group Card (seq 단위) +interface SortableApprovalGroupProps { + group: ApprovalLineItem[]; + index: number; + onRemoveGroup: () => void; + canRemove: boolean; + selected: boolean; + onSelect: () => void; + onChangeRole: (role: ApprovalRole) => void; +} + +function SortableApprovalGroup({ + group, + index, + onRemoveGroup, + canRemove, + selected, + onSelect, + onChangeRole, +}: SortableApprovalGroupProps) { + const seq = group[0].seq; + const role = group[0].role; + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: group[0].id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } as React.CSSProperties; + + const isParallel = role === "7" || role === "4"; + + return ( + <div + ref={setNodeRef} + style={style} + className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`} + > + {/* Drag handle */} + <div + {...attributes} + {...listeners} + className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" + > + <GripVertical className="w-5 h-5" /> + </div> + + {/* Group select (skip for drafter) */} + {index !== 0 ? ( + <Checkbox checked={selected} onCheckedChange={onSelect} onClick={(e) => e.stopPropagation()} /> + ) : ( + <div className="w-4 h-4" /> + )} + + {/* seq index */} + <Badge variant="outline">{parseInt(seq) + 1}</Badge> + + {/* Group details */} + <div className="flex-1 grid grid-cols-3 gap-3"> + {/* Users in group */} + <div className="flex flex-col justify-center gap-1"> + {group.map((u) => ( + <div key={u.id} className="text-sm"> + {u.name || "Knox 이름 없음"} + {u.deptName ? ` / ${u.deptName}` : ""} + </div> + ))} + </div> + + {/* Role UI */} + <div className="flex items-center"> + {seq === "0" ? ( + <Badge variant="secondary" className="w-full justify-center">기안</Badge> + ) : isParallel ? ( + <Badge variant="secondary" className="w-full justify-center">{getRoleText(role)}</Badge> + ) : ( + <ToggleGroup + type="single" + value={role} + onValueChange={(val) => val && onChangeRole(val as ApprovalRole)} + > + <ToggleGroupItem value="1">결재</ToggleGroupItem> + <ToggleGroupItem value="2">합의</ToggleGroupItem> + <ToggleGroupItem value="9">통보</ToggleGroupItem> + </ToggleGroup> + )} + </div> + + {/* Delete */} + <div className="flex items-center justify-end"> + {canRemove && ( + <Button type="button" variant="ghost" size="sm" onClick={onRemoveGroup}> + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </div> + </div> + ); +} + +export function ApprovalLineSelector({ + value, + onChange, + placeholder = "결재자를 검색하세요...", + maxSelections = 10, + domainFilter, + className, +}: ApprovalLineSelectorProps) { + const aplns = value; + const [selectedSeqs, setSelectedSeqs] = React.useState<string[]>([]); + + const toggleSelectGroup = (seq: string) => { + setSelectedSeqs((prev) => (prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq])); + }; + const clearSelection = () => setSelectedSeqs([]); + + // drag sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Utilities + const reorderBySeq = (list: ApprovalLineItem[]): ApprovalLineItem[] => { + const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); + const seqMap = new Map<string, string>(); + let nextSeq = 0; + return sorted.map((apln) => { + if (!seqMap.has(apln.seq)) { + seqMap.set(apln.seq, nextSeq.toString()); + nextSeq += 1; + } + return { ...apln, seq: seqMap.get(apln.seq)! }; + }); + }; + + const addApprovalUsers = (users: UserSelectItem[]) => { + const newAplns = [...aplns]; + users.forEach((user) => { + const exists = newAplns.findIndex((apln) => apln.userId === user.id.toString()); + if (exists === -1) { + // 현재 존재하는 seq 중 최댓값 + 1을 다음 seq로 사용 (0은 상신자) + const uniqueSeqs = Array.from(new Set(newAplns.map((a) => parseInt(a.seq)))); + const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : -1; + const newSeq = (Math.max(1, maxSeq + 1)).toString(); + newAplns.push({ + id: generateUniqueId(), + epId: (user as any).epId, + userId: user.id.toString(), + emailAddress: user.email, + name: user.name, + deptName: (user as any).deptName ?? undefined, + role: "1", + seq: newSeq, + opinion: "", + }); + } + }); + onChange(newAplns); + }; + + const removeApprovalGroup = (seq: string) => { + if (seq === "0") return; // 상신자 삭제 불가 + const remaining = aplns.filter((a) => a.seq !== seq); + onChange(reorderBySeq(remaining)); + }; + + const applyParallel = () => { + if (selectedSeqs.length < 2) { + toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다."); + return; + } + const current = aplns; + const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq)); + const roles = Array.from(new Set(selectedAplns.map((a) => a.role))); + if (roles.length !== 1) { + toast.error("선택된 항목의 역할이 동일해야 합니다."); + return; + } + const role = roles[0]; + let newRole: ApprovalRole; + if (role === "1") newRole = "7"; + else if (role === "2") newRole = "4"; + else if (role === "9") newRole = "9"; + else { + toast.error("결재, 합의 또는 통보만 병렬 지정 가능합니다."); + return; + } + const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq))); + const updated = current.map((a) => + selectedSeqs.includes(a.seq) ? { ...a, role: newRole, seq: minSeq.toString() } : a, + ); + onChange(reorderBySeq(updated)); + clearSelection(); + }; + + const applyAfter = () => { + if (selectedSeqs.length !== 1) { + toast.error("후결은 한 명만 지정할 수 있습니다."); + return; + } + const targetSeq = selectedSeqs[0]; + const targetRole = aplns.find((a) => a.seq === targetSeq)?.role; + if (targetRole === "7" || targetRole === "4") { + toast.error("병렬 그룹은 후결로 전환할 수 없습니다."); + return; + } + const updated = aplns.map((a) => + a.seq === targetSeq ? { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole } : a, + ); + onChange(reorderBySeq(updated)); + clearSelection(); + }; + + const ungroupParallel = () => { + if (selectedSeqs.length === 0) { + toast.error("해제할 결재선을 선택하세요."); + return; + } + let newSeqCounter = 1; // 0은 상신자 유지 + const updated = aplns.map((a) => { + if (selectedSeqs.includes(a.seq)) { + let newRole: ApprovalRole = a.role; + if (a.role === "7") newRole = "1"; + if (a.role === "4") newRole = "2"; + return { ...a, role: newRole, seq: "" }; + } + return { ...a }; + }); + const reassigned = updated + .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0")) + .map((a) => { + if (a.seq === "0") return a; + const next = { ...a, seq: newSeqCounter.toString() }; + newSeqCounter += 1; + return next; + }); + onChange(reassigned); + clearSelection(); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeKey = active.id as string; + const overKey = over.id as string; + + const idToSeq = new Map<string, string>(); + aplns.forEach((a) => idToSeq.set(a.id, a.seq)); + const activeSeq = idToSeq.get(activeKey); + const overSeq = idToSeq.get(overKey); + if (!activeSeq || !overSeq) return; + if (activeSeq === "0" || overSeq === "0") return; // 상신자 이동 불가 + + const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); + const keyOrder = seqOrder.map((seq) => aplns.find((a) => a.seq === seq)!.id); + const oldIndex = keyOrder.indexOf(activeKey); + const newIndex = keyOrder.indexOf(overKey); + const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); + + const keyToNewSeq = new Map<string, string>(); + newKeyOrder.forEach((k, idx) => keyToNewSeq.set(k, idx.toString())); + + const updatedAplns: ApprovalLineItem[] = []; + newKeyOrder.forEach((k) => { + const oldSeq = idToSeq.get(k)!; + const groupItems = aplns.filter((a) => a.seq === oldSeq); + groupItems.forEach((item) => { + updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! }); + }); + }); + onChange(updatedAplns); + }; + + // Render groups by seq + const groups = React.useMemo(() => { + const grouped = Object.values( + aplns.reduce<Record<string, ApprovalLineItem[]>>((acc, apln) => { + acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln]; + return acc; + }, {}), + ).sort((a, b) => parseInt(a[0].seq) - parseInt(b[0].seq)); + return grouped; + }, [aplns]); + + return ( + <div className={className}> + <h3 className="text-lg font-semibold">결재 경로</h3> + + {/* Controls */} + <div className="flex justify-end gap-2 mb-2"> + <Button type="button" variant="outline" size="sm" onClick={applyParallel}> + 병렬 + </Button> + <Button type="button" variant="outline" size="sm" onClick={applyAfter}> + 후결 + </Button> + <Button type="button" variant="outline" size="sm" onClick={ungroupParallel}> + 해제 + </Button> + </div> + + {/* User add */} + <div className="p-4 border border-dashed border-gray-300 rounded-lg"> + <div className="mb-2"> + <label className="text-sm font-medium text-gray-700">결재자 추가</label> + <p className="text-xs text-gray-500">사용자를 검색하여 결재 라인에 추가하세요</p> + </div> + <UserSelector + selectedUsers={[]} + onUsersChange={addApprovalUsers} + placeholder={placeholder} + domainFilter={domainFilter} + maxSelections={maxSelections} + /> + </div> + + {/* Groups */} + <div className="mt-4"> + {aplns.length > 0 ? ( + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + modifiers={[restrictToVerticalAxis, restrictToParentElement]} + onDragEnd={handleDragEnd} + > + <SortableContext items={groups.map((g) => g[0].id)} strategy={verticalListSortingStrategy}> + <div className="space-y-3"> + {groups.map((group, idx) => ( + <SortableApprovalGroup + key={group[0].id} + group={group} + index={idx} + onRemoveGroup={() => removeApprovalGroup(group[0].seq)} + canRemove={idx !== 0 && aplns.length > 1} + selected={selectedSeqs.includes(group[0].seq)} + onSelect={() => toggleSelectGroup(group[0].seq)} + onChangeRole={(role) => { + // 단일 그룹일 때만 역할 변경 허용 + if (group.length > 1) return; + const gid = group[0].id; + const updated = aplns.map((a) => (a.id === gid ? { ...a, role } : a)); + onChange(updated); + }} + /> + ))} + </div> + </SortableContext> + </DndContext> + ) : ( + <div className="text-center py-8 text-gray-500"> + <p>결재자를 추가해주세요</p> + </div> + )} + </div> + + <Separator className="my-4" /> + </div> + ); +} + +export default ApprovalLineSelector; + + diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx index 8b58aba6..bfe66981 100644 --- a/components/knox/approval/ApprovalSubmit.tsx +++ b/components/knox/approval/ApprovalSubmit.tsx @@ -1,1092 +1,1294 @@ -'use client' - -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; - -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { Checkbox } from '@/components/ui/checkbox'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import { toast } from 'sonner'; -import { Loader2, Trash2, FileText, AlertCircle, GripVertical } from 'lucide-react'; -import { debugLog, debugError } from '@/lib/debug-utils' +"use client"; + +import { useState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { toast } from "sonner"; +import { + Loader2, + Trash2, + FileText, + AlertCircle, + GripVertical, +} from "lucide-react"; +import { debugLog, debugError } from "@/lib/debug-utils"; // dnd-kit imports for drag and drop import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; // 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier import { - restrictToVerticalAxis, - restrictToParentElement, -} from '@dnd-kit/modifiers'; + restrictToVerticalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - useSortable, -} from '@dnd-kit/sortable'; -import { - CSS, -} from '@dnd-kit/utilities'; + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; // API 함수 및 타입 -import { submitApproval, submitSecurityApproval, createSubmitApprovalRequest, createApprovalLine } from '@/lib/knox-api/approval/approval'; -import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval'; +import { + submitApproval, + submitSecurityApproval, + createSubmitApprovalRequest, + createApprovalLine, +} from "@/lib/knox-api/approval/approval"; +import type { + ApprovalLine, + SubmitApprovalRequest, +} from "@/lib/knox-api/approval/approval"; // 역할 텍스트 매핑 (기존 mock util 대체) const getRoleText = (role: string) => { - const map: Record<string, string> = { - '0': '기안', - '1': '결재', - '2': '합의', - '3': '후결', - '4': '병렬합의', - '7': '병렬결재', - '9': '통보', - }; - return map[role] || role; + const map: Record<string, string> = { + "0": "기안", + "1": "결재", + "2": "합의", + "3": "후결", + "4": "병렬합의", + "7": "병렬결재", + "9": "통보", + }; + return map[role] || role; }; // TiptapEditor 컴포넌트 -import RichTextEditor from '@/components/rich-text-editor/RichTextEditor'; +import RichTextEditor from "@/components/rich-text-editor/RichTextEditor"; // UserSelector 컴포넌트 -import { UserSelector, type UserSelectItem } from '@/components/common/user/user-selector'; -import { useSession } from 'next-auth/react'; +import { + UserSelector, + type UserSelectItem, +} from "@/components/common/user/user-selector"; +import { useSession } from "next-auth/react"; // UserSelector에서 반환되는 사용자에 epId가 포함될 수 있으므로 확장 타입 정의 interface ExtendedUserSelectItem extends UserSelectItem { - epId?: string; + epId?: string; } // 역할 코드 타입 정의 -type ApprovalRole = '0' | '1' | '2' | '3' | '4' | '7' | '9'; +type ApprovalRole = "0" | "1" | "2" | "3" | "4" | "7" | "9"; // 결재 라인 아이템 타입 정의 (고유 ID 포함) interface ApprovalLineItem { - id: string; // 내부 고유 식별자 - epId?: string; // Knox 고유 ID (전사 고유) - userId?: string; // DB User PK - emailAddress?: string; - name?: string; // 사용자 이름 - deptName?: string; // 부서명 - role: ApprovalRole; - seq: string; - opinion?: string; + id: string; // 내부 고유 식별자 + epId?: string; // Knox 고유 ID (전사 고유) + userId?: string; // DB User PK + emailAddress?: string; + name?: string; // 사용자 이름 + deptName?: string; // 부서명 + role: ApprovalRole; + seq: string; + opinion?: string; } const formSchema = z.object({ - subject: z.string().min(1, '제목은 필수입니다'), - contents: z.string().min(1, '내용은 필수입니다'), - contentsType: z.literal('HTML'), - docSecuType: z.enum(['PERSONAL', 'CONFIDENTIAL', 'CONFIDENTIAL_STRICT']), - urgYn: z.boolean(), - importantYn: z.boolean(), - notifyOption: z.enum(['0', '1', '2', '3']), - docMngSaveCode: z.enum(['0', '1']), - sbmLang: z.enum(['ko', 'ja', 'zh', 'en']), - timeZone: z.string().default('GMT+9'), - aplns: z.array(z.object({ - id: z.string(), // 고유 식별자 - epId: z.string().optional(), - userId: z.string().optional(), - emailAddress: z.string().email('유효한 이메일 주소를 입력해주세요').optional(), - name: z.string().optional(), - deptName: z.string().optional(), - role: z.enum(['0', '1', '2', '3', '4', '7', '9']), - seq: z.string(), - opinion: z.string().optional() - })).min(1, '최소 1개의 결재 경로가 필요합니다'), - // 첨부파일 (선택) - attachments: z.any().optional() + subject: z.string().min(1, "제목은 필수입니다"), + contents: z.string().min(1, "내용은 필수입니다"), + contentsType: z.literal("HTML"), + docSecuType: z.enum(["PERSONAL", "CONFIDENTIAL", "CONFIDENTIAL_STRICT"]), + urgYn: z.boolean(), + importantYn: z.boolean(), + notifyOption: z.enum(["0", "1", "2", "3"]), + docMngSaveCode: z.enum(["0", "1"]), + sbmLang: z.enum(["ko", "ja", "zh", "en"]), + timeZone: z.string().default("GMT+9"), + aplns: z + .array( + z.object({ + id: z.string(), // 고유 식별자 + epId: z.string().optional(), + userId: z.string().optional(), + emailAddress: z + .string() + .email("유효한 이메일 주소를 입력해주세요") + .optional(), + name: z.string().optional(), + deptName: z.string().optional(), + role: z.enum(["0", "1", "2", "3", "4", "7", "9"]), + seq: z.string(), + opinion: z.string().optional(), + }), + ) + .min(1, "최소 1개의 결재 경로가 필요합니다"), + // 첨부파일 (선택) + attachments: z.any().optional(), }); type FormData = z.infer<typeof formSchema>; interface ApprovalSubmitProps { - onSubmitSuccess?: (apInfId: string) => void; + onSubmitSuccess?: (apInfId: string) => void; } // Sortable한 결재 라인 컴포넌트 interface SortableApprovalLineProps { - apln: ApprovalLineItem; - index: number; - form: ReturnType<typeof useForm<FormData>>; - onRemove: () => void; - canRemove: boolean; - selected: boolean; - onSelect: () => void; + apln: ApprovalLineItem; + index: number; + form: ReturnType<typeof useForm<FormData>>; + onRemove: () => void; + canRemove: boolean; + selected: boolean; + onSelect: () => void; } // eslint-disable-next-line @typescript-eslint/no-unused-vars -function SortableApprovalLine({ apln, index, form, onRemove, canRemove, selected, onSelect }: SortableApprovalLineProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: apln.id }); // 고유 ID 사용 - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, // 드래그 중일 때 투명도 조절 - }; - - - return ( - <div - ref={setNodeRef} - style={style} - className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? 'ring-2 ring-primary' : ''}`} - > - {/* 드래그 핸들 */} - <div - {...attributes} - {...listeners} - className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" - > - <GripVertical className="w-5 h-5" /> - </div> - - {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */} - {index !== 0 ? ( - <Checkbox - checked={selected} - onCheckedChange={() => onSelect()} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로) - )} - - {/* 실제 seq 기준 표시 */} - <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge> - - <div className="flex-1 grid grid-cols-4 gap-3"> - {/* 사용자 정보 표시 */} - <div className="flex items-center space-x-2"> - <div> - <div className="font-medium text-sm"> - {(apln.name || 'Knox 이름 없음')}{apln.deptName ? ` / ${apln.deptName}` : ''} +function SortableApprovalLine({ + apln, + index, + form, + onRemove, + canRemove, + selected, + onSelect, +}: SortableApprovalLineProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: apln.id }); // 고유 ID 사용 + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, // 드래그 중일 때 투명도 조절 + }; + + return ( + <div + ref={setNodeRef} + style={style} + className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`} + > + {/* 드래그 핸들 */} + <div + {...attributes} + {...listeners} + className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" + > + <GripVertical className="w-5 h-5" /> </div> - </div> - <FormField - control={form.control} - name={`aplns.${index}.id`} - render={() => ( - <FormItem className="hidden"> - <FormControl> - <Input type="hidden" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name={`aplns.${index}.epId`} - render={() => ( - <FormItem className="hidden"> - <FormControl> - <Input type="hidden" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name={`aplns.${index}.userId`} - render={() => ( - <FormItem className="hidden"> - <FormControl> - <Input type="hidden" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name={`aplns.${index}.emailAddress`} - render={() => ( - <FormItem className="hidden"> - <FormControl> - <Input type="hidden" /> - </FormControl> - <FormMessage /> - </FormItem> + + {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */} + {index !== 0 ? ( + <Checkbox + checked={selected} + onCheckedChange={() => onSelect()} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로) )} - /> - </div> - {/* 역할 선택 */} - {index === 0 ? ( - // 상신자는 역할 선택 대신 고정 표시 - <div className="flex items-center"> - <Badge variant="secondary">기안</Badge> - </div> - ) : ( - <FormField - control={form.control} - name={`aplns.${index}.role`} - render={({ field }) => { - // 병렬 여부 판단 - const isParallel = field.value === '4' || field.value === '7'; - - // 병렬, 후결 값을 제외한 기본 역할 - const baseRole: ApprovalRole = field.value === '7' ? '1' : field.value === '4' ? '2' : field.value === '3' ? '1' : field.value as ApprovalRole; - - // 기본 역할 변경 핸들러 - const handleBaseRoleChange = (val: string) => { - if (!val) return; - let newRole = val; - if (isParallel) { - if (val === '1') newRole = '7'; - else if (val === '2') newRole = '4'; - } - field.onChange(newRole); - }; - - // 병렬인 경우 한 개 버튼으로 표시 - if (isParallel) { - return ( - <FormItem className="w-full"> - <Badge className="w-full justify-center" variant="secondary"> - {getRoleText(field.value)} - </Badge> - </FormItem> - ); - } - - return ( - <FormItem> - <div className="flex flex-col gap-2"> - <ToggleGroup - type="single" - value={baseRole} - onValueChange={handleBaseRoleChange} - > - <ToggleGroupItem value="1">결재</ToggleGroupItem> - <ToggleGroupItem value="2">합의</ToggleGroupItem> - <ToggleGroupItem value="9">통보</ToggleGroupItem> - </ToggleGroup> - </div> - <FormMessage /> - </FormItem> - ); - }} - /> - )} - - {/* 의견 입력란 제거됨 */} - - {/* 역할 표시 */} - <div className="flex items-center justify-between"> - <Badge variant="secondary"> - {getRoleText(apln.role)} - </Badge> - - {canRemove && ( - <Button - type="button" - variant="ghost" - size="sm" - onClick={onRemove} - > - <Trash2 className="w-4 h-4" /> - </Button> - )} + {/* 실제 seq 기준 표시 */} + <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge> + + <div className="flex-1 grid grid-cols-4 gap-3"> + {/* 사용자 정보 표시 */} + <div className="flex items-center space-x-2"> + <div> + <div className="font-medium text-sm"> + {apln.name || "Knox 이름 없음"} + {apln.deptName ? ` / ${apln.deptName}` : ""} + </div> + </div> + <FormField + control={form.control} + name={`aplns.${index}.id`} + render={() => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`aplns.${index}.epId`} + render={() => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`aplns.${index}.userId`} + render={() => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`aplns.${index}.emailAddress`} + render={() => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 역할 선택 */} + {index === 0 ? ( + // 상신자는 역할 선택 대신 고정 표시 + <div className="flex items-center"> + <Badge variant="secondary">기안</Badge> + </div> + ) : ( + <FormField + control={form.control} + name={`aplns.${index}.role`} + render={({ field }) => { + // 병렬 여부 판단 + const isParallel = + field.value === "4" || field.value === "7"; + + // 병렬, 후결 값을 제외한 기본 역할 + const baseRole: ApprovalRole = + field.value === "7" + ? "1" + : field.value === "4" + ? "2" + : field.value === "3" + ? "1" + : (field.value as ApprovalRole); + + // 기본 역할 변경 핸들러 + const handleBaseRoleChange = (val: string) => { + if (!val) return; + let newRole = val; + if (isParallel) { + if (val === "1") newRole = "7"; + else if (val === "2") newRole = "4"; + } + field.onChange(newRole); + }; + + // 병렬인 경우 한 개 버튼으로 표시 + if (isParallel) { + return ( + <FormItem className="w-full"> + <Badge + className="w-full justify-center" + variant="secondary" + > + {getRoleText(field.value)} + </Badge> + </FormItem> + ); + } + + return ( + <FormItem> + <div className="flex flex-col gap-2"> + <ToggleGroup + type="single" + value={baseRole} + onValueChange={handleBaseRoleChange} + > + <ToggleGroupItem value="1"> + 결재 + </ToggleGroupItem> + <ToggleGroupItem value="2"> + 합의 + </ToggleGroupItem> + <ToggleGroupItem value="9"> + 통보 + </ToggleGroupItem> + </ToggleGroup> + </div> + <FormMessage /> + </FormItem> + ); + }} + /> + )} + + {/* 의견 입력란 제거됨 */} + + {/* 역할 표시 */} + <div className="flex items-center justify-between"> + <Badge variant="secondary">{getRoleText(apln.role)}</Badge> + + {canRemove && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={onRemove} + > + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </div> </div> - </div> - </div> - ); + ); } // Sortable Approval Group (seq 단위 카드) interface SortableApprovalGroupProps { - group: ApprovalLineItem[]; // 동일 seq 항목들 - index: number; - form: ReturnType<typeof useForm<FormData>>; - onRemoveGroup: () => void; - canRemove: boolean; - selected: boolean; - onSelect: () => void; + group: ApprovalLineItem[]; // 동일 seq 항목들 + index: number; + form: ReturnType<typeof useForm<FormData>>; + onRemoveGroup: () => void; + canRemove: boolean; + selected: boolean; + onSelect: () => void; } -function SortableApprovalGroup({ group, index, form, onRemoveGroup, canRemove, selected, onSelect }: SortableApprovalGroupProps) { - const seq = group[0].seq; - const role = group[0].role; - // 그룹을 식별할 안정적인 고유 키(첫 구성원의 id 활용) - const groupKey = group[0].id; - - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: groupKey }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( - <div - ref={setNodeRef} - style={style} - className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? 'ring-2 ring-primary' : ''}`} - > - {/* 드래그 핸들 */} - <div - {...attributes} - {...listeners} - className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" - > - <GripVertical className="w-5 h-5" /> - </div> - - {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */} - {index !== 0 ? ( - <Checkbox - checked={selected} - onCheckedChange={onSelect} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - <div className="w-4 h-4" /> // 기안자용 빈 공간 - )} - - {/* seq 표시 */} - <Badge variant="outline">{parseInt(seq) + 1}</Badge> - - {/* 그룹 상세 정보 */} - <div className="flex-1 grid grid-cols-3 gap-3"> - {/* 사용자 목록 */} - <div className="flex flex-col justify-center gap-1"> - {group.map((u) => ( - <div key={u.id} className="text-sm"> - {(u.name || 'Knox 이름 없음')}{u.deptName ? ` / ${u.deptName}` : ''} +function SortableApprovalGroup({ + group, + index, + form, + onRemoveGroup, + canRemove, + selected, + onSelect, +}: SortableApprovalGroupProps) { + const seq = group[0].seq; + const role = group[0].role; + // 그룹을 식별할 안정적인 고유 키(첫 구성원의 id 활용) + const groupKey = group[0].id; + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: groupKey }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( + <div + ref={setNodeRef} + style={style} + className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`} + > + {/* 드래그 핸들 */} + <div + {...attributes} + {...listeners} + className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" + > + <GripVertical className="w-5 h-5" /> </div> - ))} - </div> - {/* 역할 */} - <div className="flex items-center"> - {seq === '0' ? ( - <Badge variant="secondary" className="w-full justify-center"> - 기안 - </Badge> - ) : role === '7' || role === '4' ? ( - <Badge variant="secondary" className="w-full justify-center"> - {getRoleText(role)} - </Badge> - ) : ( - // 단일일 때는 기존 토글 재사용 (첫 항목 기준) - <FormField - control={form.control} - name={`aplns.${form.getValues('aplns').findIndex((a) => a.id === group[0].id)}.role`} - render={({ field }) => ( - <ToggleGroup - type="single" - value={field.value} - onValueChange={field.onChange} - > - <ToggleGroupItem value="1">결재</ToggleGroupItem> - <ToggleGroupItem value="2">합의</ToggleGroupItem> - <ToggleGroupItem value="9">통보</ToggleGroupItem> - </ToggleGroup> - )} - /> - )} - </div> - - {/* 삭제 버튼 */} - <div className="flex items-center justify-end"> - {canRemove && ( - <Button type="button" variant="ghost" size="sm" onClick={onRemoveGroup}> - <Trash2 className="w-4 h-4" /> - </Button> - )} - </div> - </div> - </div> - ); -} + {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */} + {index !== 0 ? ( + <Checkbox + checked={selected} + onCheckedChange={onSelect} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <div className="w-4 h-4" /> // 기안자용 빈 공간 + )} -export default function ApprovalSubmit({ onSubmitSuccess }: ApprovalSubmitProps) { - const { data: session } = useSession(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitResult, setSubmitResult] = useState<{ apInfId: string } | null>(null); + {/* seq 표시 */} + <Badge variant="outline">{parseInt(seq) + 1}</Badge> + + {/* 그룹 상세 정보 */} + <div className="flex-1 grid grid-cols-3 gap-3"> + {/* 사용자 목록 */} + <div className="flex flex-col justify-center gap-1"> + {group.map((u) => ( + <div key={u.id} className="text-sm"> + {u.name || "Knox 이름 없음"} + {u.deptName ? ` / ${u.deptName}` : ""} + </div> + ))} + </div> - const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]); + {/* 역할 */} + <div className="flex items-center"> + {seq === "0" ? ( + <Badge + variant="secondary" + className="w-full justify-center" + > + 기안 + </Badge> + ) : role === "7" || role === "4" ? ( + <Badge + variant="secondary" + className="w-full justify-center" + > + {getRoleText(role)} + </Badge> + ) : ( + // 단일일 때는 기존 토글 재사용 (첫 항목 기준) + <FormField + control={form.control} + name={`aplns.${form.getValues("aplns").findIndex((a) => a.id === group[0].id)}.role`} + render={({ field }) => ( + <ToggleGroup + type="single" + value={field.value} + onValueChange={field.onChange} + > + <ToggleGroupItem value="1"> + 결재 + </ToggleGroupItem> + <ToggleGroupItem value="2"> + 합의 + </ToggleGroupItem> + <ToggleGroupItem value="9"> + 통보 + </ToggleGroupItem> + </ToggleGroup> + )} + /> + )} + </div> - // 그룹 단위 선택/해제 - const toggleSelectGroup = (seq: string) => { - setSelectedSeqs((prev) => - prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq] + {/* 삭제 버튼 */} + <div className="flex items-center justify-end"> + {canRemove && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={onRemoveGroup} + > + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </div> + </div> ); - }; - const clearSelection = () => setSelectedSeqs([]); - - const form = useForm<FormData>({ - resolver: zodResolver(formSchema), - defaultValues: { - subject: '', - contents: '', - contentsType: 'HTML', - docSecuType: 'PERSONAL', - urgYn: false, - importantYn: false, - notifyOption: '0', - docMngSaveCode: '0', - sbmLang: 'ko', - timeZone: 'GMT+9', - aplns: [], - attachments: undefined - } - }); - - const aplns = form.watch('aplns'); - - // 병렬 전환 핸들러 - const applyParallel = () => { - if (selectedSeqs.length < 2) { - toast.error('두 명 이상 선택해야 병렬 지정이 가능합니다.'); - return; - } - - const current = form.getValues('aplns'); - const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq)); - - const roles = Array.from(new Set(selectedAplns.map((a) => a.role))); - if (roles.length !== 1) { - toast.error('선택된 항목의 역할이 동일해야 합니다.'); - return; - } - - const role = roles[0]; - let newRole: ApprovalRole; - if (role === '1') { - newRole = '7'; // 병렬 결재 - } else if (role === '2') { - newRole = '4'; // 병렬 합의 - } else if (role === '9') { - newRole = '9'; // 병렬 통보(역할 코드 유지) - } else { - toast.error('결재, 합의 또는 통보만 병렬 지정 가능합니다.'); - return; - } - - const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq))); - - const updated = current.map((a) => { - if (selectedSeqs.includes(a.seq)) { - return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() }; - } - return a; - }); - - form.setValue('aplns', reorderBySeq(updated), { shouldDirty: true }); - clearSelection(); - }; - - // 후결 전환 핸들러 - const applyAfter = () => { - if (selectedSeqs.length !== 1) { - toast.error('후결은 한 명만 지정할 수 있습니다.'); - return; - } - - const targetSeq = selectedSeqs[0]; - - // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가 - const targetRole = form.getValues('aplns').find((a) => a.seq === targetSeq)?.role; - if (targetRole === '7' || targetRole === '4') { - toast.error('병렬 그룹은 후결로 전환할 수 없습니다.'); - return; - } - - const updated = form.getValues('aplns').map((a) => { - if (a.seq === targetSeq) { - return { ...a, role: (a.role === '3' ? '1' : '3') as ApprovalRole }; - } - return a; - }); - - form.setValue('aplns', reorderBySeq(updated), { shouldDirty: true }); - clearSelection(); - }; - - // 병렬 해제 핸들러 - const ungroupParallel = () => { - if (selectedSeqs.length === 0) { - toast.error('해제할 결재선을 선택하세요.'); - return; - } - - let newSeqCounter = 1; // 0은 상신자 유지 - const updated = form.getValues('aplns').map((a) => { - if (selectedSeqs.includes(a.seq)) { - let newRole: ApprovalRole = a.role; - if (a.role === '7') newRole = '1'; - if (a.role === '4') newRole = '2'; - - return { ...a, role: newRole, seq: '' }; // seq 임시 비움 - } - return { ...a }; - }); +} - // seq 재할당 (상신자 제외하고 순차) - const reassigned = updated - .sort((x, y) => parseInt(x.seq || '0') - parseInt(y.seq || '0')) - .map((a) => { - if (a.seq === '0') return a; // 상신자 - const newItem = { ...a, seq: newSeqCounter.toString() }; - newSeqCounter += 1; - return newItem; - }); - - form.setValue('aplns', reassigned as FormData['aplns'], { shouldDirty: true }); - clearSelection(); - }; - - // seq 기준 정렬 및 재번호 부여 (병렬 그룹은 동일 seq 유지) - const reorderBySeq = (list: FormData['aplns']): FormData['aplns'] => { - const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); - - // 기존 seq -> 새 seq 매핑. 병렬 그룹(동일 seq)은 동일한 새 seq 를 갖도록 처리 - const seqMap = new Map<string, string>(); - let nextSeq = 0; - - return sorted.map((apln) => { - if (!seqMap.has(apln.seq)) { - seqMap.set(apln.seq, nextSeq.toString()); - nextSeq += 1; - } - return { ...apln, seq: seqMap.get(apln.seq)! }; +export default function ApprovalSubmit({ + onSubmitSuccess, +}: ApprovalSubmitProps) { + const { data: session } = useSession(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitResult, setSubmitResult] = useState<{ + apInfId: string; + } | null>(null); + + const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]); + + // 그룹 단위 선택/해제 + const toggleSelectGroup = (seq: string) => { + setSelectedSeqs((prev) => + prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq], + ); + }; + const clearSelection = () => setSelectedSeqs([]); + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + subject: "", + contents: "", + contentsType: "HTML", + docSecuType: "PERSONAL", + urgYn: false, + importantYn: false, + notifyOption: "0", + docMngSaveCode: "0", + sbmLang: "ko", + timeZone: "GMT+9", + aplns: [], + attachments: undefined, + }, }); - }; - - // 로그인 사용자를 첫 번째 결재자로 보장하는 effect - useEffect(() => { - if (!session?.user) return; - - const currentEmail = session.user.email ?? ''; - const currentEpId = (session.user as { epId?: string }).epId; - const currentUserId = session.user.id ?? undefined; - let currentAplns = form.getValues('aplns'); + const aplns = form.watch("aplns"); - // 이미 포함되어 있는지 확인 (epId 또는 email 기준) - const selfIndex = currentAplns.findIndex( - (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail - ); - - if (selfIndex === -1) { - // 맨 앞에 상신자 추가 - const newSelf: FormData['aplns'][number] = { - id: generateUniqueId(), - epId: currentEpId, - userId: currentUserId ? currentUserId.toString() : undefined, - emailAddress: currentEmail, - name: session.user.name ?? undefined, - role: '0', // 기안 - seq: '0', - opinion: '' - }; - - currentAplns = [newSelf, ...currentAplns]; - } - - // seq 재정렬 보장 - currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() })); - - form.setValue('aplns', currentAplns, { shouldValidate: false, shouldDirty: true }); - }, [session, form]); - - // dnd-kit sensors - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - // 고유 ID 생성 함수 - const generateUniqueId = () => { - return `apln-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - }; - - // 결재자 추가 (UserSelector를 통해) - const addApprovalUsers = (users: UserSelectItem[]) => { - const newAplns = [...aplns]; - - users.forEach((user) => { - // 이미 추가된 사용자인지 확인 - const existingIndex = newAplns.findIndex(apln => apln.userId === user.id.toString()); - if (existingIndex === -1) { - // 새 사용자 추가 - const newSeq = (newAplns.length).toString(); // 0은 상신자 - const newApln: FormData['aplns'][number] = { - id: generateUniqueId(), // 고유 ID 생성 - epId: (user as ExtendedUserSelectItem).epId, // epId가 전달되면 사용 - userId: user.id.toString(), - emailAddress: user.email, - name: user.name, - deptName: (user as ExtendedUserSelectItem).deptName ?? undefined, - role: '1', // 기본값: 결재 - seq: newSeq, - opinion: '' - }; - newAplns.push(newApln); - } - }); - - form.setValue('aplns', newAplns); - }; - - // 그룹 삭제 (seq 기반) - const removeApprovalGroup = (seq: string) => { - if (seq === '0') return; // 상신자 삭제 불가 - - const remaining = aplns.filter((a) => a.seq !== seq); - - // seq 재정렬 (병렬 그룹 유지) - const reordered = reorderBySeq(remaining); - form.setValue('aplns', reordered); - }; - - // 기존 단일 삭제 로직은 더 이상 사용하지 않음 (호환 위해 남겨둠) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const removeApprovalLine = (index: number) => { - // 첫 번째(상신자)는 삭제 불가 - if (index === 0) return; - - if (aplns.length > 1) { - const newAplns = aplns.filter((_: FormData['aplns'][number], i: number) => i !== index); - // 순서 재정렬 (ID는 유지) - const reorderedAplns = newAplns.map((apln: FormData['aplns'][number], i: number) => ({ - ...apln, - seq: (i).toString() - })); - form.setValue('aplns', reorderedAplns); - } - }; - - // 드래그앤드롭 핸들러 (그룹 이동 지원) - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (!over || active.id === over.id) return; - - // 현재 id는 그룹의 고유 key(첫 라인 id) - const activeKey = active.id as string; - const overKey = over.id as string; - - // key → seq 매핑 생성 - const idToSeq = new Map<string, string>(); - aplns.forEach((a) => { - // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑 - idToSeq.set(a.id, a.seq); - }); + // 병렬 전환 핸들러 + const applyParallel = () => { + if (selectedSeqs.length < 2) { + toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다."); + return; + } - const activeSeq = idToSeq.get(activeKey); - const overSeq = idToSeq.get(overKey); + const current = form.getValues("aplns"); + const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq)); - if (!activeSeq || !overSeq) return; + const roles = Array.from(new Set(selectedAplns.map((a) => a.role))); + if (roles.length !== 1) { + toast.error("선택된 항목의 역할이 동일해야 합니다."); + return; + } - if (activeSeq === '0' || overSeq === '0') return; // 상신자는 이동 불가 + const role = roles[0]; + let newRole: ApprovalRole; + if (role === "1") { + newRole = "7"; // 병렬 결재 + } else if (role === "2") { + newRole = "4"; // 병렬 합의 + } else if (role === "9") { + newRole = "9"; // 병렬 통보(역할 코드 유지) + } else { + toast.error("결재, 합의 또는 통보만 병렬 지정 가능합니다."); + return; + } - // 현재 그룹 순서를 key 기반으로 계산 - const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); - const keyOrder = seqOrder.map((seq) => { - return aplns.find((a) => a.seq === seq)!.id; - }); + const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq))); - const oldIndex = keyOrder.indexOf(activeKey); - const newIndex = keyOrder.indexOf(overKey); + const updated = current.map((a) => { + if (selectedSeqs.includes(a.seq)) { + return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() }; + } + return a; + }); - const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); + form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true }); + clearSelection(); + }; - // key → 새 seq 매핑 - const keyToNewSeq = new Map<string, string>(); - newKeyOrder.forEach((k, idx) => { - keyToNewSeq.set(k, idx.toString()); - }); + // 후결 전환 핸들러 + const applyAfter = () => { + if (selectedSeqs.length !== 1) { + toast.error("후결은 한 명만 지정할 수 있습니다."); + return; + } - // aplns 재구성 + seq 재할당 - const updatedAplns: FormData['aplns'] = []; - newKeyOrder.forEach((k) => { - const oldSeq = idToSeq.get(k)!; - const groupItems = aplns.filter((a) => a.seq === oldSeq); - groupItems.forEach((item) => { - updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! }); - }); - }); + const targetSeq = selectedSeqs[0]; - form.setValue('aplns', updatedAplns, { shouldValidate: false, shouldDirty: true }); - }; - - const onSubmit = async (data: FormData) => { - setIsSubmitting(true); - setSubmitResult(null); - - try { - // 세션 정보 확인 - if (!session?.user) { - toast.error('로그인이 필요합니다.'); - return; - } - - const currentEmail = session.user.email ?? ''; - const currentEpId = (session.user as { epId?: string }).epId; - const currentUserId = session.user.id ?? ''; - - debugLog('Current User', session.user); - - if (!currentEpId) { - toast.error('사용자 정보가 올바르지 않습니다.'); - return; - } - - // 결재 경로 생성 (ID 제거하고 API 호출) - const approvalLines: ApprovalLine[] = await Promise.all( - data.aplns.map((apln) => - createApprovalLine( - { epId: apln.epId }, - apln.role, - apln.seq, - { opinion: apln.opinion } - ) - ) - ); - - debugLog('Approval Lines', approvalLines); - - // 상신 요청 생성 - const attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined; - - const submitRequest: SubmitApprovalRequest = await createSubmitApprovalRequest( - data.contents, - data.subject, - approvalLines, - { - contentsType: data.contentsType, - docSecuType: data.docSecuType, - urgYn: data.urgYn ? 'Y' : 'N', - importantYn: data.importantYn ? 'Y' : 'N', - notifyOption: data.notifyOption, - docMngSaveCode: data.docMngSaveCode, - sbmLang: data.sbmLang, - timeZone: data.timeZone, - attachments: attachmentsArray + // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가 + const targetRole = form.getValues("aplns").find((a) => a.seq === targetSeq)?.role; + if (targetRole === "7" || targetRole === "4") { + toast.error("병렬 그룹은 후결로 전환할 수 없습니다."); + return; } - ); - - // API 호출 (보안 등급에 따라 분기) - const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT'; - - debugLog('Submit Request', submitRequest); - - const response = isSecure - ? await submitSecurityApproval(submitRequest) - : await submitApproval(submitRequest, { - userId: currentUserId, - epId: currentEpId, - emailAddress: currentEmail - }); - - debugLog('Submit Response', response); - - if (response.result === 'SUCCESS') { - setSubmitResult({ apInfId: response.data.apInfId }); - toast.success('결재가 성공적으로 상신되었습니다.'); - onSubmitSuccess?.(response.data.apInfId); - form.reset(); - } else { - toast.error(`결재 상신에 실패했습니다: ${response.result}`); - } - } catch (error) { - debugError('결재 상신 오류', error); - toast.error('결재 상신 중 오류가 발생했습니다.'); - } finally { - setIsSubmitting(false); - } - }; - - return ( - <Card className="w-full max-w-5xl"> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <FileText className="w-5 h-5" /> - 결재 상신 - </CardTitle> - <CardDescription> - 새로운 결재를 상신합니다. - </CardDescription> - </CardHeader> - - <CardContent className="space-y-6"> - {submitResult && ( - <div className="p-4 bg-green-50 border border-green-200 rounded-lg"> - <div className="flex items-center gap-2 text-green-700"> - <AlertCircle className="w-4 h-4" /> - <span className="font-medium">상신 완료</span> - </div> - <p className="text-sm text-green-600 mt-1"> - 결재 ID: {submitResult.apInfId} - </p> - </div> - )} - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> - {/* 기본 정보 */} - <div className="space-y-4"> - - <Separator /> - - {/* 결재 경로 */} - <div className="space-y-4"> - <h3 className="text-lg font-semibold">결재 경로</h3> - - {/* 상단 제어 버튼 */} - <div className="flex justify-end gap-2 mb-2"> - <Button variant="outline" size="sm" onClick={applyParallel}>병렬</Button> - <Button variant="outline" size="sm" onClick={applyAfter}>후결</Button> - <Button variant="outline" size="sm" onClick={ungroupParallel}>해제</Button> - </div> - {/* 결재자 추가 섹션 */} - <div className="p-4 border border-dashed border-gray-300 rounded-lg"> - <div className="mb-2"> - <label className="text-sm font-medium text-gray-700">결재자 추가</label> - <p className="text-xs text-gray-500">사용자를 검색하여 결재 라인에 추가하세요</p> - </div> - <UserSelector - selectedUsers={[]} - onUsersChange={addApprovalUsers} - placeholder="결재자를 검색하세요..." - domainFilter={{ type: "exclude", domains: ["partners"] }} - maxSelections={10} // 최대 10명까지 추가 가능 - /> - </div> + const updated = form.getValues("aplns").map((a) => { + if (a.seq === targetSeq) { + return { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole }; + } + return a; + }); + + form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true }); + clearSelection(); + }; + + // 병렬 해제 핸들러 + const ungroupParallel = () => { + if (selectedSeqs.length === 0) { + toast.error("해제할 결재선을 선택하세요."); + return; + } - {/* 그룹 기반 렌더링 */} - {aplns.length > 0 && ( - (() => { - const groups = Object.values( - aplns.reduce<Record<string, ApprovalLineItem[]>>((acc, apln) => { - acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln]; - return acc; - }, {}) - ).sort((a, b) => parseInt(a[0].seq) - parseInt(b[0].seq)); - - return ( - <DndContext - sensors={sensors} - collisionDetection={closestCenter} - modifiers={[restrictToVerticalAxis, restrictToParentElement]} - onDragEnd={handleDragEnd} - > - <SortableContext items={groups.map(g => g[0].id)} strategy={verticalListSortingStrategy}> - <div className="space-y-3"> - {groups.map((group, idx) => ( - <SortableApprovalGroup - key={group[0].id} - group={group} - index={idx} - form={form} - onRemoveGroup={() => removeApprovalGroup(group[0].seq)} - canRemove={idx !== 0 && aplns.length > 1} - selected={selectedSeqs.includes(group[0].seq)} - onSelect={() => toggleSelectGroup(group[0].seq)} - /> - ))} - </div> - </SortableContext> - </DndContext> - ); - })() - )} + let newSeqCounter = 1; // 0은 상신자 유지 + const updated = form.getValues("aplns").map((a) => { + if (selectedSeqs.includes(a.seq)) { + let newRole: ApprovalRole = a.role; + if (a.role === "7") newRole = "1"; + if (a.role === "4") newRole = "2"; + + return { ...a, role: newRole, seq: "" }; // seq 임시 비움 + } + return { ...a }; + }); + + // seq 재할당 (상신자 제외하고 순차) + const reassigned = updated + .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0")) + .map((a) => { + if (a.seq === "0") return a; // 상신자 + const newItem = { ...a, seq: newSeqCounter.toString() }; + newSeqCounter += 1; + return newItem; + }); + + form.setValue("aplns", reassigned as FormData["aplns"], { shouldDirty: true }); + clearSelection(); + }; + + // seq 기준 정렬 및 재번호 부여 (병렬 그룹은 동일 seq 유지) + const reorderBySeq = (list: FormData["aplns"]): FormData["aplns"] => { + const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); + + // 기존 seq -> 새 seq 매핑. 병렬 그룹(동일 seq)은 동일한 새 seq 를 갖도록 처리 + const seqMap = new Map<string, string>(); + let nextSeq = 0; + + return sorted.map((apln) => { + if (!seqMap.has(apln.seq)) { + seqMap.set(apln.seq, nextSeq.toString()); + nextSeq += 1; + } + return { ...apln, seq: seqMap.get(apln.seq)! }; + }); + }; + + // 로그인 사용자를 첫 번째 결재자로 보장하는 effect + useEffect(() => { + if (!session?.user) return; + + const currentEmail = session.user.email ?? ""; + const currentEpId = (session.user as { epId?: string }).epId; + const currentUserId = session.user.id ?? undefined; + + let currentAplns = form.getValues("aplns"); + + // 이미 포함되어 있는지 확인 (epId 또는 email 기준) + const selfIndex = currentAplns.findIndex( + (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail, + ); + + if (selfIndex === -1) { + // 맨 앞에 상신자 추가 + const newSelf: FormData["aplns"][number] = { + id: generateUniqueId(), + epId: currentEpId, + userId: currentUserId ? currentUserId.toString() : undefined, + emailAddress: currentEmail, + name: session.user.name ?? undefined, + role: "0", // 기안 + seq: "0", + opinion: "", + }; + + currentAplns = [newSelf, ...currentAplns]; + } - {aplns.length === 0 && ( - <div className="text-center py-8 text-gray-500"> - <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" /> - <p>결재자를 추가해주세요</p> - </div> - )} - </div> - - <FormField - control={form.control} - name="subject" - render={({ field }) => ( - <FormItem> - <FormLabel>제목 *</FormLabel> - <FormControl> - <Input placeholder="결재 제목을 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="contents" - render={({ field }) => ( - <FormItem> - <FormLabel>내용 *</FormLabel> - <FormControl> - <RichTextEditor - value={field.value} - onChange={field.onChange} - height="400px" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */} - <div className="grid grid-cols-3 gap-4"> - {/* 보안 등급 */} - <FormField - control={form.control} - name="docSecuType" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <FormLabel>보안</FormLabel> - <FormControl> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <SelectTrigger className="w-24"> - <SelectValue placeholder="등급" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="PERSONAL">개인</SelectItem> - <SelectItem value="CONFIDENTIAL">기밀</SelectItem> - <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem> - </SelectContent> - </Select> - </FormControl> - </FormItem> - )} - /> + // seq 재정렬 보장 + currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() })); + + form.setValue("aplns", currentAplns, { shouldValidate: false, shouldDirty: true }); + }, [session, form]); + + // dnd-kit sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); - {/* 긴급 여부 */} - <FormField - control={form.control} - name="urgYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <FormLabel>긴급</FormLabel> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> + // 고유 ID 생성 함수 + const generateUniqueId = () => { + return `apln-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + }; + + // 결재자 추가 (UserSelector를 통해) + const addApprovalUsers = (users: UserSelectItem[]) => { + const newAplns = [...aplns]; + + users.forEach((user) => { + // 이미 추가된 사용자인지 확인 + const existingIndex = newAplns.findIndex(apln => apln.userId === user.id.toString()); + if (existingIndex === -1) { + // 새 사용자 추가 + const newSeq = (newAplns.length).toString(); // 0은 상신자 + const newApln: FormData["aplns"][number] = { + id: generateUniqueId(), // 고유 ID 생성 + epId: (user as ExtendedUserSelectItem).epId, // epId가 전달되면 사용 + userId: user.id.toString(), + emailAddress: user.email, + name: user.name, + deptName: (user as ExtendedUserSelectItem).deptName ?? undefined, + role: "1", // 기본값: 결재 + seq: newSeq, + opinion: "", + }; + newAplns.push(newApln); + } + }); + + form.setValue("aplns", newAplns); + }; + + // 그룹 삭제 (seq 기반) + const removeApprovalGroup = (seq: string) => { + if (seq === "0") return; // 상신자 삭제 불가 + + const remaining = aplns.filter((a) => a.seq !== seq); + + // seq 재정렬 (병렬 그룹 유지) + const reordered = reorderBySeq(remaining); + form.setValue("aplns", reordered); + }; + + // 기존 단일 삭제 로직은 더 이상 사용하지 않음 (호환 위해 남겨둠) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const removeApprovalLine = (index: number) => { + // 첫 번째(상신자)는 삭제 불가 + if (index === 0) return; + + if (aplns.length > 1) { + const newAplns = aplns.filter((_: FormData["aplns"][number], i: number) => i !== index); + // 순서 재정렬 (ID는 유지) + const reorderedAplns = newAplns.map((apln: FormData["aplns"][number], i: number) => ({ + ...apln, + seq: (i).toString(), + })); + form.setValue("aplns", reorderedAplns); + } + }; + + // 드래그앤드롭 핸들러 (그룹 이동 지원) + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) return; + + // 현재 id는 그룹의 고유 key(첫 라인 id) + const activeKey = active.id as string; + const overKey = over.id as string; + + // key → seq 매핑 생성 + const idToSeq = new Map<string, string>(); + aplns.forEach((a) => { + // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑 + idToSeq.set(a.id, a.seq); + }); + + const activeSeq = idToSeq.get(activeKey); + const overSeq = idToSeq.get(overKey); + + if (!activeSeq || !overSeq) return; + + if (activeSeq === "0" || overSeq === "0") return; // 상신자는 이동 불가 + + // 현재 그룹 순서를 key 기반으로 계산 + const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); + const keyOrder = seqOrder.map((seq) => { + return aplns.find((a) => a.seq === seq)!.id; + }); + + const oldIndex = keyOrder.indexOf(activeKey); + const newIndex = keyOrder.indexOf(overKey); + + const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); + + // key → 새 seq 매핑 + const keyToNewSeq = new Map<string, string>(); + newKeyOrder.forEach((k, idx) => { + keyToNewSeq.set(k, idx.toString()); + }); + + // aplns 재구성 + seq 재할당 + const updatedAplns: FormData["aplns"] = []; + newKeyOrder.forEach((k) => { + const oldSeq = idToSeq.get(k)!; + const groupItems = aplns.filter((a) => a.seq === oldSeq); + groupItems.forEach((item) => { + updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! }); + }); + }); + + form.setValue("aplns", updatedAplns, { shouldValidate: false, shouldDirty: true }); + }; + + const onSubmit = async (data: FormData) => { + setIsSubmitting(true); + setSubmitResult(null); + + try { + // 세션 정보 확인 + if (!session?.user) { + toast.error("로그인이 필요합니다."); + return; + } + + const currentEmail = session.user.email ?? ""; + const currentEpId = (session.user as { epId?: string }).epId; + const currentUserId = session.user.id ?? ""; + + debugLog("Current User", session.user); + + if (!currentEpId) { + toast.error("사용자 정보가 올바르지 않습니다."); + return; + } + + // 결재 경로 생성 (ID 제거하고 API 호출) + const approvalLines: ApprovalLine[] = await Promise.all( + data.aplns.map((apln) => + createApprovalLine( + { epId: apln.epId }, + apln.role, + apln.seq, + { opinion: apln.opinion }, + ), + ), + ); + + debugLog("Approval Lines", approvalLines); + + // 상신 요청 생성 + const attachmentsArray = data.attachments + ? Array.from(data.attachments as FileList) + : undefined; + + const submitRequest: SubmitApprovalRequest = + await createSubmitApprovalRequest( + data.contents, + data.subject, + approvalLines, + { + contentsType: data.contentsType, + docSecuType: data.docSecuType, + urgYn: data.urgYn ? "Y" : "N", + importantYn: data.importantYn ? "Y" : "N", + notifyOption: data.notifyOption, + docMngSaveCode: data.docMngSaveCode, + sbmLang: data.sbmLang, + timeZone: data.timeZone, + attachments: attachmentsArray, + }, + ); - {/* 중요 여부 */} - <FormField - control={form.control} - name="importantYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <FormLabel>중요</FormLabel> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - </div> - - {/* 첨부 파일 */} - <FormField - control={form.control} - name="attachments" - render={({ field }) => ( - <FormItem> - <FormLabel>첨부 파일</FormLabel> - <FormControl> - <Input - type="file" - multiple - onChange={(e) => field.onChange(e.target.files)} - /> - </FormControl> - <FormDescription>필요 시 파일을 선택하세요. (다중 선택 가능)</FormDescription> - <FormMessage /> - </FormItem> + // API 호출 (보안 등급에 따라 분기) + const isSecure = + data.docSecuType === "CONFIDENTIAL" || + data.docSecuType === "CONFIDENTIAL_STRICT"; + + debugLog("Submit Request", submitRequest); + + const response = isSecure + ? await submitSecurityApproval(submitRequest) + : await submitApproval(submitRequest, { + userId: currentUserId, + epId: currentEpId, + emailAddress: currentEmail, + }); + + debugLog("Submit Response", response); + + if (response.result === "SUCCESS") { + setSubmitResult({ apInfId: response.data.apInfId }); + toast.success("결재가 성공적으로 상신되었습니다."); + onSubmitSuccess?.(response.data.apInfId); + form.reset(); + } else { + toast.error(`결재 상신에 실패했습니다: ${response.result}`); + } + } catch (error) { + debugError("결재 상신 오류", error); + toast.error("결재 상신 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Card className="w-full max-w-5xl"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 결재 상신 + </CardTitle> + <CardDescription>새로운 결재를 상신합니다.</CardDescription> + </CardHeader> + + <CardContent className="space-y-6"> + {submitResult && ( + <div className="p-4 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-2 text-green-700"> + <AlertCircle className="w-4 h-4" /> + <span className="font-medium">상신 완료</span> + </div> + <p className="text-sm text-green-600 mt-1"> + 결재 ID: {submitResult.apInfId} + </p> + </div> )} - /> - </div> - - <Separator /> - - {/* 제출 버튼 */} - <div className="flex justify-end space-x-3"> - <Button - type="button" - variant="outline" - onClick={() => form.reset()} - disabled={isSubmitting} - > - 초기화 - </Button> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting ? ( - <> - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - 상신 중... - </> - ) : ( - '결재 상신' - )} - </Button> - </div> - </form> - </Form> - </CardContent> - </Card> - ); -}
\ No newline at end of file + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-6" + > + {/* 기본 정보 */} + <div className="space-y-4"> + <Separator /> + + {/* 결재 경로 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold"> + 결재 경로 + </h3> + + {/* 상단 제어 버튼 */} + <div className="flex justify-end gap-2 mb-2"> + <Button + type="button" + variant="outline" + size="sm" + onClick={applyParallel} + > + 병렬 + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={applyAfter} + > + 후결 + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={ungroupParallel} + > + 해제 + </Button> + </div> + + {/* 결재자 추가 섹션 */} + <div className="p-4 border border-dashed border-gray-300 rounded-lg"> + <div className="mb-2"> + <label className="text-sm font-medium text-gray-700"> + 결재자 추가 + </label> + <p className="text-xs text-gray-500"> + 사용자를 검색하여 결재 라인에 + 추가하세요 + </p> + </div> + <UserSelector + selectedUsers={[]} + onUsersChange={addApprovalUsers} + placeholder="결재자를 검색하세요..." + domainFilter={{ + type: "exclude", + domains: ["partners"], + }} + maxSelections={10} // 최대 10명까지 추가 가능 + /> + </div> + + {/* 그룹 기반 렌더링 */} + {aplns.length > 0 && + (() => { + const groups = Object.values( + aplns.reduce< + Record< + string, + ApprovalLineItem[] + > + >((acc, apln) => { + acc[apln.seq] = acc[apln.seq] + ? [...acc[apln.seq], apln] + : [apln]; + return acc; + }, {}), + ).sort( + (a, b) => + parseInt(a[0].seq) - + parseInt(b[0].seq), + ); + + return ( + <DndContext + sensors={sensors} + collisionDetection={ + closestCenter + } + modifiers={[ + restrictToVerticalAxis, + restrictToParentElement, + ]} + onDragEnd={handleDragEnd} + > + <SortableContext + items={groups.map( + (g) => g[0].id, + )} + strategy={ + verticalListSortingStrategy + } + > + <div className="space-y-3"> + {groups.map( + (group, idx) => ( + <SortableApprovalGroup + key={ + group[0] + .id + } + group={ + group + } + index={idx} + form={form} + onRemoveGroup={() => + removeApprovalGroup( + group[0] + .seq, + ) + } + canRemove={ + idx !== + 0 && + aplns.length > + 1 + } + selected={selectedSeqs.includes( + group[0] + .seq, + )} + onSelect={() => + toggleSelectGroup( + group[0] + .seq, + ) + } + /> + ), + )} + </div> + </SortableContext> + </DndContext> + ); + })()} + + {aplns.length === 0 && ( + <div className="text-center py-8 text-gray-500"> + <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" /> + <p>결재자를 추가해주세요</p> + </div> + )} + </div> + + <FormField + control={form.control} + name="subject" + render={({ field }) => ( + <FormItem> + <FormLabel>제목 *</FormLabel> + <FormControl> + <Input + placeholder="결재 제목을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contents" + render={({ field }) => ( + <FormItem> + <FormLabel>내용 *</FormLabel> + <FormControl> + <RichTextEditor + value={field.value} + onChange={field.onChange} + height="400px" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */} + <div className="grid grid-cols-3 gap-4"> + {/* 보안 등급 */} + <FormField + control={form.control} + name="docSecuType" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <FormLabel>보안</FormLabel> + <FormControl> + <Select + onValueChange={ + field.onChange + } + defaultValue={field.value} + > + <SelectTrigger className="w-24"> + <SelectValue placeholder="등급" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="PERSONAL"> + 개인 + </SelectItem> + <SelectItem value="CONFIDENTIAL"> + 기밀 + </SelectItem> + <SelectItem value="CONFIDENTIAL_STRICT"> + 극기밀 + </SelectItem> + </SelectContent> + </Select> + </FormControl> + </FormItem> + )} + /> + + {/* 긴급 여부 */} + <FormField + control={form.control} + name="urgYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <FormLabel>긴급</FormLabel> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={ + field.onChange + } + /> + </FormControl> + </FormItem> + )} + /> + + {/* 중요 여부 */} + <FormField + control={form.control} + name="importantYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <FormLabel>중요</FormLabel> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={ + field.onChange + } + /> + </FormControl> + </FormItem> + )} + /> + </div> + + {/* 첨부 파일 */} + <FormField + control={form.control} + name="attachments" + render={({ field }) => ( + <FormItem> + <FormLabel>첨부 파일</FormLabel> + <FormControl> + <Input + type="file" + multiple + onChange={(e) => + field.onChange( + e.target.files, + ) + } + /> + </FormControl> + <FormDescription> + 필요 시 파일을 선택하세요. (다중 + 선택 가능) + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <Separator /> + + {/* 제출 버튼 */} + <div className="flex justify-end space-x-3"> + <Button + type="button" + variant="outline" + onClick={() => form.reset()} + disabled={isSubmitting} + > + 초기화 + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 상신 중... + </> + ) : ( + "결재 상신" + )} + </Button> + </div> + </form> + </Form> + </CardContent> + </Card> + ); +} 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"); <CommandList> <CommandEmpty>No results found.</CommandEmpty> - {main.map((group) => ( - <CommandGroup key={group.title} heading={group.title}> + {main.map((group: MenuSection) => ( + <CommandGroup key={group.titleKey} heading={t(group.titleKey)}> {group.items.map((navItem) => ( <CommandItem - key={navItem.title} - value={navItem.title} + key={`${navItem.titleKey}:${navItem.href}`} + value={t(navItem.titleKey)} onSelect={() => { - runCommand(() => router.push(navItem.href as string)) + runCommand(() => router.push(`/${lng}${navItem.href}`)) }} > <div className="mr-2 flex h-4 w-4 items-center justify-center"> <Circle className="h-3 w-3" /> </div> - {navItem.title} + {t(navItem.titleKey)} </CommandItem> ))} </CommandGroup> @@ -104,14 +108,14 @@ const isPartnerRoute = pathname.includes("/partners"); // .filter((navitem) => !navitem.external) .map((navItem) => ( <CommandItem - key={navItem.title} - value={navItem.title} + key={`${navItem.titleKey}:${navItem.href}`} + value={t(navItem.titleKey)} onSelect={() => { - runCommand(() => router.push(navItem.href as string)) + runCommand(() => router.push(`/${lng}${navItem.href}`)) }} > <File /> - {navItem.title} + {t(navItem.titleKey)} </CommandItem> ))} </CommandGroup> 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 ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBlockquote().run())} + disabled={disabled} + > + <QuoteIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>인용문</p> + </TooltipContent> + </Tooltip> + ) +} + + 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 ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={handleToggleBulletList} + disabled={disabled} + > + <ListIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>글머리 기호</p> + </TooltipContent> + </Tooltip> + ) +} + + 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 ( + <> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={false} onPressedChange={() => executeCommand(() => editor.chain().focus().undo().run())} disabled={!editor.can().undo() || disabled}> + <Undo className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>실행 취소 (Ctrl+Z)</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={false} onPressedChange={() => executeCommand(() => editor.chain().focus().redo().run())} disabled={!editor.can().redo() || disabled}> + <Redo className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>다시 실행 (Ctrl+Y)</p> + </TooltipContent> + </Tooltip> + </> + ) +} + + 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 ( + <> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isBold} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBold().run())} disabled={disabled}> + <Bold className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>굵게 (Ctrl+B)</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isItalic} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleItalic().run())} disabled={disabled}> + <Italic className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>기울임 (Ctrl+I)</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isUnderline} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleUnderline().run())} disabled={disabled}> + <UnderlineIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>밑줄</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isStrike} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleStrike().run())} disabled={disabled}> + <Strikethrough className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>취소선</p> + </TooltipContent> + </Tooltip> + </> + ) +} + + 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 ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleOrderedList().run())} + disabled={disabled} + > + <ListOrderedIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>번호 매기기</p> + </TooltipContent> + </Tooltip> + ) +} + + 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<number | undefined>(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<string> => + 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 ( - <TooltipProvider> - <div className="border border-input bg-transparent rounded-t-md"> - <div className="flex flex-wrap gap-1 p-1"> - {/* 텍스트 스타일 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.bold} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBold().run()) - } - disabled={disabled} - > - <Bold className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>굵게 (Ctrl+B)</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.italic} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleItalic().run()) - } - disabled={disabled} - > - <Italic className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>기울임 (Ctrl+I)</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.underline} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleUnderline().run()) - } - disabled={disabled} - > - <UnderlineIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>밑줄</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.strike} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleStrike().run()) - } - disabled={disabled} - > - <Strikethrough className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>취소선</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 제목 및 단락 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}> - <Type className="h-4 w-4" /> - </Toggle> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - {([1, 2, 3] as Array<1 | 2 | 3>).map((level) => ( - <DropdownMenuItem - key={level} - onClick={() => - executeCommand(() => - editor.chain().focus().toggleHeading({ level }).run() - ) - } - className="flex items-center" - > - <span - className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base' - }`} - > - 제목 {level} - </span> - </DropdownMenuItem> - ))} - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setParagraph().run()) - } - className="flex items-center" - > - <span>본문</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - {/* 글자 크기 - 동적 width 적용 */} - <div className="flex items-center space-x-1"> - <Input - type="number" - min="8" - max="72" - value={fontSize} - onChange={(e) => { - 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} - /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> - <Type className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map((size) => ( - <DropdownMenuItem - key={size} - onClick={() => { - setFontSize(size.toString()) - executeCommand(() => - editor - .chain() - .focus() - .setMark('textStyle', { fontSize: `${size}px` }) - .run() - ) - }} - className="flex items-center" - > - <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span> - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - </div> - - <Separator orientation="vertical" className="h-6" /> - - {/* 리스트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.bulletList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBulletList().run()) - } - disabled={disabled} - > - <List className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>글머리 기호</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.orderedList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleOrderedList().run()) - } - disabled={disabled} - > - <ListOrdered className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>번호 매기기</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.blockquote} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBlockquote().run()) - } - disabled={disabled} - > - <Quote className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>인용문</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 텍스트 정렬 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Tooltip> - <TooltipTrigger asChild> - <Toggle size="sm" pressed={toolbarState.textAlign !== 'left'} disabled={disabled}> - {toolbarState.textAlign === 'center' ? ( - <AlignCenter className="h-4 w-4" /> - ) : toolbarState.textAlign === 'right' ? ( - <AlignRight className="h-4 w-4" /> - ) : toolbarState.textAlign === 'justify' ? ( - <AlignJustify className="h-4 w-4" /> - ) : ( - <AlignLeft className="h-4 w-4" /> - )} - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>텍스트 정렬</p> - </TooltipContent> - </Tooltip> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setTextAlign('left').run()) - } - className="flex items-center" - > - <AlignLeft className="mr-2 h-4 w-4" /> - <span>왼쪽 정렬</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setTextAlign('center').run()) - } - className="flex items-center" - > - <AlignCenter className="mr-2 h-4 w-4" /> - <span>가운데 정렬</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setTextAlign('right').run()) - } - className="flex items-center" - > - <AlignRight className="mr-2 h-4 w-4" /> - <span>오른쪽 정렬</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setTextAlign('justify').run()) - } - className="flex items-center" - > - <AlignJustify className="mr-2 h-4 w-4" /> - <span>양쪽 정렬</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - <Separator orientation="vertical" className="h-6" /> - - {/* 링크 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.link} - onPressedChange={() => { - 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} - > - <LinkIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>링크 {toolbarState.link ? '제거' : '삽입'}</p> - </TooltipContent> - </Tooltip> - - {/* 이미지 업로드 */} - <Tooltip> - <TooltipTrigger asChild> - <div className="relative"> - <input - type="file" - accept="image/*" - className="hidden" - id="image-upload-rt" - onChange={(e) => { - const file = e.target.files?.[0] - if (file) handleImageUpload(file) - }} - /> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => { - document.getElementById('image-upload-rt')?.click() - }} - disabled={disabled} - > - <ImageIcon className="h-4 w-4" /> - </Toggle> - </div> - </TooltipTrigger> - <TooltipContent> - <p>이미지 삽입</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 첨자 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.subscript} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleSubscript().run()) - } - disabled={disabled} - > - <SubscriptIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>아래 첨자</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.superscript} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleSuperscript().run()) - } - disabled={disabled} - > - <SuperscriptIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>위 첨자</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 하이라이트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.highlight} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleHighlight().run()) - } - disabled={disabled} - > - <Highlighter className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>하이라이트</p> - </TooltipContent> - </Tooltip> - - {/* 체크리스트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.taskList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleTaskList().run()) - } - disabled={disabled} - > - <CheckSquare className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>체크리스트</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 테이블 */} - {!toolbarState.table ? ( - <Dialog open={isTableDialogOpen} onOpenChange={setIsTableDialogOpen}> - <DialogTrigger asChild> - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => { - if (editor && editor.isActive('table')) { - alert('커서를 테이블 밖으로 이동시키세요') - return - } - setIsTableDialogOpen(true) - }} - disabled={disabled} - > - <TableIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>테이블 삽입</p> - </TooltipContent> - </Tooltip> - </DialogTrigger> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>테이블 크기 설정</DialogTitle> - <DialogDescription> - 생성할 테이블의 행과 열 수를 입력하세요 (1-20) - </DialogDescription> - </DialogHeader> - <div className="grid grid-cols-2 gap-4 py-4"> - <div className="space-y-2"> - <Label htmlFor="table-rows">행 수</Label> - <Input - id="table-rows" - type="number" - min="1" - max="20" - value={tableRows} - onChange={(e) => setTableRows(e.target.value)} - placeholder="3" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="table-cols">열 수</Label> - <Input - id="table-cols" - type="number" - min="1" - max="20" - value={tableCols} - onChange={(e) => setTableCols(e.target.value)} - placeholder="3" - /> - </div> - </div> - <DialogFooter> - <Button variant="outline" onClick={() => setIsTableDialogOpen(false)}> - 취소 - </Button> - <Button - onClick={() => { - const rows = parseInt(tableRows, 10) - const cols = parseInt(tableCols, 10) - if (rows >= 1 && rows <= 20 && cols >= 1 && cols <= 20) { - executeCommand(() => - editor.chain().focus().insertTable({ rows, cols }).run() - ) - setIsTableDialogOpen(false) - } - }} - disabled={ - !tableRows || - !tableCols || - parseInt(tableRows, 10) < 1 || - parseInt(tableRows, 10) > 20 || - parseInt(tableCols, 10) < 1 || - parseInt(tableCols, 10) > 20 - } - > - 생성 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) : ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Tooltip> - <TooltipTrigger asChild> - <Toggle size="sm" pressed={true} disabled={disabled}> - <TableIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>테이블 편집</p> - </TooltipContent> - </Tooltip> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addRowBefore().run()) - } - className="flex items-center" - > - <span>위에 행 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addRowAfter().run()) - } - className="flex items-center" - > - <span>아래에 행 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addColumnBefore().run()) - } - className="flex items-center" - > - <span>왼쪽에 열 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addColumnAfter().run()) - } - className="flex items-center" - > - <span>오른쪽에 열 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteRow().run()) - } - className="flex items-center" - > - <span>행 삭제</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteColumn().run()) - } - className="flex items-center" - > - <span>열 삭제</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteTable().run()) - } - className="flex items-center text-red-600" - > - <span>테이블 삭제</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - )} - - <Separator orientation="vertical" className="h-6" /> - - {/* 실행 취소/다시 실행 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => - executeCommand(() => editor.chain().focus().undo().run()) - } - disabled={!editor.can().undo() || disabled} - > - <Undo className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>실행 취소 (Ctrl+Z)</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => - executeCommand(() => editor.chain().focus().redo().run()) - } - disabled={!editor.can().redo() || disabled} - > - <Redo className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>다시 실행 (Ctrl+Y)</p> - </TooltipContent> - </Tooltip> - </div> - </div> - </TooltipProvider> - ) - } - - // --------------------------------------------------------------------------- - // 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 ( - <div - className={`border rounded-md bg-background ${height === '100%' ? 'flex flex-col h-full' : ''}`} - style={containerStyle} - > - <div className="flex-shrink-0 border-b"> - <Toolbar editor={editor} disabled={disabled} /> + <div className={`border rounded-md bg-background flex flex-col ${className ?? ''}`} style={containerStyle}> + <div className="flex-none border-b"> + <Toolbar editor={editor} disabled={disabled} onSelectImageFile={handleImageUpload} /> </div> - <div className="overflow-y-auto" style={editorContentStyle}> + <div className="flex-1 min-h-0 overflow-y-auto"> <EditorContent editor={editor} className="h-full" /> </div> </div> ) -}
\ 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 ( + <DropdownMenu> + <DropdownMenuTrigger asChild onMouseDown={e => e.preventDefault()}> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + 스타일 + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem + className="flex items-center" + onSelect={() => + executeCommand(() => + editor + .chain() + .focus() + .setMark('textStyle', { fontSize: '32px' }) + .setBold() + .run() + ) + } + > + 제목 (굵게 + 32pt) + </DropdownMenuItem> + <DropdownMenuItem + className="flex items-center" + onSelect={() => + executeCommand(() => + editor + .chain() + .focus() + .unsetBold() + .setParagraph() + .setMark('textStyle', { fontSize: null as unknown as string }) + .run() + ) + } + > + 본문 (기본) + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} + + 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 ( + <DropdownMenu> + <DropdownMenuTrigger asChild onMouseDown={e => e.preventDefault()}> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + 정렬 + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('left').run())} className="flex items-center"> + <AlignLeft className="mr-2 h-4 w-4" /> + <span>왼쪽 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('center').run())} className="flex items-center"> + <AlignCenter className="mr-2 h-4 w-4" /> + <span>가운데 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('right').run())} className="flex items-center"> + <AlignRight className="mr-2 h-4 w-4" /> + <span>오른쪽 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('justify').run())} className="flex items-center"> + <AlignJustify className="mr-2 h-4 w-4" /> + <span>양쪽 정렬</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} + + 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<string>('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 ( + <TooltipProvider> + <div className="border border-input bg-transparent rounded-t-md"> + <div className="flex flex-wrap gap-1 p-1"> + <StyleMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + + <InlineStyleMenu + editor={editor} + disabled={disabled} + isBold={toolbarState.bold} + isItalic={toolbarState.italic} + isUnderline={toolbarState.underline} + isStrike={toolbarState.strike} + executeCommand={executeCommand} + /> + + <Separator orientation="vertical" className="h-6" /> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}> + <Type className="h-4 w-4" /> + </Toggle> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + {([1, 2, 3] as Array<1 | 2 | 3>).map(level => ( + <DropdownMenuItem + key={level} + onSelect={() => executeCommand(() => editor.chain().focus().toggleHeading({ level }).run())} + className="flex items-center" + > + <span className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base'}`}> + 제목 {level} + </span> + </DropdownMenuItem> + ))} + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setParagraph().run())} className="flex items-center"> + <span>본문</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + <div className="flex items-center gap-1"> + <Input + type="number" + min="8" + max="72" + value={fontSize} + onChange={e => { + 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} + /> + <div className="flex items-center gap-1"> + <Button + type="button" + size="sm" + variant="outline" + className="h-8 w-8 p-0" + onMouseDown={e => e.preventDefault()} + onClick={() => { + const next = Math.max(8, Math.min(72, parseInt(fontSize || '16', 10) - 1)) + setFontSize(String(next)) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${next}px` }).run()) + }} + disabled={disabled} + > + - + </Button> + <Button + type="button" + size="sm" + variant="outline" + className="h-8 w-8 p-0" + onMouseDown={e => e.preventDefault()} + onClick={() => { + const next = Math.max(8, Math.min(72, parseInt(fontSize || '16', 10) + 1)) + setFontSize(String(next)) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${next}px` }).run()) + }} + disabled={disabled} + > + + + </Button> + </div> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + <Type className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map(size => ( + <DropdownMenuItem + key={size} + onSelect={() => { + setFontSize(size.toString()) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()) + }} + className="flex items-center" + > + <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + + <Separator orientation="vertical" className="h-6" /> + + <div className="flex items-center gap-1"> + <BulletListButton editor={editor} disabled={disabled} isActive={toolbarState.bulletList} executeCommand={executeCommand} /> + <OrderedListButton editor={editor} disabled={disabled} isActive={toolbarState.orderedList} executeCommand={executeCommand} /> + <BlockquoteButton editor={editor} disabled={disabled} isActive={toolbarState.blockquote} executeCommand={executeCommand} /> + </div> + + <Separator orientation="vertical" className="h-6" /> + + <TextAlignMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + + <Separator orientation="vertical" className="h-6" /> + + <Tooltip> + <TooltipTrigger asChild> + <div className="relative"> + <input + type="file" + accept="image/*" + className="hidden" + id={`image-upload-rt-${imageInputId}`} + onChange={e => { + const file = e.target.files?.[0] + if (file) onSelectImageFile?.(file) + }} + /> + <Toggle + size="sm" + pressed={false} + onPressedChange={() => { + const el = document.getElementById(`image-upload-rt-${imageInputId}`) as HTMLInputElement | null + el?.click() + }} + disabled={disabled} + aria-label="이미지 삽입" + > + <ImageIcon className="h-4 w-4" /> + </Toggle> + </div> + </TooltipTrigger> + <TooltipContent> + <p>이미지 삽입</p> + </TooltipContent> + </Tooltip> + + {editor.isActive('image') && ( + <div className="flex items-center gap-1 ml-1"> + <Label className="text-xs">너비</Label> + <Input + type="number" + min={5} + max={100} + value={imageWidthPct} + onChange={e => { + 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} + /> + <span className="text-xs">%</span> + </div> + )} + + <Separator orientation="vertical" className="h-6" /> + + <HistoryMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + </div> + </div> + </TooltipProvider> + ) +} + + 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}`, + } + }, + }, + }, + }, + ] + }, +}) + + |
