diff options
34 files changed, 7452 insertions, 817 deletions
diff --git a/app/[lng]/evcp/(evcp)/(system)/permissions/page.tsx b/app/[lng]/evcp/(evcp)/(system)/permissions/page.tsx index 2d7b94e2..3c57976a 100644 --- a/app/[lng]/evcp/(evcp)/(system)/permissions/page.tsx +++ b/app/[lng]/evcp/(evcp)/(system)/permissions/page.tsx @@ -12,7 +12,7 @@ import { Shield, Users, Key, Menu, Search, Plus } from "lucide-react"; import { RolePermissionManager } from "@/components/permissions/role-permission-manager"; import { PermissionAssignmentManager } from "@/components/permissions/permission-assignment-manager"; import { UserPermissionManager } from "@/components/permissions/user-permission-manager"; -import { MenuPermissionManager } from "@/components/permissions/menu-permission-manager"; +import { PermissionGroupAssignmentManager } from "@/components/permissions/permission-group-assignment-manager"; export default function PermissionManagementPage() { const [searchTerm, setSearchTerm] = useState(""); @@ -41,9 +41,9 @@ export default function PermissionManagementPage() { <Key className="mr-2 h-4 w-4" /> 권한별 관리 </TabsTrigger> - <TabsTrigger value="by-menu"> + <TabsTrigger value="by-group"> <Menu className="mr-2 h-4 w-4" /> - 메뉴별 관리 + 그룹별 관리 </TabsTrigger> </TabsList> @@ -62,9 +62,9 @@ export default function PermissionManagementPage() { <PermissionAssignmentManager /> </TabsContent> - {/* 메뉴별 권한 설정 */} - <TabsContent value="by-menu"> - <MenuPermissionManager /> +{/* 권한 그룹별 사용자/역할 관리 */} +<TabsContent value="by-group"> + <PermissionGroupAssignmentManager /> </TabsContent> </Tabs> </div> diff --git a/app/api/data-room/[projectId]/[fileId]/route.ts b/app/api/data-room/[projectId]/[fileId]/route.ts index 176aaf63..9ee01eb2 100644 --- a/app/api/data-room/[projectId]/[fileId]/route.ts +++ b/app/api/data-room/[projectId]/[fileId]/route.ts @@ -1,15 +1,20 @@ -// app/api/files/[projectId]/[fileId]/route.ts +// app/api/data-room/[projectId]/[fileId]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth/next'; import { authOptions } from '@/app/api/auth/[...nextauth]/route' import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { fileActivityLogs, fileItems } from '@/db/schema'; +import { and, eq, sql } from 'drizzle-orm'; +import db from "@/db/db" // 파일 정보 조회 export async function GET( request: NextRequest, - { params }: { params: { projectId: string; fileId: string } } + { params }: { params: Promise<{ projectId: string; fileId: string }> } ) { try { + const { projectId, fileId } = await params; + const session = await getServerSession(authOptions); if (!session?.user) { return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); @@ -25,7 +30,7 @@ export async function GET( const fileService = new FileService(); const hasAccess = await fileService.checkFileAccess( - params.fileId, + fileId, context, 'view' ); @@ -38,7 +43,7 @@ export async function GET( } // 파일 정보 반환 - const file = await fileService.downloadFile(params.fileId, context); + const file = await fileService.downloadFile(fileId, context); if (!file) { return NextResponse.json( @@ -57,51 +62,157 @@ export async function GET( } } -// 파일 수정 +// 파일 수정 (이름 변경, 카테고리 변경, 파일 이동 통합) export async function PATCH( request: NextRequest, - { params }: { params: { projectId: string; fileId: string } } + { params }: { params: Promise<{ projectId: string; fileId: string }> } ) { try { + const { projectId, fileId } = await params; + + console.log('PATCH 요청 받음:', { projectId, fileId }); + const session = await getServerSession(authOptions); if (!session?.user) { return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); } - const context: FileAccessContext = { - userId: session.user.id, - userDomain: session.user.domain || 'partners', - userEmail: session.user.email, - ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, - userAgent: request.headers.get('user-agent') || undefined, + const body = await request.json(); + const { name, category, parentId, applyToChildren } = body; + + console.log('요청 본문:', { name, category, parentId, applyToChildren }); + + // 권한 확인 + const fileItem = await db + .select() + .from(fileItems) + .where( + and( + eq(fileItems.id, fileId), + eq(fileItems.projectId, projectId) + ) + ) + .limit(1); + + if (!fileItem[0]) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }); + } + + // 내부 사용자만 수정 가능 + if (session.user.domain === 'partners') { + return NextResponse.json({ error: 'Permission denied' }, { status: 403 }); + } + + const updateData: any = { + updatedBy: session.user.id, + updatedAt: new Date(), }; - const fileService = new FileService(); - const hasAccess = await fileService.checkFileAccess( - params.fileId, - context, - 'edit' - ); + // 이름 변경 처리 + if (name !== undefined) { + // 파일인 경우 확장자 유지 + if (fileItem[0].type === 'file') { + const originalExt = fileItem[0].name.lastIndexOf('.'); + const newNameWithoutExt = name.lastIndexOf('.') > -1 + ? name.substring(0, name.lastIndexOf('.')) + : name; + const ext = originalExt > -1 ? fileItem[0].name.substring(originalExt) : ''; + updateData.name = newNameWithoutExt + ext; + } else { + updateData.name = name; + } - if (!hasAccess) { - return NextResponse.json( - { error: '파일 수정 권한이 없습니다' }, - { status: 403 } - ); + // 경로 업데이트 (하위 항목들도 업데이트 필요) + if (fileItem[0].type === 'folder') { + const oldPath = fileItem[0].path + fileItem[0].name + '/'; + const newPath = fileItem[0].path + updateData.name + '/'; + + // 하위 항목들의 경로 업데이트 + await db + .update(fileItems) + .set({ + path: sql`REPLACE(path, ${oldPath}, ${newPath})` + }) + .where( + and( + eq(fileItems.projectId, projectId), + sql`path LIKE ${oldPath + '%'}` + ) + ); + } } - const body = await request.json(); + // 카테고리 변경 처리 + if (category !== undefined) { + updateData.category = category; + + // 폴더인 경우 하위 항목들도 같은 카테고리로 변경할지 옵션 제공 + if (fileItem[0].type === 'folder' && applyToChildren) { + await updateChildrenCategory(fileId, category, projectId); + } + } + + // 부모 폴더 변경 (파일 이동) 처리 + if (parentId !== undefined) { + updateData.parentId = parentId; + + // 새 부모 폴더의 경로 가져오기 + if (parentId) { + const parentFolder = await db + .select() + .from(fileItems) + .where(eq(fileItems.id, parentId)) + .limit(1); + + if (parentFolder[0]) { + updateData.path = parentFolder[0].path + parentFolder[0].name + '/'; + } + } else { + updateData.path = '/'; + } + } + + console.log('업데이트 데이터:', updateData); + + // 업데이트 실행 + const [updatedItem] = await db + .update(fileItems) + .set(updateData) + .where(eq(fileItems.id, fileId)) + .returning(); + + // 활동 로그 기록 + const action = name ? 'rename' : category ? 'category_change' : 'move'; + const actionDetails: any = {}; - // 파일 이동 처리 - if (body.parentId !== undefined) { - await fileService.moveFile(params.fileId, body.parentId, context); + if (name) { + actionDetails.from = fileItem[0].name; + actionDetails.to = updateData.name; + } else if (category) { + actionDetails.from = fileItem[0].category; + actionDetails.to = category; + } else if (parentId !== undefined) { + actionDetails.from = fileItem[0].parentId; + actionDetails.to = parentId; } - return NextResponse.json({ success: true }); + await db.insert(fileActivityLogs).values({ + fileItemId: fileId, + projectId, + action, + actionDetails, + userId: session.user.id, + userEmail: session.user.email, + userDomain: session.user.domain, + }); + + console.log('업데이트 완료:', updatedItem); + + return NextResponse.json(updatedItem); } catch (error) { console.error('파일 수정 오류:', error); return NextResponse.json( - { error: '파일 수정에 실패했습니다' }, + { error: '파일 수정에 실패했습니다', details: error.message }, { status: 500 } ); } @@ -110,9 +221,11 @@ export async function PATCH( // 파일 삭제 export async function DELETE( request: NextRequest, - { params }: { params: { projectId: string; fileId: string } } + { params }: { params: Promise<{ projectId: string; fileId: string }> } ) { try { + const { projectId, fileId } = await params; + const session = await getServerSession(authOptions); if (!session?.user) { return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); @@ -127,7 +240,7 @@ export async function DELETE( }; const fileService = new FileService(); - await fileService.deleteFile(params.fileId, context); + await fileService.deleteFile(fileId, context); return NextResponse.json({ success: true }); } catch (error) { @@ -144,4 +257,33 @@ export async function DELETE( { status: 500 } ); } +} + +// 하위 항목 카테고리 업데이트 함수 +async function updateChildrenCategory( + parentId: string, + category: string, + projectId: string +) { + const children = await db + .select() + .from(fileItems) + .where( + and( + eq(fileItems.parentId, parentId), + eq(fileItems.projectId, projectId) + ) + ); + + for (const child of children) { + await db + .update(fileItems) + .set({ category }) + .where(eq(fileItems.id, child.id)); + + // 재귀적으로 하위 폴더 처리 + if (child.type === 'folder') { + await updateChildrenCategory(child.id, category, projectId); + } + } }
\ No newline at end of file diff --git a/app/api/email-template/preview/route.ts b/app/api/email-template/preview/route.ts new file mode 100644 index 00000000..6e066d27 --- /dev/null +++ b/app/api/email-template/preview/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from 'next/server';
+import db from "@/db/db";
+import { templateDetailView } from '@/db/schema';
+import { eq } from 'drizzle-orm';
+import handlebars from 'handlebars';
+
+// Handlebars helper 등록
+handlebars.registerHelper('formatDate', function(date: any, format: string) {
+ if (!date) return '';
+
+ const d = new Date(date);
+ if (isNaN(d.getTime())) return '';
+
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ const hours = String(d.getHours()).padStart(2, '0');
+ const minutes = String(d.getMinutes()).padStart(2, '0');
+ const seconds = String(d.getSeconds()).padStart(2, '0');
+
+ switch (format) {
+ case 'YYYY-MM-DD':
+ return `${year}-${month}-${day}`;
+ case 'YYYY-MM-DD HH:mm:ss':
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+ case 'YYYY-MM-DD HH:mm':
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
+ default:
+ return d.toLocaleDateString('ko-KR');
+ }
+});
+
+export async function POST(request: Request) {
+ try {
+ const { templateSlug, sampleData } = await request.json();
+
+ if (!templateSlug) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: '템플릿 슬러그가 필요합니다.'
+ },
+ { status: 400 }
+ );
+ }
+
+ // 데이터베이스에서 템플릿 조회
+ const templates = await db
+ .select()
+ .from(templateDetailView)
+ .where(eq(templateDetailView.slug, templateSlug))
+ .limit(1);
+
+ if (templates.length === 0) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: '템플릿을 찾을 수 없습니다.'
+ },
+ { status: 404 }
+ );
+ }
+
+ const template = templates[0];
+
+ if (!template.isActive) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: '비활성화된 템플릿입니다.'
+ },
+ { status: 400 }
+ );
+ }
+
+ // HTML 템플릿에서 t helper 제거 ({{t 'key'}} -> {{key}})
+ let subjectTemplateStr = template.subject || '';
+ let contentTemplateStr = template.content || '';
+
+ // {{t 'key'}} 패턴을 {{key}}로 치환
+ subjectTemplateStr = subjectTemplateStr.replace(/\{\{t\s+['"]([^'"]+)['"]\}\}/g, '{{$1}}');
+ contentTemplateStr = contentTemplateStr.replace(/\{\{t\s+['"]([^'"]+)['"]\}\}/g, '{{$1}}');
+
+ // Handlebars 템플릿 컴파일 및 렌더링
+ try {
+ const subjectTemplate = handlebars.compile(subjectTemplateStr);
+ const contentTemplate = handlebars.compile(contentTemplateStr);
+
+ const renderedSubject = subjectTemplate(sampleData);
+ const renderedContent = contentTemplate(sampleData);
+
+ return NextResponse.json({
+ success: true,
+ subject: renderedSubject,
+ html: renderedContent
+ });
+ } catch (compileError) {
+ console.error('템플릿 컴파일 오류:', compileError);
+ return NextResponse.json(
+ {
+ success: false,
+ error: '템플릿 처리 중 오류가 발생했습니다.'
+ },
+ { status: 500 }
+ );
+ }
+ } catch (error) {
+ console.error('미리보기 생성 실패:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: '미리보기를 생성하는데 실패했습니다.'
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index c64676c6..69aac8c3 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -18,6 +18,7 @@ export async function POST(request: NextRequest) { const project = await projectService.createProject( { name: body.name, + code: body.code, description: body.description, isPublic: body.isPublic || false, }, diff --git a/app/api/upload/project-doc-template/chunk/route.ts b/app/api/upload/project-doc-template/chunk/route.ts new file mode 100644 index 00000000..2cba654a --- /dev/null +++ b/app/api/upload/project-doc-template/chunk/route.ts @@ -0,0 +1,178 @@ +// app/api/upload/project-doc-template/chunk/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { mkdir, writeFile, readFile, rm } from 'fs/promises'; +import path from 'path'; +import { saveBuffer, saveDRMFile } from '@/lib/file-stroage'; +import { decryptWithServerAction } from '@/components/drm/drmUtils'; + +// 허용된 파일 타입 +const ALLOWED_MIME_TYPES = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +]; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + const chunk = formData.get('chunk') as File; + const filename = formData.get('filename') as string; + const chunkIndex = parseInt(formData.get('chunkIndex') as string); + const totalChunks = parseInt(formData.get('totalChunks') as string); + const fileId = formData.get('fileId') as string; + + // 필수 매개변수 검증 + if (!chunk || !filename || isNaN(chunkIndex) || isNaN(totalChunks) || !fileId) { + return NextResponse.json({ + success: false, + error: '필수 매개변수가 누락되었습니다' + }, { status: 400 }); + } + + // 파일 확장자 검증 + const fileExtension = filename.toLowerCase().split('.').pop(); + if (!fileExtension || !['doc', 'docx'].includes(fileExtension)) { + return NextResponse.json({ + success: false, + error: '워드 파일(.doc, .docx)만 업로드 가능합니다' + }, { status: 400 }); + } + + // 임시 디렉토리 생성 + const tempDir = path.join(process.cwd(), 'temp', 'project-doc-templates', fileId); + await mkdir(tempDir, { recursive: true }); + + // 청크 파일 저장 + const chunkPath = path.join(tempDir, `chunk-${chunkIndex}`); + const buffer = Buffer.from(await chunk.arrayBuffer()); + await writeFile(chunkPath, buffer); + + console.log(`📄 [ProjectDocTemplate] 청크 저장: ${chunkIndex + 1}/${totalChunks} - ${filename}`); + + // 마지막 청크인 경우 모든 청크를 합쳐 최종 파일 생성 + if (chunkIndex === totalChunks - 1) { + console.log(`🔄 [ProjectDocTemplate] 파일 병합 시작: ${filename}`); + + try { + // 모든 청크를 순서대로 읽어서 병합 + const chunks: Buffer[] = []; + let totalSize = 0; + + for (let i = 0; i < totalChunks; i++) { + const chunkData = await readFile(path.join(tempDir, `chunk-${i}`)); + chunks.push(chunkData); + totalSize += chunkData.length; + } + + // 모든 청크를 하나의 Buffer로 병합 + const mergedBuffer = Buffer.concat(chunks, totalSize); + console.log(`✅ [ProjectDocTemplate] 병합 완료: ${filename} (${totalSize} bytes)`); + + // MIME 타입 결정 + const mimeType = fileExtension === 'docx' + ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + : 'application/msword'; + + // 환경에 따른 저장 방식 선택 + const isProduction = process.env.NODE_ENV === 'production'; + let saveResult; + + if (isProduction) { + // Production: DRM 파일 처리 + console.log(`🔐 [ProjectDocTemplate] Production - DRM 처리: ${filename}`); + + const mergedFile = new File([mergedBuffer], filename, { + type: mimeType, + lastModified: Date.now(), + }); + + saveResult = await saveDRMFile( + mergedFile, + decryptWithServerAction, + 'project-doc-templates', // 프로젝트 문서 템플릿 전용 디렉토리 + ); + } else { + // Development: 일반 파일 저장 + console.log(`🛠️ [ProjectDocTemplate] Development - 일반 저장: ${filename}`); + + saveResult = await saveBuffer({ + buffer: mergedBuffer, + fileName: filename, + directory: 'project-doc-templates', // 프로젝트 문서 템플릿 전용 디렉토리 + originalName: filename + }); + } + + // 임시 파일 정리 (비동기로 처리) + rm(tempDir, { recursive: true, force: true }) + .then(() => console.log(`🗑️ [ProjectDocTemplate] 임시 파일 정리 완료: ${fileId}`)) + .catch((e: unknown) => console.error('[ProjectDocTemplate] 청크 정리 오류:', e)); + + if (saveResult.success) { + const envPrefix = isProduction ? '🔐' : '🛠️'; + console.log(`${envPrefix} [ProjectDocTemplate] 파일 저장 완료: ${saveResult.fileName}`); + + return NextResponse.json({ + success: true, + fileName: filename, + filePath: saveResult.publicPath, + hashedFileName: saveResult.fileName, + fileSize: totalSize, + mimeType, + environment: isProduction ? 'production' : 'development', + processingType: isProduction ? 'DRM' : 'standard', + templateType: 'project-doc-template' + }); + } else { + console.error(`[ProjectDocTemplate] 파일 저장 실패:`, saveResult.error); + return NextResponse.json({ + success: false, + error: saveResult.error || '파일 저장에 실패했습니다', + environment: isProduction ? 'production' : 'development' + }, { status: 500 }); + } + + } catch (mergeError) { + console.error('[ProjectDocTemplate] 파일 병합 오류:', mergeError); + + // 오류 발생 시 임시 파일 정리 + rm(tempDir, { recursive: true, force: true }) + .catch((e: unknown) => console.error('[ProjectDocTemplate] 임시 파일 정리 오류:', e)); + + return NextResponse.json({ + success: false, + error: '파일 병합 중 오류가 발생했습니다' + }, { status: 500 }); + } + } + + // 중간 청크 업로드 성공 응답 + return NextResponse.json({ + success: true, + chunkIndex, + message: `청크 ${chunkIndex + 1}/${totalChunks} 업로드 완료` + }); + + } catch (error) { + console.error('[ProjectDocTemplate] 청크 업로드 오류:', error); + + const errorMessage = error instanceof Error ? error.message : '서버 오류가 발생했습니다'; + + return NextResponse.json({ + success: false, + error: errorMessage + }, { status: 500 }); + } +} + +// OPTIONS 요청 처리 (CORS) +export async function OPTIONS(request: NextRequest) { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +}
\ No newline at end of file diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx index 483ef773..587beb22 100644 --- a/components/file-manager/FileManager.tsx +++ b/components/file-manager/FileManager.tsx @@ -1,12 +1,12 @@ 'use client'; import React, { useState, useEffect, useCallback } from 'react'; -import { - Folder, - File, - FolderPlus, - Upload, - Trash2, +import { + Folder, + File, + FolderPlus, + Upload, + Trash2, Edit2, Download, Share2, @@ -93,6 +93,8 @@ import { } from "@/components/ui/file-list"; import { decryptWithServerAction } from '@/components/drm/drmUtils'; import { Progress } from '@/components/ui/progress'; +// Import the secure viewer component +import { SecurePDFViewer } from './SecurePDFViewer'; interface FileItem { id: string; @@ -126,12 +128,12 @@ interface FileManagerProps { projectId: string; } -// 카테고리별 아이콘과 색상 +// Category configuration with icons and colors const categoryConfig = { - public: { icon: Globe, color: 'text-green-500', label: '공개' }, - restricted: { icon: Eye, color: 'text-yellow-500', label: '제한' }, - confidential: { icon: Lock, color: 'text-red-500', label: '기밀' }, - internal: { icon: Shield, color: 'text-blue-500', label: '내부' }, + public: { icon: Globe, color: 'text-green-500', label: 'Public' }, + restricted: { icon: Eye, color: 'text-yellow-500', label: 'Restricted' }, + confidential: { icon: Lock, color: 'text-red-500', label: 'Confidential' }, + internal: { icon: Shield, color: 'text-blue-500', label: 'Internal' }, }; // Tree Item Component @@ -143,172 +145,183 @@ const TreeItem: React.FC<{ onToggleExpand: (id: string) => void; onSelectItem: (id: string) => void; onDoubleClick: (item: FileItem) => void; + onView: (item: FileItem) => void; onDownload: (item: FileItem) => void; onDownloadFolder: (item: FileItem) => void; onDelete: (ids: string[]) => void; onShare: (item: FileItem) => void; onRename: (item: FileItem) => void; isInternalUser: boolean; -}> = ({ - item, - level, - expandedFolders, - selectedItems, - onToggleExpand, +}> = ({ + item, + level, + expandedFolders, + selectedItems, + onToggleExpand, onSelectItem, onDoubleClick, + onView, onDownload, onDownloadFolder, onDelete, onShare, onRename, - isInternalUser + isInternalUser }) => { - const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; - const isExpanded = expandedFolders.has(item.id); - const isSelected = selectedItems.has(item.id); - const CategoryIcon = categoryConfig[item.category].icon; - const categoryColor = categoryConfig[item.category].color; - const categoryLabel = categoryConfig[item.category].label; + const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; + const isExpanded = expandedFolders.has(item.id); + const isSelected = selectedItems.has(item.id); + const CategoryIcon = categoryConfig[item.category].icon; + const categoryColor = categoryConfig[item.category].color; + const categoryLabel = categoryConfig[item.category].label; - const formatFileSize = (bytes?: number) => { - if (!bytes) return '-'; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; - }; + const formatFileSize = (bytes?: number) => { + if (!bytes) return '-'; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; + }; - return ( - <> - <div - className={cn( - "flex items-center p-2 rounded-lg cursor-pointer transition-colors", - "hover:bg-accent", - isSelected && "bg-accent" - )} - style={{ paddingLeft: `${level * 24 + 8}px` }} - onClick={() => onSelectItem(item.id)} - onDoubleClick={() => onDoubleClick(item)} - > - <div className="flex items-center mr-2"> - {item.type === 'folder' && ( - <button - onClick={(e) => { - e.stopPropagation(); - onToggleExpand(item.id); - }} - className="p-0.5 hover:bg-gray-200 rounded" - > - {isExpanded ? ( - <ChevronDown className="h-4 w-4" /> - ) : ( - <ChevronRight className="h-4 w-4" /> - )} - </button> + return ( + <> + <div + className={cn( + "flex items-center p-2 rounded-lg cursor-pointer transition-colors", + "hover:bg-accent", + isSelected && "bg-accent" )} - {item.type === 'file' && ( - <div className="w-5" /> - )} - </div> - - {item.type === 'folder' ? ( - <Folder className="h-5 w-5 text-blue-500 mr-2" /> - ) : ( - <File className="h-5 w-5 text-gray-500 mr-2" /> - )} - - <span className="flex-1">{item.name}</span> - - <Badge variant="outline" className="mr-2"> - <CategoryIcon className={cn("h-3 w-3 mr-1", categoryColor)} /> - {categoryLabel} - </Badge> - - <span className="text-sm text-muted-foreground mr-4"> - {formatFileSize(item.size)} - </span> - <span className="text-sm text-muted-foreground mr-2"> - {new Date(item.updatedAt).toLocaleDateString()} - </span> - - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="sm"> - <MoreVertical className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent> - {item.type === 'file' && item.permissions?.canDownload && ( - <DropdownMenuItem onClick={() => onDownload(item)}> - <Download className="h-4 w-4 mr-2" /> - 다운로드 - </DropdownMenuItem> - )} - + style={{ paddingLeft: `${level * 24 + 8}px` }} + onClick={() => onSelectItem(item.id)} + onDoubleClick={() => onDoubleClick(item)} + > + <div className="flex items-center mr-2"> {item.type === 'folder' && ( - <DropdownMenuItem onClick={() => onDownloadFolder(item)}> - <Download className="h-4 w-4 mr-2" /> - 폴더 전체 다운로드 - </DropdownMenuItem> - )} - - {isInternalUser && ( - <> - <DropdownMenuItem onClick={() => onShare(item)}> - <Share2 className="h-4 w-4 mr-2" /> - 공유 - </DropdownMenuItem> - - {item.permissions?.canEdit && ( - <DropdownMenuItem onClick={() => onRename(item)}> - <Edit2 className="h-4 w-4 mr-2" /> - 이름 변경 - </DropdownMenuItem> + <button + onClick={(e) => { + e.stopPropagation(); + onToggleExpand(item.id); + }} + className="p-0.5 hover:bg-gray-200 rounded" + > + {isExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> )} - </> + </button> )} - - {item.permissions?.canDelete && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuItem - className="text-destructive" - onClick={() => onDelete([item.id])} - > - <Trash2 className="h-4 w-4 mr-2" /> - 삭제 - </DropdownMenuItem> - </> + {item.type === 'file' && ( + <div className="w-5" /> )} - </DropdownMenuContent> - </DropdownMenu> - </div> - - {item.type === 'folder' && isExpanded && item.children && ( - <div> - {item.children.map((child) => ( - <TreeItem - key={child.id} - item={child} - level={level + 1} - expandedFolders={expandedFolders} - selectedItems={selectedItems} - onToggleExpand={onToggleExpand} - onSelectItem={onSelectItem} - onDoubleClick={onDoubleClick} - onDownload={onDownload} - onDownloadFolder={onDownloadFolder} - onDelete={onDelete} - onShare={onShare} - onRename={onRename} - isInternalUser={isInternalUser} - /> - ))} + </div> + + {item.type === 'folder' ? ( + <Folder className="h-5 w-5 text-blue-500 mr-2" /> + ) : ( + <File className="h-5 w-5 text-gray-500 mr-2" /> + )} + + <span className="flex-1">{item.name}</span> + + <Badge variant="outline" className="mr-2"> + <CategoryIcon className={cn("h-3 w-3 mr-1", categoryColor)} /> + {categoryLabel} + </Badge> + + <span className="text-sm text-muted-foreground mr-4"> + {formatFileSize(item.size)} + </span> + <span className="text-sm text-muted-foreground mr-2"> + {new Date(item.updatedAt).toLocaleDateString()} + </span> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="sm"> + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent> + {item.type === 'file' && ( + <> + <DropdownMenuItem onClick={() => onView(item)}> + <Eye className="h-4 w-4 mr-2" /> + View + </DropdownMenuItem> + {item.permissions?.canDownload && ( + <DropdownMenuItem onClick={() => onDownload(item)}> + <Download className="h-4 w-4 mr-2" /> + Download + </DropdownMenuItem> + )} + </> + )} + + {item.type === 'folder' && ( + <DropdownMenuItem onClick={() => onDownloadFolder(item)}> + <Download className="h-4 w-4 mr-2" /> + Download Folder + </DropdownMenuItem> + )} + + {isInternalUser && ( + <> + <DropdownMenuItem onClick={() => onShare(item)}> + <Share2 className="h-4 w-4 mr-2" /> + Share + </DropdownMenuItem> + + {item.permissions?.canEdit && ( + <DropdownMenuItem onClick={() => onRename(item)}> + <Edit2 className="h-4 w-4 mr-2" /> + Rename + </DropdownMenuItem> + )} + </> + )} + + {item.permissions?.canDelete && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + className="text-destructive" + onClick={() => onDelete([item.id])} + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> </div> - )} - </> - ); -}; + + {item.type === 'folder' && isExpanded && item.children && ( + <div> + {item.children.map((child) => ( + <TreeItem + key={child.id} + item={child} + level={level + 1} + expandedFolders={expandedFolders} + selectedItems={selectedItems} + onToggleExpand={onToggleExpand} + onSelectItem={onSelectItem} + onDoubleClick={onDoubleClick} + onView={onView} + onDownload={onDownload} + onDownloadFolder={onDownloadFolder} + onDelete={onDelete} + onShare={onShare} + onRename={onRename} + isInternalUser={isInternalUser} + /> + ))} + </div> + )} + </> + ); + }; export function FileManager({ projectId }: FileManagerProps) { const { data: session } = useSession(); @@ -321,19 +334,23 @@ export function FileManager({ projectId }: FileManagerProps) { const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); - - // 업로드 상태 + + console.log(items,"items") + + // Upload states const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]); const [uploadCategory, setUploadCategory] = useState<string>('confidential'); - - // 다이얼로그 상태 + + // Dialog states const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [shareDialogOpen, setShareDialogOpen] = useState(false); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); const [renameDialogOpen, setRenameDialogOpen] = useState(false); - - // 다이얼로그 데이터 + const [viewerDialogOpen, setViewerDialogOpen] = useState(false); + const [viewerFileUrl, setViewerFileUrl] = useState<string | null>(null); + + // Dialog data const [dialogValue, setDialogValue] = useState(''); const [selectedCategory, setSelectedCategory] = useState<string>('confidential'); const [selectedFile, setSelectedFile] = useState<FileItem | null>(null); @@ -343,76 +360,76 @@ export function FileManager({ projectId }: FileManagerProps) { expiresAt: '', maxDownloads: '', }); - + const { toast } = useToast(); - // 사용자가 내부 사용자인지 확인 + // Check if user is internal const isInternalUser = session?.user?.domain !== 'partners'; - // 트리 구조 생성 함수 + // Build tree structure function const buildTree = (flatItems: FileItem[]): FileItem[] => { const itemMap = new Map<string, FileItem>(); const rootItems: FileItem[] = []; - - // 모든 아이템을 맵에 저장 (children 초기화) + + // Store all items in map (initialize children) flatItems.forEach(item => { itemMap.set(item.id, { ...item, children: [] }); }); - - // 부모-자식 관계 설정 + + // Set parent-child relationships flatItems.forEach(item => { const mappedItem = itemMap.get(item.id)!; - + if (!item.parentId) { - // parentId가 없으면 루트 아이템 + // No parentId means root item rootItems.push(mappedItem); } else { - // parentId가 있으면 부모의 children에 추가 + // Has parentId, add to parent's children const parent = itemMap.get(item.parentId); if (parent) { if (!parent.children) parent.children = []; parent.children.push(mappedItem); } else { - // 부모를 찾을 수 없으면 루트로 처리 + // Can't find parent, treat as root rootItems.push(mappedItem); } } }); - + return rootItems; }; - // 파일 목록 가져오기 + // Fetch file list const fetchItems = useCallback(async () => { setLoading(true); try { const params = new URLSearchParams(); - - // 트리 뷰일 때는 전체 목록을 가져옴 + + // For tree view, get entire list if (viewMode === 'list') { params.append('viewMode', 'tree'); - // 트리 뷰에서도 현재 경로 정보는 유지 (하이라이팅 등에 사용) + // Keep current path info for tree view (used for highlighting, etc.) if (currentParentId) params.append('currentParentId', currentParentId); } else { - // 그리드 뷰일 때는 현재 폴더의 내용만 가져옴 + // For grid view, only get current folder contents if (currentParentId) params.append('parentId', currentParentId); } - + const response = await fetch(`/api/data-room/${projectId}?${params}`); if (!response.ok) throw new Error('Failed to fetch files'); - + const data = await response.json(); setItems(data); - - // 트리 구조 생성 + + // Build tree structure if (viewMode === 'list') { const tree = buildTree(data); setTreeItems(tree); } } catch (error) { toast({ - title: '오류', - description: '파일을 불러오는데 실패했습니다.', + title: 'Error', + description: 'Failed to load files.', variant: 'destructive', }); } finally { @@ -424,7 +441,7 @@ export function FileManager({ projectId }: FileManagerProps) { fetchItems(); }, [fetchItems]); - // 폴더 생성 + // Create folder const createFolder = async () => { try { const response = await fetch(`/api/data-room/${projectId}`, { @@ -446,66 +463,66 @@ export function FileManager({ projectId }: FileManagerProps) { await fetchItems(); setFolderDialogOpen(false); setDialogValue(''); - + toast({ - title: '성공', - description: '폴더가 생성되었습니다.', + title: 'Success', + description: 'Folder created successfully.', }); } catch (error: any) { toast({ - title: '오류', - description: error.message || '폴더 생성에 실패했습니다.', + title: 'Error', + description: error.message || 'Failed to create folder.', variant: 'destructive', }); } }; - // 파일 업로드 처리 + // Handle file upload const handleFileUpload = async (files: FileList | File[]) => { const fileArray = Array.from(files); - - // 업로드 파일 목록 초기화 + + // Initialize uploading file list const newUploadingFiles: UploadingFile[] = fileArray.map(file => ({ file, progress: 0, status: 'pending' as const })); - + setUploadingFiles(newUploadingFiles); - - // 각 파일 업로드 처리 + + // Process each file upload for (let i = 0; i < fileArray.length; i++) { const file = fileArray[i]; - + try { - // 상태 업데이트: 업로드 중 - setUploadingFiles(prev => prev.map((f, idx) => + // Update status: uploading + setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'uploading', progress: 20 } : f )); - // DRM 복호화 - setUploadingFiles(prev => prev.map((f, idx) => + // DRM decryption + setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'processing', progress: 40 } : f )); - + const decryptedData = await decryptWithServerAction(file); - - // FormData 생성 + + // Create FormData const formData = new FormData(); const blob = new Blob([decryptedData], { type: file.type }); formData.append('file', blob, file.name); formData.append('category', uploadCategory); - formData.append('fileSize', file.size.toString()); // 파일 크기 전달 + formData.append('fileSize', file.size.toString()); // Pass file size if (currentParentId) { formData.append('parentId', currentParentId); } - - // 업로드 진행률 업데이트 - setUploadingFiles(prev => prev.map((f, idx) => + + // Update upload progress + setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, progress: 60 } : f )); - // API 호출 + // API call const response = await fetch(`/api/data-room/${projectId}/upload`, { method: 'POST', body: formData, @@ -515,99 +532,99 @@ export function FileManager({ projectId }: FileManagerProps) { const error = await response.json(); throw new Error(error.error || 'Upload failed'); } - - // 성공 - setUploadingFiles(prev => prev.map((f, idx) => + + // Success + setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'completed', progress: 100 } : f )); - + } catch (error: any) { - // 실패 - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { - ...f, - status: 'error', - error: error.message || '업로드 실패' + // Failure + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { + ...f, + status: 'error', + error: error.message || 'Upload failed' } : f )); } } - - // 모든 업로드 완료 후 목록 새로고침 + + // Refresh list after all uploads complete await fetchItems(); - - // 성공한 파일이 있으면 토스트 표시 + + // Show toast if any files succeeded const successCount = newUploadingFiles.filter(f => f.status === 'completed').length; if (successCount > 0) { toast({ - title: '업로드 완료', - description: `${successCount}개 파일이 업로드되었습니다.`, + title: 'Upload Complete', + description: `${successCount} file(s) uploaded successfully.`, }); } }; - // 폴더 다운로드 + // Download folder const downloadFolder = async (folder: FileItem) => { if (folder.type !== 'folder') return; - + try { toast({ - title: '권한 확인 중', - description: '폴더 내 파일들의 다운로드 권한을 확인하고 있습니다...', + title: 'Checking Permissions', + description: 'Verifying download permissions for folder contents...', }); - // 폴더 다운로드 API 호출 + // Call folder download API const response = await fetch(`/api/data-room/${projectId}/download-folder/${folder.id}`, { method: 'GET', }); - + if (!response.ok) { const error = await response.json(); - - // 권한이 없는 파일이 있는 경우 상세 정보 제공 + + // If there are files without permission, provide details if (error.unauthorizedFiles) { toast({ - title: '다운로드 권한 부족', - description: `${error.unauthorizedFiles.length}개 파일에 대한 권한이 없습니다: ${error.unauthorizedFiles.join(', ')}`, + title: 'Insufficient Permissions', + description: `No permission for ${error.unauthorizedFiles.length} file(s): ${error.unauthorizedFiles.join(', ')}`, variant: 'destructive', }); return; } - - throw new Error(error.error || '폴더 다운로드 실패'); + + throw new Error(error.error || 'Folder download failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); - - // 폴더명을 파일명에 포함 + + // Include folder name in filename const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const fileName = `${folder.name}_${timestamp}.zip`; - + const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); - + window.URL.revokeObjectURL(url); document.body.removeChild(a); toast({ - title: '다운로드 완료', - description: `${folder.name} 폴더가 다운로드되었습니다.`, + title: 'Download Complete', + description: `${folder.name} folder downloaded successfully.`, }); } catch (error: any) { toast({ - title: '오류', - description: error.message || '폴더 다운로드에 실패했습니다.', + title: 'Error', + description: error.message || 'Failed to download folder.', variant: 'destructive', }); } }; - // 파일 공유 + // Share file const shareFile = async () => { if (!selectedFile) return; @@ -626,45 +643,45 @@ export function FileManager({ projectId }: FileManagerProps) { } const data = await response.json(); - - // 공유 링크 복사 + + // Copy share link to clipboard await navigator.clipboard.writeText(data.shareUrl); - + toast({ - title: '공유 링크 생성됨', - description: '링크가 클립보드에 복사되었습니다.', + title: 'Share Link Created', + description: 'Link copied to clipboard.', }); - + setShareDialogOpen(false); setSelectedFile(null); } catch (error) { toast({ - title: '오류', - description: '공유 링크 생성에 실패했습니다.', + title: 'Error', + description: 'Failed to create share link.', variant: 'destructive', }); } }; - // 다중 파일 다운로드 + // Download multiple files const downloadMultipleFiles = async (itemIds: string[]) => { - // 선택된 파일들 중 실제 파일만 필터링 (폴더 제외) - const filesToDownload = items.filter(item => - itemIds.includes(item.id) && - item.type === 'file' && - item.permissions?.canDownload + // Filter only actual files (exclude folders) that can be downloaded + const filesToDownload = items.filter(item => + itemIds.includes(item.id) && + item.type === 'file' && + item.permissions?.canDownload === 'true' ); if (filesToDownload.length === 0) { toast({ - title: '알림', - description: '다운로드 가능한 파일이 없습니다.', + title: 'Notice', + description: 'No downloadable files selected.', variant: 'default', }); return; } - // 단일 파일인 경우 일반 다운로드 사용 + // Use regular download for single file if (filesToDownload.length === 1) { await downloadFile(filesToDownload[0]); return; @@ -672,98 +689,119 @@ export function FileManager({ projectId }: FileManagerProps) { try { toast({ - title: '다운로드 준비 중', - description: `${filesToDownload.length}개 파일을 압축하고 있습니다...`, + title: 'Preparing Download', + description: `Compressing ${filesToDownload.length} files...`, }); - // 여러 파일 다운로드 API 호출 + // Call multiple files download API const response = await fetch(`/api/data-room/${projectId}/download-multiple`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileIds: filesToDownload.map(f => f.id) }) }); - + if (!response.ok) { - throw new Error('다운로드 실패'); + throw new Error('Download failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); - - // 현재 날짜시간을 파일명에 포함 + + // Include timestamp in filename const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const fileName = `files_${timestamp}.zip`; - + const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); - + window.URL.revokeObjectURL(url); document.body.removeChild(a); toast({ - title: '다운로드 완료', - description: `${filesToDownload.length}개 파일이 다운로드되었습니다.`, + title: 'Download Complete', + description: `${filesToDownload.length} files downloaded successfully.`, }); } catch (error) { - console.error('다중 다운로드 오류:', error); - - // 실패 시 개별 다운로드 옵션 제공 + console.error('Multiple download error:', error); + + // Offer individual downloads on failure toast({ - title: '압축 다운로드 실패', - description: '개별 다운로드를 시도하시겠습니까?', + title: 'Batch Download Failed', + description: 'Would you like to try individual downloads?', action: ( <Button size="sm" variant="outline" onClick={() => { - // 개별 다운로드 실행 + // Execute individual downloads filesToDownload.forEach(async (file, index) => { - // 다운로드 간격을 두어 브라우저 부하 감소 + // Add delay between downloads to reduce browser load setTimeout(() => downloadFile(file), index * 500); }); }} > - 개별 다운로드 + Download Individually </Button> ), }); } }; - // 파일 다운로드 + // View file with PDFTron + const viewFile = async (file: FileItem) => { + try { + + + + setViewerFileUrl(file.filePath); + setSelectedFile(file); + setViewerDialogOpen(true); + + + + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to open file for viewing.', + variant: 'destructive', + }); + } + }; + + // Download file const downloadFile = async (file: FileItem) => { try { const response = await fetch(`/api/data-room/${projectId}/${file.id}/download`); - + if (!response.ok) { throw new Error('Download failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); - + const a = document.createElement('a'); a.href = url; a.download = file.name; document.body.appendChild(a); a.click(); - + window.URL.revokeObjectURL(url); document.body.removeChild(a); } catch (error) { toast({ - title: '오류', - description: '다운로드에 실패했습니다.', + title: 'Error', + description: 'Download failed.', variant: 'destructive', }); } }; - // 파일 삭제 + // Delete files const deleteItems = async (itemIds: string[]) => { try { await Promise.all( @@ -774,27 +812,106 @@ export function FileManager({ projectId }: FileManagerProps) { await fetchItems(); setSelectedItems(new Set()); - + + toast({ + title: 'Success', + description: 'Selected items deleted successfully.', + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to delete items.', + variant: 'destructive', + }); + } + }; + + // Rename item + const renameItem = async () => { + if (!selectedFile) return; + + try { + const response = await fetch( + `/api/data-room/${projectId}/${selectedFile.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: dialogValue }), + } + ); + + if (!response.ok) { + throw new Error('Failed to rename'); + } + + await fetchItems(); + setRenameDialogOpen(false); + setSelectedFile(null); + setDialogValue(''); + + toast({ + title: 'Success', + description: 'Item renamed successfully.', + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to rename item.', + variant: 'destructive', + }); + } + }; + + // Change category + const changeCategory = async ( + itemId: string, + newCategory: string, + applyToChildren: boolean = false + ) => { + try { + const response = await fetch( + `/api/data-room/${projectId}/${itemId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + category: newCategory, + applyToChildren + }), + } + ); + + if (!response.ok) { + throw new Error('Failed to change category'); + } + + await fetchItems(); + toast({ - title: '성공', - description: '선택한 항목이 삭제되었습니다.', + title: 'Success', + description: 'Category updated successfully.', }); } catch (error) { toast({ - title: '오류', - description: '삭제에 실패했습니다.', + title: 'Error', + description: 'Failed to change category.', variant: 'destructive', }); } }; - // 폴더 더블클릭 처리 + // Category change dialog states + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const [applyToChildren, setApplyToChildren] = useState(false); + const [newCategory, setNewCategory] = useState('confidential'); + + // Handle folder double click const handleFolderOpen = (folder: FileItem) => { if (viewMode === 'grid') { setCurrentPath([...currentPath, folder.name]); setCurrentParentId(folder.id); } else { - // 트리 뷰에서는 expand/collapse + // In tree view, expand/collapse const newExpanded = new Set(expandedFolders); if (newExpanded.has(folder.id)) { newExpanded.delete(folder.id); @@ -806,7 +923,7 @@ export function FileManager({ projectId }: FileManagerProps) { setSelectedItems(new Set()); }; - // 폴더 확장 토글 + // Toggle folder expansion const toggleFolderExpand = (folderId: string) => { const newExpanded = new Set(expandedFolders); if (newExpanded.has(folderId)) { @@ -817,7 +934,7 @@ export function FileManager({ projectId }: FileManagerProps) { setExpandedFolders(newExpanded); }; - // 아이템 선택 + // Toggle item selection const toggleItemSelection = (itemId: string) => { const newSelected = new Set(selectedItems); if (newSelected.has(itemId)) { @@ -828,18 +945,18 @@ export function FileManager({ projectId }: FileManagerProps) { setSelectedItems(newSelected); }; - // 경로 탐색 + // Navigate to path const navigateToPath = (index: number) => { if (index === -1) { setCurrentPath([]); setCurrentParentId(null); } else { setCurrentPath(currentPath.slice(0, index + 1)); - // parentId 업데이트 로직 필요 + // Need to update parentId logic } }; - // 필터링된 아이템 + // Filtered items const filteredItems = items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()) ); @@ -848,7 +965,7 @@ export function FileManager({ projectId }: FileManagerProps) { item.name.toLowerCase().includes(searchQuery.toLowerCase()) ); - // 파일 크기 포맷 + // Format file size const formatFileSize = (bytes?: number) => { if (!bytes) return '-'; const sizes = ['B', 'KB', 'MB', 'GB']; @@ -858,7 +975,7 @@ export function FileManager({ projectId }: FileManagerProps) { return ( <div className="flex flex-col h-full"> - {/* 툴바 */} + {/* Toolbar */} <div className="border-b p-4"> <div className="flex items-center justify-between mb-3"> <div className="flex items-center gap-2"> @@ -869,40 +986,40 @@ export function FileManager({ projectId }: FileManagerProps) { onClick={() => setFolderDialogOpen(true)} > <FolderPlus className="h-4 w-4 mr-1" /> - 새 폴더 + New Folder </Button> - <Button - size="sm" + <Button + size="sm" variant="outline" onClick={() => setUploadDialogOpen(true)} > <Upload className="h-4 w-4 mr-1" /> - 업로드 + Upload </Button> </> )} - + {selectedItems.size > 0 && ( <> - {/* 다중 다운로드 버튼 */} - {items.filter(item => - selectedItems.has(item.id) && - item.type === 'file' && - item.permissions?.canDownload + {/* Multiple download button */} + {items.filter(item => + selectedItems.has(item.id) && + item.type === 'file' && + item.permissions?.canDownload ==='true' ).length > 0 && ( - <Button - size="sm" - variant="outline" - onClick={() => downloadMultipleFiles(Array.from(selectedItems))} - > - <Download className="h-4 w-4 mr-1" /> - 다운로드 ({items.filter(item => - selectedItems.has(item.id) && item.type === 'file' - ).length}) - </Button> - )} - - {/* 삭제 버튼 */} + <Button + size="sm" + variant="outline" + onClick={() => downloadMultipleFiles(Array.from(selectedItems))} + > + <Download className="h-4 w-4 mr-1" /> + Download ({items.filter(item => + selectedItems.has(item.id) && item.type === 'file' + ).length}) + </Button> + )} + + {/* Delete button */} {items.find(item => selectedItems.has(item.id))?.permissions?.canDelete && ( <Button size="sm" @@ -910,31 +1027,31 @@ export function FileManager({ projectId }: FileManagerProps) { onClick={() => deleteItems(Array.from(selectedItems))} > <Trash2 className="h-4 w-4 mr-1" /> - 삭제 ({selectedItems.size}) + Delete ({selectedItems.size}) </Button> )} </> )} - + {!isInternalUser && ( <Badge variant="secondary" className="ml-2"> <Shield className="h-3 w-3 mr-1" /> - 외부 사용자 + External User </Badge> )} </div> - + <div className="flex items-center gap-2"> <div className="relative"> <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Input - placeholder="검색..." + placeholder="Search..." className="pl-8 w-64" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> </div> - + <Button size="sm" variant="ghost" @@ -947,41 +1064,41 @@ export function FileManager({ projectId }: FileManagerProps) { {/* Breadcrumb */} <Breadcrumb> - <BreadcrumbList> - <BreadcrumbItem> - <BreadcrumbLink onClick={() => navigateToPath(-1)}> - Home - </BreadcrumbLink> - </BreadcrumbItem> - {currentPath.map((path, index) => ( - <BreadcrumbItem key={index}> - <ChevronRight className="h-4 w-4" /> - <BreadcrumbLink onClick={() => navigateToPath(index)}> - {path} + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink onClick={() => navigateToPath(-1)}> + Home </BreadcrumbLink> </BreadcrumbItem> - ))} + {currentPath.map((path, index) => ( + <BreadcrumbItem key={index}> + <ChevronRight className="h-4 w-4" /> + <BreadcrumbLink onClick={() => navigateToPath(index)}> + {path} + </BreadcrumbLink> + </BreadcrumbItem> + ))} </BreadcrumbList> </Breadcrumb> </div> - {/* 파일 목록 */} + {/* File List */} <ScrollArea className="flex-1 p-4"> {loading ? ( <div className="flex justify-center items-center h-64"> - <div className="text-muted-foreground">로딩 중...</div> + <div className="text-muted-foreground">Loading...</div> </div> ) : filteredItems.length === 0 ? ( <div className="flex flex-col items-center justify-center h-64"> <Folder className="h-12 w-12 text-muted-foreground mb-2" /> - <p className="text-muted-foreground">비어있음</p> + <p className="text-muted-foreground">Empty</p> </div> ) : viewMode === 'grid' ? ( <div className="grid grid-cols-6 gap-4"> {filteredItems.map((item) => { const CategoryIcon = categoryConfig[item.category].icon; const categoryColor = categoryConfig[item.category].color; - + return ( <ContextMenu key={item.id}> <ContextMenuTrigger> @@ -1006,11 +1123,11 @@ export function FileManager({ projectId }: FileManagerProps) { )} <CategoryIcon className={cn("h-4 w-4 absolute -bottom-1 -right-1", categoryColor)} /> </div> - + <span className="mt-2 text-sm text-center truncate w-full"> {item.name} </span> - + {item.viewCount !== undefined && ( <div className="flex items-center gap-2 mt-1"> <span className="text-xs text-muted-foreground flex items-center"> @@ -1027,45 +1144,66 @@ export function FileManager({ projectId }: FileManagerProps) { )} </div> </ContextMenuTrigger> - + <ContextMenuContent> {item.type === 'folder' && ( <> <ContextMenuItem onClick={() => handleFolderOpen(item)}> - 열기 + Open </ContextMenuItem> <ContextMenuItem onClick={() => downloadFolder(item)}> <Download className="h-4 w-4 mr-2" /> - 폴더 전체 다운로드 + Download Folder </ContextMenuItem> </> )} - - {item.type === 'file' && item.permissions?.canDownload && ( - <ContextMenuItem onClick={() => downloadFile(item)}> - <Download className="h-4 w-4 mr-2" /> - 다운로드 - </ContextMenuItem> + + {item.type === 'file' && ( + <> + <ContextMenuItem onClick={() => viewFile(item)}> + <Eye className="h-4 w-4 mr-2" /> + View + </ContextMenuItem> + {item.permissions?.canDownload === 'true' && ( + <ContextMenuItem onClick={() => downloadFile(item)}> + <Download className="h-4 w-4 mr-2" /> + Download + </ContextMenuItem> + )} + </> )} - + {isInternalUser && ( <> <ContextMenuSeparator /> <ContextMenuSub> <ContextMenuSubTrigger> <Shield className="h-4 w-4 mr-2" /> - 카테고리 변경 + Change Category </ContextMenuSubTrigger> <ContextMenuSubContent> {Object.entries(categoryConfig).map(([key, config]) => ( - <ContextMenuItem key={key}> + <ContextMenuItem + key={key} + onClick={() => { + if (item.type === 'folder') { + // Show dialog for folders + setSelectedFile(item); + setNewCategory(key); + setCategoryDialogOpen(true); + } else { + // Change immediately for files + changeCategory(item.id, key, false); + } + }} + > <config.icon className={cn("h-4 w-4 mr-2", config.color)} /> {config.label} </ContextMenuItem> ))} </ContextMenuSubContent> </ContextMenuSub> - + <ContextMenuItem onClick={() => { setSelectedFile(item); @@ -1073,9 +1211,9 @@ export function FileManager({ projectId }: FileManagerProps) { }} > <Share2 className="h-4 w-4 mr-2" /> - 공유 + Share </ContextMenuItem> - + {item.permissions?.canEdit && ( <ContextMenuItem onClick={() => { setSelectedFile(item); @@ -1083,12 +1221,12 @@ export function FileManager({ projectId }: FileManagerProps) { setRenameDialogOpen(true); }}> <Edit2 className="h-4 w-4 mr-2" /> - 이름 변경 + Rename </ContextMenuItem> )} </> )} - + {item.permissions?.canDelete && ( <> <ContextMenuSeparator /> @@ -1097,7 +1235,7 @@ export function FileManager({ projectId }: FileManagerProps) { onClick={() => deleteItems([item.id])} > <Trash2 className="h-4 w-4 mr-2" /> - 삭제 + Delete </ContextMenuItem> </> )} @@ -1119,6 +1257,7 @@ export function FileManager({ projectId }: FileManagerProps) { onToggleExpand={toggleFolderExpand} onSelectItem={toggleItemSelection} onDoubleClick={handleFolderOpen} + onView={viewFile} onDownload={downloadFile} onDownloadFolder={downloadFolder} onDelete={deleteItems} @@ -1138,20 +1277,20 @@ export function FileManager({ projectId }: FileManagerProps) { )} </ScrollArea> - {/* 업로드 다이얼로그 */} + {/* Upload Dialog */} <Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}> <DialogContent className="max-w-2xl"> <DialogHeader> - <DialogTitle>파일 업로드</DialogTitle> + <DialogTitle>Upload Files</DialogTitle> <DialogDescription> - 파일을 드래그 앤 드롭하거나 클릭하여 선택하세요. + Drag and drop files or click to select. </DialogDescription> </DialogHeader> - + <div className="space-y-4"> - {/* 카테고리 선택 */} + {/* Category Selection */} <div> - <Label htmlFor="upload-category">카테고리</Label> + <Label htmlFor="upload-category">Category</Label> <Select value={uploadCategory} onValueChange={setUploadCategory}> <SelectTrigger> <SelectValue /> @@ -1170,7 +1309,7 @@ export function FileManager({ projectId }: FileManagerProps) { </div> {/* Dropzone */} - <Dropzone + <Dropzone onDrop={(acceptedFiles: File[]) => { handleFileUpload(acceptedFiles); }} @@ -1198,16 +1337,16 @@ export function FileManager({ projectId }: FileManagerProps) { <DropzoneInput /> <div className="flex flex-col items-center justify-center h-full"> <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" /> - <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> - <DropzoneDescription>여러 파일을 동시에 업로드할 수 있습니다</DropzoneDescription> + <DropzoneTitle>Drag files or click to upload</DropzoneTitle> + <DropzoneDescription>Multiple files can be uploaded simultaneously</DropzoneDescription> </div> </DropzoneZone> </Dropzone> - {/* 업로드 중인 파일 목록 */} + {/* Uploading File List */} {uploadingFiles.length > 0 && ( <FileList> - <FileListHeader>업로드 중인 파일</FileListHeader> + <FileListHeader>Uploading Files</FileListHeader> {uploadingFiles.map((uploadFile, index) => ( <FileListItem key={index}> <FileListIcon> @@ -1218,10 +1357,10 @@ export function FileManager({ projectId }: FileManagerProps) { <FileListDescription> <div className="flex items-center gap-2"> <FileListSize>{uploadFile.file.size}</FileListSize> - {uploadFile.status === 'uploading' && <span>업로드 중...</span>} - {uploadFile.status === 'processing' && <span>처리 중...</span>} + {uploadFile.status === 'uploading' && <span>Uploading...</span>} + {uploadFile.status === 'processing' && <span>Processing...</span>} {uploadFile.status === 'completed' && ( - <span className="text-green-600">완료</span> + <span className="text-green-600">Complete</span> )} {uploadFile.status === 'error' && ( <span className="text-red-600">{uploadFile.error}</span> @@ -1238,7 +1377,7 @@ export function FileManager({ projectId }: FileManagerProps) { size="sm" variant="ghost" onClick={() => { - setUploadingFiles(prev => + setUploadingFiles(prev => prev.filter((_, i) => i !== index) ); }} @@ -1252,44 +1391,44 @@ export function FileManager({ projectId }: FileManagerProps) { </FileList> )} </div> - + <DialogFooter> - <Button - variant="outline" + <Button + variant="outline" onClick={() => { setUploadDialogOpen(false); setUploadingFiles([]); }} > - 닫기 + Close </Button> </DialogFooter> </DialogContent> </Dialog> - {/* 폴더 생성 다이얼로그 */} + {/* Create Folder Dialog */} <Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}> <DialogContent> <DialogHeader> - <DialogTitle>새 폴더 만들기</DialogTitle> + <DialogTitle>Create New Folder</DialogTitle> <DialogDescription> - 폴더 이름과 접근 권한 카테고리를 설정하세요. + Set the folder name and access permission category. </DialogDescription> </DialogHeader> - + <div className="space-y-4"> <div> - <Label htmlFor="folder-name">폴더 이름</Label> + <Label htmlFor="folder-name">Folder Name</Label> <Input id="folder-name" value={dialogValue} onChange={(e) => setDialogValue(e.target.value)} - placeholder="폴더 이름 입력" + placeholder="Enter folder name" /> </div> - + <div> - <Label htmlFor="folder-category">카테고리</Label> + <Label htmlFor="folder-category">Category</Label> <Select value={selectedCategory} onValueChange={setSelectedCategory}> <SelectTrigger> <SelectValue /> @@ -1307,38 +1446,38 @@ export function FileManager({ projectId }: FileManagerProps) { </Select> </div> </div> - + <DialogFooter> <Button variant="outline" onClick={() => setFolderDialogOpen(false)}> - 취소 + Cancel </Button> - <Button onClick={createFolder}>생성</Button> + <Button onClick={createFolder}>Create</Button> </DialogFooter> </DialogContent> </Dialog> - {/* 파일 공유 다이얼로그 */} + {/* File Share Dialog */} <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}> <DialogContent className="max-w-md"> <DialogHeader> - <DialogTitle>파일 공유</DialogTitle> + <DialogTitle>Share File</DialogTitle> <DialogDescription> - {selectedFile?.name}을(를) 공유합니다. + Sharing {selectedFile?.name}. </DialogDescription> </DialogHeader> - + <Tabs defaultValue="link" className="w-full"> <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="link">링크 공유</TabsTrigger> - <TabsTrigger value="permission">권한 설정</TabsTrigger> + <TabsTrigger value="link">Link Sharing</TabsTrigger> + <TabsTrigger value="permission">Permission Settings</TabsTrigger> </TabsList> - + <TabsContent value="link" className="space-y-4"> <div> - <Label htmlFor="access-level">접근 레벨</Label> - <Select - value={shareSettings.accessLevel} - onValueChange={(value) => setShareSettings({...shareSettings, accessLevel: value})} + <Label htmlFor="access-level">Access Level</Label> + <Select + value={shareSettings.accessLevel} + onValueChange={(value) => setShareSettings({ ...shareSettings, accessLevel: value })} > <SelectTrigger> <SelectValue /> @@ -1347,101 +1486,305 @@ export function FileManager({ projectId }: FileManagerProps) { <SelectItem value="view_only"> <div className="flex items-center"> <Eye className="h-4 w-4 mr-2" /> - 보기만 가능 + View Only </div> </SelectItem> <SelectItem value="view_download"> <div className="flex items-center"> <Download className="h-4 w-4 mr-2" /> - 보기 + 다운로드 + View + Download </div> </SelectItem> </SelectContent> </Select> </div> - + <div> - <Label htmlFor="password">비밀번호 (선택)</Label> + <Label htmlFor="password">Password (Optional)</Label> <Input id="password" type="password" value={shareSettings.password} - onChange={(e) => setShareSettings({...shareSettings, password: e.target.value})} - placeholder="비밀번호 입력" + onChange={(e) => setShareSettings({ ...shareSettings, password: e.target.value })} + placeholder="Enter password" /> </div> - + <div> - <Label htmlFor="expires">만료일 (선택)</Label> + <Label htmlFor="expires">Expiry Date (Optional)</Label> <Input id="expires" type="datetime-local" value={shareSettings.expiresAt} - onChange={(e) => setShareSettings({...shareSettings, expiresAt: e.target.value})} + onChange={(e) => setShareSettings({ ...shareSettings, expiresAt: e.target.value })} /> </div> - + <div> - <Label htmlFor="max-downloads">최대 다운로드 횟수 (선택)</Label> + <Label htmlFor="max-downloads">Max Downloads (Optional)</Label> <Input id="max-downloads" type="number" value={shareSettings.maxDownloads} - onChange={(e) => setShareSettings({...shareSettings, maxDownloads: e.target.value})} - placeholder="무제한" + onChange={(e) => setShareSettings({ ...shareSettings, maxDownloads: e.target.value })} + placeholder="Unlimited" /> </div> </TabsContent> - + <TabsContent value="permission" className="space-y-4"> <div> - <Label htmlFor="target-domain">대상 도메인</Label> + <Label htmlFor="target-domain">Target Domain</Label> <Select> <SelectTrigger> - <SelectValue placeholder="도메인 선택" /> + <SelectValue placeholder="Select domain" /> </SelectTrigger> <SelectContent> - <SelectItem value="partners">파트너</SelectItem> - <SelectItem value="internal">내부</SelectItem> + <SelectItem value="partners">Partners</SelectItem> + <SelectItem value="internal">Internal</SelectItem> </SelectContent> </Select> </div> - + <div className="space-y-2"> - <Label>권한</Label> + <Label>Permissions</Label> <div className="space-y-2"> <div className="flex items-center justify-between"> - <Label htmlFor="can-view" className="text-sm font-normal">보기</Label> + <Label htmlFor="can-view" className="text-sm font-normal">View</Label> <Switch id="can-view" defaultChecked /> </div> <div className="flex items-center justify-between"> - <Label htmlFor="can-download" className="text-sm font-normal">다운로드</Label> + <Label htmlFor="can-download" className="text-sm font-normal">Download</Label> <Switch id="can-download" /> </div> <div className="flex items-center justify-between"> - <Label htmlFor="can-edit" className="text-sm font-normal">수정</Label> + <Label htmlFor="can-edit" className="text-sm font-normal">Edit</Label> <Switch id="can-edit" /> </div> <div className="flex items-center justify-between"> - <Label htmlFor="can-share" className="text-sm font-normal">공유</Label> + <Label htmlFor="can-share" className="text-sm font-normal">Share</Label> <Switch id="can-share" /> </div> </div> </div> </TabsContent> </Tabs> - + <DialogFooter> <Button variant="outline" onClick={() => setShareDialogOpen(false)}> - 취소 + Cancel </Button> <Button onClick={shareFile}> <Share2 className="h-4 w-4 mr-2" /> - 공유 링크 생성 + Create Share Link + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Rename Dialog */} + <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Rename</DialogTitle> + <DialogDescription> + {selectedFile?.type === 'file' + ? 'Enter the file name. (Extension will be preserved automatically)' + : 'Enter the folder name.' + } + </DialogDescription> + </DialogHeader> + + <div> + <Label htmlFor="item-name">New Name</Label> + <Input + id="item-name" + value={dialogValue} + onChange={(e) => setDialogValue(e.target.value)} + placeholder={ + selectedFile?.type === 'file' + ? selectedFile.name.substring(0, selectedFile.name.lastIndexOf('.')) + : selectedFile?.name + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + renameItem(); + } + }} + /> + {selectedFile?.type === 'file' && ( + <p className="text-sm text-muted-foreground mt-1"> + Extension: {selectedFile.name.substring(selectedFile.name.lastIndexOf('.'))} + </p> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setRenameDialogOpen(false); + setSelectedFile(null); + setDialogValue(''); + }} + > + Cancel + </Button> + <Button onClick={renameItem}>Rename</Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* Category Change Dialog (for folders) */} + <Dialog open={categoryDialogOpen} onOpenChange={setCategoryDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Change Category</DialogTitle> + <DialogDescription> + Changing category for {selectedFile?.name} folder. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div> + <Label>New Category</Label> + <div className="mt-2 space-y-2"> + {Object.entries(categoryConfig).map(([key, config]) => ( + <div + key={key} + className={cn( + "flex items-center p-3 rounded-lg border cursor-pointer transition-colors", + newCategory === key && "bg-accent border-primary" + )} + onClick={() => setNewCategory(key)} + > + <config.icon className={cn("h-5 w-5 mr-3", config.color)} /> + <div className="flex-1"> + <div className="font-medium">{config.label}</div> + <div className="text-sm text-muted-foreground"> + {key === 'public' && 'External users can access freely'} + {key === 'restricted' && 'External users can only view'} + {key === 'confidential' && 'External users cannot access'} + {key === 'internal' && 'Internal use only'} + </div> + </div> + </div> + ))} + </div> + </div> + + {selectedFile?.type === 'folder' && ( + <div className="flex items-center space-x-2"> + <Switch + id="apply-to-children" + checked={applyToChildren} + onCheckedChange={setApplyToChildren} + /> + <Label htmlFor="apply-to-children"> + Apply to all files and subfolders + </Label> + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setCategoryDialogOpen(false); + setSelectedFile(null); + setApplyToChildren(false); + }} + > + Cancel + </Button> + <Button + onClick={() => { + if (selectedFile) { + changeCategory(selectedFile.id, newCategory, applyToChildren); + setCategoryDialogOpen(false); + setSelectedFile(null); + setApplyToChildren(false); + } + }} + > + Change </Button> </DialogFooter> </DialogContent> </Dialog> + + {/* Secure Document Viewer Dialog */} + <Dialog + open={viewerDialogOpen} + onOpenChange={(open) => { + if (!open) { + setViewerDialogOpen(false); + setViewerFileUrl(null); + setSelectedFile(null); + } + }} + > + <DialogContent className="max-w-[90vw] max-h-[90vh] w-full h-full p-0"> + <DialogHeader className="px-6 py-4 border-b"> + <DialogTitle className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Eye className="h-5 w-5" /> + Secure Document Viewer + </div> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Lock className="h-4 w-4" /> + View Only Mode + </div> + </DialogTitle> + <DialogDescription> + <div className="flex items-center justify-between mt-2"> + <span>Viewing: {selectedFile?.name}</span> + <Badge variant="destructive" className="text-xs"> + <AlertCircle className="h-3 w-3 mr-1" /> + Protected Document - No Download/Copy/Print + </Badge> + </div> + </DialogDescription> + </DialogHeader> + + <div className="relative flex-1 h-[calc(90vh-120px)]"> + {viewerFileUrl && selectedFile && ( + <SecurePDFViewer + documentUrl={viewerFileUrl} + fileName={selectedFile.name} + onClose={() => { + setViewerDialogOpen(false); + setViewerFileUrl(null); + setSelectedFile(null); + }} + /> + )} + </div> + + <div className="px-6 py-3 border-t bg-muted/50"> + <div className="flex items-center justify-between text-xs text-muted-foreground"> + <div className="flex items-center gap-4"> + <span>Viewer: {session?.user?.email}</span> + <span>Time: {new Date().toLocaleString()}</span> + <span>IP logged for security</span> + </div> + <Button + size="sm" + variant="outline" + onClick={() => { + setViewerDialogOpen(false); + setViewerFileUrl(null); + setSelectedFile(null); + }} + > + <X className="h-4 w-4 mr-1" /> + Close Viewer + </Button> + </div> + </div> + </DialogContent> + </Dialog> </div> ); }
\ No newline at end of file diff --git a/components/file-manager/SecurePDFViewer.tsx b/components/file-manager/SecurePDFViewer.tsx new file mode 100644 index 00000000..cd7c081a --- /dev/null +++ b/components/file-manager/SecurePDFViewer.tsx @@ -0,0 +1,350 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import { WebViewerInstance } from '@pdftron/webviewer'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface SecurePDFViewerProps { + documentUrl: string; + fileName: string; + onClose?: () => void; +} + +export function SecurePDFViewer({ documentUrl, fileName, onClose }: SecurePDFViewerProps) { + const viewerRef = useRef<HTMLDivElement>(null); + const instanceRef = useRef<WebViewerInstance | null>(null); + const initialized = useRef(false); + const isCancelled = useRef(false); + const { data: session } = useSession(); + const [isLoading, setIsLoading] = useState(true); + + // WebViewer 초기화 + useEffect(() => { + if (!initialized.current && viewerRef.current) { + initialized.current = true; + isCancelled.current = false; + + requestAnimationFrame(() => { + if (viewerRef.current) { + import('@pdftron/webviewer').then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log('📛 WebViewer 초기화 취소됨'); + return; + } + + const viewerElement = viewerRef.current; + if (!viewerElement) return; + + WebViewer( + { + path: '/pdftronWeb', // BasicContractTemplateViewer와 동일한 경로 + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, // 동일한 라이센스 키 + fullAPI: true, + enableFilePicker: false, + enableMeasurement: false, + enableRedaction: false, + enableAnnotations: false, + enableTextSelection: false, + enableFormFilling: false, + enablePrint: false, + enableDownload: false, + }, + viewerElement + ).then(async (instance: WebViewerInstance) => { + instanceRef.current = instance; + + try { + const { disableElements } = instance.UI; + + // 보안을 위해 모든 도구 비활성화 + disableElements([ + 'toolsHeader', + 'viewControlsButton', + 'panToolButton', + 'selectToolButton', + 'menuButton', + 'leftPanel', + 'leftPanelButton', + 'searchButton', + 'notesPanel', + 'notesPanelButton', + 'toolbarGroup-Annotate', + 'toolbarGroup-Shapes', + 'toolbarGroup-Edit', + 'toolbarGroup-Insert', + 'toolbarGroup-FillAndSign', + 'toolbarGroup-Forms', + 'toolsOverlay', + 'printButton', + 'downloadButton', + 'saveAsButton', + 'filePickerHandler', + 'textPopup', + 'contextMenuPopup', + 'pageManipulationOverlay', + 'documentControl', + 'header', + 'ribbons', + 'toggleNotesButton' + ]); + + // CSS 적용으로 추가 보안 + const iframeWindow = instance.UI.iframeWindow; + if (iframeWindow && iframeWindow.document) { + const style = iframeWindow.document.createElement('style'); + style.textContent = ` + /* Hide all toolbars and buttons */ + .HeaderToolsContainer, + .Header, + .ToolsHeader, + .LeftHeader, + .RightHeader, + .DocumentContainer > div:first-child { + display: none !important; + } + + /* Disable right-click context menu */ + * { + -webkit-user-select: none !important; + -moz-user-select: none !important; + -ms-user-select: none !important; + user-select: none !important; + } + + /* Hide page controls */ + .DocumentContainer .PageControls { + display: none !important; + } + + /* Disable text selection cursor */ + .pageContainer { + cursor: default !important; + } + + /* Prevent drag and drop */ + * { + -webkit-user-drag: none !important; + -khtml-user-drag: none !important; + -moz-user-drag: none !important; + -o-user-drag: none !important; + user-drag: none !important; + } + `; + iframeWindow.document.head.appendChild(style); + } + + console.log('📝 WebViewer 초기화 완료'); + + // 문서 로드 + await loadSecureDocument(instance, documentUrl, fileName); + + } catch (uiError) { + console.warn('⚠️ UI 설정 중 오류:', uiError); + toast.error('뷰어 설정 중 오류가 발생했습니다.'); + } + }).catch((error) => { + console.error('❌ WebViewer 초기화 실패:', error); + setIsLoading(false); + toast.error('뷰어 초기화에 실패했습니다.'); + }); + }); + } + }); + } + + return () => { + if (instanceRef.current) { + instanceRef.current.UI.dispose(); + instanceRef.current = null; + } + isCancelled.current = true; + }; + }, []); + + // 문서 로드 함수 + const loadSecureDocument = async ( + instance: WebViewerInstance, + documentPath: string, + docFileName: string + ) => { + setIsLoading(true); + try { + // 절대 URL로 변환 + const fullPath = documentPath.startsWith('http') + ? documentPath + : `${window.location.origin}${documentPath}`; + + console.log('📄 보안 문서 로드 시작:', fullPath); + + // 문서 로드 + await instance.UI.loadDocument(fullPath, { + filename: docFileName, + extension: docFileName.split('.').pop()?.toLowerCase(), + }); + + const { documentViewer, annotationManager } = instance.Core; + + // 문서 로드 완료 이벤트 + documentViewer.addEventListener('documentLoaded', async () => { + setIsLoading(false); + + // 워터마크 추가 + const watermarkText = `${session?.user?.email || 'CONFIDENTIAL'}\n${new Date().toLocaleString()}`; + + // 대각선 워터마크 + documentViewer.setWatermark({ + text: watermarkText, + fontSize: 30, + fontFamily: 'Arial', + color: 'rgba(255, 0, 0, 0.3)', + opacity: 30, + diagonal: true, + }); + + // 각 페이지에 커스텀 워터마크 추가 + const pageCount = documentViewer.getPageCount(); + for (let i = 1; i <= pageCount; i++) { + const pageInfo = documentViewer.getDocument().getPageInfo(i); + const { width, height } = pageInfo; + + // FreeTextAnnotation 생성 + const watermarkAnnot = new instance.Core.Annotations.FreeTextAnnotation(); + watermarkAnnot.PageNumber = i; + watermarkAnnot.X = width / 4; + watermarkAnnot.Y = height / 2; + watermarkAnnot.Width = width / 2; + watermarkAnnot.Height = 100; + watermarkAnnot.setContents( + `${session?.user?.email}\n${docFileName}\n${new Date().toLocaleDateString()}` + ); + watermarkAnnot.FillColor = new instance.Core.Annotations.Color(255, 0, 0, 0.1); + watermarkAnnot.TextColor = new instance.Core.Annotations.Color(255, 0, 0, 0.3); + watermarkAnnot.FontSize = '24pt'; + watermarkAnnot.TextAlign = 'center'; + watermarkAnnot.Rotation = -45; + watermarkAnnot.ReadOnly = true; + watermarkAnnot.Locked = true; + watermarkAnnot.Printable = true; + + annotationManager.addAnnotation(watermarkAnnot); + } + + annotationManager.drawAnnotations(documentViewer.getCurrentPage()); + + // Pan 모드로 설정 (텍스트 선택 불가) + documentViewer.setToolMode(documentViewer.getTool('Pan')); + + // 페이지 이동 로깅 + documentViewer.addEventListener('pageNumberUpdated', (pageNumber: number) => { + console.log(`Page ${pageNumber} viewed at ${new Date().toISOString()}`); + // 서버로 감사 로그 전송 가능 + }); + + console.log('✅ 보안 문서 로드 완료'); + toast.success('문서가 안전하게 로드되었습니다.'); + }); + + // 에러 처리 + documentViewer.addEventListener('error', (error: any) => { + console.error('Document loading error:', error); + setIsLoading(false); + toast.error('문서 로드 중 오류가 발생했습니다.'); + }); + + } catch (err) { + console.error('❌ 문서 로딩 중 오류:', err); + toast.error(`문서 로드 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`); + setIsLoading(false); + } + }; + + // 키보드 단축키 차단 + useEffect(() => { + const preventShortcuts = (e: KeyboardEvent) => { + // Ctrl+C, Ctrl+A, Ctrl+P, Ctrl+S, F12 등 차단 + if ( + (e.ctrlKey && ['c', 'a', 'p', 's', 'x', 'v'].includes(e.key.toLowerCase())) || + (e.metaKey && ['c', 'a', 'p', 's', 'x', 'v'].includes(e.key.toLowerCase())) || + e.key === 'F12' || + (e.ctrlKey && e.shiftKey && ['I', 'C', 'J'].includes(e.key)) + ) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }; + + const preventContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + const preventDrag = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + document.addEventListener('keydown', preventShortcuts); + document.addEventListener('contextmenu', preventContextMenu); + document.addEventListener('dragstart', preventDrag); + + return () => { + document.removeEventListener('keydown', preventShortcuts); + document.removeEventListener('contextmenu', preventContextMenu); + document.removeEventListener('dragstart', preventDrag); + }; + }, []); + + return ( + <div className="relative w-full h-full overflow-hidden"> + <div + ref={viewerRef} + className="w-full h-full" + style={{ + position: 'relative', + overflow: 'hidden', + contain: 'layout style paint', + minHeight: '600px' + }} + onCopy={(e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }} + onCut={(e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }} + onPaste={(e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }} + > + {isLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-90 z-50"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">보안 문서 로딩 중...</p> + </div> + )} + </div> + + {/* 보안 오버레이 */} + <div + className="absolute inset-0 z-10 pointer-events-none" + style={{ + background: 'transparent', + userSelect: 'none', + WebkitUserSelect: 'none', + MozUserSelect: 'none', + msUserSelect: 'none' + }} + /> + </div> + ); +}
\ No newline at end of file diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index 19c9a616..d001463e 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -1055,8 +1055,18 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat if (templateType === 'SPREAD_LIST' && tableData.length > 0) { updateProgress('Processing data rows...', 60, 100); - - dataSheets.forEach(dataSheet => { + + const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET; + + const matchingDataSheets = dataSheets.filter(ds => + ds.SHEET_NAME === activeSheetName + ); + + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + } + + matchingDataSheets.forEach(dataSheet => { if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { const { ATT_ID, IN } = mapping; @@ -1115,8 +1125,17 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat } else if (templateType === 'SPREAD_ITEM' && selectedRow) { updateProgress('Setting up form fields...', 60, 100); - - dataSheets.forEach(dataSheet => { + const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET; + + const matchingDataSheets = dataSheets.filter(ds => + ds.SHEET_NAME === activeSheetName + ); + + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + } + + matchingDataSheets.forEach(dataSheet => { dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { const { ATT_ID, IN } = mapping; const cellPos = parseCellAddress(IN); diff --git a/components/permissions/permission-crud-manager.tsx b/components/permissions/permission-crud-manager.tsx index 01c9959f..a9b2f64e 100644 --- a/components/permissions/permission-crud-manager.tsx +++ b/components/permissions/permission-crud-manager.tsx @@ -26,6 +26,16 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Table, TableBody, TableCell, @@ -52,7 +62,8 @@ import { Key, Shield, Copy, - CheckCircle + CheckCircle, + AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -90,6 +101,8 @@ export function PermissionCrudManager() { const [loading, setLoading] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingPermission, setEditingPermission] = useState<Permission | null>(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingPermission, setDeletingPermission] = useState<Permission | null>(null); useEffect(() => { loadPermissions(); @@ -139,20 +152,25 @@ export function PermissionCrudManager() { setFilteredPermissions(filtered); }; - const handleDelete = async (id: number) => { - if (!confirm("이 권한을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { - return; - } - + const handleDelete = async () => { + if (!deletingPermission) return; + try { - await deletePermission(id); + await deletePermission(deletingPermission.id); toast.success("권한이 삭제되었습니다."); loadPermissions(); + setDeleteDialogOpen(false); + setDeletingPermission(null); } catch (error) { toast.error("권한 삭제에 실패했습니다."); } }; + const openDeleteDialog = (permission: Permission) => { + setDeletingPermission(permission); + setDeleteDialogOpen(true); + }; + return ( <div className="space-y-6"> {/* 헤더 및 필터 */} @@ -280,7 +298,7 @@ export function PermissionCrudManager() { </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem - onClick={() => handleDelete(permission.id)} + onClick={() => openDeleteDialog(permission)} className="text-destructive" disabled={permission.isSystem} > @@ -314,6 +332,64 @@ export function PermissionCrudManager() { loadPermissions(); }} /> + + {/* 삭제 확인 다이얼로그 */} + <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + <div className="flex items-center gap-2"> + <AlertTriangle className="h-5 w-5 text-destructive" /> + 권한 삭제 확인 + </div> + </AlertDialogTitle> + <AlertDialogDescription> + {deletingPermission && ( + <div className="space-y-4"> + <p> + <span className="font-semibold">"{deletingPermission.name}"</span> 권한을 삭제하시겠습니까? + </p> + + <div className="p-3 bg-muted rounded-lg space-y-2"> + <div className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">권한 키:</span> + <code className="px-2 py-0.5 bg-background rounded">{deletingPermission.permissionKey}</code> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">리소스:</span> + <span>{deletingPermission.resource}</span> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">액션:</span> + <span>{deletingPermission.action}</span> + </div> + </div> + + <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg"> + <p className="text-sm text-destructive font-medium"> + ⚠️ 주의: 이 작업은 되돌릴 수 없습니다 + </p> + <p className="text-sm text-muted-foreground mt-1"> + 이 권한과 관련된 모든 역할 및 사용자 할당이 제거됩니다. + </p> + </div> + </div> + )} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => setDeletingPermission(null)}> + 취소 + </AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </div> ); } diff --git a/components/permissions/permission-group-assignment-manager.tsx b/components/permissions/permission-group-assignment-manager.tsx new file mode 100644 index 00000000..cd7531a0 --- /dev/null +++ b/components/permissions/permission-group-assignment-manager.tsx @@ -0,0 +1,666 @@ +// components/permissions/permission-group-assignment-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Users, + User, + Plus, + X, + Search, + Package, + Shield, + Loader2, + UserPlus, + ChevronRight +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getPermissionGroupAssignments, + assignGroupToRoles, + assignGroupToUsers, + removeGroupFromRole, + removeGroupFromUser, + searchRoles, + searchUsers, +} from "@/lib/permissions/permission-group-assignment-actions"; + +interface PermissionGroup { + id: number; + groupKey: string; + name: string; + description?: string; + domain?: string; + permissionCount: number; + isActive: boolean; +} + +interface AssignedRole { + id: number; + name: string; + domain: string; + userCount: number; + assignedAt: Date; + assignedBy?: string; +} + +interface AssignedUser { + id: number; + name: string; + email: string; + imageUrl?: string; + domain: string; + companyName?: string; + assignedAt: Date; + assignedBy?: string; +} + +export function PermissionGroupAssignmentManager() { + const [groups, setGroups] = useState<PermissionGroup[]>([]); + const [selectedGroup, setSelectedGroup] = useState<PermissionGroup | null>(null); + const [assignedRoles, setAssignedRoles] = useState<AssignedRole[]>([]); + const [assignedUsers, setAssignedUsers] = useState<AssignedUser[]>([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [addRoleDialogOpen, setAddRoleDialogOpen] = useState(false); + const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); + + useEffect(() => { + loadGroups(); + }, []); + + useEffect(() => { + if (selectedGroup) { + loadAssignments(selectedGroup.id); + } + }, [selectedGroup]); + + const loadGroups = async () => { + setLoading(true); + try { + const data = await getPermissionGroupAssignments(); + setGroups(data.groups); + } catch (error) { + toast.error("권한 그룹을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const loadAssignments = async (groupId: number) => { + try { + const data = await getPermissionGroupAssignments(groupId); + setAssignedRoles(data.roles); + setAssignedUsers(data.users); + } catch (error) { + toast.error("할당 정보를 불러오는데 실패했습니다."); + } + }; + + const handleRemoveRole = async (roleId: number) => { + if (!selectedGroup) return; + + try { + await removeGroupFromRole(selectedGroup.id, roleId); + toast.success("역할에서 권한 그룹이 제거되었습니다."); + loadAssignments(selectedGroup.id); + } catch (error) { + toast.error("권한 그룹 제거에 실패했습니다."); + } + }; + + const handleRemoveUser = async (userId: number) => { + if (!selectedGroup) return; + + try { + await removeGroupFromUser(selectedGroup.id, userId); + toast.success("사용자에서 권한 그룹이 제거되었습니다."); + loadAssignments(selectedGroup.id); + } catch (error) { + toast.error("권한 그룹 제거에 실패했습니다."); + } + }; + + // 그룹 필터링 + const filteredGroups = groups.filter(g => + g.name.toLowerCase().includes(searchQuery.toLowerCase()) || + g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()) || + g.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <div className="grid grid-cols-2 gap-6"> + {/* 권한 그룹 목록 */} + <Card> + <CardHeader> + <CardTitle>권한 그룹</CardTitle> + <CardDescription>할당을 관리할 권한 그룹을 선택하세요.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 검색 */} + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="그룹 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + {/* 그룹 목록 */} + {loading ? ( + <div className="flex justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + </div> + ) : ( + <ScrollArea className="h-[500px]"> + <div className="space-y-2"> + {filteredGroups.map(group => ( + <button + key={group.id} + onClick={() => setSelectedGroup(group)} + className={cn( + "w-full text-left p-4 rounded-lg border transition-colors", + selectedGroup?.id === group.id + ? "bg-primary/10 border-primary" + : "hover:bg-muted" + )} + > + <div className="space-y-2"> + <div className="flex items-start justify-between"> + <div> + <div className="font-medium">{group.name}</div> + <code className="text-xs bg-muted px-1 py-0.5 rounded"> + {group.groupKey} + </code> + </div> + <div className="flex flex-col items-end gap-1"> + {group.domain && ( + <Badge variant="outline" className="text-xs"> + {group.domain} + </Badge> + )} + <Badge variant="secondary" className="text-xs"> + {group.permissionCount}개 권한 + </Badge> + </div> + </div> + {group.description && ( + <p className="text-sm text-muted-foreground"> + {group.description} + </p> + )} + </div> + </button> + ))} + </div> + </ScrollArea> + )} + </div> + </CardContent> + </Card> + + {/* 할당 관리 */} + {selectedGroup ? ( + <Card> + <CardHeader> + <div> + <CardTitle>{selectedGroup.name}</CardTitle> + <CardDescription className="mt-2"> + <div className="flex gap-2"> + <Badge>{selectedGroup.groupKey}</Badge> + {selectedGroup.domain && ( + <Badge variant="outline">{selectedGroup.domain}</Badge> + )} + <Badge variant="secondary">{selectedGroup.permissionCount}개 권한</Badge> + </div> + </CardDescription> + </div> + </CardHeader> + <CardContent> + <Tabs defaultValue="roles"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="roles"> + <Users className="mr-2 h-4 w-4" /> + 역할 ({assignedRoles.length}) + </TabsTrigger> + <TabsTrigger value="users"> + <User className="mr-2 h-4 w-4" /> + 사용자 ({assignedUsers.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="roles" className="mt-4"> + <div className="space-y-4"> + <Button + size="sm" + onClick={() => setAddRoleDialogOpen(true)} + > + <Plus className="mr-2 h-4 w-4" /> + 역할 추가 + </Button> + + <div className="space-y-2"> + {assignedRoles.map((role) => ( + <div key={role.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div className="flex-1"> + <div className="font-medium">{role.name}</div> + <div className="text-sm text-muted-foreground"> + {role.domain} • {role.userCount}명 사용자 + </div> + <div className="text-xs text-muted-foreground mt-1"> + {new Date(role.assignedAt).toLocaleDateString()} 할당 + {role.assignedBy && ` • ${role.assignedBy}`} + </div> + </div> + <Button + size="sm" + variant="ghost" + onClick={() => handleRemoveRole(role.id)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + + {assignedRoles.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <Users className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">할당된 역할이 없습니다.</p> + </div> + )} + </div> + </div> + </TabsContent> + + <TabsContent value="users" className="mt-4"> + <div className="space-y-4"> + <Button + size="sm" + onClick={() => setAddUserDialogOpen(true)} + > + <Plus className="mr-2 h-4 w-4" /> + 사용자 추가 + </Button> + + <div className="space-y-2"> + {assignedUsers.map((user) => ( + <div key={user.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div> + <div className="font-medium">{user.name}</div> + <div className="text-sm text-muted-foreground">{user.email}</div> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="text-xs"> + {user.domain} + </Badge> + {user.companyName && ( + <span className="text-xs text-muted-foreground"> + {user.companyName} + </span> + )} + </div> + </div> + </div> + <Button + size="sm" + variant="ghost" + onClick={() => handleRemoveUser(user.id)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + + {assignedUsers.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <User className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">할당된 사용자가 없습니다.</p> + </div> + )} + </div> + </div> + </TabsContent> + </Tabs> + </CardContent> + </Card> + ) : ( + <Card className="flex items-center justify-center"> + <div className="text-center text-muted-foreground"> + <Package className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>권한 그룹을 선택하면 할당 정보가 표시됩니다.</p> + </div> + </Card> + )} + + {/* 역할 추가 다이얼로그 */} + {selectedGroup && ( + <AddRoleDialog + open={addRoleDialogOpen} + onOpenChange={setAddRoleDialogOpen} + group={selectedGroup} + onSuccess={() => { + setAddRoleDialogOpen(false); + loadAssignments(selectedGroup.id); + }} + /> + )} + + {/* 사용자 추가 다이얼로그 */} + {selectedGroup && ( + <AddUserDialog + open={addUserDialogOpen} + onOpenChange={setAddUserDialogOpen} + group={selectedGroup} + onSuccess={() => { + setAddUserDialogOpen(false); + loadAssignments(selectedGroup.id); + }} + /> + )} + </div> + ); +} + +// 역할 추가 다이얼로그 +function AddRoleDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + onSuccess: () => void; +}) { + const [availableRoles, setAvailableRoles] = useState<any[]>([]); + const [selectedRoles, setSelectedRoles] = useState<number[]>([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + loadAvailableRoles(); + } + }, [open]); + + const loadAvailableRoles = async () => { + setLoading(true); + try { + const data = await searchRoles(group.id); + setAvailableRoles(data); + } catch (error) { + toast.error("역할 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async () => { + if (selectedRoles.length === 0) { + toast.error("역할을 선택해주세요."); + return; + } + + setSaving(true); + try { + await assignGroupToRoles(group.id, selectedRoles); + toast.success("역할에 권한 그룹이 추가되었습니다."); + onSuccess(); + } catch (error) { + toast.error("권한 그룹 추가에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>역할 추가</DialogTitle> + <DialogDescription> + "{group.name}" 그룹을 할당할 역할을 선택하세요. + </DialogDescription> + </DialogHeader> + + {loading ? ( + <div className="flex justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + </div> + ) : ( + <div className="space-y-4"> + <ScrollArea className="h-[300px] border rounded-md p-4"> + <div className="space-y-2"> + {availableRoles.map((role) => ( + <label + key={role.id} + className="flex items-center gap-3 p-2 hover:bg-muted rounded cursor-pointer" + > + <Checkbox + checked={selectedRoles.includes(role.id)} + onCheckedChange={(checked) => { + if (checked) { + setSelectedRoles([...selectedRoles, role.id]); + } else { + setSelectedRoles(selectedRoles.filter(id => id !== role.id)); + } + }} + /> + <div className="flex-1"> + <div className="font-medium">{role.name}</div> + <div className="text-sm text-muted-foreground"> + {role.domain} • {role.userCount}명 사용자 + </div> + </div> + </label> + ))} + </div> + </ScrollArea> + + <div className="text-sm text-muted-foreground"> + {selectedRoles.length}개 역할 선택됨 + </div> + </div> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +// 사용자 추가 다이얼로그 +function AddUserDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + onSuccess: () => void; +}) { + const [searchQuery, setSearchQuery] = useState(""); + const [availableUsers, setAvailableUsers] = useState<any[]>([]); + const [selectedUsers, setSelectedUsers] = useState<number[]>([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery && open) { + searchUsersData(searchQuery); + } + }, 300); + return () => clearTimeout(timer); + }, [searchQuery, open]); + + const searchUsersData = async (query: string) => { + setLoading(true); + try { + const data = await searchUsers(query, group.id); + setAvailableUsers(data); + } catch (error) { + toast.error("사용자 검색에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async () => { + if (selectedUsers.length === 0) { + toast.error("사용자를 선택해주세요."); + return; + } + + setSaving(true); + try { + await assignGroupToUsers(group.id, selectedUsers); + toast.success("사용자에게 권한 그룹이 추가되었습니다."); + onSuccess(); + } catch (error) { + toast.error("권한 그룹 추가에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>사용자 추가</DialogTitle> + <DialogDescription> + "{group.name}" 그룹을 할당할 사용자를 검색하고 선택하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 검색 */} + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="이름, 이메일로 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + {/* 사용자 목록 */} + {loading ? ( + <div className="flex justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + </div> + ) : ( + <> + <ScrollArea className="h-[300px] border rounded-md p-4"> + <div className="space-y-2"> + {availableUsers.map((user) => ( + <label + key={user.id} + className="flex items-center gap-3 p-2 hover:bg-muted rounded cursor-pointer" + > + <Checkbox + checked={selectedUsers.includes(user.id)} + onCheckedChange={(checked) => { + if (checked) { + setSelectedUsers([...selectedUsers, user.id]); + } else { + setSelectedUsers(selectedUsers.filter(id => id !== user.id)); + } + }} + /> + <Avatar className="h-8 w-8"> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div className="flex-1"> + <div className="font-medium">{user.name}</div> + <div className="text-sm text-muted-foreground">{user.email}</div> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="text-xs"> + {user.domain} + </Badge> + {user.companyName && ( + <span className="text-xs text-muted-foreground"> + {user.companyName} + </span> + )} + </div> + </div> + </label> + ))} + </div> + </ScrollArea> + + {availableUsers.length > 0 && ( + <div className="text-sm text-muted-foreground"> + {selectedUsers.length}명 선택됨 + </div> + )} + </> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving || selectedUsers.length === 0}> + {saving ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/permissions/permission-group-manager.tsx b/components/permissions/permission-group-manager.tsx index 11aac6cf..ff7bef7f 100644 --- a/components/permissions/permission-group-manager.tsx +++ b/components/permissions/permission-group-manager.tsx @@ -3,6 +3,9 @@ "use client"; import { useState, useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,6 +23,16 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Select, SelectContent, SelectItem, @@ -47,6 +60,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Shield, Plus, @@ -99,6 +121,19 @@ interface Permission { scope: string; } +// 폼 스키마 정의 +const permissionGroupFormSchema = z.object({ + groupKey: z.string() + .min(1, "그룹 키는 필수입니다.") + .regex(/^[a-z0-9_]+$/, "소문자, 숫자, 언더스코어만 사용 가능합니다."), + name: z.string().min(1, "그룹명은 필수입니다."), + description: z.string().optional(), + domain: z.string().optional(), + isActive: z.boolean().default(true), +}); + +type PermissionGroupFormValues = z.infer<typeof permissionGroupFormSchema>; + export function PermissionGroupManager() { const [groups, setGroups] = useState<PermissionGroup[]>([]); const [selectedGroup, setSelectedGroup] = useState<PermissionGroup | null>(null); @@ -109,6 +144,7 @@ export function PermissionGroupManager() { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingGroup, setEditingGroup] = useState<PermissionGroup | null>(null); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null); useEffect(() => { loadGroups(); @@ -143,19 +179,23 @@ export function PermissionGroupManager() { }; const handleDelete = async (id: number) => { - if (!confirm("이 권한 그룹을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { - return; - } + setDeletingGroupId(id); + }; + + const confirmDelete = async () => { + if (!deletingGroupId) return; try { - await deletePermissionGroup(id); + await deletePermissionGroup(deletingGroupId); toast.success("권한 그룹이 삭제되었습니다."); - if (selectedGroup?.id === id) { + if (selectedGroup?.id === deletingGroupId) { setSelectedGroup(null); } loadGroups(); } catch (error) { toast.error("권한 그룹 삭제에 실패했습니다."); + } finally { + setDeletingGroupId(null); } }; @@ -268,6 +308,24 @@ export function PermissionGroupManager() { }} /> )} + + {/* 삭제 확인 다이얼로그 */} + <AlertDialog open={!!deletingGroupId} onOpenChange={(open) => !open && setDeletingGroupId(null)}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>권한 그룹 삭제</AlertDialogTitle> + <AlertDialogDescription> + 이 권한 그룹을 삭제하시겠습니까? 관련된 모든 할당이 제거되며, 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => setDeletingGroupId(null)}>취소</AlertDialogCancel> + <AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </div> ); } @@ -290,6 +348,9 @@ function GroupCard({ onDelete: () => void; onManagePermissions: () => void; }) { + + console.log(group,"group") + return ( <Card className={cn( @@ -456,7 +517,7 @@ function GroupDetailCard({ ); } -// 그룹 생성/수정 폼 다이얼로그 +// 그룹 생성/수정 폼 다이얼로그 - react-hook-form 적용 function GroupFormDialog({ open, onOpenChange, @@ -468,48 +529,55 @@ function GroupFormDialog({ group?: PermissionGroup | null; onSuccess: () => void; }) { - const [formData, setFormData] = useState({ - groupKey: "", - name: "", - description: "", - domain: "", - isActive: true, - }); const [saving, setSaving] = useState(false); + + const form = useForm<PermissionGroupFormValues>({ + resolver: zodResolver(permissionGroupFormSchema), + defaultValues: { + groupKey: "", + name: "", + description: "", + domain: undefined, + isActive: true, + }, + }); + + console.log(form.getValues()) useEffect(() => { if (group) { - setFormData({ + form.reset({ groupKey: group.groupKey, name: group.name, description: group.description || "", - domain: group.domain || "", + domain: group.domain || undefined, isActive: group.isActive, }); } else { - setFormData({ + form.reset({ groupKey: "", name: "", description: "", - domain: "", + domain: undefined, isActive: true, }); } - }, [group]); - - const handleSubmit = async () => { - if (!formData.groupKey || !formData.name) { - toast.error("필수 항목을 입력해주세요."); - return; - } + }, [group, form]); + const onSubmit = async (values: PermissionGroupFormValues) => { setSaving(true); try { + // domain이 undefined인 경우 빈 문자열로 변환 + const submitData = { + ...values, + domain: values.domain || "", + }; + if (group) { - await updatePermissionGroup(group.id, formData); + await updatePermissionGroup(group.id, submitData); toast.success("권한 그룹이 수정되었습니다."); } else { - await createPermissionGroup(formData); + await createPermissionGroup(submitData); toast.success("권한 그룹이 생성되었습니다."); } onSuccess(); @@ -530,72 +598,131 @@ function GroupFormDialog({ </DialogDescription> </DialogHeader> - <div className="grid gap-4 py-4"> - <div> - <Label>그룹 키*</Label> - <Input - value={formData.groupKey} - onChange={(e) => setFormData({ ...formData, groupKey: e.target.value })} - placeholder="예: rfq_manager" + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="groupKey" + render={({ field }) => ( + <FormItem> + <FormLabel>그룹 키 *</FormLabel> + <FormControl> + <Input + placeholder="예: rfq_manager" + {...field} + /> + </FormControl> + <FormDescription> + 소문자, 숫자, 언더스코어만 사용 가능합니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} /> - </div> - <div> - <Label>그룹명*</Label> - <Input - value={formData.name} - onChange={(e) => setFormData({ ...formData, name: e.target.value })} - placeholder="예: RFQ 관리자 권한" + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>그룹명 *</FormLabel> + <FormControl> + <Input + placeholder="예: RFQ 관리자 권한" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - <div> - <Label>설명</Label> - <Textarea - value={formData.description} - onChange={(e) => setFormData({ ...formData, description: e.target.value })} - placeholder="그룹에 대한 설명" + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="그룹에 대한 설명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - <div> - <Label>도메인</Label> - <Select - value={formData.domain} - onValueChange={(v) => setFormData({ ...formData, domain: v })} - > - <SelectTrigger> - <SelectValue placeholder="도메인 선택" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="">전체</SelectItem> - <SelectItem value="evcp">EVCP</SelectItem> - <SelectItem value="partners">Partners</SelectItem> - <SelectItem value="procurement">Procurement</SelectItem> - <SelectItem value="sales">Sales</SelectItem> - <SelectItem value="engineering">Engineering</SelectItem> - </SelectContent> - </Select> - </div> + <FormField + control={form.control} + name="domain" + render={({ field }) => ( + <FormItem> + <FormLabel>도메인</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value || "none"} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="도메인 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="evcp">eVCP</SelectItem> + <SelectItem value="partners">Partners</SelectItem> + <SelectItem value="procurement">Procurement</SelectItem> + <SelectItem value="sales">Sales</SelectItem> + <SelectItem value="engineering">Engineering</SelectItem> + </SelectContent> + </Select> + <FormDescription> + 권한 그룹이 속한 도메인을 선택하세요. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> - <div className="flex items-center gap-2"> - <Checkbox - id="isActive" - checked={formData.isActive} - onCheckedChange={(v) => setFormData({ ...formData, isActive: !!v })} + <FormField + control={form.control} + name="isActive" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel> + 활성 상태 + </FormLabel> + <FormDescription> + 비활성화 시 이 그룹의 권한이 적용되지 않습니다. + </FormDescription> + </div> + </FormItem> + )} /> - <Label htmlFor="isActive">활성 상태</Label> - </div> - </div> - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleSubmit} disabled={saving}> - {saving ? "저장 중..." : group ? "수정" : "생성"} - </Button> - </DialogFooter> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + 취소 + </Button> + <Button type="submit" disabled={saving}> + {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {saving ? "저장 중..." : group ? "수정" : "생성"} + </Button> + </DialogFooter> + </form> + </Form> </DialogContent> </Dialog> ); @@ -790,6 +917,7 @@ function GroupPermissionsDialog({ 취소 </Button> <Button onClick={handleSave} disabled={saving}> + {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {saving ? "저장 중..." : "저장"} </Button> </DialogFooter> diff --git a/components/permissions/role-permission-manager.tsx b/components/permissions/role-permission-manager.tsx index b229ec57..63961300 100644 --- a/components/permissions/role-permission-manager.tsx +++ b/components/permissions/role-permission-manager.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/table"; import { toast } from "sonner"; import { assignPermissionsToRole, getRolePermissions } from "@/lib/permissions/service"; +import { RoleSelector } from "@/components/permissions/role-selector"; export function RolePermissionManager() { const [selectedRole, setSelectedRole] = useState<string>(""); @@ -31,9 +32,11 @@ export function RolePermissionManager() { const [selectedPermissions, setSelectedPermissions] = useState<Set<number>>(new Set()); const [loading, setLoading] = useState(false); + console.log(selectedRole) + useEffect(() => { if (selectedRole) { - loadRolePermissions(selectedRole); + loadRolePermissions(selectedRole.id); } }, [selectedRole]); @@ -56,7 +59,7 @@ export function RolePermissionManager() { try { setLoading(true); await assignPermissionsToRole( - parseInt(selectedRole), + parseInt(selectedRole.id), Array.from(selectedPermissions) ); toast.success("권한이 성공적으로 저장되었습니다."); @@ -97,21 +100,16 @@ export function RolePermissionManager() { <div className="space-y-6"> {/* 역할 선택 */} <div className="flex items-center gap-4"> - <Select value={selectedRole} onValueChange={setSelectedRole}> - <SelectTrigger className="w-[300px]"> - <SelectValue placeholder="역할 선택..." /> - </SelectTrigger> - <SelectContent> - <SelectItem value="1">EVCP Admin</SelectItem> - <SelectItem value="2">EVCP Manager</SelectItem> - <SelectItem value="3">EVCP User</SelectItem> - <SelectItem value="4">Partner Admin</SelectItem> - <SelectItem value="5">Partner User</SelectItem> - </SelectContent> - </Select> - - <Button - onClick={handleSave} + <RoleSelector + selectedRoleId={selectedRole?.id} + onRoleSelect={setSelectedRole} + placeholder="역할을 선택하세요..." + className='max-w-[400px]' + domain="evcp" // 선택사항: 특정 도메인만 필터링 + /> + + <Button + onClick={handleSave} disabled={!selectedRole || loading} > 권한 저장 diff --git a/components/permissions/role-selector.tsx b/components/permissions/role-selector.tsx new file mode 100644 index 00000000..5f62ca51 --- /dev/null +++ b/components/permissions/role-selector.tsx @@ -0,0 +1,227 @@ +// components/permissions/role-selector.tsx + +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Shield, Users } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { getRoles } from "@/lib/permissions/service" + +export interface Role { + id: number; + name: string; + domain: string; + description?: string; + userCount?: number; +} + +interface RoleSelectorProps { + selectedRoleId?: number | null; + onRoleSelect: (role: Role) => void; + placeholder?: string; + domain?: string; // 특정 도메인의 역할만 필터링 + className?: string; +} + +export function RoleSelector({ + selectedRoleId, + onRoleSelect, + placeholder = "역할 선택...", + domain, + className +}: RoleSelectorProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + const [roles, setRoles] = React.useState<Role[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedRole, setSelectedRole] = React.useState<Role | null>(null) + + // 역할 데이터 로드 + React.useEffect(() => { + async function loadRoles() { + setIsLoading(true); + try { + const allRoles = await getRoles(); + + // domain이 지정된 경우 해당 도메인만 필터링 + const filteredByDomain = domain + ? allRoles.filter((r: Role) => r.domain === domain) + : allRoles; + + console.log(`Loaded ${filteredByDomain.length} roles${domain ? ` for domain: ${domain}` : ''}`); + setRoles(filteredByDomain); + + // 초기 선택된 역할이 있으면 설정 + if (selectedRoleId) { + const selected = filteredByDomain.find((r: Role) => r.id === selectedRoleId); + if (selected) { + setSelectedRole(selected); + } + } + } catch (error) { + console.error("역할 목록 로드 오류:", error); + setRoles([]); + } finally { + setIsLoading(false); + } + } + + loadRoles(); + }, [selectedRoleId, domain]); + + // 클라이언트 측에서 검색어로 필터링 + const filteredRoles = React.useMemo(() => { + if (!searchTerm.trim()) return roles; + + const lowerSearch = searchTerm.toLowerCase(); + return roles.filter( + role => + role.name.toLowerCase().includes(lowerSearch) || + role.domain.toLowerCase().includes(lowerSearch) || + (role.description && role.description.toLowerCase().includes(lowerSearch)) + ); + }, [roles, searchTerm]); + + // 역할 선택 처리 + const handleSelectRole = (role: Role) => { + setSelectedRole(role); + onRoleSelect(role); + setOpen(false); + }; + + // 도메인별 색상 결정 + const getDomainColor = (domain: string) => { + switch(domain?.toLowerCase()) { + case 'evcp': + return 'default'; + case 'partners': + return 'secondary'; + case 'admin': + return 'destructive'; + default: + return 'outline'; + } + }; + + // 도메인별 그룹화 + const groupedRoles = React.useMemo(() => { + return filteredRoles.reduce((acc, role) => { + const key = role.domain || 'other'; + if (!acc[key]) acc[key] = []; + acc[key].push(role); + return acc; + }, {} as Record<string, Role[]>); + }, [filteredRoles]); + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className={cn("w-full justify-between", className)} + disabled={isLoading} + > + {isLoading ? ( + <span className="text-muted-foreground">역할 로딩 중...</span> + ) : selectedRole ? ( + <div className="flex items-center gap-2 w-full"> + <Shield className="h-4 w-4 text-muted-foreground" /> + <span className="truncate">{selectedRole.name}</span> + <Badge + variant={getDomainColor(selectedRole.domain)} + className="ml-auto text-xs" + > + {selectedRole.domain} + </Badge> + {selectedRole.userCount !== undefined && ( + <Badge variant="outline" className="text-xs"> + {selectedRole.userCount}명 + </Badge> + )} + </div> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="역할명, 도메인으로 검색..." + onValueChange={setSearchTerm} + /> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + {isLoading ? ( + <div className="py-6 text-center text-sm text-muted-foreground"> + 역할 목록 로딩 중... + </div> + ) : Object.keys(groupedRoles).length === 0 ? ( + <CommandEmpty> + {searchTerm + ? "검색 결과가 없습니다" + : domain + ? `${domain} 도메인에 역할이 없습니다` + : "역할이 없습니다"} + </CommandEmpty> + ) : ( + Object.entries(groupedRoles).map(([groupDomain, groupRoles]) => ( + <CommandGroup key={groupDomain} heading={groupDomain.toUpperCase()}> + {groupRoles.map((role) => ( + <CommandItem + key={role.id} + value={`${role.name} ${role.domain} ${role.description || ''}`} + onSelect={() => handleSelectRole(role)} + className="cursor-pointer" + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedRole?.id === role.id + ? "opacity-100" + : "opacity-0" + )} + /> + <div className="flex items-center gap-2 flex-1"> + <Shield className="h-4 w-4 text-muted-foreground" /> + <div className="flex-1"> + <div className="font-medium">{role.name}</div> + {role.description && ( + <div className="text-xs text-muted-foreground truncate"> + {role.description} + </div> + )} + </div> + <div className="flex items-center gap-1"> + {role.userCount !== undefined && ( + <Badge variant="outline" className="text-xs"> + <Users className="h-3 w-3 mr-1" /> + {role.userCount} + </Badge> + )} + </div> + </div> + </CommandItem> + ))} + </CommandGroup> + )) + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file diff --git a/components/project/ProjectDashboard.tsx b/components/project/ProjectDashboard.tsx index d9ec2e0c..5f8afb75 100644 --- a/components/project/ProjectDashboard.tsx +++ b/components/project/ProjectDashboard.tsx @@ -15,7 +15,10 @@ import { Download, HardDrive, UserCog, - Loader2 + Loader2, + Edit2, + Check, + ChevronsUpDown } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -36,10 +39,25 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useToast } from '@/hooks/use-toast'; import { useSession } from 'next-auth/react'; +import { getUsersForFilter } from '@/lib/gtc-contract/service'; +import { cn } from '@/lib/utils'; interface ProjectDashboardProps { projectId: string; @@ -67,6 +85,13 @@ interface ProjectStats { }; } +interface User { + id: number; + name: string; + email: string; + domain?: string; // 'partners' | 'internal' etc +} + export function ProjectDashboard({ projectId }: ProjectDashboardProps) { const { data: session } = useSession(); const [isOwner, setIsOwner] = useState(false); @@ -75,41 +100,46 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { const [members, setMembers] = useState<any[]>([]); const [loading, setLoading] = useState(true); - console.log(stats) - - // 다이얼로그 상태 + // Dialog states const [addMemberOpen, setAddMemberOpen] = useState(false); const [transferOwnershipOpen, setTransferOwnershipOpen] = useState(false); - const [newMemberEmail, setNewMemberEmail] = useState(''); - const [newMemberRole, setNewMemberRole] = useState('viewer'); const [newOwnerId, setNewOwnerId] = useState(''); + // User selection related states + const [availableUsers, setAvailableUsers] = useState<User[]>([]); + const [selectedUser, setSelectedUser] = useState<User | null>(null); + const [userSearchTerm, setUserSearchTerm] = useState(''); + const [userPopoverOpen, setUserPopoverOpen] = useState(false); + const [loadingUsers, setLoadingUsers] = useState(false); + const [isExternalUser, setIsExternalUser] = useState(false); + const [newMemberRole, setNewMemberRole] = useState<string>('viewer'); + const { toast } = useToast(); - // 프로젝트 정보 및 권한 확인 + // Fetch project info and permissions useEffect(() => { const fetchProjectData = async () => { try { - // 권한 확인 + // Check permissions const accessRes = await fetch(`/api/projects/${projectId}/access`); const accessData = await accessRes.json(); setIsOwner(accessData.isOwner); setProjectRole(accessData.role); - // Owner인 경우 통계 가져오기 + // Get stats if owner if (accessData.isOwner) { const statsRes = await fetch(`/api/projects/${projectId}/stats`); const statsData = await statsRes.json(); setStats(statsData); } - // 멤버 목록 가져오기 + // Get member list const membersRes = await fetch(`/api/projects/${projectId}/members`); const membersData = await membersRes.json(); setMembers(membersData.member); } catch (error) { - console.error('프로젝트 데이터 로드 실패:', error); + console.error('Failed to load project data:', error); } finally { setLoading(false); } @@ -118,39 +148,84 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { fetchProjectData(); }, [projectId]); - // 멤버 추가 + // Fetch user list when dialog opens + useEffect(() => { + if (addMemberOpen) { + fetchAvailableUsers(); + } else { + // Reset when dialog closes + setSelectedUser(null); + setUserSearchTerm(''); + setNewMemberRole('viewer'); + setIsExternalUser(false); + } + }, [addMemberOpen]); + + const fetchAvailableUsers = async () => { + try { + setLoadingUsers(true); + const users = await getUsersForFilter(); + // Exclude members already in project + const memberUserIds = members.map(m => m.userId); + const filteredUsers = users.filter(u => !memberUserIds.includes(u.id)); + setAvailableUsers(filteredUsers); + } catch (error) { + console.error('Failed to load user list:', error); + toast({ + title: 'Error', + description: 'Unable to load user list.', + variant: 'destructive', + }); + } finally { + setLoadingUsers(false); + } + }; + + // Add member const handleAddMember = async () => { + if (!selectedUser) { + toast({ + title: 'Error', + description: 'Please select a user.', + variant: 'destructive', + }); + return; + } + try { const response = await fetch(`/api/projects/${projectId}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - email: newMemberEmail, + userId: selectedUser.id, role: newMemberRole, }), }); if (!response.ok) { - throw new Error('멤버 추가 실패'); + throw new Error('Failed to add member'); } toast({ - title: '성공', - description: '새 멤버가 추가되었습니다.', + title: 'Success', + description: 'New member has been added.', }); setAddMemberOpen(false); - // 멤버 목록 새로고침 + // Refresh member list + const membersRes = await fetch(`/api/projects/${projectId}/members`); + const membersData = await membersRes.json(); + setMembers(membersData.member); } catch (error) { toast({ - title: '오류', - description: '멤버 추가에 실패했습니다.', + title: 'Error', + description: 'Failed to add member.', variant: 'destructive', }); } }; - // 소유권 이전 + // Transfer ownership const handleTransferOwnership = async () => { try { const response = await fetch(`/api/projects/${projectId}/transfer-ownership`, { @@ -162,20 +237,20 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { }); if (!response.ok) { - throw new Error('소유권 이전 실패'); + throw new Error('Failed to transfer ownership'); } toast({ - title: '성공', - description: '프로젝트 소유권이 이전되었습니다.', + title: 'Success', + description: 'Project ownership has been transferred.', }); setTransferOwnershipOpen(false); setIsOwner(false); } catch (error) { toast({ - title: '오류', - description: '소유권 이전에 실패했습니다.', + title: 'Error', + description: 'Failed to transfer ownership.', variant: 'destructive', }); } @@ -192,16 +267,22 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { const roleConfig = { owner: { label: 'Owner', icon: Crown, color: 'text-yellow-500' }, admin: { label: 'Admin', icon: Shield, color: 'text-blue-500' }, - editor: { label: 'Editor', icon: FolderOpen, color: 'text-green-500' }, + editor: { label: 'Editor', icon: Edit2, color: 'text-green-500' }, viewer: { label: 'Viewer', icon: Eye, color: 'text-gray-500' }, }; + // User search filtering + const filteredUsers = availableUsers.filter(user => + user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) || + user.email.toLowerCase().includes(userSearchTerm.toLowerCase()) + ); + if (loading) { return ( <div className="flex items-center justify-center min-h-[400px]"> <div className="text-center space-y-3"> <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" /> - <p className="text-sm text-muted-foreground">프로젝트 정보를 불러오는 중...</p> + <p className="text-sm text-muted-foreground">Loading project information...</p> </div> </div> ); @@ -209,10 +290,10 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { return ( <div className="p-6 space-y-6"> - {/* 헤더 */} + {/* Header */} <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> - <h1 className="text-2xl font-bold">프로젝트 대시보드</h1> + <h1 className="text-2xl font-bold">Project Dashboard</h1> <Badge variant="outline" className="flex items-center gap-1"> {roleConfig[projectRole as keyof typeof roleConfig].icon && React.createElement(roleConfig[projectRole as keyof typeof roleConfig].icon, { @@ -227,22 +308,22 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { <div className="flex gap-2"> <Button onClick={() => setAddMemberOpen(true)}> <UserPlus className="h-4 w-4 mr-2" /> - 멤버 추가 + Add Member </Button> <Button variant="outline"> <Settings className="h-4 w-4 mr-2" /> - 설정 + Settings </Button> </div> )} </div> - {/* Owner 전용 통계 */} + {/* Owner-only statistics */} {isOwner && stats && ( <div className="grid grid-cols-4 gap-4"> <Card> <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">총 파일 수</CardTitle> + <CardTitle className="text-sm font-medium">Total Files</CardTitle> </CardHeader> <CardContent> <div className="text-2xl font-bold">{stats.storage.fileCount}</div> @@ -254,16 +335,16 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { <Card> <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">멤버</CardTitle> + <CardTitle className="text-sm font-medium">Members</CardTitle> </CardHeader> <CardContent> <div className="text-2xl font-bold">{stats.users.total}</div> <div className="flex gap-2 mt-1"> <span className="text-xs text-muted-foreground"> - 관리자 {stats.users.byRole.admins} + Admins {stats.users.byRole.admins} </span> <span className="text-xs text-muted-foreground"> - 편집자 {stats.users.byRole.editors} + Editors {stats.users.byRole.editors} </span> </div> </CardContent> @@ -271,38 +352,38 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { <Card> <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">조회수 (30일)</CardTitle> + <CardTitle className="text-sm font-medium">Views (30 days)</CardTitle> </CardHeader> <CardContent> <div className="text-2xl font-bold">{stats.activity.views}</div> <p className="text-xs text-muted-foreground mt-1"> - 활성 사용자 {stats.users.active}명 + {stats.users.active} active users </p> </CardContent> </Card> <Card> <CardHeader className="pb-2"> - <CardTitle className="text-sm font-medium">다운로드 (30일)</CardTitle> + <CardTitle className="text-sm font-medium">Downloads (30 days)</CardTitle> </CardHeader> <CardContent> <div className="text-2xl font-bold">{stats.activity.downloads}</div> <p className="text-xs text-muted-foreground mt-1"> - 업로드 {stats.activity.uploads}개 + {stats.activity.uploads} uploads </p> </CardContent> </Card> </div> )} - {/* 탭 컨텐츠 */} + {/* Tab content */} <Tabs defaultValue="members"> <TabsList> - <TabsTrigger value="members">멤버</TabsTrigger> + <TabsTrigger value="members">Members</TabsTrigger> {isOwner && ( <> - <TabsTrigger value="permissions">권한 관리</TabsTrigger> - <TabsTrigger value="danger">위험 영역</TabsTrigger> + <TabsTrigger value="permissions">Permission Management</TabsTrigger> + <TabsTrigger value="danger">Danger Zone</TabsTrigger> </> )} </TabsList> @@ -310,9 +391,9 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { <TabsContent value="members" className="mt-6"> <Card> <CardHeader> - <CardTitle>프로젝트 멤버</CardTitle> + <CardTitle>Project Members</CardTitle> <CardDescription> - 이 프로젝트에 접근할 수 있는 사용자 목록 + List of users who can access this project </CardDescription> </CardHeader> <CardContent> @@ -347,17 +428,17 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { <TabsContent value="danger" className="mt-6"> <Card className="border-red-200"> <CardHeader> - <CardTitle className="text-red-600">위험 영역</CardTitle> + <CardTitle className="text-red-600">Danger Zone</CardTitle> <CardDescription> - 이 작업들은 되돌릴 수 없습니다. 신중하게 진행하세요. + These actions cannot be undone. Please proceed with caution. </CardDescription> </CardHeader> <CardContent className="space-y-4"> <div className="flex items-center justify-between p-4 border rounded-lg"> <div> - <h3 className="font-medium">소유권 이전</h3> + <h3 className="font-medium">Transfer Ownership</h3> <p className="text-sm text-muted-foreground"> - 프로젝트 소유권을 다른 멤버에게 이전합니다 + Transfer project ownership to another member </p> </div> <Button @@ -365,20 +446,20 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { onClick={() => setTransferOwnershipOpen(true)} > <UserCog className="h-4 w-4 mr-2" /> - 소유권 이전 + Transfer Ownership </Button> </div> <div className="flex items-center justify-between p-4 border rounded-lg border-red-200"> <div> - <h3 className="font-medium text-red-600">프로젝트 삭제</h3> + <h3 className="font-medium text-red-600">Delete Project</h3> <p className="text-sm text-muted-foreground"> - 프로젝트와 모든 파일을 영구적으로 삭제합니다 + Permanently delete project and all files </p> </div> <Button variant="destructive"> <Trash2 className="h-4 w-4 mr-2" /> - 프로젝트 삭제 + Delete Project </Button> </div> </CardContent> @@ -387,67 +468,259 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { )} </Tabs> - {/* 멤버 추가 다이얼로그 */} + {/* Add Member Dialog */} <Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}> - <DialogContent> + <DialogContent className="max-w-lg"> <DialogHeader> - <DialogTitle>멤버 추가</DialogTitle> + <DialogTitle>Add Member</DialogTitle> <DialogDescription> - 프로젝트에 새 멤버를 추가합니다 + Add a member to the project </DialogDescription> </DialogHeader> - - <div className="space-y-4"> - <div> - <Label htmlFor="email">이메일</Label> - <Input - id="email" - type="email" - value={newMemberEmail} - onChange={(e) => setNewMemberEmail(e.target.value)} - placeholder="user@example.com" - /> - </div> - - <div> - <Label htmlFor="role">역할</Label> - <Select value={newMemberRole} onValueChange={setNewMemberRole}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem> - <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem> - <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem> - </SelectContent> - </Select> - </div> - </div> - + + <Tabs defaultValue="internal" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="internal">Internal Users</TabsTrigger> + <TabsTrigger value="external" className="flex items-center gap-2"> + External Users + <Badge variant="outline" className="ml-1 text-xs">Viewer Only</Badge> + </TabsTrigger> + </TabsList> + + <TabsContent value="internal" className="space-y-4 mt-4"> + <div className="space-y-2"> + <Label htmlFor="internal-user">Select User</Label> + + {loadingUsers ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="ml-2 text-sm text-muted-foreground">Loading user list...</span> + </div> + ) : ( + <> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={userPopoverOpen} + className="w-full justify-between" + > + <span className="truncate"> + {selectedUser && selectedUser.domain !== 'partners' ? ( + <div className="text-left"> + <div className="font-medium">{selectedUser.name}</div> + <div className="text-xs text-muted-foreground">{selectedUser.email}</div> + </div> + ) : ( + "Select internal user..." + )} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="Search by name or email..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>No user found.</CommandEmpty> + <CommandGroup heading="Internal User List"> + {filteredUsers + .filter(u => u.domain !== 'partners') + .map((user) => ( + <CommandItem + key={user.id} + onSelect={() => { + setSelectedUser(user); + setUserPopoverOpen(false); + setIsExternalUser(false); + setNewMemberRole('viewer'); + }} + value={`${user.name} ${user.email}`} + className="truncate" + > + <Users className="mr-2 h-4 w-4 text-blue-500 flex-shrink-0" /> + <div className="flex-1 truncate"> + <div className="font-medium truncate">{user.name}</div> + <div className="text-xs text-muted-foreground truncate">{user.email}</div> + </div> + <Check + className={cn( + "ml-2 h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id && !isExternalUser ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + <p className="text-xs text-muted-foreground"> + Internal users can be assigned any role. + </p> + </> + )} + </div> + + <div className="space-y-2"> + <Label htmlFor="internal-role">Role</Label> + <Select + value={newMemberRole} + onValueChange={setNewMemberRole} + disabled={!selectedUser || isExternalUser} + > + <SelectTrigger id="internal-role"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="viewer">Viewer - Read only</SelectItem> + <SelectItem value="editor">Editor - Can edit files</SelectItem> + <SelectItem value="admin">Admin - Project management</SelectItem> + </SelectContent> + </Select> + </div> + </TabsContent> + + <TabsContent value="external" className="space-y-4 mt-4"> + <div className="rounded-lg bg-amber-50 border border-amber-200 p-3 mb-4"> + <p className="text-sm text-amber-800"> + <strong>Security Policy Notice</strong><br/> + External users (partners) can only be granted Viewer permissions due to security policy. + </p> + </div> + + <div className="space-y-2"> + <Label htmlFor="external-user">Select Partner</Label> + + {loadingUsers ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="ml-2 text-sm text-muted-foreground">Loading user list...</span> + </div> + ) : ( + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={userPopoverOpen} + className="w-full justify-between" + > + <span className="truncate"> + {selectedUser && selectedUser.domain === 'partners' ? ( + <span className="flex items-center gap-2"> + {selectedUser.name} + <Badge variant="outline" className="ml-1 text-xs">External</Badge> + </span> + ) : ( + "Select external user..." + )} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="Search by name..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>No external users found.</CommandEmpty> + <CommandGroup heading="Partner List"> + {filteredUsers + .filter(u => u.domain === 'partners') + .map((user) => ( + <CommandItem + key={user.id} + onSelect={() => { + setSelectedUser(user); + setUserPopoverOpen(false); + setIsExternalUser(true); + setNewMemberRole('viewer'); + }} + value={user.name} + className="truncate" + > + <Users className="mr-2 h-4 w-4 text-amber-600" /> + <span className="truncate flex-1">{user.name}</span> + <Badge variant="outline" className="text-xs mx-2">External</Badge> + <Check + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id && isExternalUser ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + )} + </div> + + <div className="space-y-2"> + <Label htmlFor="external-role">Role</Label> + <Select value="viewer" disabled> + <SelectTrigger id="external-role" className="opacity-60"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="viewer">Viewer - Read Only (Fixed)</SelectItem> + </SelectContent> + </Select> + </div> + </TabsContent> + </Tabs> + <DialogFooter> - <Button variant="outline" onClick={() => setAddMemberOpen(false)}> - 취소 + <Button + variant="outline" + onClick={() => { + setAddMemberOpen(false); + setSelectedUser(null); + setUserSearchTerm(''); + setNewMemberRole('viewer'); + setIsExternalUser(false); + }} + > + Cancel + </Button> + <Button + onClick={handleAddMember} + disabled={!selectedUser} + > + Add </Button> - <Button onClick={handleAddMember}>추가</Button> </DialogFooter> </DialogContent> </Dialog> - {/* 소유권 이전 다이얼로그 */} + {/* Transfer Ownership Dialog */} <Dialog open={transferOwnershipOpen} onOpenChange={setTransferOwnershipOpen}> <DialogContent> <DialogHeader> - <DialogTitle>소유권 이전</DialogTitle> + <DialogTitle>Transfer Ownership</DialogTitle> <DialogDescription className="text-red-600"> - 주의: 이 작업은 되돌릴 수 없습니다. 프로젝트의 모든 권한이 새 소유자에게 이전됩니다. + Warning: This action is irreversible. All permissions will be transferred to the new owner. </DialogDescription> </DialogHeader> <div> - <Label htmlFor="new-owner">새 소유자 선택</Label> + <Label htmlFor="new-owner">Select New Owner</Label> <Select value={newOwnerId} onValueChange={setNewOwnerId}> <SelectTrigger> - <SelectValue placeholder="멤버 선택" /> + <SelectValue placeholder="Choose member" /> </SelectTrigger> <SelectContent> {members @@ -463,10 +736,10 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { <DialogFooter> <Button variant="outline" onClick={() => setTransferOwnershipOpen(false)}> - 취소 + Cancel </Button> <Button variant="destructive" onClick={handleTransferOwnership}> - 소유권 이전 + Transfer </Button> </DialogFooter> </DialogContent> diff --git a/components/project/ProjectList.tsx b/components/project/ProjectList.tsx index 4a4f7962..9dec7e77 100644 --- a/components/project/ProjectList.tsx +++ b/components/project/ProjectList.tsx @@ -2,13 +2,12 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; -import { - Plus, - Folder, - Users, - Globe, +import { + Plus, + Folder, + Users, + Globe, Lock, Crown, Calendar, @@ -34,6 +33,7 @@ import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useToast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; +import { useRouter, usePathname } from "next/navigation" interface Project { id: string; @@ -65,11 +65,16 @@ export function ProjectList() { const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - + const pathname = usePathname() + + const internal = pathname?.includes('evcp') + + console.log(projects) + const router = useRouter(); const { toast } = useToast(); - // React Hook Form 설정 + // React Hook Form setup const { register, handleSubmit, @@ -100,8 +105,8 @@ export function ProjectList() { setProjects(data); } catch (error) { toast({ - title: '오류', - description: '프로젝트 목록을 불러올 수 없습니다.', + title: 'Error', + description: 'Unable to load project list.', variant: 'destructive', }); } @@ -116,25 +121,25 @@ export function ProjectList() { body: JSON.stringify(data), }); - if (!response.ok) throw new Error('프로젝트 생성 실패'); + if (!response.ok) throw new Error('Failed to create project'); const project = await response.json(); - + toast({ - title: '성공', - description: '프로젝트가 생성되었습니다.', + title: 'Success', + description: 'Project has been created.', }); setCreateDialogOpen(false); reset(); fetchProjects(); - - // 생성된 프로젝트로 이동 + + // Navigate to created project router.push(`/evcp/data-room/${project.id}`); } catch (error) { toast({ - title: '오류', - description: '프로젝트 생성에 실패했습니다.', + title: 'Error', + description: 'Failed to create project.', variant: 'destructive', }); } finally { @@ -150,19 +155,20 @@ export function ProjectList() { }; const filteredProjects = { - owned: projects.owned?.filter(p => + owned: projects.owned?.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()) - ), - member: projects.member?.filter(p => + ) || [], // Return empty array instead of undefined + member: projects.member?.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()) - ), - public: projects.public?.filter(p => + ) || [], + public: projects.public?.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()) - ), + ) || [], }; + const ProjectCard = ({ project, role }: { project: Project; role?: string }) => ( - <Card + <Card className="cursor-pointer hover:shadow-lg transition-shadow" onClick={() => router.push(`/evcp/data-room/${project.id}/files`)} > @@ -182,7 +188,7 @@ export function ProjectList() { )} </div> <CardDescription className="line-clamp-2"> - {project.description || '설명이 없습니다'} + {project.description || 'No description'} </CardDescription> </CardHeader> <CardContent> @@ -217,26 +223,30 @@ export function ProjectList() { return ( <> - {/* 헤더 */} + {/* Header */} <div className="flex items-center justify-between mb-6"> <div> - <h1 className="text-3xl font-bold">프로젝트</h1> + <h1 className="text-3xl font-bold">Projects</h1> <p className="text-muted-foreground mt-1"> - 파일을 관리하고 팀과 협업하세요 + Manage files and collaborate with your team </p> </div> - {/* <Button onClick={() => setCreateDialogOpen(true)}> - <Plus className="h-4 w-4 mr-2" /> - 새 프로젝트 - </Button> */} + {internal && + <Button onClick={() => setCreateDialogOpen(true)}> + <Plus className="h-4 w-4 mr-2" /> + New Project + </Button> + + } + </div> - {/* 검색 및 필터 */} + {/* Search and Filter */} <div className="flex items-center gap-3 mb-6"> <div className="relative flex-1 max-w-md"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Input - placeholder="프로젝트 검색..." + placeholder="Search projects..." className="pl-9" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} @@ -251,22 +261,85 @@ export function ProjectList() { </Button> </div> - {/* 프로젝트 목록 */} + {/* Project List */} <Tabs defaultValue="owned" className="space-y-6"> <TabsList> + {internal && + <TabsTrigger value="owned"> + My Projects ({filteredProjects.owned?.length}) + </TabsTrigger> + } + <TabsTrigger value="member"> - 참여 프로젝트 ({filteredProjects.member?.length}) + Joined Projects ({filteredProjects.member?.length}) </TabsTrigger> <TabsTrigger value="public"> - 공개 프로젝트 ({filteredProjects.public?.length}) + Public Projects ({filteredProjects.public?.length}) </TabsTrigger> </TabsList> + {/* My Projects Tab */} + {internal && + <TabsContent value="owned"> + {filteredProjects.owned?.length === 0 ? ( + <div className="text-center py-12"> + <Crown className="h-12 w-12 text-muted-foreground mx-auto mb-3" /> + <p className="text-muted-foreground">You don't own any projects</p> + <Button + className="mt-4" + onClick={() => setCreateDialogOpen(true)} + > + <Plus className="h-4 w-4 mr-2" /> + Create your first project + </Button> + </div> + ) : viewMode === 'grid' ? ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + {filteredProjects.owned?.map(project => ( + <ProjectCard key={project.id} project={project} role="owner" /> + ))} + </div> + ) : ( + <div className="space-y-2"> + {filteredProjects.owned?.map(project => ( + <Card + key={project.id} + className="cursor-pointer hover:shadow transition-shadow" + onClick={() => router.push(`/evcp/data-room/${project.id}/files`)} + > + <CardContent className="flex items-center justify-between p-4"> + <div className="flex items-center gap-3"> + <Folder className="h-5 w-5 text-blue-500" /> + <div> + <p className="font-medium">{project.code} {project.name}</p> + <p className="text-sm text-muted-foreground"> + {project.description || 'No description'} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <Badge variant="secondary">Owner</Badge> + {project.isPublic ? ( + <Globe className="h-4 w-4 text-green-500" /> + ) : ( + <Lock className="h-4 w-4 text-gray-500" /> + )} + </div> + </CardContent> + </Card> + ))} + </div> + )} + </TabsContent> + + + } + <TabsContent value="member"> {filteredProjects.member?.length === 0 ? ( <div className="text-center py-12"> <Users className="h-12 w-12 text-muted-foreground mx-auto mb-3" /> - <p className="text-muted-foreground">참여 중인 프로젝트가 없습니다</p> + <p className="text-muted-foreground">You are not a member of any projects</p> </div> ) : viewMode === 'grid' ? ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> @@ -277,7 +350,7 @@ export function ProjectList() { ) : ( <div className="space-y-2"> {filteredProjects.member?.map(project => ( - <Card + <Card key={project.id} className="cursor-pointer hover:shadow transition-shadow" onClick={() => router.push(`/evcp/data-room/${project.id}/files`)} @@ -286,9 +359,9 @@ export function ProjectList() { <div className="flex items-center gap-3"> <Folder className="h-5 w-5 text-blue-500" /> <div> - <p className="font-medium">{project.name}</p> + <p className="font-medium">{project.code} {project.name}</p> <p className="text-sm text-muted-foreground"> - {project.description || '설명이 없습니다'} + {project.description || 'No description'} </p> </div> </div> @@ -311,7 +384,7 @@ export function ProjectList() { {filteredProjects.public?.length === 0 ? ( <div className="text-center py-12"> <Globe className="h-12 w-12 text-muted-foreground mx-auto mb-3" /> - <p className="text-muted-foreground">공개 프로젝트가 없습니다</p> + <p className="text-muted-foreground">No public projects</p> </div> ) : viewMode === 'grid' ? ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> @@ -322,7 +395,7 @@ export function ProjectList() { ) : ( <div className="space-y-2"> {filteredProjects.public?.map(project => ( - <Card + <Card key={project.id} className="cursor-pointer hover:shadow transition-shadow" onClick={() => router.push(`/evcp/data-room/${project.id}/files`)} @@ -331,13 +404,13 @@ export function ProjectList() { <div className="flex items-center gap-3"> <Globe className="h-5 w-5 text-green-500" /> <div> - <p className="font-medium">{project.name}</p> + <p className="font-medium">{project.code} {project.name}</p> <p className="text-sm text-muted-foreground"> - {project.description || '설명이 없습니다'} + {project.description || 'No description'} </p> </div> </div> - <Badge variant="outline">공개</Badge> + <Badge variant="outline">Public</Badge> </CardContent> </Card> ))} @@ -346,32 +419,32 @@ export function ProjectList() { </TabsContent> </Tabs> - {/* 프로젝트 생성 다이얼로그 */} + {/* Create Project Dialog */} <Dialog open={createDialogOpen} onOpenChange={handleDialogClose}> <DialogContent> <DialogHeader> - <DialogTitle>새 프로젝트 만들기</DialogTitle> + <DialogTitle>Create New Project</DialogTitle> <DialogDescription> - 팀과 파일을 공유할 새 프로젝트를 생성합니다 + Create a new project to share files with your team </DialogDescription> </DialogHeader> - + <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <div> <Label htmlFor="code"> - 프로젝트 코드 <span className="text-red-500">*</span> + Project Code <span className="text-red-500">*</span> </Label> <Input id="code" {...register('code', { - required: '프로젝트 코드는 필수입니다', + required: 'Project code is required', minLength: { value: 2, - message: '프로젝트 코드는 최소 2자 이상이어야 합니다', + message: 'Project code must be at least 2 characters', }, pattern: { value: /^[A-Z0-9]+$/, - message: '프로젝트 코드는 대문자와 숫자만 사용 가능합니다', + message: 'Project code can only contain uppercase letters and numbers', }, })} placeholder="SN1001" @@ -384,52 +457,52 @@ export function ProjectList() { <div> <Label htmlFor="name"> - 프로젝트 이름 <span className="text-red-500">*</span> + Project Name <span className="text-red-500">*</span> </Label> <Input id="name" {...register('name', { - required: '프로젝트 이름은 필수입니다', + required: 'Project name is required', minLength: { value: 2, - message: '프로젝트 이름은 최소 2자 이상이어야 합니다', + message: 'Project name must be at least 2 characters', }, maxLength: { value: 50, - message: '프로젝트 이름은 50자를 초과할 수 없습니다', + message: 'Project name cannot exceed 50 characters', }, })} - placeholder="예: FNLG" + placeholder="e.g. FNLG" className={errors.name ? 'border-red-500' : ''} /> {errors.name && ( <p className="text-sm text-red-500 mt-1">{errors.name.message}</p> )} </div> - + <div> - <Label htmlFor="description">설명 (선택)</Label> + <Label htmlFor="description">Description (Optional)</Label> <Input id="description" {...register('description', { maxLength: { value: 200, - message: '설명은 200자를 초과할 수 없습니다', + message: 'Description cannot exceed 200 characters', }, })} - placeholder="프로젝트에 대한 간단한 설명" + placeholder="Brief description of the project" className={errors.description ? 'border-red-500' : ''} /> {errors.description && ( <p className="text-sm text-red-500 mt-1">{errors.description.message}</p> )} </div> - + <div className="flex items-center justify-between"> <div> - <Label htmlFor="public">공개 프로젝트</Label> + <Label htmlFor="public">Public Project</Label> <p className="text-sm text-muted-foreground"> - 모든 사용자가 이 프로젝트를 볼 수 있습니다 + All users can view this project </p> </div> <Switch @@ -438,21 +511,21 @@ export function ProjectList() { onCheckedChange={(checked) => setValue('isPublic', checked)} /> </div> - + <DialogFooter> - <Button - type="button" - variant="outline" + <Button + type="button" + variant="outline" onClick={() => handleDialogClose(false)} disabled={isSubmitting} > - 취소 + Cancel </Button> - <Button + <Button type="submit" disabled={!isValid || isSubmitting} > - {isSubmitting ? '생성 중...' : '프로젝트 생성'} + {isSubmitting ? 'Creating...' : 'Create Project'} </Button> </DialogFooter> </form> diff --git a/components/project/ProjectNav.tsx b/components/project/ProjectNav.tsx index acf9bfd8..aac934ad 100644 --- a/components/project/ProjectNav.tsx +++ b/components/project/ProjectNav.tsx @@ -2,7 +2,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useRouter, usePathname } from 'next/navigation'; +import { useRouter, usePathname, useParams } from 'next/navigation'; import { Home, FolderOpen, @@ -39,6 +39,8 @@ export function ProjectNav({ projectId }: ProjectNavProps) { const [projectRole, setProjectRole] = useState(''); const router = useRouter(); const pathname = usePathname(); + const params = useParams() || {} + const lng = params.lng ? String(params.lng) : "ko" useEffect(() => { // 프로젝트 정보 가져오기 @@ -56,38 +58,40 @@ export function ProjectNav({ projectId }: ProjectNavProps) { } }; + console.log(pathname, projectId) + const navItems = [ { - label: '대시보드', + label: 'Dashboard', icon: Home, href: `/evcp/data-room/${projectId}`, - active: pathname === `/evcp/data-room/${projectId}`, + active: pathname === `/${lng}/evcp/data-room/${projectId}`, }, { - label: '파일', + label: 'Files', icon: FolderOpen, href: `/evcp/data-room/${projectId}/files`, - active: pathname === `/evcp/data-room/${projectId}/files`, + active: pathname?.includes('files') , }, { - label: '멤버', + label: 'Members', icon: Users, href: `/evcp/data-room/${projectId}/members`, - active: pathname === `/evcp/data-room/${projectId}/members`, + active: pathname?.includes('members'), requireRole: ['owner', 'admin'], }, { - label: '통계', + label: 'Stats', icon: BarChart3, href: `/evcp/data-room/${projectId}/stats`, - active: pathname === `/evcp/data-room/${projectId}/stats`, + active: pathname?.includes('stats') , requireRole: ['owner'], }, { - label: '설정', + label: 'Settings', icon: Settings, href: `/evcp/data-room/${projectId}/settings`, - active: pathname === `/evcp/data-room/${projectId}/settings`, + active: pathname?.includes('settiings') , requireRole: ['owner', 'admin'], }, ]; @@ -104,7 +108,7 @@ export function ProjectNav({ projectId }: ProjectNavProps) { <Breadcrumb> <BreadcrumbList> <BreadcrumbItem> - <BreadcrumbLink href="/evcp/data-room">프로젝트</BreadcrumbLink> + <BreadcrumbLink href="/evcp/data-room">Project</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> @@ -119,7 +123,7 @@ export function ProjectNav({ projectId }: ProjectNavProps) { </Badge> <Button variant="outline" size="sm"> <Share2 className="h-4 w-4 mr-1" /> - 공유 + Share </Button> </div> </div> diff --git a/components/vendor-data/vendor-data-container.tsx b/components/vendor-data/vendor-data-container.tsx index 207abcf1..18091070 100644 --- a/components/vendor-data/vendor-data-container.tsx +++ b/components/vendor-data/vendor-data-container.tsx @@ -94,9 +94,7 @@ export function VendorDataContainer({ // URL에서 모드 추출 (ship 프로젝트면 무조건 ENG로, 아니면 URL 또는 기본값) const modeFromUrl = searchParams?.get('mode') - const initialMode = isShipProject - ? "ENG" - : (modeFromUrl === "ENG" || modeFromUrl === "IM") ? modeFromUrl : "IM" + const initialMode ="ENG" // 모드 초기화 (기존의 useState 초기값 대신) React.useEffect(() => { @@ -388,8 +386,9 @@ const handleModeChange = async (mode: "IM" | "ENG") => { className="w-full" > <TabsList className="w-full"> - <TabsTrigger value="IM" className="flex-1">H/O</TabsTrigger> - <TabsTrigger value="ENG" className="flex-1">ENG</TabsTrigger> + <TabsTrigger value="ENG" className="flex-1">Engineering</TabsTrigger> + <TabsTrigger value="IM" className="flex-1">Handover</TabsTrigger> + </TabsList> <TabsContent value="IM" className="mt-0"> @@ -441,21 +440,22 @@ const handleModeChange = async (mode: "IM" | "ENG") => { {!isShipProject && ( // ship 프로젝트가 아닐 때만 모드 선택 버튼 표시 <div className="flex justify-center space-x-1 my-2"> + <Button - variant={selectedMode === "IM" ? "default" : "ghost"} + variant={selectedMode === "ENG" ? "default" : "ghost"} size="sm" className="h-8 px-2" - onClick={() => handleModeChange("IM")} + onClick={() => handleModeChange("ENG")} > - H/O + Engineering </Button> <Button - variant={selectedMode === "ENG" ? "default" : "ghost"} + variant={selectedMode === "IM" ? "default" : "ghost"} size="sm" className="h-8 px-2" - onClick={() => handleModeChange("ENG")} + onClick={() => handleModeChange("IM")} > - ENG + Handover </Button> </div> )} diff --git a/db/schema/fileSystem.ts b/db/schema/fileSystem.ts index a66e3180..0ce47c87 100644 --- a/db/schema/fileSystem.ts +++ b/db/schema/fileSystem.ts @@ -67,8 +67,8 @@ export const fileItems = pgTable("file_items", { // 파일 정보 mimeType: varchar("mime_type", { length: 255 }), size: bigint("size", { mode: "number" }).default(0).notNull(), - filePath: text("file_path"), // S3 키 또는 로컬 경로 - fileUrl: text("file_url"), // 직접 접근 URL (CDN 등) + filePath: text("file_path"), + fileUrl: text("file_url"), // 권한 카테고리 (외부 사용자용) category: fileCategoryEnum("category").default("confidential").notNull(), diff --git a/db/schema/index.ts b/db/schema/index.ts index 61d477e6..384c6e9c 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -43,6 +43,7 @@ export * from './rfqVendor'; export * from './generalContract'; export * from './rfqLastTBE'; export * from './pcr'; +export * from './project-doc-templates'; export * from './permissions'; diff --git a/db/schema/project-doc-templates.ts b/db/schema/project-doc-templates.ts new file mode 100644 index 00000000..6ec9500a --- /dev/null +++ b/db/schema/project-doc-templates.ts @@ -0,0 +1,124 @@ +// db/schema/project-doc-templates.ts +import { pgTable, serial, varchar, text, timestamp, boolean, integer, jsonb, AnyPgColumn } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { projects } from "./projects"; +import { users } from "./users"; + +// 프로젝트 문서 템플릿 테이블 +export const projectDocTemplates = pgTable("project_doc_templates", { + id: serial("id").primaryKey(), + + // 템플릿 기본 정보 + templateName: varchar("template_name", { length: 255 }).notNull(), + templateCode: varchar("template_code", { length: 100 }).unique().notNull(), + description: text("description"), + + // 프로젝트 정보 (선택적 - 전사 템플릿인 경우 null) + projectId: integer("project_id").references(() => projects.id), + projectCode: varchar("project_code", { length: 50 }), + projectName: varchar("project_name", { length: 255 }), + + // 템플릿 타입 + templateType: varchar("template_type", { length: 50 }).notNull().default("PROJECT"), // PROJECT, COMPANY_WIDE + documentType: varchar("document_type", { length: 100 }).notNull(), // CONTRACT, SPECIFICATION, REPORT, etc. + + // 파일 정보 + filePath: text("file_path").notNull(), + fileName: varchar("file_name", { length: 255 }).notNull(), + fileSize: integer("file_size"), + mimeType: varchar("mime_type", { length: 100 }), + + // 버전 관리 + version: integer("version").notNull().default(1), + isLatest: boolean("is_latest").notNull().default(true), + parentTemplateId: integer("parent_template_id").references((): AnyPgColumn => projectDocTemplates.id), + + // 변수 정보 + variables: jsonb("variables").$type<DocTemplateVariable[]>().notNull().default([]), + requiredVariables: jsonb("required_variables").$type<string[]>().notNull().default([]), + + // 사용 권한 및 상태 + status: varchar("status", { length: 20 }).notNull().default("ACTIVE"), // ACTIVE, INACTIVE, DRAFT, ARCHIVED + isPublic: boolean("is_public").notNull().default(false), // 다른 프로젝트에서도 사용 가능 여부 + requiresApproval: boolean("requires_approval").notNull().default(false), + + // 감사 필드 + createdBy: integer('created_by').references(() => users.id), // 생성자 + createdByName: varchar("created_by_name", { length: 50 }), // 생성자 + + updatedBy: integer('updated_by').references(() => users.id), // 수정자 + updatedByName: varchar("updated_by_name", { length: 50 }), // 생성자 + + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + deletedAt: timestamp("deleted_at"), +}); + +// 템플릿 변수 타입 +export type DocTemplateVariable = { + name: string; + displayName: string; + type: "text" | "number" | "date" | "select"; + required: boolean; + defaultValue?: string; + options?: string[]; // select 타입인 경우 + description?: string; + validation?: { + pattern?: string; + min?: number; + max?: number; + }; +}; + +// 템플릿 사용 이력 테이블 +export const projectDocTemplateUsage = pgTable("project_doc_template_usage", { + id: serial("id").primaryKey(), + templateId: integer("template_id").notNull().references(() => projectDocTemplates.id), + + // 생성된 문서 정보 + generatedDocumentId: varchar("generated_document_id", { length: 100 }).unique(), + generatedFilePath: text("generated_file_path"), + generatedFileName: varchar("generated_file_name", { length: 255 }), + + // 사용된 변수 값 + usedVariables: jsonb("used_variables").$type<Record<string, any>>(), + + // 프로젝트 정보 + usedInProjectId: integer("used_in_project_id"), + usedInProjectCode: varchar("used_in_project_code", { length: 50 }), + + // 사용자 정보 + usedBy: varchar("used_by", { length: 100 }), + usedAt: timestamp("used_at").notNull().defaultNow(), + + // 추가 메타데이터 + metadata: jsonb("metadata"), +}); + + +// Relations +export const projectDocTemplatesRelations = relations(projectDocTemplates, ({ one, many }) => ({ + project: one(projects, { + fields: [projectDocTemplates.projectId], + references: [projects.id], + }), + parentTemplate: one(projectDocTemplates, { + fields: [projectDocTemplates.parentTemplateId], + references: [projectDocTemplates.id], + }), + childTemplates: many(projectDocTemplates), + usageHistory: many(projectDocTemplateUsage), +})); + +export const projectDocTemplateUsageRelations = relations(projectDocTemplateUsage, ({ one }) => ({ + template: one(projectDocTemplates, { + fields: [projectDocTemplateUsage.templateId], + references: [projectDocTemplates.id], + }), +})); + +// 타입 정의 +export type ProjectDocTemplate = typeof projectDocTemplates.$inferSelect; +export type NewProjectDocTemplate = typeof projectDocTemplates.$inferInsert; +export type ProjectDocTemplateUsage = typeof projectDocTemplateUsage.$inferSelect; +export type NewProjectDocTemplateUsage = typeof projectDocTemplateUsage.$inferInsert;
\ No newline at end of file diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index fc3cd1f2..0f68ed68 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -38,10 +38,10 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) { case 'view': // 사전견적 요청 상태에서는 상세보기 제한 - if (bidding.status === 'request_for_quotation') { + if (bidding.status === 'request_for_quotation' || bidding.status === 'received_quotation' || bidding.status === 'set_target_price') { toast({ title: '접근 제한', - description: '사전견적 요청 상태에서는 본입찰을 이용할 수 없습니다.', + description: '사전견적 상태에서는 본입찰을 이용할 수 없습니다.', variant: 'destructive', }) return diff --git a/lib/forms/sedp-actions.ts b/lib/forms/sedp-actions.ts index 24ae2e66..d20fb9fc 100644 --- a/lib/forms/sedp-actions.ts +++ b/lib/forms/sedp-actions.ts @@ -74,7 +74,7 @@ export async function fetchTagDataFromSEDP( export async function fetchTemplateFromSEDP( projectCode: string, formCode: string -): Promise<SEDPTemplateData> { +): Promise<SEDPTemplateData[]> { try { // Get the token const apiKey = await getSEDPToken(); @@ -82,9 +82,8 @@ export async function fetchTemplateFromSEDP( // Define the API base URL const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - // Make the API call - const response = await fetch( - `${SEDP_API_BASE_URL}/Template/GetByRegisterID`, + const responseAdapter = await fetch( + `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, { method: 'POST', headers: { @@ -94,23 +93,59 @@ export async function fetchTemplateFromSEDP( 'ProjectNo': projectCode }, body: JSON.stringify({ - WithContent: true, ProjectNo: projectCode, - REG_TYPE_ID: formCode + "TOOL_ID": "eVCP" }) } ); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`SEDP Template API request failed: ${response.status} ${response.statusText} - ${errorText}`); + if (!responseAdapter.ok) { + throw new Error(`새 레지스터 요청 실패: ${responseAdapter.status} ${responseAdapter.statusText}`); } - const data = await response.json(); - return data as SEDPTemplateData; + const dataAdapter = await responseAdapter.json(); + const templateList = dataAdapter.find(v => v.REG_TYPE_ID === formCode)?.MAP_TMPLS || []; + + // 각 TMPL_ID에 대해 API 호출 + const templatePromises = templateList.map(async (tmplId: string) => { + const response = await fetch( + `${SEDP_API_BASE_URL}/Template/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + WithContent: true, + ProjectNo: projectCode, + TMPL_ID: tmplId + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP Template API request failed for TMPL_ID ${tmplId}: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data as SEDPTemplateData; + }); + + // 모든 API 호출을 병렬로 실행하고 결과를 수집 + const templates = await Promise.all(templatePromises); + + // null이나 undefined가 아닌 값들만 필터링 (응답이 없는 경우 제외) + const validTemplates = templates.filter(template => template != null); + + return validTemplates; + } catch (error: unknown) { console.error('Error calling SEDP Template API:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to fetch template from SEDP API: ${errorMessage}`); } -} +}
\ No newline at end of file diff --git a/lib/mail/templates/custom-rfq-invitation.hbs b/lib/mail/templates/custom-rfq-invitation.hbs new file mode 100644 index 00000000..9303035b --- /dev/null +++ b/lib/mail/templates/custom-rfq-invitation.hbs @@ -0,0 +1,332 @@ +<!DOCTYPE html>
+<html lang="{{language}}">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>RFQ 견적 요청</title>
+ <style>
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ line-height: 1.8;
+ color: #333;
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 20px;
+ background-color: #f8fafc;
+ }
+ .container {
+ background-color: white;
+ border-radius: 12px;
+ padding: 40px;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.08);
+ border: 1px solid #e2e8f0;
+ }
+ .header {
+ border-bottom: 4px solid #1e40af;
+ padding-bottom: 30px;
+ margin-bottom: 40px;
+ text-align: center;
+ }
+ .company-logo {
+ font-size: 28px;
+ font-weight: bold;
+ color: #1e40af;
+ margin-bottom: 10px;
+ letter-spacing: -0.5px;
+ }
+ .greeting {
+ font-size: 18px;
+ color: #1f2937;
+ margin-bottom: 30px;
+ line-height: 1.6;
+ }
+ .rfq-info {
+ background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
+ color: white;
+ padding: 25px;
+ border-radius: 10px;
+ margin: 30px 0;
+ text-align: center;
+ }
+ .rfq-title {
+ font-size: 24px;
+ font-weight: bold;
+ margin-bottom: 10px;
+ }
+ .rfq-code {
+ font-size: 18px;
+ font-weight: 600;
+ opacity: 0.9;
+ }
+ .project-section, .rfq-section, .contract-section, .notice-section {
+ margin: 30px 0;
+ padding: 25px;
+ background-color: #f8fafc;
+ border-radius: 8px;
+ border-left: 5px solid #3b82f6;
+ }
+ .section-title {
+ font-size: 20px;
+ font-weight: bold;
+ color: #1e40af;
+ margin-bottom: 20px;
+ padding-bottom: 10px;
+ border-bottom: 2px solid #e2e8f0;
+ }
+ .info-grid {
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ gap: 15px;
+ margin: 20px 0;
+ }
+ .info-item {
+ display: flex;
+ margin-bottom: 12px;
+ }
+ .info-label {
+ font-weight: 600;
+ color: #4b5563;
+ min-width: 120px;
+ margin-right: 15px;
+ }
+ .info-value {
+ color: #111827;
+ flex: 1;
+ }
+ .warranty-box {
+ background-color: #f0f9ff;
+ border: 2px solid #3b82f6;
+ padding: 20px;
+ border-radius: 8px;
+ margin: 20px 0;
+ font-style: italic;
+ }
+ .requirements-list {
+ background-color: #fef3c7;
+ border-left: 4px solid #f59e0b;
+ padding: 20px;
+ margin: 20px 0;
+ border-radius: 6px;
+ }
+ .requirements-list ul {
+ margin: 0;
+ padding-left: 20px;
+ }
+ .requirements-list li {
+ margin: 8px 0;
+ line-height: 1.6;
+ }
+ .contract-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin: 15px 0;
+ }
+ .contract-badge {
+ background-color: #dcfce7;
+ color: #166534;
+ padding: 8px 16px;
+ border-radius: 20px;
+ font-size: 14px;
+ font-weight: 600;
+ border: 2px solid #bbf7d0;
+ }
+ .contract-badge::before {
+ content: "📄 ";
+ }
+ .notice-section {
+ background-color: #fef2f2;
+ border-left-color: #ef4444;
+ border: 2px solid #fecaca;
+ }
+ .notice-list {
+ list-style: disc;
+ padding-left: 20px;
+ margin: 15px 0;
+ }
+ .notice-list li {
+ margin: 10px 0;
+ line-height: 1.6;
+ color: #7f1d1d;
+ }
+ .closing {
+ margin-top: 40px;
+ padding: 30px;
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
+ border-radius: 10px;
+ text-align: center;
+ font-style: italic;
+ color: #4b5563;
+ }
+ .highlight-arrow {
+ font-size: 18px;
+ font-weight: bold;
+ color: #dc2626;
+ text-align: center;
+ margin: 20px 0;
+ background-color: #fef2f2;
+ padding: 10px;
+ border-radius: 6px;
+ display: inline-block;
+ }
+ .footer-note {
+ margin-top: 30px;
+ padding: 20px;
+ background-color: #f9fafb;
+ border-radius: 6px;
+ font-size: 14px;
+ color: #6b7280;
+ border: 1px solid #e5e7eb;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <!-- Header -->
+ <div class="header">
+ <div class="company-logo">{{t 'email.company_name'}}</div>
+ </div>
+
+ <!-- Greeting -->
+ <div class="greeting">
+ 안녕하세요.<br><br>
+ 귀사 일익 번창하심을 기원합니다.<br><br>
+ 폐사는 귀사와 표제 프로젝트의 품목에 대한 거래를 위해<br>
+ 다음 품목에 대해 귀사의 견적 제출을 요청하오니 아래 내용 및 당사 시스템 접속하시어<br>
+ 견적의 상세 내용 확인하신 뒤 견적마감일까지 견적 제출 바랍니다.<br><br>
+ 귀사의 견적은 반드시 견적마감일 이전에 폐사로 제출되어야 하며,<br>
+ 견적마감일 전 별도의 지연 통보 없이 미제출될 경우에는 대상에서 제외될 수 있습니다.
+ </div>
+
+ <!-- RFQ Information -->
+ <div class="rfq-info">
+ <div class="rfq-title">{{rfqTitle}}</div>
+ <div class="rfq-code">RFQ No: {{rfqCode}}</div>
+ </div>
+
+ <!-- 1. 프로젝트 정보 -->
+ <div class="project-section">
+ <div class="section-title">1. 프로젝트 정보</div>
+ <div class="info-grid">
+ <div class="info-item">
+ <div class="info-label">프로젝트정보</div>
+ <div class="info-value">[{{projectCode}}] {{projectName}}</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">고객정보</div>
+ <div class="info-value">{{customerName}} ({{customerCode}})</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">선종</div>
+ <div class="info-value">{{shipType}} ({{shipClass}})</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">척수</div>
+ <div class="info-value">{{shipCount}}척</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">선급</div>
+ <div class="info-value">{{projectFlag}}</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">국적</div>
+ <div class="info-value">{{flag}}</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">계약발효일</div>
+ <div class="info-value">{{contractStartDate}} - {{contractEndDate}}</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">Key Event</div>
+ <div class="info-value">(S/C) {{scDate}} - (D/L) {{dlDate}}</div>
+ </div>
+ </div>
+ </div>
+
+ <!-- 2. 견적요청 정보 -->
+ <div class="rfq-section">
+ <div class="section-title">2. 견적요청 정보</div>
+ <div class="info-grid">
+ <div class="info-item">
+ <div class="info-label">PKG 정보</div>
+ <div class="info-value">[{{packageNo}}] {{packageName}}</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">자재그룹 정보</div>
+ <div class="info-value">[{{materialGroup}}] {{materialGroupDesc}}</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">품목</div>
+ <div class="info-value">{{#if itemCode}}{{itemCode}} - {{/if}}{{itemName}}</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">품목 수</div>
+ <div class="info-value">{{itemCount}}개</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">PR 번호</div>
+ <div class="info-value">{{#if prNumber}}{{prNumber}}{{else}}해당없음{{/if}}</div>
+ </div>
+ <div class="info-item">
+ <div class="info-label">PR 발행일</div>
+ <div class="info-value">{{#if prIssueDate}}{{prIssueDate}}{{else}}해당없음{{/if}}</div>
+ </div>
+ </div>
+
+ <div class="warranty-box">
+ <strong>Warranty</strong> {{warrantyDescription}}<br>
+ {{repairDescription}}<br>
+ {{totalWarrantyDescription}}
+ </div>
+
+ <div class="requirements-list">
+ <strong>필수제출정보</strong>
+ <ul>
+ {{#each requiredDocuments}}
+ <li>{{this}}</li>
+ {{/each}}
+ </ul>
+ </div>
+ </div>
+
+ <!-- 3. 필수 기본계약 -->
+ <div class="contract-section">
+ <div class="section-title">3. 필수 기본계약</div>
+ <div class="contract-badges">
+ {{#if contractRequirements.hasNda}}<span class="contract-badge">{{contractRequirements.ndaDescription}}</span>{{/if}}
+ {{#if contractRequirements.hasGeneralGtc}}<span class="contract-badge">{{contractRequirements.generalGtcDescription}}</span>{{/if}}
+ {{#if contractRequirements.hasProjectGtc}}<span class="contract-badge">{{contractRequirements.projectGtcDescription}}</span>{{/if}}
+ {{#if contractRequirements.hasAgreement}}<span class="contract-badge">{{contractRequirements.agreementDescription}}</span>{{/if}}
+ </div>
+
+ </div>
+
+ <!-- 4. 유의사항 -->
+ <div class="notice-section">
+ <div class="section-title">4. 유의사항</div>
+ <ul class="notice-list">
+ <li>발주자는 최저가 견적을 제출하지 않은 협력사를 선정할 수 있으며, 견적의 일부 또는 전부를 승인하거나 거절할 수 있고 거부하는 경우 별도 통보할 의무가 없다. 또한, 최종 탈락 사실을 통보할 의무도 없다.</li>
+ <li>협력사는 견적 제출을 위해 소요되는 비용 일체를 부담하며, 최종 계약자로 선정되지 못한 경우에도 발주자에게 보상을 청구할 수 없다. 또한, 선주 승인조건으로 선정된 협력사는 반드시 선주 승인을 득한 후 작업에 착수하여야 하며, 승인 거절 시 계약은 미 체결, 해제될 수 있고 이 경우 협력사는 어떠한 보상도 청구할 수 없다.</li>
+ </ul>
+ </div>
+
+ <!-- Closing -->
+ <div class="closing">
+ 이번 기회를 통하여 귀사와의 협업으로 다가올 미래 조선/해양산업 시장에서 함께 성장해 나갈 수 있기를 기대합니다.
+ </div>
+
+ <!-- Footer Note -->
+ <div class="footer-note">
+ <p><strong>📧 발송 정보:</strong></p>
+ <p>수신: {{vendorName}} ({{vendorCountry}})</p>
+ <p>발신: {{companyName}} {{picName}} 프로 {{picTeam}}</p>
+ <p>견적마감일: {{formattedDueDate}}</p>
+ <p>발송일시: {{formatDate now 'YYYY-MM-DD HH:mm:ss'}}</p>
+ <p>시스템: {{systemName}}</p>
+ {{#if hasAttachments}}
+ <p>첨부파일: {{attachmentsCount}}개 포함</p>
+ {{/if}}
+ </div>
+ </div>
+</body>
+</html>
diff --git a/lib/permissions/permission-group-actions.ts b/lib/permissions/permission-group-actions.ts index 51e3c2c0..474dc21b 100644 --- a/lib/permissions/permission-group-actions.ts +++ b/lib/permissions/permission-group-actions.ts @@ -117,10 +117,13 @@ export async function updatePermissionGroup(id: number, data: any) { // 권한 그룹 삭제 export async function deletePermissionGroup(id: number) { - const currentUser = await getCurrentUser(); - if (!currentUser) throw new Error("Unauthorized"); + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) - if (!await checkUserPermission(currentUser.id, "admin.permissions.manage")) { + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { throw new Error("권한 관리 권한이 없습니다."); } diff --git a/lib/permissions/permission-group-assignment-actions.ts b/lib/permissions/permission-group-assignment-actions.ts new file mode 100644 index 00000000..d1311559 --- /dev/null +++ b/lib/permissions/permission-group-assignment-actions.ts @@ -0,0 +1,496 @@ +// app/actions/permission-group-assignment-actions.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and, inArray, sql, ne, notInArray, or } from "drizzle-orm"; +import { + permissionGroups, + permissionGroupMembers, + permissions, + rolePermissions, + userPermissions, + roles, + users, + userRoles, + permissionAuditLogs +} from "@/db/schema"; +import { checkUserPermission } from "./service"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// 권한 그룹 할당 정보 조회 +export async function getPermissionGroupAssignments(groupId?: number) { + try { + if (!groupId) { + // 모든 그룹 목록 반환 + const groups = await db + .select({ + id: permissionGroups.id, + groupKey: permissionGroups.groupKey, + name: permissionGroups.name, + description: permissionGroups.description, + domain: permissionGroups.domain, + isActive: permissionGroups.isActive, + permissionCount: sql<number>`count(distinct ${permissionGroupMembers.permissionId})`.mapWith(Number), + }) + .from(permissionGroups) + .leftJoin(permissionGroupMembers, eq(permissionGroupMembers.groupId, permissionGroups.id)) + .where(eq(permissionGroups.isActive, true)) + .groupBy(permissionGroups.id) + .orderBy(permissionGroups.name); + + return { groups }; + } + + // 특정 그룹의 할당 정보 조회 + // 그룹에 속한 모든 권한 ID 조회 + const groupPermissionIds = await db + .select({ permissionId: permissionGroupMembers.permissionId }) + .from(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + const permissionIds = groupPermissionIds.map(p => p.permissionId); + + if (permissionIds.length === 0) { + return { roles: [], users: [] }; + } + + // 해당 그룹의 권한들이 할당된 역할 조회 + const assignedRoles = await db + .selectDistinct({ + id: roles.id, + name: roles.name, + domain: roles.domain, + grantedAt: rolePermissions.grantedAt, + grantedBy: rolePermissions.grantedBy, + }) + .from(rolePermissions) + .innerJoin(roles, eq(roles.id, rolePermissions.roleId)) + .where( + and( + eq(rolePermissions.permissionGroupId, groupId), + eq(rolePermissions.isActive, true) + ) + ); + + // 역할별 사용자 수 조회 + const rolesWithUserCount = await Promise.all( + assignedRoles.map(async (role) => { + const userCount = await db + .select({ count: sql<number>`count(*)`.mapWith(Number) }) + .from(userRoles) + .where(eq(userRoles.roleId, role.id)); + + // grantedBy가 userId인 경우 사용자 정보 조회 + let assignedBy = 'system'; + if (role.grantedBy) { + const [user] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, role.grantedBy)); + if (user) assignedBy = user.name; + } + + return { + ...role, + userCount: userCount[0]?.count || 0, + assignedAt: role.grantedAt, + assignedBy + }; + }) + ); + + // 해당 그룹의 권한들이 직접 할당된 사용자 조회 + const assignedUsers = await db + .selectDistinct({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + grantedAt: userPermissions.grantedAt, + grantedBy: userPermissions.grantedBy, + }) + .from(userPermissions) + .innerJoin(users, eq(users.id, userPermissions.userId)) + .where( + and( + eq(userPermissions.permissionGroupId, groupId), + eq(userPermissions.isActive, true), + eq(userPermissions.isGrant, true) + ) + ); + + // 사용자별 회사 정보 및 할당자 정보 추가 + const usersWithDetails = await Promise.all( + assignedUsers.map(async (user) => { + // 회사 정보는 companyId로 조회 (vendors 테이블 필요) + let assignedBy = 'system'; + if (user.grantedBy) { + const [grantUser] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, user.grantedBy)); + if (grantUser) assignedBy = grantUser.name; + } + + return { + ...user, + companyName: null, // 필요시 vendors 테이블 조인 + assignedAt: user.grantedAt, + assignedBy + }; + }) + ); + + return { + roles: rolesWithUserCount, + users: usersWithDetails + }; + } catch (error) { + console.error('Failed to get permission group assignments:', error); + throw new Error('권한 그룹 할당 정보 조회에 실패했습니다.'); + } +} + +// 역할 검색 (그룹에 아직 할당되지 않은) +export async function searchRoles(groupId: number) { + try { + // 이미 해당 그룹이 할당된 역할 ID 조회 + const assignedRoleIds = await db + .selectDistinct({ roleId: rolePermissions.roleId }) + .from(rolePermissions) + .where( + and( + eq(rolePermissions.permissionGroupId, groupId), + eq(rolePermissions.isActive, true) + ) + ); + + const assignedIds = assignedRoleIds.map(r => r.roleId); + + // 할당되지 않은 역할 조회 + const availableRoles = await db + .select({ + id: roles.id, + name: roles.name, + domain: roles.domain, + }) + .from(roles) + .where( + assignedIds.length > 0 + ? notInArray(roles.id, assignedIds) + : undefined + ); + + // 역할별 사용자 수 추가 + const rolesWithUserCount = await Promise.all( + availableRoles.map(async (role) => { + const userCount = await db + .select({ count: sql<number>`count(*)`.mapWith(Number) }) + .from(userRoles) + .where(eq(userRoles.roleId, role.id)); + + return { + ...role, + userCount: userCount[0]?.count || 0 + }; + }) + ); + + return rolesWithUserCount; + } catch (error) { + console.error('Failed to search roles:', error); + throw new Error('역할 검색에 실패했습니다.'); + } +} + +// 사용자 검색 (그룹에 아직 할당되지 않은) +export async function searchUsers(query: string, groupId: number) { + try { + // 이미 해당 그룹이 할당된 사용자 ID 조회 + const assignedUserIds = await db + .selectDistinct({ userId: userPermissions.userId }) + .from(userPermissions) + .where( + and( + eq(userPermissions.permissionGroupId, groupId), + eq(userPermissions.isActive, true), + eq(userPermissions.isGrant, true) + ) + ); + + const assignedIds = assignedUserIds.map(u => u.userId); + + // 할당되지 않은 사용자 검색 + const availableUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + }) + .from(users) + .where( + and( + or( + sql`${users.name} ILIKE ${`%${query}%`}`, + sql`${users.email} ILIKE ${`%${query}%`}` + ), + eq(users.isActive, true), + assignedIds.length > 0 + ? notInArray(users.id, assignedIds) + : undefined + ) + ) + .limit(20); + + return availableUsers.map(user => ({ + ...user, + companyName: null // 필요시 vendors 테이블 조인 + })); + } catch (error) { + console.error('Failed to search users:', error); + throw new Error('사용자 검색에 실패했습니다.'); + } +} + +// 그룹을 역할에 할당 +export async function assignGroupToRoles(groupId: number, roleIds: number[]) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + const currentUserId = Number(session.user.id); + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + try { + // 그룹에 속한 모든 권한 ID 조회 + const groupPermissions = await db + .select({ permissionId: permissionGroupMembers.permissionId }) + .from(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + if (groupPermissions.length === 0) { + throw new Error("그룹에 권한이 없습니다."); + } + + await db.transaction(async (tx) => { + for (const roleId of roleIds) { + // 각 역할에 대해 그룹의 모든 권한 할당 + for (const { permissionId } of groupPermissions) { + // 기존 할당 확인 (중복 방지) + const existing = await tx + .select() + .from(rolePermissions) + .where( + and( + eq(rolePermissions.roleId, roleId), + eq(rolePermissions.permissionId, permissionId), + eq(rolePermissions.permissionGroupId, groupId) + ) + ) + .limit(1); + + if (existing.length === 0) { + await tx.insert(rolePermissions).values({ + roleId, + permissionId, + permissionGroupId: groupId, + grantedBy: currentUserId, + grantedAt: new Date(), + isActive: true + }); + } + } + + // 감사 로그 추가 + await tx.insert(permissionAuditLogs).values({ + targetType: 'role', + targetId: roleId, + permissionGroupId: groupId, + action: 'grant', + performedBy: currentUserId, + reason: `권한 그룹 ${groupId} 할당` + }); + } + }); + + return { success: true, count: roleIds.length }; + } catch (error) { + console.error('Failed to assign group to roles:', error); + throw new Error('역할에 권한 그룹 할당에 실패했습니다.'); + } +} + +// 그룹을 사용자에 할당 +export async function assignGroupToUsers(groupId: number, userIds: number[]) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + const currentUserId = Number(session.user.id); + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + try { + // 그룹에 속한 모든 권한 ID 조회 + const groupPermissions = await db + .select({ permissionId: permissionGroupMembers.permissionId }) + .from(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + if (groupPermissions.length === 0) { + throw new Error("그룹에 권한이 없습니다."); + } + + await db.transaction(async (tx) => { + for (const userId of userIds) { + // 각 사용자에 대해 그룹의 모든 권한 할당 + for (const { permissionId } of groupPermissions) { + // 기존 할당 확인 (중복 방지) + const existing = await tx + .select() + .from(userPermissions) + .where( + and( + eq(userPermissions.userId, userId), + eq(userPermissions.permissionId, permissionId) + ) + ) + .limit(1); + + if (existing.length === 0) { + await tx.insert(userPermissions).values({ + userId, + permissionId, + permissionGroupId: groupId, + isGrant: true, + grantedBy: currentUserId, + grantedAt: new Date(), + isActive: true + }); + } else if (existing[0].permissionGroupId !== groupId) { + // 다른 그룹으로 할당되어 있다면 업데이트 + await tx.update(userPermissions) + .set({ + permissionGroupId: groupId, + grantedBy: currentUserId, + grantedAt: new Date() + }) + .where( + and( + eq(userPermissions.userId, userId), + eq(userPermissions.permissionId, permissionId) + ) + ); + } + } + + // 감사 로그 추가 + await tx.insert(permissionAuditLogs).values({ + targetType: 'user', + targetId: userId, + permissionGroupId: groupId, + action: 'grant', + performedBy: currentUserId, + reason: `권한 그룹 ${groupId} 할당` + }); + } + }); + + return { success: true, count: userIds.length }; + } catch (error) { + console.error('Failed to assign group to users:', error); + throw new Error('사용자에게 권한 그룹 할당에 실패했습니다.'); + } +} + +// 역할에서 그룹 제거 +export async function removeGroupFromRole(groupId: number, roleId: number) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + const currentUserId = Number(session.user.id); + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + try { + await db.transaction(async (tx) => { + // 해당 그룹으로 할당된 모든 권한 제거 + await tx.delete(rolePermissions) + .where( + and( + eq(rolePermissions.roleId, roleId), + eq(rolePermissions.permissionGroupId, groupId) + ) + ); + + // 감사 로그 추가 + await tx.insert(permissionAuditLogs).values({ + targetType: 'role', + targetId: roleId, + permissionGroupId: groupId, + action: 'revoke', + performedBy: currentUserId, + reason: `권한 그룹 ${groupId} 제거` + }); + }); + + return { success: true }; + } catch (error) { + console.error('Failed to remove group from role:', error); + throw new Error('역할에서 권한 그룹 제거에 실패했습니다.'); + } +} + +// 사용자에서 그룹 제거 +export async function removeGroupFromUser(groupId: number, userId: number) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + const currentUserId = Number(session.user.id); + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + try { + await db.transaction(async (tx) => { + // 해당 그룹으로 할당된 모든 권한 제거 + await tx.delete(userPermissions) + .where( + and( + eq(userPermissions.userId, userId), + eq(userPermissions.permissionGroupId, groupId) + ) + ); + + // 감사 로그 추가 + await tx.insert(permissionAuditLogs).values({ + targetType: 'user', + targetId: userId, + permissionGroupId: groupId, + action: 'revoke', + performedBy: currentUserId, + reason: `권한 그룹 ${groupId} 제거` + }); + }); + + return { success: true }; + } catch (error) { + console.error('Failed to remove group from user:', error); + throw new Error('사용자에서 권한 그룹 제거에 실패했습니다.'); + } +}
\ No newline at end of file diff --git a/lib/permissions/permission-settings-actions.ts b/lib/permissions/permission-settings-actions.ts index 5d04a1d3..bb82b456 100644 --- a/lib/permissions/permission-settings-actions.ts +++ b/lib/permissions/permission-settings-actions.ts @@ -12,6 +12,37 @@ import { import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { checkUserPermission } from "./service"; +import fs from 'fs/promises'; +import path from 'path'; + +// i18n 번역 파일을 읽어오는 헬퍼 함수 +async function getTranslations(locale: string = 'ko') { + try { + const filePath = path.join(process.cwd(), 'i18n', 'locales', locale, 'menu.json'); + const fileContent = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(fileContent); + } catch (error) { + console.error(`Failed to load translations for ${locale}:`, error); + return {}; + } +} + +// 중첩된 객체에서 키로 값을 가져오는 헬퍼 함수 +function getNestedValue(obj: any, key: string): string { + const keys = key.split('.'); + let value = obj; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k]; + } else { + return key; // 키를 찾지 못하면 원본 키를 반환 + } + } + + return typeof value === 'string' ? value : key; +} + // 모든 권한 조회 export async function getAllPermissions() { @@ -110,7 +141,11 @@ export async function deletePermission(id: number) { } // 메뉴 권한 분석 + export async function analyzeMenuPermissions() { + // 한국어 번역 파일 로드 + const translations = await getTranslations('ko'); + const menus = await db.select().from(menuAssignments); const analysis = await Promise.all( @@ -126,20 +161,26 @@ export async function analyzeMenuPermissions() { .innerJoin(permissions, eq(permissions.id, menuRequiredPermissions.permissionId)) .where(eq(menuRequiredPermissions.menuPath, menu.menuPath)); + // i18n 키를 실제 텍스트로 변환 + const menuTitleTranslated = getNestedValue(translations, menu.menuTitle); + const menuDescriptionTranslated = menu.menuDescription + ? getNestedValue(translations, menu.menuDescription) + : ''; + // 제안할 권한 생성 const suggestedPermissions = []; const resourceName = menu.menuPath.split('/').pop() || 'unknown'; - // 기본 메뉴 접근 권한 + // 기본 메뉴 접근 권한 (번역된 제목 사용) suggestedPermissions.push({ permissionKey: `${resourceName}.menu_access`, - name: `${menu.menuTitle} 접근`, + name: `${menuTitleTranslated} 접근`, permissionType: "menu_access", action: "access", scope: "assigned", }); - // CRUD 권한 제안 + // CRUD 권한 제안 (번역된 제목 사용) const actions = [ { action: "view", name: "조회", type: "data_read" }, { action: "create", name: "생성", type: "data_write" }, @@ -150,7 +191,7 @@ export async function analyzeMenuPermissions() { actions.forEach(({ action, name, type }) => { suggestedPermissions.push({ permissionKey: `${resourceName}.${action}`, - name: `${menu.menuTitle} ${name}`, + name: `${menuTitleTranslated} ${name}`, permissionType: type, action, scope: "assigned", @@ -159,7 +200,9 @@ export async function analyzeMenuPermissions() { return { menuPath: menu.menuPath, - menuTitle: menu.menuTitle, + menuTitle: menuTitleTranslated, // 번역된 제목 + menuTitleKey: menu.menuTitle, // 원본 i18n 키 (필요한 경우) + menuDescription: menuDescriptionTranslated, // 번역된 설명 domain: menu.domain, existingPermissions: existing, suggestedPermissions: suggestedPermissions.filter( @@ -172,6 +215,7 @@ export async function analyzeMenuPermissions() { return analysis; } + // 메뉴 기반 권한 생성 export async function generateMenuPermissions( permissionsToCreate: Array<{ diff --git a/lib/permissions/service.ts b/lib/permissions/service.ts index 3ef1ff04..b3e6b4bc 100644 --- a/lib/permissions/service.ts +++ b/lib/permissions/service.ts @@ -3,7 +3,7 @@ "use server"; import db from "@/db/db"; -import { eq, and, inArray, or, ilike } from "drizzle-orm"; +import { eq, and, inArray, or, ilike, sql } from "drizzle-orm"; import { permissions, rolePermissions, @@ -70,21 +70,58 @@ export async function assignPermissionsToRole( // 역할의 권한 목록 조회 +// 역할 권한 조회 (기존 함수) export async function getRolePermissions(roleId: number) { - const allPermissions = await db.select().from(permissions) - .where(eq(permissions.isActive, true)); - - const rolePerms = await db.select({ - permissionId: rolePermissions.permissionId, - }) + try { + // 역할에 할당된 권한 조회 + const assignedPermissions = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + resource: permissions.resource, + action: permissions.action, + permissionType: permissions.permissionType, + scope: permissions.scope, + menuPath: permissions.menuPath, + }) .from(rolePermissions) - .where(eq(rolePermissions.roleId, roleId)); - - return { + .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId)) + .where( + and( + eq(rolePermissions.roleId, roleId), + eq(rolePermissions.isActive, true) + ) + ); + + // 모든 활성 권한 조회 + const allPermissions = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + resource: permissions.resource, + action: permissions.action, + permissionType: permissions.permissionType, + scope: permissions.scope, + menuPath: permissions.menuPath, + }) + .from(permissions) + .where(eq(permissions.isActive, true)) + .orderBy(permissions.resource, permissions.name); + + return { permissions: allPermissions, - assignedPermissionIds: rolePerms.map(rp => rp.permissionId), - }; -} + assignedPermissionIds: assignedPermissions.map(p => p.id) + }; + } catch (error) { + console.error('Failed to get role permissions:', error); + throw new Error('역할 권한 조회에 실패했습니다.'); + } + } + // 권한 체크 함수 export async function checkUserPermission( @@ -431,4 +468,27 @@ export async function updateMenuPermissions( ); } }); -}
\ No newline at end of file +} + +// 역할 목록 조회 +export async function getRoles() { + try { + const rolesData = await db + .select({ + id: roles.id, + name: roles.name, + domain: roles.domain, + description: roles.description, + userCount: sql<number>`count(distinct ${userRoles.userId})`.mapWith(Number), + }) + .from(roles) + .leftJoin(userRoles, eq(userRoles.roleId, roles.id)) + .groupBy(roles.id) + .orderBy(roles.domain, roles.name); + + return rolesData; + } catch (error) { + console.log('Failed to get roles:', error); + throw new Error('역할 목록 조회에 실패했습니다.'); + } + }
\ No newline at end of file diff --git a/lib/project-doc-templates/service.ts b/lib/project-doc-templates/service.ts new file mode 100644 index 00000000..a5bccce5 --- /dev/null +++ b/lib/project-doc-templates/service.ts @@ -0,0 +1,485 @@ +// lib/project-doc-templates/service.ts +"use server"; + +import db from "@/db/db"; +import { projectDocTemplates, projectDocTemplateUsage, projects, type NewProjectDocTemplate, type DocTemplateVariable } from "@/db/schema"; +import { eq, and, desc, asc, isNull, sql, inArray, count, or, ilike } from "drizzle-orm"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { GetDOCTemplatesSchema } from "./validations"; +import { filterColumns } from "@/lib/filter-columns"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +// 기본 변수 정의 +const DEFAULT_VARIABLES: DocTemplateVariable[] = [ + { + name: "document_number", + displayName: "문서번호", + type: "text", + required: true, + description: "문서 고유 번호", + }, + { + name: "project_code", + displayName: "프로젝트 코드", + type: "text", + required: true, + description: "프로젝트 식별 코드", + }, + { + name: "project_name", + displayName: "프로젝트명", + type: "text", + required: true, + description: "프로젝트 이름", + }, + { + name: "created_date", + displayName: "작성일", + type: "date", + required: false, + defaultValue: "{{today}}", + description: "문서 작성 날짜", + }, + { + name: "author_name", + displayName: "작성자", + type: "text", + required: false, + description: "문서 작성자 이름", + }, + { + name: "department", + displayName: "부서명", + type: "text", + required: false, + description: "작성 부서", + }, +]; + + +export async function getProjectDocTemplates( + input: GetDOCTemplatesSchema +) { + try { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터 조건 + const advancedWhere = filterColumns({ + table: projectDocTemplates, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 조건 (중복 제거됨) + let globalWhere: SQL | undefined = undefined; + if (input.search?.trim()) { + const searchTerm = `%${input.search.trim()}%`; + globalWhere = or( + ilike(projectDocTemplates.templateName, searchTerm), + ilike(projectDocTemplates.templateCode, searchTerm), + ilike(projectDocTemplates.projectCode, searchTerm), + ilike(projectDocTemplates.projectName, searchTerm), + ilike(projectDocTemplates.documentType, searchTerm), + ilike(projectDocTemplates.fileName, searchTerm), // 중복 제거 + ilike(projectDocTemplates.createdByName, searchTerm), + ilike(projectDocTemplates.updatedByName, searchTerm), + ilike(projectDocTemplates.status, searchTerm), + ilike(projectDocTemplates.description, searchTerm) // 추가 고려 + ); + } + + // WHERE 조건 결합 + const whereCondition = and(advancedWhere, globalWhere, eq(projectDocTemplates.isLatest,true)); + + // 정렬 조건 (타입 안정성 개선) + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + const column = projectDocTemplates[item.id as keyof typeof projectDocTemplates]; + if (!column) { + console.warn(`Invalid sort column: ${item.id}`); + return null; + } + return item.desc ? desc(column) : asc(column); + }).filter(Boolean) as SQL[] + : [desc(projectDocTemplates.createdAt)]; + + // 데이터 조회 (프로젝트 정보 조인 추가 고려) + const [data, totalCount] = await Promise.all([ + db + .select({ + ...projectDocTemplates, + // 프로젝트 정보가 필요한 경우 + // project: projects + }) + .from(projectDocTemplates) + // .leftJoin(projects, eq(projectDocTemplates.projectId, projects.id)) + .where(whereCondition) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset), + db + .select({ count: count() }) + .from(projectDocTemplates) + .where(whereCondition) + .then((res) => res[0]?.count ?? 0), + ]); + + const pageCount = Math.ceil(totalCount / input.perPage); + + return { + data, + pageCount, + totalCount, + }; + } catch (error) { + console.error("Failed to fetch project doc templates:", error); + throw new Error("템플릿 목록을 불러오는데 실패했습니다."); + } +} + +// 템플릿 생성 +export async function createProjectDocTemplate(data: { + templateName: string; + templateCode?: string; + description?: string; + projectId?: number; + templateType: "PROJECT" | "COMPANY_WIDE"; + documentType: string; + filePath: string; + fileName: string; + fileSize?: number; + mimeType?: string; + variables?: DocTemplateVariable[]; + isPublic?: boolean; + requiresApproval?: boolean; + createdBy?: string; +}) { + try { + // 템플릿 코드 자동 생성 (없을 경우) + const templateCode = data.templateCode || `TPL_${Date.now()}`; + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + + // 프로젝트 정보 조회 (projectId가 있는 경우) + let projectInfo = null; + if (data.projectId) { + projectInfo = await db + .select() + .from(projects) + .where(eq(projects.id, data.projectId)) + .then((res) => res[0]); + } + + // 변수 정보 설정 (기본 변수 + 사용자 정의 변수) + const variables = [...DEFAULT_VARIABLES, ...(data.variables || [])]; + const requiredVariables = variables + .filter((v) => v.required) + .map((v) => v.name); + + const newTemplate: NewProjectDocTemplate = { + templateName: data.templateName, + templateCode, + description: data.description, + projectId: data.projectId, + projectCode: projectInfo?.code, + projectName: projectInfo?.name, + templateType: data.templateType, + documentType: data.documentType, + filePath: data.filePath, + fileName: data.fileName, + fileSize: data.fileSize, + mimeType: data.mimeType, + variables, + requiredVariables, + isPublic: data.isPublic || false, + requiresApproval: data.requiresApproval || false, + status: "ACTIVE", + createdBy: Number(session.user.id), + createdByName: Number(session.user.name), + }; + + const [template] = await db + .insert(projectDocTemplates) + .values(newTemplate) + .returning(); + + revalidateTag("project-doc-templates"); + revalidatePath("/project-doc-templates"); + + return { success: true, data: template }; + } catch (error) { + console.error("Failed to create template:", error); + return { success: false, error: "템플릿 생성에 실패했습니다." }; + } +} + +// 템플릿 상세 조회 +export async function getProjectDocTemplateById(templateId: number) { + try { + const template = await db + .select({ + template: projectDocTemplates, + project: projects, + }) + .from(projectDocTemplates) + .leftJoin(projects, eq(projectDocTemplates.projectId, projects.id)) + .where(eq(projectDocTemplates.id, templateId)) + .then((res) => res[0]); + + if (!template) { + throw new Error("템플릿을 찾을 수 없습니다."); + } + + // 버전 히스토리 조회 + const versionHistory = await db + .select() + .from(projectDocTemplates) + .where( + and( + eq(projectDocTemplates.templateCode, template.template.templateCode), + isNull(projectDocTemplates.deletedAt) + ) + ) + .orderBy(desc(projectDocTemplates.version)); + + // 사용 이력 조회 (최근 10건) + const usageHistory = await db + .select() + .from(projectDocTemplateUsage) + .where(eq(projectDocTemplateUsage.templateId, templateId)) + .orderBy(desc(projectDocTemplateUsage.usedAt)) + .limit(10); + + return { + ...template.template, + project: template.project, + versionHistory, + usageHistory, + }; + } catch (error) { + console.error("Failed to fetch template details:", error); + throw new Error("템플릿 상세 정보를 불러오는데 실패했습니다."); + } +} + +// 템플릿 업데이트 +export async function updateProjectDocTemplate( + templateId: number, + data: Partial<{ + templateName: string; + description: string; + documentType: string; + variables: TemplateVariable[]; + isPublic: boolean; + requiresApproval: boolean; + status: string; + updatedBy: string; + }> +) { + try { + // 변수 정보 업데이트 시 requiredVariables도 함께 업데이트 + const updateData: any = { ...data, updatedAt: new Date() }; + + if (data.variables) { + updateData.variables = data.variables; + updateData.requiredVariables = data.variables + .filter((v) => v.required) + .map((v) => v.name); + } + + const [updated] = await db + .update(projectDocTemplates) + .set(updateData) + .where(eq(projectDocTemplates.id, templateId)) + .returning(); + + revalidateTag("project-doc-templates"); + revalidatePath("/project-doc-templates"); + + return { success: true, data: updated }; + } catch (error) { + console.error("Failed to update template:", error); + return { success: false, error: "템플릿 업데이트에 실패했습니다." }; + } +} + +// 새 버전 생성 +export async function createTemplateVersion( + templateId: number, + data: { + filePath: string; + fileName: string; + fileSize?: number; + mimeType?: string; + variables?: DocTemplateVariable[]; + createdBy?: string; + } +) { + try { + + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + + // 기존 템플릿 조회 + const existingTemplate = await db + .select() + .from(projectDocTemplates) + .where(eq(projectDocTemplates.id, templateId)) + .then((res) => res[0]); + + if (!existingTemplate) { + throw new Error("템플릿을 찾을 수 없습니다."); + } + + // 모든 버전의 isLatest를 false로 업데이트 + await db + .update(projectDocTemplates) + .set({ isLatest: false }) + .where(eq(projectDocTemplates.templateCode, existingTemplate.templateCode)); + + // 새 버전 생성 + const newVersion = existingTemplate.version + 1; + const variables = data.variables || existingTemplate.variables; + + const [newTemplate] = await db + .insert(projectDocTemplates) + .values({ + ...existingTemplate, + id: undefined, // 새 ID 자동 생성 + version: newVersion, + isLatest: true, + parentTemplateId: templateId, + filePath: data.filePath, + fileName: data.fileName, + fileSize: data.fileSize, + mimeType: data.mimeType, + variables, + requiredVariables: variables + .filter((v: DocTemplateVariable) => v.required) + .map((v: DocTemplateVariable) => v.name), + createdBy:Number(session.user.id), + creaetedByName:session.user.name, + updatedBy:Number(session.user.id), + updatedByName:session.user.name, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + revalidateTag("project-doc-templates"); + revalidatePath("/project-doc-templates"); + + return { success: true, data: newTemplate }; + } catch (error) { + console.error("Failed to create template version:", error); + return { success: false, error: "새 버전 생성에 실패했습니다." }; + } +} + +// 템플릿 삭제 (soft delete) +export async function deleteProjectDocTemplate(templateId: number) { + try { + await db + .update(projectDocTemplates) + .set({ + deletedAt: new Date(), + status: "ARCHIVED" + }) + .where(eq(projectDocTemplates.id, templateId)); + + revalidateTag("project-doc-templates"); + revalidatePath("/project-doc-templates"); + + return { success: true }; + } catch (error) { + console.error("Failed to delete template:", error); + return { success: false, error: "템플릿 삭제에 실패했습니다." }; + } +} + +// 템플릿 파일 저장 +export async function tlsaveTemplateFile( + templateId: number, + formData: FormData +) { + try { + const file = formData.get("file") as File; + if (!file) { + throw new Error("파일이 없습니다."); + } + + // 파일 저장 로직 (실제 구현 필요) + const fileName = file.name; + const filePath = `/uploads/templates/${templateId}/${fileName}`; + + // 템플릿 파일 경로 업데이트 + await db + .update(projectDocTemplates) + .set({ + filePath, + fileName, + updatedAt: new Date(), + }) + .where(eq(projectDocTemplates.id, templateId)); + + revalidateTag("project-doc-templates"); + + return { success: true, filePath }; + } catch (error) { + console.error("Failed to save template file:", error); + return { success: false, error: "파일 저장에 실패했습니다." }; + } +} + +// 사용 가능한 프로젝트 목록 조회 +export async function getAvailableProjects() { + try { + const projectList = await db + .select() + .from(projects) + // .where(eq(projects.status, "ACTIVE")) + .orderBy(projects.code); + + return projectList; + } catch (error) { + console.error("Failed to fetch projects:", error); + return []; + } +} + +// 템플릿 사용 기록 생성 +export async function recordTemplateUsage( + templateId: number, + data: { + generatedDocumentId: string; + generatedFilePath: string; + generatedFileName: string; + usedVariables: Record<string, any>; + usedInProjectId?: number; + usedInProjectCode?: string; + usedBy: string; + metadata?: any; + } +) { + try { + const [usage] = await db + .insert(projectDocTemplateUsage) + .values({ + templateId, + ...data, + }) + .returning(); + + return { success: true, data: usage }; + } catch (error) { + console.error("Failed to record template usage:", error); + return { success: false, error: "사용 기록 생성에 실패했습니다." }; + } +}
\ No newline at end of file diff --git a/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx b/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx new file mode 100644 index 00000000..fb36aebd --- /dev/null +++ b/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx @@ -0,0 +1,642 @@ +"use client"; + +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { toast } from "sonner"; +import { v4 as uuidv4 } from "uuid"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone"; +import { Progress } from "@/components/ui/progress"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Plus, X, FileText, AlertCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { createProjectDocTemplate } from "@/lib/project-doc-templates/service"; +import { ProjectSelector } from "@/components/ProjectSelector"; +import type { TemplateVariable } from "@/db/schema/project-doc-templates"; +import type { Project } from "@/lib/rfqs/service"; + +// 기본 변수들 (읽기 전용) +const DEFAULT_VARIABLES_DISPLAY: TemplateVariable[] = [ + { name: "document_number", displayName: "문서번호", type: "text", required: true, description: "문서 고유 번호" }, + { name: "project_code", displayName: "프로젝트 코드", type: "text", required: true, description: "프로젝트 식별 코드" }, + { name: "project_name", displayName: "프로젝트명", type: "text", required: true, description: "프로젝트 이름" }, +]; + +const templateFormSchema = z.object({ + templateName: z.string().min(1, "템플릿 이름을 입력해주세요."), + templateCode: z.string().optional(), + description: z.string().optional(), + projectId: z.number({ + required_error: "프로젝트를 선택해주세요.", + }), + customVariables: z.array(z.object({ + name: z.string().min(1, "변수명을 입력해주세요."), + displayName: z.string().min(1, "표시명을 입력해주세요."), + type: z.enum(["text", "number", "date", "select"]), + required: z.boolean(), + defaultValue: z.string().optional(), + description: z.string().optional(), + })).default([]), + file: z.instanceof(File, { + message: "파일을 업로드해주세요.", + }), +}) +.refine((data) => { + if (data.file && data.file.size > 100 * 1024 * 1024) return false; + return true; +}, { + message: "파일 크기는 100MB 이하여야 합니다.", + path: ["file"], +}) +.refine((data) => { + if (data.file) { + const validTypes = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ]; + return validTypes.includes(data.file.type); + } + return true; +}, { + message: "워드 파일(.doc, .docx)만 업로드 가능합니다.", + path: ["file"], +}); + +type TemplateFormValues = z.infer<typeof templateFormSchema>; + +export function AddProjectDocTemplateDialog() { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const [selectedFile, setSelectedFile] = React.useState<File | null>(null); + const [uploadProgress, setUploadProgress] = React.useState(0); + const [showProgress, setShowProgress] = React.useState(false); + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null); + const router = useRouter(); + + const form = useForm<TemplateFormValues>({ + resolver: zodResolver(templateFormSchema), + defaultValues: { + templateName: "", + templateCode: "", + description: "", + customVariables: [], + }, + mode: "onChange", + }); + + // 프로젝트 선택 시 처리 + const handleProjectSelect = (project: Project) => { + setSelectedProject(project); + form.setValue("projectId", project.id); + // 템플릿 이름 자동 설정 (원하면) + if (!form.getValues("templateName")) { + form.setValue("templateName", `${project.projectCode} 벤더문서 커버 템플릿`); + } + }; + + const handleFileChange = (files: File[]) => { + if (files.length > 0) { + const file = files[0]; + setSelectedFile(file); + form.setValue("file", file); + } + }; + + // 사용자 정의 변수 추가 + const addCustomVariable = () => { + const currentVars = form.getValues("customVariables"); + form.setValue("customVariables", [ + ...currentVars, + { + name: "", + displayName: "", + type: "text", + required: false, + defaultValue: "", + description: "", + }, + ]); + }; + + // 사용자 정의 변수 제거 + const removeCustomVariable = (index: number) => { + const currentVars = form.getValues("customVariables"); + form.setValue("customVariables", currentVars.filter((_, i) => i !== index)); + }; + + // 청크 업로드 + const CHUNK_SIZE = 1 * 1024 * 1024; + + const uploadFileInChunks = async (file: File, fileId: string) => { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + setShowProgress(true); + setUploadProgress(0); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('chunk', chunk); + formData.append('filename', file.name); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + formData.append('fileId', fileId); + + const response = await fetch('/api/upload/project-doc-template/chunk', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`청크 업로드 실패: ${response.statusText}`); + } + + const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100); + setUploadProgress(progress); + + const result = await response.json(); + if (chunkIndex === totalChunks - 1) { + return result; + } + } + }; + + async function onSubmit(formData: TemplateFormValues) { + setIsLoading(true); + try { + // 파일 업로드 + const fileId = uuidv4(); + const uploadResult = await uploadFileInChunks(formData.file, fileId); + + if (!uploadResult?.success) { + throw new Error("파일 업로드에 실패했습니다."); + } + + // 템플릿 생성 (고정값들 적용) + const result = await createProjectDocTemplate({ + templateName: formData.templateName, + templateCode: formData.templateCode, + description: formData.description, + projectId: formData.projectId, + templateType: "PROJECT", // 고정 + documentType: "VENDOR_DOC_COVER", // 벤더문서 커버로 고정 + filePath: uploadResult.filePath, + fileName: uploadResult.fileName, + fileSize: formData.file.size, + mimeType: formData.file.type, + variables: formData.customVariables, + isPublic: false, // 고정 + requiresApproval: false, // 고정 + }); + + if (!result.success) { + throw new Error(result.error || "템플릿 생성에 실패했습니다."); + } + + toast.success("템플릿이 성공적으로 추가되었습니다."); + form.reset(); + setSelectedFile(null); + setSelectedProject(null); + setOpen(false); + setShowProgress(false); + router.refresh(); + } catch (error) { + console.error("Submit error:", error); + toast.error(error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } + + const customVariables = form.watch("customVariables"); + + // 다이얼로그 닫을 때 폼 초기화 + React.useEffect(() => { + if (!open) { + form.reset(); + setSelectedFile(null); + setSelectedProject(null); + setShowProgress(false); + setUploadProgress(0); + } + }, [open, form]); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + 템플릿 추가 + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0"> + {/* 헤더 - 고정 */} + <DialogHeader className="px-6 py-4 border-b"> + <DialogTitle>프로젝트 벤더문서 커버 템플릿 추가</DialogTitle> + <DialogDescription> + 프로젝트별 벤더문서 커버 템플릿을 등록합니다. 기본 변수(document_number, project_code, project_name)는 자동으로 포함됩니다. + </DialogDescription> + </DialogHeader> + + {/* 본문 - 스크롤 영역 */} + <div className="flex-1 overflow-y-auto px-6 py-4"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 프로젝트 선택 및 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 프로젝트 선택 - 필수 */} + <FormField + control={form.control} + name="projectId" + render={() => ( + <FormItem> + <FormLabel> + 프로젝트 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={selectedProject?.id} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 선택하세요..." + filterType="plant" // 또는 필요한 타입 + /> + </FormControl> + <FormDescription> + 템플릿을 적용할 프로젝트를 선택하세요. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {selectedProject && ( + <div className="p-3 bg-muted rounded-lg"> + <p className="text-sm"> + <span className="font-medium">선택된 프로젝트:</span> {selectedProject.projectCode} - {selectedProject.projectName} + </p> + </div> + )} + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="templateName" + render={({ field }) => ( + <FormItem> + <FormLabel> + 템플릿 이름 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="예: S123 벤더문서 커버 템플릿" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="templateCode" + render={({ field }) => ( + <FormItem> + <FormLabel>템플릿 코드</FormLabel> + <FormControl> + <Input placeholder="자동 생성됨 (선택사항)" {...field} /> + </FormControl> + <FormDescription>비워두면 자동으로 생성됩니다.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="템플릿에 대한 설명을 입력하세요." + className="min-h-[80px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 고정 정보 표시 */} + <div className="flex gap-2"> + <Badge variant="outline">템플릿 타입: 프로젝트</Badge> + <Badge variant="outline">문서 타입: 벤더문서 커버</Badge> + </div> + </CardContent> + </Card> + + {/* 변수 설정 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">템플릿 변수</CardTitle> + <CardDescription> + 문서에서 사용할 변수를 정의합니다. 템플릿에서 {'{{변수명}}'} 형식으로 사용됩니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 기본 변수 표시 */} + <div> + <div className="flex items-center mb-2"> + <Badge variant="outline" className="text-xs"> + 기본 변수 (자동 포함) + </Badge> + </div> + <div className="space-y-2"> + {DEFAULT_VARIABLES_DISPLAY.map((variable) => ( + <div key={variable.name} className="flex items-center gap-2 p-2 bg-muted/50 rounded"> + <Badge variant="secondary"> + {`{{${variable.name}}}`} + </Badge> + <span className="text-sm">{variable.displayName}</span> + <span className="text-xs text-muted-foreground">({variable.description})</span> + {variable.required && ( + <Badge variant="destructive" className="text-xs">필수</Badge> + )} + </div> + ))} + </div> + </div> + + {/* 사용자 정의 변수 */} + <div> + <div className="flex items-center justify-between mb-2"> + <Badge variant="outline" className="text-xs"> + 사용자 정의 변수 + </Badge> + <Button + type="button" + variant="outline" + size="sm" + onClick={addCustomVariable} + > + <Plus className="mr-2 h-4 w-4" /> + 변수 추가 + </Button> + </div> + + {customVariables.length > 0 ? ( + <div className="space-y-3"> + {customVariables.map((_, index) => ( + <div key={index} className="p-3 border rounded-lg space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">변수 #{index + 1}</span> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeCustomVariable(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + + <div className="grid grid-cols-3 gap-3"> + <FormField + control={form.control} + name={`customVariables.${index}.name`} + render={({ field }) => ( + <FormItem> + <FormLabel>변수명</FormLabel> + <FormControl> + <Input placeholder="예: vendor_name" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`customVariables.${index}.displayName`} + render={({ field }) => ( + <FormItem> + <FormLabel>표시명</FormLabel> + <FormControl> + <Input placeholder="예: 벤더명" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`customVariables.${index}.type`} + render={({ field }) => ( + <FormItem> + <FormLabel>타입</FormLabel> + <FormControl> + <select + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" + {...field} + > + <option value="text">텍스트</option> + <option value="number">숫자</option> + <option value="date">날짜</option> + <option value="select">선택</option> + </select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="grid grid-cols-2 gap-3"> + <FormField + control={form.control} + name={`customVariables.${index}.defaultValue`} + render={({ field }) => ( + <FormItem> + <FormLabel>기본값</FormLabel> + <FormControl> + <Input placeholder="선택사항" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`customVariables.${index}.required`} + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-2"> + <FormLabel className="text-sm">필수</FormLabel> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name={`customVariables.${index}.description`} + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Input placeholder="변수 설명 (선택사항)" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + ))} + </div> + ) : ( + <div className="text-center py-4 text-sm text-muted-foreground"> + 추가된 사용자 정의 변수가 없습니다. + </div> + )} + </div> + </CardContent> + </Card> + + {/* 파일 업로드 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">템플릿 파일</CardTitle> + <CardDescription> + 워드 파일(.doc, .docx)을 업로드하세요. 파일 내 {'{{변수명}}'} 형식으로 변수를 사용할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent> + <FormField + control={form.control} + name="file" + render={() => ( + <FormItem> + <FormControl> + <Dropzone + onDrop={handleFileChange} + accept={{ + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'] + }} + > + <DropzoneZone> + <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" /> + <DropzoneTitle> + {selectedFile ? selectedFile.name : "워드 파일을 여기에 드래그하세요"} + </DropzoneTitle> + <DropzoneDescription> + {selectedFile + ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB` + : "또는 클릭하여 파일을 선택하세요 (최대 100MB)"} + </DropzoneDescription> + <DropzoneInput /> + </DropzoneZone> + </Dropzone> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {showProgress && ( + <div className="space-y-2 mt-4"> + <div className="flex justify-between text-sm"> + <span>업로드 진행률</span> + <span>{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} /> + </div> + )} + + {/* 변수 사용 안내 */} + <div className="mt-4 p-3 bg-blue-50 rounded-lg"> + <div className="flex items-start"> + <AlertCircle className="h-4 w-4 text-blue-600 mt-0.5 mr-2 flex-shrink-0" /> + <div className="text-sm text-blue-900"> + <p className="font-medium mb-1">변수 사용 방법</p> + <ul className="list-disc list-inside space-y-1 text-xs"> + <li>워드 문서에서 {'{{변수명}}'} 형식으로 변수를 삽입하세요.</li> + <li>예시: {'{{document_number}}'}, {'{{project_code}}'}, {'{{project_name}}'}</li> + <li>문서 생성 시 변수가 실제 값으로 치환됩니다.</li> + </ul> + </div> + </div> + </div> + </CardContent> + </Card> + </form> + </Form> + </div> + + {/* 푸터 - 고정 */} + <DialogFooter className="px-6 py-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="button" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading || !form.watch("file") || !form.watch("projectId")} + > + {isLoading ? "처리 중..." : "템플릿 추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/project-doc-templates/table/doc-template-table.tsx b/lib/project-doc-templates/table/doc-template-table.tsx new file mode 100644 index 00000000..7d8210d8 --- /dev/null +++ b/lib/project-doc-templates/table/doc-template-table.tsx @@ -0,0 +1,716 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { type ColumnDef } from "@tanstack/react-table"; +import { + Download, + Ellipsis, + Paperclip, + Eye, + Copy, + GitBranch, + Globe, + Lock, + FolderOpen, + FileText +} from "lucide-react"; +import { toast } from "sonner"; +import { formatDateTime } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { DataTable } from "@/components/data-table/data-table"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table"; +import { + getProjectDocTemplates, + deleteProjectDocTemplate, + createTemplateVersion +} from "@/lib/project-doc-templates/service"; +import type { ProjectDocTemplate } from "@/db/schema/project-doc-templates"; +import { quickDownload } from "@/lib/file-download"; +import { AddProjectDocTemplateDialog } from "./add-project-doc-template-dialog"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { TemplateDetailDialog } from "./template-detail-dialog"; +import { TemplateEditSheet } from "./template-edit-sheet"; + +// 문서 타입 라벨 매핑 +const DOCUMENT_TYPE_LABELS: Record<string, string> = { + CONTRACT: "계약서", + SPECIFICATION: "사양서", + REPORT: "보고서", + DRAWING: "도면", + MANUAL: "매뉴얼", + PROCEDURE: "절차서", + STANDARD: "표준문서", + OTHER: "기타", +}; + +// 파일 다운로드 함수 +const handleFileDownload = async (filePath: string, fileName: string) => { + try { + await quickDownload(filePath, fileName); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } +}; + +// 컬럼 정의 함수 - 핸들러들을 props로 받음 +export function getColumns({ + onViewDetail, + onEdit, + onDelete, + onCreateVersion, +}: { + onViewDetail: (template: ProjectDocTemplate) => void; + onEdit: (template: ProjectDocTemplate) => void; + onDelete: (template: ProjectDocTemplate) => void; + onCreateVersion: (template: ProjectDocTemplate) => void; +}): ColumnDef<ProjectDocTemplate>[] { + + // 체크박스 컬럼 + const selectColumn: ColumnDef<ProjectDocTemplate> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + }; + + // 다운로드 컬럼 + const downloadColumn: ColumnDef<ProjectDocTemplate> = { + id: "download", + header: "", + cell: ({ row }) => { + const template = row.original; + return ( + <Button + variant="ghost" + size="icon" + onClick={() => handleFileDownload(template.filePath, template.fileName)} + title={`${template.fileName} 다운로드`} + className="hover:bg-muted" + > + <Paperclip className="h-4 w-4" /> + <span className="sr-only">다운로드</span> + </Button> + ); + }, + maxSize: 30, + enableSorting: false, + }; + + // 액션 컬럼 + const actionsColumn: ColumnDef<ProjectDocTemplate> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const template = row.original; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-44"> + <DropdownMenuItem onSelect={() => onViewDetail(template)}> + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + + <DropdownMenuItem onSelect={() => onEdit(template)}> + 수정하기 + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem onSelect={() => onCreateVersion(template)}> + <GitBranch className="mr-2 h-4 w-4" /> + 새 버전 생성 + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onSelect={() => onDelete(template)} + className="text-destructive" + > + 삭제하기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + maxSize: 30, + }; + + // 데이터 컬럼들 + const dataColumns: ColumnDef<ProjectDocTemplate>[] = [ + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />, + cell: ({ row }) => { + const status = row.getValue("status") as string; + const statusMap: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = { + ACTIVE: { label: "활성", variant: "default" }, + INACTIVE: { label: "비활성", variant: "secondary" }, + DRAFT: { label: "초안", variant: "outline" }, + ARCHIVED: { label: "보관", variant: "secondary" }, + }; + const statusInfo = statusMap[status] || { label: status, variant: "outline" }; + return ( + <Badge variant={statusInfo.variant}> + {statusInfo.label} + </Badge> + ); + }, + size: 80, + enableResizing: true, + }, + { + accessorKey: "templateName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="템플릿명" />, + cell: ({ row }) => { + const template = row.original; + + return ( + <div className="flex flex-col min-w-0"> + <button + onClick={() => onViewDetail(template)} + className="truncate text-left hover:text-blue-600 hover:underline cursor-pointer transition-colors" + title="클릭하여 상세보기" + > + {template.templateName} + </button> + {template.templateCode && ( + <span className="text-xs text-muted-foreground">{template.templateCode}</span> + )} + </div> + ); + }, + size: 250, + enableResizing: true, + }, + { + accessorKey: "projectCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트" />, + cell: ({ row }) => { + const template = row.original; + if (!template.projectCode) return <span className="text-muted-foreground">-</span>; + + return ( + <div className="flex flex-col min-w-0"> + <span className="font-medium truncate">{template.projectCode}</span> + {template.projectName && ( + <span className="text-xs text-muted-foreground truncate">{template.projectName}</span> + )} + </div> + ); + }, + size: 150, + enableResizing: true, + }, + { + accessorKey: "version", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="버전" />, + cell: ({ row }) => { + const template = row.original; + return ( + <div className="flex items-center gap-2"> + <Badge variant="outline" className="text-xs"> + v{template.version} + </Badge> + {template.isLatest && ( + <Badge variant="secondary" className="text-xs"> + 최신 + </Badge> + )} + </div> + ); + }, + size: 100, + enableResizing: true, + }, + { + id: "variables", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="변수" />, + cell: ({ row }) => { + const template = row.original; + const variableCount = template.variables?.length || 0; + const requiredCount = template.requiredVariables?.length || 0; + + return ( + <div className="text-xs"> + <span>{variableCount}개</span> + {requiredCount > 0 && ( + <span className="text-muted-foreground ml-1"> + (필수: {requiredCount}) + </span> + )} + </div> + ); + }, + size: 100, + enableResizing: true, + }, + { + accessorKey: "fileName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />, + cell: ({ row }) => { + const fileName = row.getValue("fileName") as string; + const template = row.original; + const fileSizeMB = template.fileSize ? (template.fileSize / (1024 * 1024)).toFixed(2) : null; + + return ( + <div className="min-w-0 max-w-full"> + <span className="block truncate text-sm" title={fileName}> + {fileName} + </span> + {fileSizeMB && ( + <span className="text-xs text-muted-foreground">{fileSizeMB} MB</span> + )} + </div> + ); + }, + size: 200, + enableResizing: true, + }, + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />, + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date; + const template = row.original; + return ( + <div className="text-xs"> + <div>{date ? formatDateTime(date, "KR") : "-"}</div> + {template.createdByName && ( + <span className="text-muted-foreground">{template.createdByName}</span> + )} + </div> + ); + }, + size: 140, + enableResizing: true, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />, + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date; + const template = row.original; + return ( + <div className="text-xs"> + <div>{date ? formatDateTime(date, "KR") : "-"}</div> + {template.updatedByName && ( + <span className="text-muted-foreground">{template.updatedByName}</span> + )} + </div> + ); + }, + size: 140, + enableResizing: true, + }, + ]; + + return [selectColumn, downloadColumn, ...dataColumns, actionsColumn]; +} + +// 메인 테이블 컴포넌트 +interface ProjectDocTemplateTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getProjectDocTemplates>>, + ] +> +} + +export function ProjectDocTemplateTable({ + promises +}: ProjectDocTemplateTableProps) { + const router = useRouter(); + const [selectedTemplate, setSelectedTemplate] = React.useState<ProjectDocTemplate | null>(null); + const [detailOpen, setDetailOpen] = React.useState(false); + const [editOpen, setEditOpen] = React.useState(false); + const [versionTemplate, setVersionTemplate] = React.useState<ProjectDocTemplate | null>(null); + const [versionOpen, setVersionOpen] = React.useState(false); + + const [{ data, pageCount }] = React.use(promises); + + // 액션 핸들러들 + const handleViewDetail = (template: ProjectDocTemplate) => { + setSelectedTemplate(template); + setDetailOpen(true); + }; + + const handleEdit = (template: ProjectDocTemplate) => { + setSelectedTemplate(template); + setEditOpen(true); + }; + + const handleDelete = async (template: ProjectDocTemplate) => { + if (confirm("정말로 이 템플릿을 삭제하시겠습니까?")) { + const result = await deleteProjectDocTemplate(template.id); + if (result.success) { + toast.success("템플릿이 삭제되었습니다."); + router.refresh(); + } else { + toast.error(result.error || "삭제에 실패했습니다."); + } + } + }; + + const handleCreateVersion = (template: ProjectDocTemplate) => { + setVersionTemplate(template); + setVersionOpen(true); + }; + + // 컬럼 설정 - 핸들러들을 전달 + const columns = React.useMemo( + () => getColumns({ + onViewDetail: handleViewDetail, + onEdit: handleEdit, + onDelete: handleDelete, + onCreateVersion: handleCreateVersion, + }), + [] + ); + + // 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<ProjectDocTemplate>[] = [ + { id: "templateName", label: "템플릿명", type: "text" }, + { + id: "status", + label: "상태", + type: "select", + options: [ + { label: "활성", value: "ACTIVE" }, + { label: "비활성", value: "INACTIVE" }, + { label: "초안", value: "DRAFT" }, + { label: "보관", value: "ARCHIVED" }, + ], + }, + { id: "projectCode", label: "프로젝트 코드", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields: advancedFilterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }); + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <AddProjectDocTemplateDialog /> + {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <Button + variant="outline" + size="sm" + onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows; + console.log("Selected templates:", selectedRows.map(r => r.original)); + toast.info(`${selectedRows.length}개 템플릿 선택됨`); + }} + > + 선택 항목 처리 ({table.getFilteredSelectedRowModel().rows.length}) + </Button> + )} + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 상세보기 다이얼로그 */} + {selectedTemplate && ( + <TemplateDetailDialog + template={selectedTemplate} + open={detailOpen} + onOpenChange={setDetailOpen} + /> + )} + + {/* 수정 Sheet */} + {selectedTemplate && ( + <TemplateEditSheet + template={selectedTemplate} + open={editOpen} + onOpenChange={setEditOpen} + onSuccess={() => { + router.refresh(); + }} + /> + )} + + {/* 새 버전 생성 다이얼로그 */} + {versionTemplate && ( + <CreateVersionDialog + template={versionTemplate} + open={versionOpen} + onOpenChange={(open) => { + setVersionOpen(open); + if (!open) setVersionTemplate(null); + }} + onSuccess={() => { + setVersionOpen(false); + setVersionTemplate(null); + router.refresh(); + toast.success("새 버전이 생성되었습니다."); + }} + /> + )} + </> + ); +} + +// 새 버전 생성 다이얼로그 컴포넌트 +interface CreateVersionDialogProps { + template: ProjectDocTemplate; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + + +function CreateVersionDialog({ template, onClose, onSuccess }: CreateVersionDialogProps) { + const [isLoading, setIsLoading] = React.usseState(false); + const [selectedFile, setSelectedFile] = React.useState<File | null>(null); + const [uploadProgress, setUploadProgress] = React.useState(0); + const [showProgress, setShowProgress] = React.useState(false); + + // 청크 업로드 함수 + const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB + + const uploadFileInChunks = async (file: File, fileId: string) => { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + setShowProgress(true); + setUploadProgress(0); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('chunk', chunk); + formData.append('filename', file.name); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + formData.append('fileId', fileId); + + const response = await fetch('/api/upload/project-doc-template/chunk', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`청크 업로드 실패: ${response.statusText}`); + } + + const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100); + setUploadProgress(progress); + + const result = await response.json(); + if (chunkIndex === totalChunks - 1) { + return result; + } + } + }; + + const handleSubmit = async () => { + if (!selectedFile) { + toast.error("파일을 선택해주세요."); + return; + } + + setIsLoading(true); + try { + // 1. 파일 업로드 (청크 방식) + const fileId = `version_${template.id}_${Date.now()}`; + const uploadResult = await uploadFileInChunks(selectedFile, fileId); + + if (!uploadResult?.success) { + throw new Error("파일 업로드에 실패했습니다."); + } + + // 2. 업로드된 파일 정보로 새 버전 생성 + const result = await createTemplateVersion(template.id, { + filePath: uploadResult.filePath, + fileName: uploadResult.fileName, + fileSize: uploadResult.fileSize || selectedFile.size, + mimeType: uploadResult.mimeType || selectedFile.type, + variables: template.variables, // 기존 변수 유지 + }); + + if (result.success) { + toast.success(`버전 ${template.version + 1}이 생성되었습니다.`); + onSuccess(); + } else { + throw new Error(result.error); + } + } catch (error) { + console.error("Failed to create version:", error); + toast.error(error instanceof Error ? error.message : "새 버전 생성에 실패했습니다."); + } finally { + setIsLoading(false); + setShowProgress(false); + setUploadProgress(0); + } + }; + + return ( + <Dialog open={true} onOpenChange={onClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>새 버전 생성</DialogTitle> + <DialogDescription> + {template.templateName}의 새 버전을 생성합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 버전 정보 표시 */} + <div className="rounded-lg border p-3 bg-muted/50"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">현재 버전</span> + <Badge variant="outline">v{template.version}</Badge> + </div> + <div className="flex items-center justify-between mt-2"> + <span className="text-sm font-medium">새 버전</span> + <Badge variant="default">v{template.version + 1}</Badge> + </div> + </div> + + {/* 파일 선택 */} + <div> + <Label htmlFor="file"> + 새 템플릿 파일 <span className="text-red-500">*</span> + </Label> + <div className="mt-2"> + <Input + id="file" + type="file" + accept=".doc,.docx" + onChange={(e) => setSelectedFile(e.target.files?.[0] || null)} + disabled={isLoading} + /> + </div> + {selectedFile && ( + <p className="mt-2 text-sm text-muted-foreground"> + 선택된 파일: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + + {/* 업로드 진행률 */} + {showProgress && ( + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span>업로드 진행률</span> + <span>{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} /> + </div> + )} + + {/* 안내 메시지 */} + <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3"> + <div className="flex"> + <AlertCircle className="h-4 w-4 text-yellow-600 mr-2 flex-shrink-0 mt-0.5" /> + <div className="text-sm text-yellow-800"> + <p className="font-medium">주의사항</p> + <ul className="mt-1 list-disc list-inside text-xs space-y-1"> + <li>새 버전 생성 후에는 이전 버전으로 되돌릴 수 없습니다.</li> + <li>기존 변수 설정은 그대로 유지됩니다.</li> + <li>파일 형식은 기존과 동일해야 합니다 (.doc/.docx).</li> + </ul> + </div> + </div> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={onClose} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isLoading || !selectedFile} + > + {isLoading ? ( + <> + <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> + 생성 중... + </> + ) : ( + '버전 생성' + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/project-doc-templates/table/project-doc-template-editor.tsx b/lib/project-doc-templates/table/project-doc-template-editor.tsx new file mode 100644 index 00000000..e4f798a9 --- /dev/null +++ b/lib/project-doc-templates/table/project-doc-template-editor.tsx @@ -0,0 +1,645 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { + Save, + RefreshCw, + Type, + FileText, + AlertCircle, + Copy, + Download, + Settings, + ChevronDown, + ChevronUp +} from "lucide-react"; +import type { WebViewerInstance } from "@pdftron/webviewer"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + getProjectDocTemplateById, + updateProjectDocTemplate, + type DocTemplateVariable +} from "@/lib/project-doc-templates/service"; +import type { ProjectDocTemplate } from "@/db/schema/project-doc-templates"; +import { BasicContractTemplateViewer } from "@/lib/basic-contract/template/basic-contract-template-viewer"; +import { v4 as uuidv4 } from 'uuid'; +import { Progress } from "@/components/ui/progress"; + +interface ProjectDocTemplateEditorProps { + templateId: string | number; + filePath: string; + fileName: string; + refreshAction?: () => Promise<void>; + mode?: "view" | "edit"; +} + +// 변수별 한글 설명 매핑은 기존과 동일 +const VARIABLE_DESCRIPTION_MAP: Record<string, string> = { + "document_number": "문서번호", + "project_code": "프로젝트 코드", + "project_name": "프로젝트명", + "created_date": "작성일", + "author_name": "작성자", + "department": "부서명", + "company_name": "회사명", + "company_address": "회사주소", + "representative_name": "대표자명", + "signature_date": "서명날짜", + "today_date": "오늘날짜", + "tax_id": "사업자등록번호", + "phone_number": "전화번호", + "email": "이메일", +}; + +const VARIABLE_PATTERN = /\{\{([^}]+)\}\}/g; + +export function ProjectDocTemplateEditor({ + templateId, + filePath, + fileName, + refreshAction, + mode = "edit", +}: ProjectDocTemplateEditorProps) { + const [instance, setInstance] = React.useState<WebViewerInstance | null>(null); + const [isSaving, setIsSaving] = React.useState(false); + const [documentVariables, setDocumentVariables] = React.useState<string[]>([]); + const [templateInfo, setTemplateInfo] = React.useState<ProjectDocTemplate | null>(null); + const [predefinedVariables, setPredefinedVariables] = React.useState<DocTemplateVariable[]>([]); + const [isVariablePanelOpen, setIsVariablePanelOpen] = React.useState(false); // 변수 패널 접기/펴기 상태 + const [uploadProgress, setUploadProgress] = React.useState(0); + const [showProgress, setShowProgress] = React.useState(false); + + // 템플릿 정보 로드 + React.useEffect(() => { + const loadTemplateInfo = async () => { + try { + const data = await getProjectDocTemplateById(Number(templateId)); + setTemplateInfo(data as ProjectDocTemplate); + setPredefinedVariables(data.variables || []); + + console.log("📋 템플릿 정보:", data); + console.log("📝 정의된 변수들:", data.variables); + } catch (error) { + console.error("템플릿 정보 로드 오류:", error); + toast.error("템플릿 정보를 불러오는데 실패했습니다."); + } + }; + + if (templateId) { + loadTemplateInfo(); + } + }, [templateId]); + + // 문서에서 변수 추출 - 기존과 동일 + const extractVariablesFromDocument = async () => { + if (!instance) return; + + try { + const { documentViewer } = instance.Core; + const doc = documentViewer.getDocument(); + + if (!doc) return; + + const textContent = await doc.getDocumentCompletePromise().then(async () => { + const pageCount = doc.getPageCount(); + let fullText = ""; + + for (let i = 1; i <= pageCount; i++) { + try { + const pageText = await doc.loadPageText(i); + fullText += pageText + " "; + } catch (error) { + console.warn(`페이지 ${i} 텍스트 추출 실패:`, error); + } + } + + return fullText; + }); + + const matches = textContent.match(VARIABLE_PATTERN); + const variables = matches + ? [...new Set(matches.map(match => match.replace(/[{}]/g, '')))] + : []; + + setDocumentVariables(variables); + + if (variables.length > 0) { + console.log("🔍 문서에서 발견된 변수들:", variables); + + const undefinedVars = variables.filter( + v => !predefinedVariables.find(pv => pv.name === v) + ); + + if (undefinedVars.length > 0) { + toast.warning( + `정의되지 않은 변수가 발견되었습니다: ${undefinedVars.join(", ")}`, + { duration: 5000 } + ); + } + } + + } catch (error) { + console.error("변수 추출 중 오류:", error); + } + }; + + // 청크 업로드 함수 + const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB + + const uploadFileInChunks = async (file: Blob, fileName: string, fileId: string) => { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + setShowProgress(true); + setUploadProgress(0); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('chunk', chunk); + formData.append('filename', fileName); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + formData.append('fileId', fileId); + + const response = await fetch('/api/upload/project-doc-template/chunk', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`청크 업로드 실패: ${response.statusText}`); + } + + const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100); + setUploadProgress(progress); + + const result = await response.json(); + if (chunkIndex === totalChunks - 1) { + return result; + } + } + }; + + // 문서 저장 - 개선된 버전 + const handleSave = async () => { + if (!instance || mode === "view") { + toast.error(mode === "view" ? "읽기 전용 모드입니다." : "뷰어가 준비되지 않았습니다."); + return; + } + + setIsSaving(true); + try { + const { documentViewer } = instance.Core; + const doc = documentViewer.getDocument(); + + if (!doc) { + throw new Error("문서를 찾을 수 없습니다."); + } + + // Word 문서 데이터 추출 + const data = await doc.getFileData({ + downloadType: "office", + includeAnnotations: true + }); + + // Blob 생성 + const fileBlob = new Blob([data], { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + }); + + // 파일 업로드 (청크 방식) + const fileId = `template_${templateId}_${Date.now()}`; + const uploadResult = await uploadFileInChunks(fileBlob, fileName, fileId); + + if (!uploadResult?.success) { + throw new Error("파일 업로드에 실패했습니다."); + } + + // 서버에 파일 경로 업데이트 + const updateResult = await updateProjectDocTemplate(Number(templateId), { + filePath: uploadResult.filePath, + fileName: uploadResult.fileName, + fileSize: uploadResult.fileSize, + variables: documentVariables.length > 0 ? + documentVariables.map(varName => { + const existing = predefinedVariables.find(v => v.name === varName); + return existing || { + name: varName, + displayName: VARIABLE_DESCRIPTION_MAP[varName] || varName, + type: "text" as const, + required: false, + description: "" + }; + }) : predefinedVariables + }); + + if (!updateResult.success) { + throw new Error(updateResult.error || "템플릿 업데이트에 실패했습니다."); + } + + toast.success("템플릿이 성공적으로 저장되었습니다."); + + // 변수 재추출 + await extractVariablesFromDocument(); + + // 페이지 새로고침 + if (refreshAction) { + await refreshAction(); + } + + } catch (error) { + console.error("저장 오류:", error); + toast.error(error instanceof Error ? error.message : "저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); + setShowProgress(false); + setUploadProgress(0); + } + }; + + // 나머지 함수들은 기존과 동일 + React.useEffect(() => { + if (instance) { + const { documentViewer } = instance.Core; + + const onDocumentLoaded = () => { + setTimeout(() => extractVariablesFromDocument(), 1000); + }; + + documentViewer.addEventListener("documentLoaded", onDocumentLoaded); + + return () => { + documentViewer.removeEventListener("documentLoaded", onDocumentLoaded); + }; + } + }, [instance, predefinedVariables]); + + const insertVariable = async (variable: DocTemplateVariable) => { + if (!instance) { + toast.error("뷰어가 준비되지 않았습니다."); + return; + } + + const textToInsert = `{{${variable.name}}}`; // variable.name으로 수정 + + try { + // textarea를 보이는 위치에 잠시 생성 + const tempTextArea = document.createElement('textarea'); + tempTextArea.value = textToInsert; + tempTextArea.style.position = 'fixed'; + tempTextArea.style.top = '20px'; + tempTextArea.style.left = '20px'; + tempTextArea.style.width = '200px'; + tempTextArea.style.height = '30px'; + tempTextArea.style.fontSize = '12px'; + tempTextArea.style.zIndex = '10000'; + tempTextArea.style.opacity = '0.01'; // 거의 투명하게 + + document.body.appendChild(tempTextArea); + + // 포커스와 선택을 확실하게 + tempTextArea.focus(); + tempTextArea.select(); + tempTextArea.setSelectionRange(0, tempTextArea.value.length); // 전체 선택 보장 + + let successful = false; + try { + successful = document.execCommand('copy'); + console.log('복사 시도 결과:', successful, '복사된 텍스트:', textToInsert); + } catch (err) { + console.error('execCommand 실패:', err); + } + + // 잠시 후 제거 (즉시 제거하면 복사가 안될 수 있음) + setTimeout(() => { + document.body.removeChild(tempTextArea); + }, 100); + + if (successful) { + toast.success( + <div> + <p className="font-medium">{variable.displayName} 변수가 복사되었습니다.</p> + <code className="bg-gray-100 px-1 rounded text-xs">{textToInsert}</code> + <p className="text-sm text-muted-foreground mt-1"> + 문서에서 원하는 위치에 Ctrl+V로 붙여넣기 하세요. + </p> + </div>, + { duration: 3000 } + ); + + // 복사 확인용 - 개발 중에만 사용 + if (process.env.NODE_ENV === 'development') { + navigator.clipboard.readText().then(text => { + console.log('클립보드 내용:', text); + }).catch(err => { + console.log('클립보드 읽기 실패:', err); + }); + } + } else { + // 복사 실패 시 대안 제공 + const fallbackInput = document.createElement('input'); + fallbackInput.value = textToInsert; + fallbackInput.style.position = 'fixed'; + fallbackInput.style.top = '50%'; + fallbackInput.style.left = '50%'; + fallbackInput.style.transform = 'translate(-50%, -50%)'; + fallbackInput.style.zIndex = '10001'; + fallbackInput.style.padding = '8px'; + fallbackInput.style.border = '2px solid #3b82f6'; + fallbackInput.style.borderRadius = '4px'; + fallbackInput.style.backgroundColor = 'white'; + + document.body.appendChild(fallbackInput); + fallbackInput.select(); + + toast.error( + <div> + <p className="font-medium">자동 복사 실패</p> + <p className="text-sm">표시된 텍스트를 Ctrl+C로 복사하세요.</p> + </div>, + { + duration: 5000, + onDismiss: () => { + if (document.body.contains(fallbackInput)) { + document.body.removeChild(fallbackInput); + } + } + } + ); + } + + } catch (error) { + console.error("변수 삽입 오류:", error); + toast.error("변수 삽입 중 오류가 발생했습니다."); + } + }; + + const handleDownload = async () => { + try { + const response = await fetch(filePath); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + toast.success("파일이 다운로드되었습니다."); + } catch (error) { + console.error("다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + const handleRefresh = () => { + window.location.reload(); + }; + + const isReadOnly = mode === "view"; + + return ( + <div className="h-full flex flex-col"> + {/* 상단 도구 모음 */} + <div className="border-b bg-gray-50 p-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + {!isReadOnly && ( + <Button + variant="outline" + size="sm" + onClick={handleSave} + disabled={isSaving || !instance} + > + {isSaving ? ( + <> + <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + 저장 + </> + )} + </Button> + )} + + <Button + variant="outline" + size="sm" + onClick={handleDownload} + > + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </Button> + + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + > + <RefreshCw className="mr-2 h-4 w-4" /> + 새로고침 + </Button> + </div> + + <div className="flex items-center space-x-2"> + {templateInfo?.projectCode && ( + <Badge variant="outline"> + <FileText className="mr-1 h-3 w-3" /> + {templateInfo.projectCode} + </Badge> + )} + <Badge variant="secondary"> + {fileName} + </Badge> + {documentVariables.length > 0 && ( + <Badge variant="secondary"> + <Type className="mr-1 h-3 w-3" /> + 변수 {documentVariables.length}개 + </Badge> + )} + </div> + </div> + + + {/* 변수 도구 - Collapsible로 변경 */} + {(predefinedVariables.length > 0 || documentVariables.length > 0) && ( + <Collapsible + open={isVariablePanelOpen} + onOpenChange={setIsVariablePanelOpen} + className="mt-3" + > + <Card> + <CardHeader className="pb-3"> + <div className="flex items-center justify-between"> + <CardTitle className="text-sm flex items-center"> + <Type className="mr-2 h-4 w-4 text-blue-500" /> + 템플릿 변수 관리 + {documentVariables.length > 0 && ( + <Badge variant="secondary" className="ml-2"> + {documentVariables.length}개 + </Badge> + )} + </CardTitle> + <CollapsibleTrigger asChild> + <Button variant="ghost" size="sm"> + {isVariablePanelOpen ? ( + <> + <ChevronUp className="h-4 w-4 mr-1" /> + 접기 + </> + ) : ( + <> + <ChevronDown className="h-4 w-4 mr-1" /> + 펼치기 + </> + )} + </Button> + </CollapsibleTrigger> + </div> + </CardHeader> + + <CollapsibleContent> + <CardContent className="space-y-3"> + {/* 정의된 변수들 */} + {predefinedVariables.length > 0 && ( + <div> + <p className="text-xs text-muted-foreground mb-2">정의된 템플릿 변수 (클릭하여 복사):</p> + <TooltipProvider> + <div className="flex flex-wrap gap-1"> + {predefinedVariables.map((variable, index) => ( + <Tooltip key={index}> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-7 px-2 text-xs hover:bg-blue-50" + onClick={() => !isReadOnly && insertVariable(variable)} + disabled={isReadOnly} + > + <span className="font-mono"> + {`{{${variable.name}}}`} + </span> + {variable.required && ( + <span className="ml-1 text-red-500">*</span> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-medium">{variable.displayName}</p> + {variable.description && ( + <p className="text-xs">{variable.description}</p> + )} + {variable.defaultValue && ( + <p className="text-xs">기본값: {variable.defaultValue}</p> + )} + <p className="text-xs text-muted-foreground"> + 타입: {variable.type} {variable.required && "(필수)"} + </p> + </div> + </TooltipContent> + </Tooltip> + ))} + </div> + </TooltipProvider> + </div> + )} + + {/* 문서에서 발견된 변수들 */} + {documentVariables.length > 0 && ( + <div> + <p className="text-xs text-muted-foreground mb-2">문서에서 발견된 변수:</p> + <div className="flex flex-wrap gap-1"> + {documentVariables.map((variable, index) => { + const isDefined = predefinedVariables.find(v => v.name === variable); + return ( + <Badge + key={index} + variant={isDefined ? "secondary" : "destructive"} + className="text-xs" + > + {`{{${variable}}}`} + {!isDefined && ( + <AlertCircle className="ml-1 h-3 w-3" /> + )} + </Badge> + ); + })} + </div> + </div> + )} + + {/* 변수 사용 안내 */} + <div className="mt-3 p-2 bg-blue-50 rounded-lg"> + <div className="flex items-start"> + <AlertCircle className="h-4 w-4 text-blue-600 mt-0.5 mr-2 flex-shrink-0" /> + <div className="text-xs text-blue-900 space-y-1"> + <p className="font-medium">변수 사용 안내</p> + <ul className="list-disc list-inside space-y-0.5"> + <li>{'{{변수명}}'} 형식으로 문서에 변수를 삽입하세요.</li> + <li>필수 변수(*)는 반드시 값이 입력되어야 합니다.</li> + <li>한글 입력 제한 시 외부 에디터에서 작성 후 붙여넣기 하세요.</li> + </ul> + </div> + </div> + </div> + </CardContent> + </CollapsibleContent> + </Card> + </Collapsible> + )} + + {/* 업로드 진행률 */} + {showProgress && ( + <div className="mt-3 p-3 bg-white rounded border"> + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span>저장 진행률</span> + <span>{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} /> + </div> + </div> + )} + </div> + + {/* 뷰어 영역 */} + <div className="flex-1 relative overflow-hidden"> + <div className="absolute inset-0"> + <BasicContractTemplateViewer + templateId={templateId} + filePath={filePath} + instance={instance} + setInstance={setInstance} + /> + </div> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/lib/project-doc-templates/table/template-detail-dialog.tsx b/lib/project-doc-templates/table/template-detail-dialog.tsx new file mode 100644 index 00000000..0810a58a --- /dev/null +++ b/lib/project-doc-templates/table/template-detail-dialog.tsx @@ -0,0 +1,121 @@ +// lib/project-doc-template/template-detail-dialog.tsx +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ProjectDocTemplateEditor } from "./project-doc-template-editor"; +import { getProjectDocTemplateById } from "@/lib/project-doc-templates/service"; +import type { ProjectDocTemplate } from "@/db/schema/project-doc-templates"; +import { formatDateTime } from "@/lib/utils"; +import { FileText, Clock, User, GitBranch, Variable } from "lucide-react"; + +interface TemplateDetailDialogProps { + template: ProjectDocTemplate; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function TemplateDetailDialog({ + template, + open, + onOpenChange, +}: TemplateDetailDialogProps) { + const [templateDetail, setTemplateDetail] = React.useState<any>(null); + const [isLoading, setIsLoading] = React.useState(true); + + // 템플릿 상세 정보 로드 + React.useEffect(() => { + if (open && template.id) { + setIsLoading(true); + getProjectDocTemplateById(template.id) + .then(setTemplateDetail) + .catch(console.error) + .finally(() => setIsLoading(false)); + } + }, [open, template.id]); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-7xl h-[90vh] p-0 flex flex-col"> + <DialogHeader className="px-6 py-4 border-b"> + <DialogTitle className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + {template.templateName} + </div> + <div className="flex gap-2"> + <Badge variant="outline">v{template.version}</Badge> + {template.isLatest && <Badge variant="default">최신</Badge>} + <Badge variant="secondary">{template.status}</Badge> + </div> + </DialogTitle> + </DialogHeader> + + <Tabs defaultValue="viewer" className="flex-1 flex flex-col"> + <TabsList className="mx-6 mt-2"> + <TabsTrigger value="viewer">문서 보기</TabsTrigger> + <TabsTrigger value="history">버전 히스토리</TabsTrigger> + </TabsList> + + <TabsContent value="viewer" className="flex-1 mt-0"> + <div className="h-full"> + <ProjectDocTemplateEditor + templateId={template.id} + filePath={template.filePath} + fileName={template.fileName} + // mode="view" // 읽기 전용 모드 + /> + </div> + </TabsContent> + + + <TabsContent value="history" className="flex-1 overflow-y-auto px-6"> + <div className="space-y-4 py-4"> + {templateDetail?.versionHistory && templateDetail.versionHistory.length > 0 ? ( + templateDetail.versionHistory.map((version: any) => ( + <Card key={version.id} className={version.id === template.id ? "border-primary" : ""}> + <CardHeader> + <CardTitle className="text-base flex items-center justify-between"> + <div className="flex items-center gap-2"> + <GitBranch className="h-4 w-4" /> + 버전 {version.version} + </div> + <div className="flex gap-2"> + {version.isLatest && <Badge variant="default">최신</Badge>} + {version.id === template.id && <Badge variant="secondary">현재</Badge>} + </div> + </CardTitle> + </CardHeader> + <CardContent> + <div className="text-sm space-y-1"> + <p> + <span className="text-muted-foreground">생성일:</span> {formatDateTime(version.createdAt, "KR")} + </p> + <p> + <span className="text-muted-foreground">생성자:</span> {version.createdByName || '-'} + </p> + <p> + <span className="text-muted-foreground">파일:</span> {version.fileName} + </p> + </div> + </CardContent> + </Card> + )) + ) : ( + <p className="text-sm text-muted-foreground">버전 히스토리가 없습니다.</p> + )} + </div> + </TabsContent> + </Tabs> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/project-doc-templates/table/template-edit-sheet.tsx b/lib/project-doc-templates/table/template-edit-sheet.tsx new file mode 100644 index 00000000..a745045c --- /dev/null +++ b/lib/project-doc-templates/table/template-edit-sheet.tsx @@ -0,0 +1,305 @@ +// lib/project-doc-template/template-edit-sheet.tsx +"use client"; + +import * as React from "react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetFooter, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "sonner"; +import { updateProjectDocTemplate } from "@/lib/project-doc-templates/service"; +import type { ProjectDocTemplate, DocTemplateVariable } from "@/db/schema/project-doc-templates"; +import { Plus, X, Save, Loader2 } from "lucide-react"; + +interface TemplateEditSheetProps { + template: ProjectDocTemplate | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function TemplateEditSheet({ + template, + open, + onOpenChange, + onSuccess, +}: TemplateEditSheetProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [formData, setFormData] = React.useState({ + templateName: "", + description: "", + variables: [] as DocTemplateVariable[], + status: "ACTIVE", + }); + + // 템플릿 데이터 로드 + React.useEffect(() => { + if (template && open) { + setFormData({ + templateName: template.templateName || "", + description: template.description || "", + variables: template.variables || [], + status: template.status || "ACTIVE", + }); + } + }, [template, open]); + + // 변수 추가 + const addVariable = () => { + setFormData({ + ...formData, + variables: [ + ...formData.variables, + { + name: "", + displayName: "", + type: "text", + required: false, + defaultValue: "", + description: "", + }, + ], + }); + }; + + // 변수 제거 + const removeVariable = (index: number) => { + setFormData({ + ...formData, + variables: formData.variables.filter((_, i) => i !== index), + }); + }; + + // 변수 업데이트 + const updateVariable = (index: number, field: string, value: any) => { + const updatedVariables = [...formData.variables]; + updatedVariables[index] = { + ...updatedVariables[index], + [field]: value, + }; + setFormData({ + ...formData, + variables: updatedVariables, + }); + }; + + // 저장 처리 + const handleSave = async () => { + if (!template) return; + + setIsLoading(true); + try { + const result = await updateProjectDocTemplate(template.id, { + templateName: formData.templateName, + description: formData.description, + variables: formData.variables, + status: formData.status, + }); + + if (result.success) { + toast.success("템플릿이 수정되었습니다."); + onOpenChange(false); + onSuccess?.(); + } else { + throw new Error(result.error); + } + } catch (error) { + console.error("Failed to update template:", error); + toast.error(error instanceof Error ? error.message : "템플릿 수정에 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[600px] sm:max-w-[600px] overflow-y-auto"> + <SheetHeader> + <SheetTitle>템플릿 수정</SheetTitle> + <SheetDescription> + 템플릿 정보와 변수를 수정할 수 있습니다. 파일을 변경하려면 새 버전을 생성하세요. + </SheetDescription> + </SheetHeader> + + <div className="space-y-6 py-6"> + {/* 기본 정보 */} + <div className="space-y-4"> + <div> + <Label htmlFor="templateName">템플릿 이름</Label> + <Input + id="templateName" + value={formData.templateName} + onChange={(e) => setFormData({ ...formData, templateName: e.target.value })} + placeholder="템플릿 이름을 입력하세요" + /> + </div> + + <div> + <Label htmlFor="description">설명</Label> + <Textarea + id="description" + value={formData.description} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + placeholder="템플릿 설명을 입력하세요" + className="min-h-[80px]" + /> + </div> + + <div> + <Label htmlFor="status">상태</Label> + <select + id="status" + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + value={formData.status} + onChange={(e) => setFormData({ ...formData, status: e.target.value })} + > + <option value="ACTIVE">활성</option> + <option value="INACTIVE">비활성</option> + <option value="DRAFT">초안</option> + <option value="ARCHIVED">보관</option> + </select> + </div> + </div> + + {/* 변수 관리 */} + <div> + <div className="flex items-center justify-between mb-4"> + <Label>템플릿 변수</Label> + <Button + type="button" + variant="outline" + size="sm" + onClick={addVariable} + > + <Plus className="mr-2 h-4 w-4" /> + 변수 추가 + </Button> + </div> + + {/* 기본 변수 표시 */} + <div className="mb-4 p-3 bg-muted/50 rounded-lg"> + <p className="text-xs font-medium mb-2">기본 변수 (수정 불가)</p> + <div className="space-y-1"> + <Badge variant="secondary">document_number</Badge> + <Badge variant="secondary" className="ml-2">project_code</Badge> + <Badge variant="secondary" className="ml-2">project_name</Badge> + </div> + </div> + + {/* 사용자 정의 변수 */} + {formData.variables.length > 0 ? ( + <div className="space-y-3"> + {formData.variables.map((variable, index) => ( + <div key={index} className="p-3 border rounded-lg space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">변수 #{index + 1}</span> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeVariable(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + + <div className="grid grid-cols-2 gap-3"> + <div> + <Label>변수명</Label> + <Input + value={variable.name} + onChange={(e) => updateVariable(index, "name", e.target.value)} + placeholder="예: vendor_name" + /> + </div> + <div> + <Label>표시명</Label> + <Input + value={variable.displayName} + onChange={(e) => updateVariable(index, "displayName", e.target.value)} + placeholder="예: 벤더명" + /> + </div> + </div> + + <div className="grid grid-cols-2 gap-3"> + <div> + <Label>타입</Label> + <select + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + value={variable.type} + onChange={(e) => updateVariable(index, "type", e.target.value)} + > + <option value="text">텍스트</option> + <option value="number">숫자</option> + <option value="date">날짜</option> + <option value="select">선택</option> + </select> + </div> + <div className="flex items-center justify-between rounded-lg border p-2"> + <Label className="text-sm">필수</Label> + <Switch + checked={variable.required} + onCheckedChange={(checked) => updateVariable(index, "required", checked)} + /> + </div> + </div> + + <div> + <Label>설명</Label> + <Input + value={variable.description || ""} + onChange={(e) => updateVariable(index, "description", e.target.value)} + placeholder="변수 설명 (선택사항)" + /> + </div> + </div> + ))} + </div> + ) : ( + <div className="text-center py-4 text-sm text-muted-foreground"> + 사용자 정의 변수가 없습니다. + </div> + )} + </div> + </div> + + <SheetFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSave} + disabled={isLoading} + > + {isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + 저장 + </> + )} + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file diff --git a/lib/project-doc-templates/validations.ts b/lib/project-doc-templates/validations.ts new file mode 100644 index 00000000..e745c468 --- /dev/null +++ b/lib/project-doc-templates/validations.ts @@ -0,0 +1,31 @@ + +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { ProjectDocTemplate } from "@/db/schema"; + +export const searchParamsDOCTemplatesCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<ProjectDocTemplate>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 + search: parseAsString.withDefault(""), + +}); + +export type GetDOCTemplatesSchema = Awaited< + ReturnType<typeof searchParamsDOCTemplatesCache.parse> +>; |
