summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/common/user/user-selector.tsx447
-rw-r--r--components/knox/approval/ApprovalCancel.tsx34
-rw-r--r--components/knox/approval/ApprovalDetail.tsx58
-rw-r--r--components/knox/approval/ApprovalList.tsx38
-rw-r--r--components/knox/approval/ApprovalManager.tsx92
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx1063
-rw-r--r--components/knox/approval/index.ts3
-rw-r--r--components/knox/approval/mocks/approval-mock.ts230
-rw-r--r--components/qna/tiptap-editor.tsx16
-rw-r--r--components/rich-text-editor/RichTextEditor.tsx998
-rw-r--r--components/spread-js/dataBinding.tsx4
-rw-r--r--components/spread-js/testSheet.tsx4
12 files changed, 2374 insertions, 613 deletions
diff --git a/components/common/user/user-selector.tsx b/components/common/user/user-selector.tsx
new file mode 100644
index 00000000..4a43fa5e
--- /dev/null
+++ b/components/common/user/user-selector.tsx
@@ -0,0 +1,447 @@
+"use client"
+
+import * as React from "react"
+import { Search, X, Users, 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { Skeleton } from "@/components/ui/skeleton"
+import { searchUsersForSelector } from "@/lib/users/service"
+
+// User 타입 정의
+export interface UserSelectItem {
+ id: number
+ name: string
+ email: string
+ epId?: string | null
+ deptCode?: string | null
+ deptName?: string | null
+ imageUrl?: string | null
+ domain?: string
+ companyName?: string | null
+}
+
+// Domain 필터 타입
+export type UserDomainFilter =
+ | { type: "exclude"; domains: string[] } // partners가 아닌 경우
+ | { type: "include"; domains: string[] } // 특정 domain인 경우
+ | null // 필터 없음
+
+// 페이지네이션 정보 타입
+interface PaginationInfo {
+ page: number
+ perPage: number
+ total: number
+ pageCount: number
+ hasNextPage: boolean
+ hasPrevPage: boolean
+}
+
+export interface UserSelectorProps {
+ /** 선택된 사용자들 */
+ selectedUsers?: UserSelectItem[]
+ /** 사용자 선택 변경 콜백 */
+ onUsersChange?: (users: UserSelectItem[]) => void
+ /** 단일 선택 모드 여부 */
+ singleSelect?: boolean
+ /** domain 필터 */
+ domainFilter?: UserDomainFilter
+ /** placeholder 텍스트 */
+ placeholder?: string
+ /** 입력 없이 focus 시 표시할 placeholder */
+ noValuePlaceHolder?: string
+ /** 비활성화 여부 */
+ disabled?: boolean
+ /** 최대 선택 가능 사용자 수 */
+ maxSelections?: number
+ /** 조직도 선택 다이얼로그 오픈 콜백 (추후 구현) */
+ onOpenOrgChart?: () => void
+ /** 컴포넌트 클래스명 */
+ className?: string
+ /** 사용자 선택 후 팝오버 닫기 여부 */
+ closeOnSelect?: boolean
+}
+
+export function UserSelector({
+ selectedUsers = [],
+ onUsersChange,
+ singleSelect = false,
+ domainFilter,
+ placeholder = "사용자를 검색하세요...",
+ noValuePlaceHolder = "사용자를 검색하거나 조직도에서 찾아보세요",
+ disabled = false,
+ maxSelections,
+ onOpenOrgChart,
+ className,
+ closeOnSelect = true // 기본값으로 선택 후 닫기
+}: UserSelectorProps) {
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [isSearching, setIsSearching] = React.useState(false)
+ const [searchResults, setSearchResults] = React.useState<UserSelectItem[]>([])
+ 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)
+
+ // 검색 실행 - useCallback으로 메모이제이션
+ const performSearch = React.useCallback(async (query: string, page: number = 1) => {
+ setIsSearching(true)
+ setSearchError(null)
+
+ try {
+ const result = await searchUsersForSelector(query, page, 10, domainFilter)
+
+ if (result.success) {
+ setSearchResults(result.data)
+ setPagination(result.pagination)
+ setCurrentPage(page)
+ } else {
+ setSearchResults([])
+ setSearchError("검색 중 오류가 발생했습니다.")
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ })
+ }
+ } catch (err) {
+ console.error("사용자 검색 실패:", err)
+ setSearchResults([])
+ setSearchError("검색 중 오류가 발생했습니다.")
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ })
+ } finally {
+ setIsSearching(false)
+ }
+ }, [domainFilter])
+
+ // Debounced 검색어 변경 시 검색 실행
+ React.useEffect(() => {
+ setCurrentPage(1)
+ performSearch(debouncedSearchQuery, 1)
+ }, [debouncedSearchQuery, performSearch])
+
+ // 페이지 변경 처리 - useCallback으로 메모이제이션
+ const handlePageChange = React.useCallback((newPage: number) => {
+ if (newPage >= 1 && newPage <= pagination.pageCount) {
+ performSearch(debouncedSearchQuery, newPage)
+ }
+ }, [pagination.pageCount, performSearch, debouncedSearchQuery])
+
+ // 사용자 선택 처리 - useCallback으로 메모이제이션
+ const handleUserSelect = React.useCallback((user: UserSelectItem) => {
+ if (disabled) return
+
+ const isSelected = selectedUsers.some(u => u.id === user.id)
+ let newSelection: UserSelectItem[]
+
+ if (singleSelect) {
+ newSelection = isSelected ? [] : [user]
+ } else {
+ if (isSelected) {
+ newSelection = selectedUsers.filter(u => u.id !== user.id)
+ } else {
+ if (maxSelections && selectedUsers.length >= maxSelections) {
+ return // 최대 선택 수 도달
+ }
+ newSelection = [...selectedUsers, user]
+ }
+ }
+
+ onUsersChange?.(newSelection)
+
+ // 선택 후 팝오버 닫기 (closeOnSelect가 true이거나 단일 선택 모드일 때)
+ if ((closeOnSelect || singleSelect) && !isSelected) {
+ setIsPopoverOpen(false)
+ setSearchQuery("")
+ }
+ }, [disabled, selectedUsers, singleSelect, maxSelections, onUsersChange, closeOnSelect])
+
+ // 선택된 사용자 제거 - useCallback으로 메모이제이션
+ const handleRemoveUser = React.useCallback((userId: number) => {
+ if (disabled) return
+ const newSelection = selectedUsers.filter(u => u.id !== userId)
+ onUsersChange?.(newSelection)
+ }, [disabled, selectedUsers, onUsersChange])
+
+ // 사용자 이니셜 생성 - useMemo로 메모이제이션
+ const getUserInitials = React.useCallback((name: string) => {
+ const names = name.split(' ')
+ return names.length > 1
+ ? `${names[0][0]}${names[1][0]}`
+ : name.slice(0, 2)
+ }, [])
+
+ // Input 이벤트 핸들러들
+ const handleInputFocus = React.useCallback(() => {
+ setIsPopoverOpen(true)
+ }, [])
+
+ const handleInputChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+ setSearchQuery(e.target.value)
+ if (!isPopoverOpen) {
+ setIsPopoverOpen(true)
+ }
+ }, [isPopoverOpen])
+
+ const handleClosePopover = React.useCallback(() => {
+ setIsPopoverOpen(false)
+ }, [])
+
+ // 계산된 값들 - useMemo로 메모이제이션
+ const shouldShowResults = React.useMemo(() =>
+ searchQuery.trim() || isSearching, [searchQuery, isSearching])
+
+ const hasResults = React.useMemo(() =>
+ searchResults.length > 0, [searchResults.length])
+
+ // 검색 결과 렌더링 - 컴포넌트 분리로 가독성 향상
+ const renderSearchResults = React.useMemo(() => {
+ if (!shouldShowResults) {
+ return (
+ <div className="p-4 text-sm text-muted-foreground text-center">
+ {noValuePlaceHolder}
+ </div>
+ )
+ }
+
+ if (isSearching) {
+ return (
+ <div className="p-2 space-y-2">
+ {Array.from({ length: 3 }).map((_, i) => (
+ <div key={i} className="flex items-center space-x-2 p-2">
+ <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs">
+ ?
+ </div>
+ <div className="space-y-1 flex-1">
+ <Skeleton className="h-3 w-full" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )
+ }
+
+ if (searchError) {
+ return (
+ <div className="p-4 text-sm text-destructive text-center">
+ {searchError}
+ </div>
+ )
+ }
+
+ if (!hasResults) {
+ return (
+ <div className="p-4 text-sm text-muted-foreground text-center">
+ 검색 결과가 없습니다.
+ </div>
+ )
+ }
+
+ return (
+ <>
+ <div className="p-1">
+ {searchResults.map((user) => {
+ const isSelected = selectedUsers.some(u => u.id === user.id)
+ const canSelect = !maxSelections || selectedUsers.length < maxSelections || isSelected
+
+ return (
+ <div
+ key={user.id}
+ onClick={() => handleUserSelect(user)}
+ className={cn(
+ "flex items-center space-x-3 p-2 rounded-md cursor-pointer hover:bg-accent",
+ !canSelect && !isSelected && "opacity-50 cursor-not-allowed"
+ )}
+ >
+ <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs">
+ {getUserInitials(user.name)}
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 text-sm">
+ <span className="font-medium">{user.name}</span>
+ <span className="text-muted-foreground">·</span>
+ <span className="text-muted-foreground">{user.email}</span>
+ {user.deptName && (
+ <>
+ <span className="text-muted-foreground">·</span>
+ <span className="text-muted-foreground">{user.deptName}</span>
+ </>
+ )}
+ {isSelected && <div className="h-2 w-2 bg-primary rounded-full ml-auto" />}
+ </div>
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {/* 페이지네이션 */}
+ {pagination.pageCount > 1 && (
+ <div className="flex items-center justify-between p-3 border-t bg-muted/30">
+ <div className="text-xs text-muted-foreground">
+ {pagination.total}명 중 {((pagination.page - 1) * pagination.perPage) + 1}-{Math.min(pagination.page * pagination.perPage, pagination.total)}명
+ </div>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage - 1)}
+ disabled={!pagination.hasPrevPage}
+ className="h-8 w-8 p-0"
+ >
+ <ChevronLeft className="h-4 w-4" />
+ </Button>
+ <span className="text-xs text-muted-foreground px-2">
+ {pagination.page} / {pagination.pageCount}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage + 1)}
+ disabled={!pagination.hasNextPage}
+ className="h-8 w-8 p-0"
+ >
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ )}
+ </>
+ )
+ }, [
+ shouldShowResults,
+ noValuePlaceHolder,
+ isSearching,
+ searchError,
+ hasResults,
+ searchResults,
+ selectedUsers,
+ maxSelections,
+ handleUserSelect,
+ getUserInitials,
+ pagination,
+ currentPage,
+ handlePageChange
+ ])
+
+ return (
+ <div className={cn("space-y-2", className)}>
+ {/* 검색 입력 영역 */}
+ <div className="flex gap-2">
+ <div className="flex-1 relative">
+ <div className="relative">
+ <Input
+ ref={inputRef}
+ placeholder={placeholder}
+ value={searchQuery}
+ onChange={handleInputChange}
+ onFocus={handleInputFocus}
+ disabled={disabled}
+ className="pr-10"
+ />
+ <Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ </div>
+
+ {/* 검색 결과 팝오버 */}
+ {isPopoverOpen && (
+ <div className="absolute top-full left-0 right-0 z-50 mt-1 rounded-md border bg-popover text-popover-foreground shadow-md outline-none">
+ {/* 팝오버 헤더 */}
+ <div className="flex items-center justify-between p-3 border-b">
+ <span className="text-sm font-medium">사용자 선택</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleClosePopover}
+ className="h-6 w-6 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+
+ {/* 검색 결과 영역 */}
+ <div className="max-h-[400px] overflow-y-auto">
+ {renderSearchResults}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 조직도 찾기 버튼 */}
+ {onOpenOrgChart && (
+ <Button
+ variant="outline"
+ onClick={onOpenOrgChart}
+ disabled={disabled}
+ className="px-3"
+ >
+ <Users className="h-4 w-4 mr-2" />
+ 찾기
+ </Button>
+ )}
+ </div>
+
+ {/* 선택된 사용자들 표시 */}
+ {selectedUsers.length > 0 && (
+ <div className="flex flex-wrap gap-2">
+ {selectedUsers.map((user) => (
+ <Badge
+ key={user.id}
+ variant="secondary"
+ className="flex items-center gap-2 px-3 py-1"
+ >
+ <div className="h-5 w-5 rounded-full bg-muted flex items-center justify-center text-xs">
+ {getUserInitials(user.name)}
+ </div>
+ <span className="text-sm">{user.name}</span>
+ {user.deptName && (
+ <span className="text-xs text-muted-foreground">({user.deptName})</span>
+ )}
+ {!disabled && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-transparent"
+ onClick={() => handleRemoveUser(user.id)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ )}
+ </Badge>
+ ))}
+ </div>
+ )}
+
+ {/* 선택 제한 안내 */}
+ {maxSelections && selectedUsers.length >= maxSelections && (
+ <div className="text-xs text-muted-foreground">
+ 최대 {maxSelections}명까지 선택할 수 있습니다.
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/components/knox/approval/ApprovalCancel.tsx b/components/knox/approval/ApprovalCancel.tsx
index d077bfc6..b40b43df 100644
--- a/components/knox/approval/ApprovalCancel.tsx
+++ b/components/knox/approval/ApprovalCancel.tsx
@@ -15,19 +15,29 @@ import { Loader2, XCircle, AlertTriangle, CheckCircle } from 'lucide-react';
import { cancelApproval, getApprovalDetail } from '@/lib/knox-api/approval/approval';
import type { ApprovalDetailResponse } from '@/lib/knox-api/approval/approval';
-// Mock 데이터
-import { mockApprovalAPI, getStatusText } from './mocks/approval-mock';
+// 상태 코드 텍스트 매핑 (mock util 대체)
+const getStatusText = (status: string) => {
+ const map: Record<string, string> = {
+ '-3': '암호화실패',
+ '-2': '암호화중',
+ '-1': '예약상신',
+ '0': '보류',
+ '1': '진행중',
+ '2': '완결',
+ '3': '반려',
+ '4': '상신취소',
+ '5': '전결',
+ '6': '후완결',
+ };
+ return map[status] || '알 수 없음';
+};
interface ApprovalCancelProps {
- useFakeData?: boolean;
- systemId?: string;
initialApInfId?: string;
onCancelSuccess?: (apInfId: string) => void;
}
export default function ApprovalCancel({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
initialApInfId = '',
onCancelSuccess
}: ApprovalCancelProps) {
@@ -50,9 +60,7 @@ export default function ApprovalCancel({
setCancelResult(null);
try {
- const response = useFakeData
- ? await mockApprovalAPI.getApprovalDetail(apInfId)
- : await getApprovalDetail(apInfId, systemId);
+ const response = await getApprovalDetail(apInfId);
if (response.result === 'SUCCESS') {
setApprovalDetail(response.data);
@@ -75,9 +83,7 @@ export default function ApprovalCancel({
setIsCancelling(true);
try {
- const response = useFakeData
- ? await mockApprovalAPI.cancelApproval(approvalDetail.apInfId)
- : await cancelApproval(approvalDetail.apInfId, systemId);
+ const response = await cancelApproval(approvalDetail.apInfId);
if (response.result === 'SUCCESS') {
setCancelResult({ apInfId: response.data.apInfId });
@@ -154,14 +160,14 @@ export default function ApprovalCancel({
};
return (
- <Card className="w-full max-w-4xl">
+ <Card className="w-full max-w-5xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<XCircle className="w-5 h-5" />
결재 취소
</CardTitle>
<CardDescription>
- 결재 ID를 입력하여 상신을 취소합니다. {useFakeData && '(테스트 모드)'}
+ 상신한 결재를 취소합니다.
</CardDescription>
</CardHeader>
diff --git a/components/knox/approval/ApprovalDetail.tsx b/components/knox/approval/ApprovalDetail.tsx
index 6db43cbe..d6c883cb 100644
--- a/components/knox/approval/ApprovalDetail.tsx
+++ b/components/knox/approval/ApprovalDetail.tsx
@@ -14,12 +14,37 @@ import { Loader2, Search, FileText, Clock, User, AlertCircle } from 'lucide-reac
import { getApprovalDetail, getApprovalContent } from '@/lib/knox-api/approval/approval';
import type { ApprovalDetailResponse, ApprovalContentResponse, ApprovalLine } from '@/lib/knox-api/approval/approval';
-// Mock 데이터
-import { mockApprovalAPI, getStatusText, getRoleText, getApprovalStatusText } from './mocks/approval-mock';
+// 상태/역할 텍스트 매핑 (mock util 대체)
+const getStatusText = (status: string) => {
+ const map: Record<string, string> = {
+ '-3': '암호화실패',
+ '-2': '암호화중',
+ '-1': '예약상신',
+ '0': '보류',
+ '1': '진행중',
+ '2': '완결',
+ '3': '반려',
+ '4': '상신취소',
+ '5': '전결',
+ '6': '후완결',
+ };
+ return map[status] || status;
+};
+
+const getRoleText = (role: string) => {
+ const map: Record<string, string> = {
+ '0': '기안',
+ '1': '결재',
+ '2': '합의',
+ '3': '후결',
+ '4': '병렬합의',
+ '7': '병렬결재',
+ '9': '통보',
+ };
+ return map[role] || role;
+};
interface ApprovalDetailProps {
- useFakeData?: boolean;
- systemId?: string;
initialApInfId?: string;
}
@@ -38,8 +63,6 @@ interface ApprovalAttachment {
}
export default function ApprovalDetail({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
initialApInfId = ''
}: ApprovalDetailProps) {
const [apInfId, setApInfId] = useState(initialApInfId);
@@ -59,12 +82,8 @@ export default function ApprovalDetail({
try {
const [detailResponse, contentResponse] = await Promise.all([
- useFakeData
- ? mockApprovalAPI.getApprovalDetail(id)
- : getApprovalDetail(id, systemId),
- useFakeData
- ? mockApprovalAPI.getApprovalContent(id)
- : getApprovalContent(id, systemId)
+ getApprovalDetail(id),
+ getApprovalContent(id)
]);
if (detailResponse.result === 'SUCCESS' && contentResponse.result === 'SUCCESS') {
@@ -146,11 +165,11 @@ export default function ApprovalDetail({
// 2) fileId + 별도 엔드포인트 조합 (가이드에 명시되지 않았으므로 best-effort 처리)
if (attachment.fileId) {
- const url = `${process.env.KNOX_API_BASE_URL}/approval/api/v2.0/attachments/${attachment.fileId}`;
+ const url = `${process.env.NEXT_PUBLIC_KNOX_API_BASE_URL || ''}/approval/api/v2.0/attachments/${attachment.fileId}`;
const resp = await fetch(url, {
method: 'GET',
headers: {
- 'System-ID': systemId,
+ 'System-ID': process.env.NEXT_PUBLIC_KNOX_SYSTEM_ID || '',
},
});
if (!resp.ok) throw new Error('다운로드 실패');
@@ -177,17 +196,17 @@ export default function ApprovalDetail({
if (initialApInfId) {
fetchApprovalDetail(initialApInfId);
}
- }, [initialApInfId, useFakeData, systemId]);
+ }, [initialApInfId]);
return (
- <Card className="w-full max-w-6xl">
+ <Card className="w-full max-w-5xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
결재 상세 조회
</CardTitle>
<CardDescription>
- 결재 ID를 입력하여 상세 정보를 조회합니다. {useFakeData && '(테스트 모드)'}
+ 결재 ID를 입력하여 상세 정보를 조회합니다.
</CardDescription>
</CardHeader>
@@ -354,7 +373,7 @@ export default function ApprovalDetail({
<div className="mt-1">
<Badge variant={apln.aplnStatsCode === '1' ? 'default' :
apln.aplnStatsCode === '2' ? 'destructive' : 'outline'}>
- {getApprovalStatusText(apln.aplnStatsCode)}
+ {getStatusText(apln.aplnStatsCode)}
</Badge>
</div>
</div>
@@ -384,7 +403,8 @@ export default function ApprovalDetail({
<h3 className="text-lg font-semibold">첨부파일</h3>
<div className="space-y-2">
- {approvalData.detail.attachments.map((attachment: ApprovalAttachment, index: number) => (
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {approvalData.detail.attachments.map((attachment: any, index: number) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<FileText className="w-4 h-4 text-gray-500" />
<div className="flex-1">
diff --git a/components/knox/approval/ApprovalList.tsx b/components/knox/approval/ApprovalList.tsx
index 7f80e74a..3a766901 100644
--- a/components/knox/approval/ApprovalList.tsx
+++ b/components/knox/approval/ApprovalList.tsx
@@ -12,12 +12,24 @@ import { Loader2, List, Eye, RefreshCw, AlertCircle } from 'lucide-react';
import { getSubmissionList, getApprovalHistory } from '@/lib/knox-api/approval/approval';
import type { SubmissionListResponse, ApprovalHistoryResponse } from '@/lib/knox-api/approval/approval';
-// Mock 데이터
-import { mockApprovalAPI, getStatusText } from './mocks/approval-mock';
+// 상태 텍스트 매핑 (mock util 대체)
+const getStatusText = (status: string) => {
+ const map: Record<string, string> = {
+ '-3': '암호화실패',
+ '-2': '암호화중',
+ '-1': '예약상신',
+ '0': '보류',
+ '1': '진행중',
+ '2': '완결',
+ '3': '반려',
+ '4': '상신취소',
+ '5': '전결',
+ '6': '후완결',
+ };
+ return map[status] || '알 수 없음';
+};
interface ApprovalListProps {
- useFakeData?: boolean;
- systemId?: string;
type?: 'submission' | 'history';
onItemClick?: (apInfId: string) => void;
}
@@ -35,8 +47,6 @@ type ListItem = {
};
export default function ApprovalList({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
type = 'submission',
onItemClick
}: ApprovalListProps) {
@@ -52,17 +62,13 @@ export default function ApprovalList({
let response: SubmissionListResponse | ApprovalHistoryResponse;
if (type === 'submission') {
- response = useFakeData
- ? await mockApprovalAPI.getSubmissionList()
- : await getSubmissionList(systemId);
+ response = await getSubmissionList();
} else {
- response = useFakeData
- ? await mockApprovalAPI.getApprovalHistory()
- : await getApprovalHistory(systemId);
+ response = await getApprovalHistory();
}
if (response.result === 'SUCCESS') {
- setListData(response.data);
+ setListData(response.data as unknown as ListItem[]);
} else {
setError('목록을 가져오는데 실패했습니다.');
toast.error('목록을 가져오는데 실패했습니다.');
@@ -142,10 +148,10 @@ export default function ApprovalList({
// 컴포넌트 마운트 시 데이터 로드
useEffect(() => {
fetchData();
- }, [type, useFakeData, systemId]);
+ }, [type]);
return (
- <Card className="w-full max-w-6xl">
+ <Card className="w-full max-w-5xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<List className="w-5 h-5" />
@@ -155,7 +161,7 @@ export default function ApprovalList({
{type === 'submission'
? '상신한 결재 목록을 확인합니다.'
: '결재 처리 이력을 확인합니다.'
- } {useFakeData && '(테스트 모드)'}
+ }
</CardDescription>
</CardHeader>
diff --git a/components/knox/approval/ApprovalManager.tsx b/components/knox/approval/ApprovalManager.tsx
index cac534c4..89450445 100644
--- a/components/knox/approval/ApprovalManager.tsx
+++ b/components/knox/approval/ApprovalManager.tsx
@@ -4,8 +4,6 @@ import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
-import { Switch } from '@/components/ui/switch';
-import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { FileText, Eye, XCircle, List, History, Settings } from 'lucide-react';
@@ -16,18 +14,13 @@ import ApprovalCancel from './ApprovalCancel';
import ApprovalList from './ApprovalList';
interface ApprovalManagerProps {
- useFakeData?: boolean;
- systemId?: string;
defaultTab?: string;
}
export default function ApprovalManager({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
defaultTab = 'submit'
}: ApprovalManagerProps) {
const [currentTab, setCurrentTab] = useState(defaultTab);
- const [isTestMode, setIsTestMode] = useState(useFakeData);
const [selectedApInfId, setSelectedApInfId] = useState<string>('');
const handleSubmitSuccess = (apInfId: string) => {
@@ -45,12 +38,8 @@ export default function ApprovalManager({
setCurrentTab('detail');
};
- const handleTestModeChange = (checked: boolean) => {
- setIsTestMode(checked);
- };
-
return (
- <div className="w-full max-w-7xl mx-auto space-y-6">
+ <div className="w-full max-w-5xl mx-auto space-y-6">
{/* 헤더 */}
<Card>
<CardHeader>
@@ -65,26 +54,15 @@ export default function ApprovalManager({
<CardContent>
<div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <div className="flex items-center gap-2">
- <Label htmlFor="test-mode">테스트 모드</Label>
- <Switch
- id="test-mode"
- checked={isTestMode}
- onCheckedChange={handleTestModeChange}
- />
- </div>
- {isTestMode && (
- <Badge variant="outline" className="text-yellow-600 border-yellow-600">
- 테스트 모드 활성화
- </Badge>
- )}
- </div>
+ {/* 좌측 영역 - 현재는 테스트 모드 UI 제거로 여백 유지 */}
+ <div />
- <div className="flex items-center gap-2 text-sm text-gray-500">
- <span>시스템 ID:</span>
- <Badge variant="outline">{systemId}</Badge>
- </div>
+ {process.env.NEXT_PUBLIC_KNOX_SYSTEM_ID && (
+ <div className="flex items-center gap-2 text-sm text-gray-500">
+ <span>시스템 ID:</span>
+ <Badge variant="outline">{process.env.NEXT_PUBLIC_KNOX_SYSTEM_ID}</Badge>
+ </div>
+ )}
</div>
</CardContent>
</Card>
@@ -116,50 +94,46 @@ export default function ApprovalManager({
{/* 결재 상신 탭 */}
<TabsContent value="submit" className="space-y-6">
- <ApprovalSubmit
- useFakeData={isTestMode}
- systemId={systemId}
- onSubmitSuccess={handleSubmitSuccess}
- />
+ <div className="w-full">
+ <ApprovalSubmit onSubmitSuccess={handleSubmitSuccess} />
+ </div>
</TabsContent>
{/* 결재 상세 조회 탭 */}
<TabsContent value="detail" className="space-y-6">
- <ApprovalDetail
- useFakeData={isTestMode}
- systemId={systemId}
- initialApInfId={selectedApInfId}
- />
+ <div className="w-full">
+ <ApprovalDetail initialApInfId={selectedApInfId} />
+ </div>
</TabsContent>
{/* 결재 취소 탭 */}
<TabsContent value="cancel" className="space-y-6">
- <ApprovalCancel
- useFakeData={isTestMode}
- systemId={systemId}
- initialApInfId={selectedApInfId}
- onCancelSuccess={handleCancelSuccess}
- />
+ <div className="w-full">
+ <ApprovalCancel
+ initialApInfId={selectedApInfId}
+ onCancelSuccess={handleCancelSuccess}
+ />
+ </div>
</TabsContent>
{/* 상신함 탭 */}
<TabsContent value="list" className="space-y-6">
- <ApprovalList
- useFakeData={isTestMode}
- systemId={systemId}
- type="submission"
- onItemClick={handleListItemClick}
- />
+ <div className="w-full">
+ <ApprovalList
+ type="submission"
+ onItemClick={handleListItemClick}
+ />
+ </div>
</TabsContent>
{/* 결재 이력 탭 */}
<TabsContent value="history" className="space-y-6">
- <ApprovalList
- useFakeData={isTestMode}
- systemId={systemId}
- type="history"
- onItemClick={handleListItemClick}
- />
+ <div className="w-full">
+ <ApprovalList
+ type="history"
+ onItemClick={handleListItemClick}
+ />
+ </div>
</TabsContent>
</Tabs>
diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx
index 526a87f3..f3c1fa3d 100644
--- a/components/knox/approval/ApprovalSubmit.tsx
+++ b/components/knox/approval/ApprovalSubmit.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -8,25 +8,93 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
+import { Checkbox } from '@/components/ui/checkbox';
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { toast } from 'sonner';
-import { Loader2, Plus, Trash2, FileText, AlertCircle } from 'lucide-react';
+import { Loader2, Trash2, FileText, AlertCircle, GripVertical } from 'lucide-react';
+
+// dnd-kit imports for drag and drop
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+
+// 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier
+import {
+ restrictToVerticalAxis,
+ restrictToParentElement,
+} from '@dnd-kit/modifiers';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+ useSortable,
+} from '@dnd-kit/sortable';
+import {
+ CSS,
+} from '@dnd-kit/utilities';
// API 함수 및 타입
import { submitApproval, submitSecurityApproval, createSubmitApprovalRequest, createApprovalLine } from '@/lib/knox-api/approval/approval';
import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval';
-// Mock 데이터
-import { mockApprovalAPI, createMockApprovalLine, getRoleText } from './mocks/approval-mock';
+// 역할 텍스트 매핑 (기존 mock util 대체)
+const getRoleText = (role: string) => {
+ const map: Record<string, string> = {
+ '0': '기안',
+ '1': '결재',
+ '2': '합의',
+ '3': '후결',
+ '4': '병렬합의',
+ '7': '병렬결재',
+ '9': '통보',
+ };
+ return map[role] || role;
+};
+
+// TiptapEditor 컴포넌트
+import RichTextEditor from '@/components/rich-text-editor/RichTextEditor';
+
+// UserSelector 컴포넌트
+import { UserSelector, type UserSelectItem } from '@/components/common/user/user-selector';
+import { useSession } from 'next-auth/react';
+
+// UserSelector에서 반환되는 사용자에 epId가 포함될 수 있으므로 확장 타입 정의
+interface ExtendedUserSelectItem extends UserSelectItem {
+ epId?: string;
+}
+
+// 역할 코드 타입 정의
+type ApprovalRole = '0' | '1' | '2' | '3' | '4' | '7' | '9';
+
+// 결재 라인 아이템 타입 정의 (고유 ID 포함)
+interface ApprovalLineItem {
+ id: string; // 내부 고유 식별자
+ epId?: string; // Knox 고유 ID (전사 고유)
+ userId?: string; // DB User PK
+ emailAddress?: string;
+ name?: string; // 사용자 이름
+ deptName?: string; // 부서명
+ role: ApprovalRole;
+ seq: string;
+ opinion?: string;
+}
const formSchema = z.object({
subject: z.string().min(1, '제목은 필수입니다'),
contents: z.string().min(1, '내용은 필수입니다'),
- contentsType: z.enum(['TEXT', 'HTML', 'MIME']),
+ contentsType: z.literal('HTML'),
docSecuType: z.enum(['PERSONAL', 'CONFIDENTIAL', 'CONFIDENTIAL_STRICT']),
urgYn: z.boolean(),
importantYn: z.boolean(),
@@ -35,8 +103,12 @@ const formSchema = z.object({
sbmLang: z.enum(['ko', 'ja', 'zh', 'en']),
timeZone: z.string().default('GMT+9'),
aplns: z.array(z.object({
- userId: z.string().min(1, '사용자 ID는 필수입니다'),
+ id: z.string(), // 고유 식별자
+ epId: z.string().optional(),
+ userId: z.string().optional(),
emailAddress: z.string().email('유효한 이메일 주소를 입력해주세요').optional(),
+ name: z.string().optional(),
+ deptName: z.string().optional(),
role: z.enum(['0', '1', '2', '3', '4', '7', '9']),
seq: z.string(),
opinion: z.string().optional()
@@ -48,25 +120,344 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>;
interface ApprovalSubmitProps {
- useFakeData?: boolean;
- systemId?: string;
onSubmitSuccess?: (apInfId: string) => void;
}
-export default function ApprovalSubmit({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
- onSubmitSuccess
-}: ApprovalSubmitProps) {
+// Sortable한 결재 라인 컴포넌트
+interface SortableApprovalLineProps {
+ apln: ApprovalLineItem;
+ index: number;
+ form: ReturnType<typeof useForm<FormData>>;
+ onRemove: () => void;
+ canRemove: boolean;
+ selected: boolean;
+ onSelect: () => void;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function SortableApprovalLine({ apln, index, form, onRemove, canRemove, selected, onSelect }: SortableApprovalLineProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: apln.id }); // 고유 ID 사용
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1, // 드래그 중일 때 투명도 조절
+ };
+
+
+ return (
+ <div
+ ref={setNodeRef}
+ style={style}
+ className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? 'ring-2 ring-primary' : ''}`}
+ >
+ {/* 드래그 핸들 */}
+ <div
+ {...attributes}
+ {...listeners}
+ className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
+ >
+ <GripVertical className="w-5 h-5" />
+ </div>
+
+ {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */}
+ {index !== 0 ? (
+ <Checkbox
+ checked={selected}
+ onCheckedChange={() => onSelect()}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+ <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로)
+ )}
+
+ {/* 실제 seq 기준 표시 */}
+ <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge>
+
+ <div className="flex-1 grid grid-cols-4 gap-3">
+ {/* 사용자 정보 표시 */}
+ <div className="flex items-center space-x-2">
+ <div>
+ <div className="font-medium text-sm">
+ {(apln.name || 'Knox 이름 없음')}{apln.deptName ? ` / ${apln.deptName}` : ''}
+ </div>
+ </div>
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.id`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.epId`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.userId`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.emailAddress`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 역할 선택 */}
+ {index === 0 ? (
+ // 상신자는 역할 선택 대신 고정 표시
+ <div className="flex items-center">
+ <Badge variant="secondary">기안</Badge>
+ </div>
+ ) : (
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.role`}
+ render={({ field }) => {
+ // 병렬 여부 판단
+ const isParallel = field.value === '4' || field.value === '7';
+
+ // 병렬, 후결 값을 제외한 기본 역할
+ const baseRole: ApprovalRole = field.value === '7' ? '1' : field.value === '4' ? '2' : field.value === '3' ? '1' : field.value as ApprovalRole;
+
+ // 기본 역할 변경 핸들러
+ const handleBaseRoleChange = (val: string) => {
+ if (!val) return;
+ let newRole = val;
+ if (isParallel) {
+ if (val === '1') newRole = '7';
+ else if (val === '2') newRole = '4';
+ }
+ field.onChange(newRole);
+ };
+
+ // 병렬인 경우 한 개 버튼으로 표시
+ if (isParallel) {
+ return (
+ <FormItem className="w-full">
+ <Badge className="w-full justify-center" variant="secondary">
+ {getRoleText(field.value)}
+ </Badge>
+ </FormItem>
+ );
+ }
+
+ return (
+ <FormItem>
+ <div className="flex flex-col gap-2">
+ <ToggleGroup
+ type="single"
+ value={baseRole}
+ onValueChange={handleBaseRoleChange}
+ >
+ <ToggleGroupItem value="1">결재</ToggleGroupItem>
+ <ToggleGroupItem value="2">합의</ToggleGroupItem>
+ <ToggleGroupItem value="9">통보</ToggleGroupItem>
+ </ToggleGroup>
+ </div>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ )}
+
+ {/* 의견 입력란 제거됨 */}
+
+ {/* 역할 표시 */}
+ <div className="flex items-center justify-between">
+ <Badge variant="secondary">
+ {getRoleText(apln.role)}
+ </Badge>
+
+ {canRemove && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={onRemove}
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// Sortable Approval Group (seq 단위 카드)
+interface SortableApprovalGroupProps {
+ group: ApprovalLineItem[]; // 동일 seq 항목들
+ index: number;
+ form: ReturnType<typeof useForm<FormData>>;
+ onRemoveGroup: () => void;
+ canRemove: boolean;
+ selected: boolean;
+ onSelect: () => void;
+}
+
+function SortableApprovalGroup({ group, index, form, onRemoveGroup, canRemove, selected, onSelect }: SortableApprovalGroupProps) {
+ const seq = group[0].seq;
+ const role = group[0].role;
+ // 그룹을 식별할 안정적인 고유 키(첫 구성원의 id 활용)
+ const groupKey = group[0].id;
+
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: groupKey });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+ <div
+ ref={setNodeRef}
+ style={style}
+ className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? 'ring-2 ring-primary' : ''}`}
+ >
+ {/* 드래그 핸들 */}
+ <div
+ {...attributes}
+ {...listeners}
+ className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
+ >
+ <GripVertical className="w-5 h-5" />
+ </div>
+
+ {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */}
+ {index !== 0 ? (
+ <Checkbox
+ checked={selected}
+ onCheckedChange={onSelect}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+ <div className="w-4 h-4" /> // 기안자용 빈 공간
+ )}
+
+ {/* seq 표시 */}
+ <Badge variant="outline">{parseInt(seq) + 1}</Badge>
+
+ {/* 그룹 상세 정보 */}
+ <div className="flex-1 grid grid-cols-3 gap-3">
+ {/* 사용자 목록 */}
+ <div className="flex flex-col justify-center gap-1">
+ {group.map((u) => (
+ <div key={u.id} className="text-sm">
+ {(u.name || 'Knox 이름 없음')}{u.deptName ? ` / ${u.deptName}` : ''}
+ </div>
+ ))}
+ </div>
+
+ {/* 역할 */}
+ <div className="flex items-center">
+ {seq === '0' ? (
+ <Badge variant="secondary" className="w-full justify-center">
+ 기안
+ </Badge>
+ ) : role === '7' || role === '4' ? (
+ <Badge variant="secondary" className="w-full justify-center">
+ {getRoleText(role)}
+ </Badge>
+ ) : (
+ // 단일일 때는 기존 토글 재사용 (첫 항목 기준)
+ <FormField
+ control={form.control}
+ name={`aplns.${form.getValues('aplns').findIndex((a) => a.id === group[0].id)}.role`}
+ render={({ field }) => (
+ <ToggleGroup
+ type="single"
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <ToggleGroupItem value="1">결재</ToggleGroupItem>
+ <ToggleGroupItem value="2">합의</ToggleGroupItem>
+ <ToggleGroupItem value="9">통보</ToggleGroupItem>
+ </ToggleGroup>
+ )}
+ />
+ )}
+ </div>
+
+ {/* 삭제 버튼 */}
+ <div className="flex items-center justify-end">
+ {canRemove && (
+ <Button type="button" variant="ghost" size="sm" onClick={onRemoveGroup}>
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export default function ApprovalSubmit({ onSubmitSuccess }: ApprovalSubmitProps) {
+ const { data: session } = useSession();
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitResult, setSubmitResult] = useState<{ apInfId: string } | null>(null);
+ const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]);
+
+ // 그룹 단위 선택/해제
+ const toggleSelectGroup = (seq: string) => {
+ setSelectedSeqs((prev) =>
+ prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq]
+ );
+ };
+ const clearSelection = () => setSelectedSeqs([]);
+
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
subject: '',
contents: '',
- contentsType: 'TEXT',
+ contentsType: 'HTML',
docSecuType: 'PERSONAL',
urgYn: false,
importantYn: false,
@@ -74,109 +465,342 @@ export default function ApprovalSubmit({
docMngSaveCode: '0',
sbmLang: 'ko',
timeZone: 'GMT+9',
- aplns: [
- {
- userId: '',
- emailAddress: '',
- role: '0',
- seq: '1',
- opinion: ''
- }
- ],
+ aplns: [],
attachments: undefined
}
});
const aplns = form.watch('aplns');
- const addApprovalLine = () => {
- const newSeq = (aplns.length + 1).toString();
- form.setValue('aplns', [...aplns, {
- userId: '',
- emailAddress: '',
- role: '1',
- seq: newSeq,
- opinion: ''
- }]);
+ // 병렬 전환 핸들러
+ const applyParallel = () => {
+ if (selectedSeqs.length < 2) {
+ toast.error('두 명 이상 선택해야 병렬 지정이 가능합니다.');
+ return;
+ }
+
+ const current = form.getValues('aplns');
+ const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq));
+
+ const roles = Array.from(new Set(selectedAplns.map((a) => a.role)));
+ if (roles.length !== 1) {
+ toast.error('선택된 항목의 역할이 동일해야 합니다.');
+ return;
+ }
+
+ const role = roles[0];
+ let newRole: ApprovalRole;
+ if (role === '1') {
+ newRole = '7'; // 병렬 결재
+ } else if (role === '2') {
+ newRole = '4'; // 병렬 합의
+ } else if (role === '9') {
+ newRole = '9'; // 병렬 통보(역할 코드 유지)
+ } else {
+ toast.error('결재, 합의 또는 통보만 병렬 지정 가능합니다.');
+ return;
+ }
+
+ const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq)));
+
+ const updated = current.map((a) => {
+ if (selectedSeqs.includes(a.seq)) {
+ return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() };
+ }
+ return a;
+ });
+
+ form.setValue('aplns', reorderBySeq(updated), { shouldDirty: true });
+ clearSelection();
+ };
+
+ // 후결 전환 핸들러
+ const applyAfter = () => {
+ if (selectedSeqs.length !== 1) {
+ toast.error('후결은 한 명만 지정할 수 있습니다.');
+ return;
+ }
+
+ const targetSeq = selectedSeqs[0];
+
+ // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가
+ const targetRole = form.getValues('aplns').find((a) => a.seq === targetSeq)?.role;
+ if (targetRole === '7' || targetRole === '4') {
+ toast.error('병렬 그룹은 후결로 전환할 수 없습니다.');
+ return;
+ }
+
+ const updated = form.getValues('aplns').map((a) => {
+ if (a.seq === targetSeq) {
+ return { ...a, role: (a.role === '3' ? '1' : '3') as ApprovalRole };
+ }
+ return a;
+ });
+
+ form.setValue('aplns', reorderBySeq(updated), { shouldDirty: true });
+ clearSelection();
+ };
+
+ // 병렬 해제 핸들러
+ const ungroupParallel = () => {
+ if (selectedSeqs.length === 0) {
+ toast.error('해제할 결재선을 선택하세요.');
+ return;
+ }
+
+ let newSeqCounter = 1; // 0은 상신자 유지
+ const updated = form.getValues('aplns').map((a) => {
+ if (selectedSeqs.includes(a.seq)) {
+ let newRole: ApprovalRole = a.role;
+ if (a.role === '7') newRole = '1';
+ if (a.role === '4') newRole = '2';
+
+ return { ...a, role: newRole, seq: '' }; // seq 임시 비움
+ }
+ return { ...a };
+ });
+
+ // seq 재할당 (상신자 제외하고 순차)
+ const reassigned = updated
+ .sort((x, y) => parseInt(x.seq || '0') - parseInt(y.seq || '0'))
+ .map((a) => {
+ if (a.seq === '0') return a; // 상신자
+ const newItem = { ...a, seq: newSeqCounter.toString() };
+ newSeqCounter += 1;
+ return newItem;
+ });
+
+ form.setValue('aplns', reassigned as FormData['aplns'], { shouldDirty: true });
+ clearSelection();
+ };
+
+ // seq 기준 정렬 및 재번호 부여 (병렬 그룹은 동일 seq 유지)
+ const reorderBySeq = (list: FormData['aplns']): FormData['aplns'] => {
+ const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq));
+
+ // 기존 seq -> 새 seq 매핑. 병렬 그룹(동일 seq)은 동일한 새 seq 를 갖도록 처리
+ const seqMap = new Map<string, string>();
+ let nextSeq = 0;
+
+ return sorted.map((apln) => {
+ if (!seqMap.has(apln.seq)) {
+ seqMap.set(apln.seq, nextSeq.toString());
+ nextSeq += 1;
+ }
+ return { ...apln, seq: seqMap.get(apln.seq)! };
+ });
+ };
+
+ // 로그인 사용자를 첫 번째 결재자로 보장하는 effect
+ useEffect(() => {
+ if (!session?.user) return;
+
+ const currentEmail = session.user.email ?? '';
+ const currentEpId = (session.user as { epId?: string }).epId;
+ const currentUserId = session.user.id ?? undefined;
+
+ let currentAplns = form.getValues('aplns');
+
+ // 이미 포함되어 있는지 확인 (epId 또는 email 기준)
+ const selfIndex = currentAplns.findIndex(
+ (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail
+ );
+
+ if (selfIndex === -1) {
+ // 맨 앞에 상신자 추가
+ const newSelf: FormData['aplns'][number] = {
+ id: generateUniqueId(),
+ epId: currentEpId,
+ userId: currentUserId ? currentUserId.toString() : undefined,
+ emailAddress: currentEmail,
+ name: session.user.name ?? undefined,
+ role: '0', // 기안
+ seq: '0',
+ opinion: ''
+ };
+
+ currentAplns = [newSelf, ...currentAplns];
+ }
+
+ // seq 재정렬 보장
+ currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() }));
+
+ form.setValue('aplns', currentAplns, { shouldValidate: false, shouldDirty: true });
+ }, [session, form]);
+
+ // dnd-kit sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ // 고유 ID 생성 함수
+ const generateUniqueId = () => {
+ return `apln-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+ };
+
+ // 결재자 추가 (UserSelector를 통해)
+ const addApprovalUsers = (users: UserSelectItem[]) => {
+ const newAplns = [...aplns];
+
+ users.forEach((user) => {
+ // 이미 추가된 사용자인지 확인
+ const existingIndex = newAplns.findIndex(apln => apln.userId === user.id.toString());
+ if (existingIndex === -1) {
+ // 새 사용자 추가
+ const newSeq = (newAplns.length).toString(); // 0은 상신자
+ const newApln: FormData['aplns'][number] = {
+ id: generateUniqueId(), // 고유 ID 생성
+ epId: (user as ExtendedUserSelectItem).epId, // epId가 전달되면 사용
+ userId: user.id.toString(),
+ emailAddress: user.email,
+ name: user.name,
+ deptName: (user as ExtendedUserSelectItem).deptName ?? undefined,
+ role: '1', // 기본값: 결재
+ seq: newSeq,
+ opinion: ''
+ };
+ newAplns.push(newApln);
+ }
+ });
+
+ form.setValue('aplns', newAplns);
+ };
+
+ // 그룹 삭제 (seq 기반)
+ const removeApprovalGroup = (seq: string) => {
+ if (seq === '0') return; // 상신자 삭제 불가
+
+ const remaining = aplns.filter((a) => a.seq !== seq);
+
+ // seq 재정렬 (병렬 그룹 유지)
+ const reordered = reorderBySeq(remaining);
+ form.setValue('aplns', reordered);
};
+ // 기존 단일 삭제 로직은 더 이상 사용하지 않음 (호환 위해 남겨둠)
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const removeApprovalLine = (index: number) => {
+ // 첫 번째(상신자)는 삭제 불가
+ if (index === 0) return;
+
if (aplns.length > 1) {
- const newAplns = aplns.filter((_, i) => i !== index);
- // 순서 재정렬
- const reorderedAplns = newAplns.map((apln, i) => ({
+ const newAplns = aplns.filter((_: FormData['aplns'][number], i: number) => i !== index);
+ // 순서 재정렬 (ID는 유지)
+ const reorderedAplns = newAplns.map((apln: FormData['aplns'][number], i: number) => ({
...apln,
- seq: (i + 1).toString()
+ seq: (i).toString()
}));
form.setValue('aplns', reorderedAplns);
}
};
+ // 드래그앤드롭 핸들러 (그룹 이동 지원)
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (!over || active.id === over.id) return;
+
+ // 현재 id는 그룹의 고유 key(첫 라인 id)
+ const activeKey = active.id as string;
+ const overKey = over.id as string;
+
+ // key → seq 매핑 생성
+ const idToSeq = new Map<string, string>();
+ aplns.forEach((a) => {
+ // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑
+ idToSeq.set(a.id, a.seq);
+ });
+
+ const activeSeq = idToSeq.get(activeKey);
+ const overSeq = idToSeq.get(overKey);
+
+ if (!activeSeq || !overSeq) return;
+
+ if (activeSeq === '0' || overSeq === '0') return; // 상신자는 이동 불가
+
+ // 현재 그룹 순서를 key 기반으로 계산
+ const seqOrder = Array.from(new Set(aplns.map((a) => a.seq)));
+ const keyOrder = seqOrder.map((seq) => {
+ return aplns.find((a) => a.seq === seq)!.id;
+ });
+
+ const oldIndex = keyOrder.indexOf(activeKey);
+ const newIndex = keyOrder.indexOf(overKey);
+
+ const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex);
+
+ // key → 새 seq 매핑
+ const keyToNewSeq = new Map<string, string>();
+ newKeyOrder.forEach((k, idx) => {
+ keyToNewSeq.set(k, idx.toString());
+ });
+
+ // aplns 재구성 + seq 재할당
+ const updatedAplns: FormData['aplns'] = [];
+ newKeyOrder.forEach((k) => {
+ const oldSeq = idToSeq.get(k)!;
+ const groupItems = aplns.filter((a) => a.seq === oldSeq);
+ groupItems.forEach((item) => {
+ updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! });
+ });
+ });
+
+ form.setValue('aplns', updatedAplns, { shouldValidate: false, shouldDirty: true });
+ };
+
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
setSubmitResult(null);
try {
- // 결재 경로 생성
+ // 결재 경로 생성 (ID 제거하고 API 호출)
const approvalLines: ApprovalLine[] = await Promise.all(
- data.aplns.map(async (apln) => {
- if (useFakeData) {
- return createMockApprovalLine({
- userId: apln.userId,
- emailAddress: apln.emailAddress,
- role: apln.role,
- seq: apln.seq,
- opinion: apln.opinion
- });
- } else {
- return createApprovalLine(
- { userId: apln.userId, emailAddress: apln.emailAddress },
- apln.role,
- apln.seq,
- { opinion: apln.opinion }
- );
- }
- })
+ data.aplns.map((apln) =>
+ createApprovalLine(
+ // userId: apln.userId 는 불필요하므로 제거
+ { epId: apln.epId, emailAddress: apln.emailAddress },
+ apln.role,
+ apln.seq,
+ { opinion: apln.opinion }
+ )
+ )
);
// 상신 요청 생성
const attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined;
- const submitRequest: SubmitApprovalRequest = useFakeData
- ? {
- ...data,
- urgYn: data.urgYn ? 'Y' : 'N',
- importantYn: data.importantYn ? 'Y' : 'N',
- sbmDt: new Date().toISOString().replace(/-|:|T/g, '').slice(0, 14),
- apInfId: 'test-ap-inf-id-' + Date.now(),
- aplns: approvalLines,
- attachments: attachmentsArray
- }
- : await createSubmitApprovalRequest(
- data.contents,
- data.subject,
- approvalLines,
- {
- contentsType: data.contentsType,
- docSecuType: data.docSecuType,
- urgYn: data.urgYn ? 'Y' : 'N',
- importantYn: data.importantYn ? 'Y' : 'N',
- notifyOption: data.notifyOption,
- docMngSaveCode: data.docMngSaveCode,
- sbmLang: data.sbmLang,
- timeZone: data.timeZone,
- attachments: attachmentsArray
- }
- );
+ const submitRequest: SubmitApprovalRequest = await createSubmitApprovalRequest(
+ data.contents,
+ data.subject,
+ approvalLines,
+ {
+ contentsType: 'HTML',
+ docSecuType: data.docSecuType,
+ urgYn: data.urgYn ? 'Y' : 'N',
+ importantYn: data.importantYn ? 'Y' : 'N',
+ notifyOption: data.notifyOption,
+ docMngSaveCode: data.docMngSaveCode,
+ sbmLang: data.sbmLang,
+ timeZone: data.timeZone,
+ attachments: attachmentsArray
+ }
+ );
// API 호출 (보안 등급에 따라 분기)
const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT';
- const response = useFakeData
- ? await mockApprovalAPI.submitApproval(submitRequest)
- : isSecure
- ? await submitSecurityApproval(submitRequest, systemId)
- : await submitApproval(submitRequest, systemId);
+ console.log(submitRequest);
+
+ const response = isSecure
+ ? await submitSecurityApproval(submitRequest)
+ : await submitApproval(submitRequest);
if (response.result === 'SUCCESS') {
setSubmitResult({ apInfId: response.data.apInfId });
@@ -195,14 +819,14 @@ export default function ApprovalSubmit({
};
return (
- <Card className="w-full max-w-4xl">
+ <Card className="w-full max-w-5xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
결재 상신
</CardTitle>
<CardDescription>
- 새로운 결재를 상신합니다. {useFakeData && '(테스트 모드)'}
+ 새로운 결재를 상신합니다.
</CardDescription>
</CardHeader>
@@ -223,7 +847,80 @@ export default function ApprovalSubmit({
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
- <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <Separator />
+
+ {/* 결재 경로 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">결재 경로</h3>
+
+ {/* 상단 제어 버튼 */}
+ <div className="flex justify-end gap-2 mb-2">
+ <Button variant="outline" size="sm" onClick={applyParallel}>병렬</Button>
+ <Button variant="outline" size="sm" onClick={applyAfter}>후결</Button>
+ <Button variant="outline" size="sm" onClick={ungroupParallel}>해제</Button>
+ </div>
+
+ {/* 결재자 추가 섹션 */}
+ <div className="p-4 border border-dashed border-gray-300 rounded-lg">
+ <div className="mb-2">
+ <label className="text-sm font-medium text-gray-700">결재자 추가</label>
+ <p className="text-xs text-gray-500">사용자를 검색하여 결재 라인에 추가하세요</p>
+ </div>
+ <UserSelector
+ selectedUsers={[]}
+ onUsersChange={addApprovalUsers}
+ placeholder="결재자를 검색하세요..."
+ domainFilter={{ type: "exclude", domains: ["partners"] }}
+ maxSelections={10} // 최대 10명까지 추가 가능
+ />
+ </div>
+
+ {/* 그룹 기반 렌더링 */}
+ {aplns.length > 0 && (
+ (() => {
+ const groups = Object.values(
+ aplns.reduce<Record<string, ApprovalLineItem[]>>((acc, apln) => {
+ acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln];
+ return acc;
+ }, {})
+ ).sort((a, b) => parseInt(a[0].seq) - parseInt(b[0].seq));
+
+ return (
+ <DndContext
+ sensors={sensors}
+ collisionDetection={closestCenter}
+ modifiers={[restrictToVerticalAxis, restrictToParentElement]}
+ onDragEnd={handleDragEnd}
+ >
+ <SortableContext items={groups.map(g => g[0].id)} strategy={verticalListSortingStrategy}>
+ <div className="space-y-3">
+ {groups.map((group, idx) => (
+ <SortableApprovalGroup
+ key={group[0].id}
+ group={group}
+ index={idx}
+ form={form}
+ onRemoveGroup={() => removeApprovalGroup(group[0].seq)}
+ canRemove={idx !== 0 && aplns.length > 1}
+ selected={selectedSeqs.includes(group[0].seq)}
+ onSelect={() => toggleSelectGroup(group[0].seq)}
+ />
+ ))}
+ </div>
+ </SortableContext>
+ </DndContext>
+ );
+ })()
+ )}
+
+ {aplns.length === 0 && (
+ <div className="text-center py-8 text-gray-500">
+ <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
+ <p>결재자를 추가해주세요</p>
+ </div>
+ )}
+ </div>
<FormField
control={form.control}
@@ -246,10 +943,10 @@ export default function ApprovalSubmit({
<FormItem>
<FormLabel>내용 *</FormLabel>
<FormControl>
- <Textarea
- placeholder="결재 내용을 입력하세요"
- rows={8}
- {...field}
+ <RichTextEditor
+ value={field.value}
+ onChange={field.onChange}
+ height="400px"
/>
</FormControl>
<FormMessage />
@@ -257,64 +954,38 @@ export default function ApprovalSubmit({
)}
/>
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="contentsType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>내용 형식</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="내용 형식 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="TEXT">TEXT</SelectItem>
- <SelectItem value="HTML">HTML</SelectItem>
- <SelectItem value="MIME">MIME</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
+ {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */}
+ <div className="grid grid-cols-3 gap-4">
+ {/* 보안 등급 */}
<FormField
control={form.control}
name="docSecuType"
render={({ field }) => (
- <FormItem>
- <FormLabel>보안 등급</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="보안 등급 선택" />
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <FormLabel>보안</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <SelectTrigger className="w-24">
+ <SelectValue placeholder="등급" />
</SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="PERSONAL">개인</SelectItem>
- <SelectItem value="CONFIDENTIAL">기밀</SelectItem>
- <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
+ <SelectContent>
+ <SelectItem value="PERSONAL">개인</SelectItem>
+ <SelectItem value="CONFIDENTIAL">기밀</SelectItem>
+ <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
</FormItem>
)}
/>
- </div>
- <div className="grid grid-cols-2 gap-4">
+ {/* 긴급 여부 */}
<FormField
control={form.control}
name="urgYn"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel>긴급 여부</FormLabel>
- <FormDescription>긴급 결재로 처리</FormDescription>
- </div>
+ <FormLabel>긴급</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -325,15 +996,13 @@ export default function ApprovalSubmit({
)}
/>
+ {/* 중요 여부 */}
<FormField
control={form.control}
name="importantYn"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel>중요 여부</FormLabel>
- <FormDescription>중요 결재로 분류</FormDescription>
- </div>
+ <FormLabel>중요</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -369,116 +1038,6 @@ export default function ApprovalSubmit({
<Separator />
- {/* 결재 경로 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-lg font-semibold">결재 경로</h3>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={addApprovalLine}
- >
- <Plus className="w-4 h-4 mr-2" />
- 결재자 추가
- </Button>
- </div>
-
- <div className="space-y-3">
- {aplns.map((apln, index) => (
- <div key={index} className="flex items-center gap-3 p-3 border rounded-lg">
- <Badge variant="outline">{index + 1}</Badge>
-
- <div className="flex-1 grid grid-cols-4 gap-3">
- <FormField
- control={form.control}
- name={`aplns.${index}.userId`}
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input placeholder="사용자 ID" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`aplns.${index}.emailAddress`}
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input placeholder="이메일 (선택)" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`aplns.${index}.role`}
- render={({ field }) => (
- <FormItem>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="0">기안</SelectItem>
- <SelectItem value="1">결재</SelectItem>
- <SelectItem value="2">합의</SelectItem>
- <SelectItem value="3">후결</SelectItem>
- <SelectItem value="4">병렬합의</SelectItem>
- <SelectItem value="7">병렬결재</SelectItem>
- <SelectItem value="9">통보</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`aplns.${index}.opinion`}
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input placeholder="의견 (선택)" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <div className="flex items-center gap-2">
- <Badge variant="secondary">
- {getRoleText(apln.role)}
- </Badge>
-
- {aplns.length > 1 && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeApprovalLine(index)}
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- )}
- </div>
- </div>
- ))}
- </div>
- </div>
-
- <Separator />
-
{/* 제출 버튼 */}
<div className="flex justify-end space-x-3">
<Button
diff --git a/components/knox/approval/index.ts b/components/knox/approval/index.ts
index 0bae08f1..e3aabf8d 100644
--- a/components/knox/approval/index.ts
+++ b/components/knox/approval/index.ts
@@ -5,9 +5,6 @@ export { default as ApprovalCancel } from './ApprovalCancel';
export { default as ApprovalList } from './ApprovalList';
export { default as ApprovalManager } from './ApprovalManager';
-// Mock 데이터 및 유틸리티 함수들
-export * from './mocks/approval-mock';
-
// 타입 정의들 (re-export)
export type {
ApprovalLine,
diff --git a/components/knox/approval/mocks/approval-mock.ts b/components/knox/approval/mocks/approval-mock.ts
deleted file mode 100644
index 021eb925..00000000
--- a/components/knox/approval/mocks/approval-mock.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-import {
- ApprovalLine,
- SubmitApprovalRequest,
- SubmitApprovalResponse,
- ApprovalDetailResponse,
- ApprovalContentResponse,
- ApprovalStatusResponse,
- CancelApprovalResponse,
- SubmissionListResponse,
- ApprovalHistoryResponse
-} from '@/lib/knox-api/approval/approval';
-
-// Mock 데이터 생성 함수들
-export const createMockApprovalLine = (overrides?: Partial<ApprovalLine>): ApprovalLine => ({
- epId: '12345',
- userId: 'user123',
- emailAddress: 'user@example.com',
- seq: '1',
- role: '0', // 기안
- aplnStatsCode: '0', // 미결
- arbPmtYn: 'N',
- contentsMdfyPmtYn: 'N',
- aplnMdfyPmtYn: 'N',
- opinion: '결재 요청드립니다.',
- ...overrides
-});
-
-export const createMockSubmitApprovalRequest = (overrides?: Partial<SubmitApprovalRequest>): SubmitApprovalRequest => ({
- contents: '결재 요청 내용입니다.',
- contentsType: 'TEXT',
- docSecuType: 'PERSONAL',
- notifyOption: '0',
- urgYn: 'N',
- sbmDt: '20241215120000',
- timeZone: 'GMT+9',
- docMngSaveCode: '0',
- subject: '결재 요청 - 테스트',
- sbmLang: 'ko',
- apInfId: 'test-ap-inf-id-' + Date.now(),
- importantYn: 'N',
- aplns: [
- createMockApprovalLine({ seq: '1', role: '0' }), // 기안
- createMockApprovalLine({ seq: '2', role: '1', userId: 'approver1' }), // 결재
- createMockApprovalLine({ seq: '3', role: '1', userId: 'approver2' }), // 결재
- ],
- ...overrides
-});
-
-export const mockSubmitApprovalResponse: SubmitApprovalResponse = {
- result: 'SUCCESS',
- data: {
- apInfId: 'test-ap-inf-id-' + Date.now()
- }
-};
-
-export const mockApprovalDetailResponse: ApprovalDetailResponse = {
- result: 'SUCCESS',
- data: {
- contentsType: 'TEXT',
- sbmDt: '20241215120000',
- sbmLang: 'ko',
- apInfId: 'test-ap-inf-id-123',
- systemId: 'EVCP_SYSTEM',
- notifyOption: '0',
- urgYn: 'N',
- docSecuType: 'PERSONAL',
- status: '1', // 진행중
- timeZone: 'GMT+9',
- subject: '결재 요청 - 테스트',
- aplns: [
- createMockApprovalLine({ seq: '1', role: '0', aplnStatsCode: '1' }), // 기안 완료
- createMockApprovalLine({ seq: '2', role: '1', aplnStatsCode: '0', userId: 'approver1' }), // 결재 대기
- createMockApprovalLine({ seq: '3', role: '1', aplnStatsCode: '0', userId: 'approver2' }), // 결재 대기
- ],
- attachments: []
- }
-};
-
-export const mockApprovalContentResponse: ApprovalContentResponse = {
- result: 'SUCCESS',
- data: {
- contents: '결재 요청 내용입니다.\n\n상세한 내용은 다음과 같습니다:\n- 항목 1\n- 항목 2\n- 항목 3',
- contentsType: 'TEXT',
- apInfId: 'test-ap-inf-id-123'
- }
-};
-
-export const mockApprovalStatusResponse: ApprovalStatusResponse = {
- result: 'SUCCESS',
- data: [
- {
- apInfId: 'test-ap-inf-id-123',
- docChgNum: '1',
- status: '1' // 진행중
- }
- ]
-};
-
-export const mockCancelApprovalResponse: CancelApprovalResponse = {
- result: 'SUCCESS',
- data: {
- apInfId: 'test-ap-inf-id-123'
- }
-};
-
-export const mockSubmissionListResponse: SubmissionListResponse = {
- result: 'SUCCESS',
- data: [
- {
- apInfId: 'test-ap-inf-id-123',
- subject: '결재 요청 - 테스트',
- sbmDt: '20241215120000',
- status: '1',
- urgYn: 'N',
- docSecuType: 'PERSONAL'
- },
- {
- apInfId: 'test-ap-inf-id-124',
- subject: '결재 요청 - 테스트 2',
- sbmDt: '20241214100000',
- status: '2',
- urgYn: 'Y',
- docSecuType: 'CONFIDENTIAL'
- }
- ]
-};
-
-export const mockApprovalHistoryResponse: ApprovalHistoryResponse = {
- result: 'SUCCESS',
- data: [
- {
- apInfId: 'test-ap-inf-id-123',
- subject: '결재 요청 - 테스트',
- sbmDt: '20241215120000',
- status: '1',
- actionType: 'SUBMIT',
- actionDt: '20241215120000',
- userId: 'submitter123'
- },
- {
- apInfId: 'test-ap-inf-id-124',
- subject: '결재 요청 - 테스트 2',
- sbmDt: '20241214100000',
- status: '2',
- actionType: 'APPROVE',
- actionDt: '20241214150000',
- userId: 'approver1'
- }
- ]
-};
-
-// Mock 함수들
-export const mockApprovalAPI = {
- submitApproval: async (request: SubmitApprovalRequest): Promise<SubmitApprovalResponse> => {
- // 실제 API 호출 시뮬레이션
- await new Promise(resolve => setTimeout(resolve, 1000));
- return mockSubmitApprovalResponse;
- },
-
- getApprovalDetail: async (apInfId: string): Promise<ApprovalDetailResponse> => {
- await new Promise(resolve => setTimeout(resolve, 500));
- return mockApprovalDetailResponse;
- },
-
- getApprovalContent: async (apInfId: string): Promise<ApprovalContentResponse> => {
- await new Promise(resolve => setTimeout(resolve, 300));
- return mockApprovalContentResponse;
- },
-
- getApprovalStatus: async (apInfIds: string[]): Promise<ApprovalStatusResponse> => {
- await new Promise(resolve => setTimeout(resolve, 400));
- return mockApprovalStatusResponse;
- },
-
- cancelApproval: async (apInfId: string): Promise<CancelApprovalResponse> => {
- await new Promise(resolve => setTimeout(resolve, 800));
- return mockCancelApprovalResponse;
- },
-
- getSubmissionList: async (): Promise<SubmissionListResponse> => {
- await new Promise(resolve => setTimeout(resolve, 600));
- return mockSubmissionListResponse;
- },
-
- getApprovalHistory: async (): Promise<ApprovalHistoryResponse> => {
- await new Promise(resolve => setTimeout(resolve, 700));
- return mockApprovalHistoryResponse;
- }
-};
-
-// 상태 및 역할 텍스트 변환 함수들
-export const getStatusText = (status: string): string => {
- const statusMap: Record<string, string> = {
- '-3': '암호화실패',
- '-2': '암호화중',
- '-1': '예약상신',
- '0': '보류',
- '1': '진행중',
- '2': '완결',
- '3': '반려',
- '4': '상신취소',
- '5': '전결',
- '6': '후완결'
- };
- return statusMap[status] || '알 수 없음';
-};
-
-export const getRoleText = (role: string): string => {
- const roleMap: Record<string, string> = {
- '0': '기안',
- '1': '결재',
- '2': '합의',
- '3': '후결',
- '4': '병렬합의',
- '7': '병렬결재',
- '9': '통보'
- };
- return roleMap[role] || '알 수 없음';
-};
-
-export const getApprovalStatusText = (status: string): string => {
- const statusMap: Record<string, string> = {
- '0': '미결',
- '1': '결재',
- '2': '반려',
- '3': '전결',
- '5': '자동결재'
- };
- return statusMap[status] || '알 수 없음';
-}; \ No newline at end of file
diff --git a/components/qna/tiptap-editor.tsx b/components/qna/tiptap-editor.tsx
index 4ab7f097..5d0a84e9 100644
--- a/components/qna/tiptap-editor.tsx
+++ b/components/qna/tiptap-editor.tsx
@@ -135,22 +135,6 @@ export default function TiptapEditor({ content, setContent, disabled, height = "
},
})
- // 이미지 크기 확인 함수
- const getImageDimensions = (src: string): Promise<{ width: number; height: number }> =>
- new Promise((resolve, reject) => {
- // 1. 올바른 생성자
- const img = new Image();
-
- // 2. 로딩 완료 시 naturalWidth/Height 사용
- img.onload = () => {
- resolve({ width: img.naturalWidth, height: img.naturalHeight });
- };
-
- img.onerror = () => reject(new Error('이미지 로드 실패'));
- img.src = src; // 3. 마지막에 src 지정
- });
-
-
async function uploadImageToServer(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
diff --git a/components/rich-text-editor/RichTextEditor.tsx b/components/rich-text-editor/RichTextEditor.tsx
new file mode 100644
index 00000000..ceb76665
--- /dev/null
+++ b/components/rich-text-editor/RichTextEditor.tsx
@@ -0,0 +1,998 @@
+'use client'
+
+import React, { useCallback, useRef, useState, 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 Highlight from '@tiptap/extension-highlight'
+import TaskList from '@tiptap/extension-task-list'
+import TaskItem from '@tiptap/extension-task-item'
+import BulletList from '@tiptap/extension-bullet-list'
+import ListItem from '@tiptap/extension-list-item'
+import OrderedList from '@tiptap/extension-ordered-list'
+import Blockquote from '@tiptap/extension-blockquote'
+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'
+
+// 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'
+
+/* -------------------------------------------------------------------------------------------------
+ * 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%"
+}
+
+export default function RichTextEditor({
+ value,
+ onChange,
+ disabled,
+ height = '300px',
+}: RichTextEditorProps) {
+ // ---------------------------------------------------------------------------
+ // Editor instance
+ // ---------------------------------------------------------------------------
+ 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,
+ ],
+ content: value,
+ editable: !disabled,
+ enablePasteRules: false,
+ enableInputRules: false,
+ immediatelyRender: false,
+ 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',
+ },
+ handleDrop: (view, event, slice, moved) => {
+ if (!moved && event.dataTransfer?.files.length) {
+ const file = event.dataTransfer.files[0]
+ if (file.type.startsWith('image/')) {
+ handleImageUpload(file)
+ return true
+ }
+ }
+ return false
+ },
+ handlePaste: (view, event) => {
+ if (event.clipboardData?.files.length) {
+ const file = event.clipboardData.files[0]
+ if (file.type.startsWith('image/')) {
+ handleImageUpload(file)
+ return true
+ }
+ }
+ return false
+ },
+ },
+ onUpdate: ({ editor }) => {
+ onChange(editor.getHTML())
+ },
+ })
+
+ // ---------------------------------------------------------------------------
+ // Image handling (base64)
+ // ---------------------------------------------------------------------------
+ const handleImageUpload = async (file: File) => {
+ if (file.size > 3 * 1024 * 1024) {
+ alert('이미지 크기는 3 MB 이하만 지원됩니다.')
+ return
+ }
+ if (!file.type.startsWith('image/')) {
+ alert('이미지 파일만 업로드 가능합니다.')
+ 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()
+ }
+ 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)` }
+
+ 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>
+ <div className="overflow-y-auto" style={editorContentStyle}>
+ <EditorContent editor={editor} className="h-full" />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/spread-js/dataBinding.tsx b/components/spread-js/dataBinding.tsx
index 52171dbf..b619f9f2 100644
--- a/components/spread-js/dataBinding.tsx
+++ b/components/spread-js/dataBinding.tsx
@@ -7,8 +7,8 @@ import "@mescius/spread-sheets-resources-ko";
import { SpreadSheets } from "@mescius/spread-sheets-react";
GC.Spread.Common.CultureManager.culture("ko-kr");
-GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREADJS_KEY
-GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_SPREADJS_KEY
+GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE
+GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_DESIGNER_LICENSE
const DataBinding = () => {
let spread = null;
diff --git a/components/spread-js/testSheet.tsx b/components/spread-js/testSheet.tsx
index 0d69798e..02347b00 100644
--- a/components/spread-js/testSheet.tsx
+++ b/components/spread-js/testSheet.tsx
@@ -31,8 +31,8 @@ import { Button } from "@/components/ui/button";
// var SpreadJSKey = "xxx"; // 라이선스 키 입력
// GC.Spread.Sheets.LicenseKey = SpreadJSKey;
GC.Spread.Common.CultureManager.culture("ko-kr");
-GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREADJS_KEY
-GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_SPREADJS_KEY
+GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE
+GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_DESIGNER_LICENSE
export default function SpreadSheet() {
const [spread, setSpread] = useState(null);