summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-08-11 09:34:40 +0000
committerjoonhoekim <26rote@gmail.com>2025-08-11 09:34:40 +0000
commitbcd462d6e60871b86008e072f4b914138fc5c328 (patch)
treec22876fd6c6e7e48254587848b9dff50cdb8b032 /components
parentcbb4c7fe0b94459162ad5e998bc05cd293e0ff96 (diff)
(김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트), command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정
Diffstat (limited to 'components')
-rw-r--r--components/common/organization/organization-manager-selector.tsx338
-rw-r--r--components/knox/approval/ApprovalLineSelector.tsx444
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx2230
-rw-r--r--components/layout/command-menu.tsx26
-rw-r--r--components/rich-text-editor/BlockquoteButton.tsx38
-rw-r--r--components/rich-text-editor/BulletListButton.tsx46
-rw-r--r--components/rich-text-editor/HistoryMenu.tsx43
-rw-r--r--components/rich-text-editor/InlineStyleMenu.tsx67
-rw-r--r--components/rich-text-editor/OrderedListButton.tsx38
-rw-r--r--components/rich-text-editor/RichTextEditor.tsx1050
-rw-r--r--components/rich-text-editor/StyleMenu.tsx65
-rw-r--r--components/rich-text-editor/TextAlignMenu.tsx46
-rw-r--r--components/rich-text-editor/Toolbar.tsx350
-rw-r--r--components/rich-text-editor/extensions/font-size.ts31
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}`,
+ }
+ },
+ },
+ },
+ },
+ ]
+ },
+})
+
+