summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/file-manager/FileManager copy.tsx1852
-rw-r--r--components/file-manager/FileManager.tsx729
-rw-r--r--components/file-manager/SecurePDFViewer.tsx9
-rw-r--r--components/file-manager/creaetWaterMarks.tsx71
-rw-r--r--components/form-data/form-data-table.tsx190
-rw-r--r--components/layout/HeaderDataroom.tsx202
-rw-r--r--components/layout/HeaderSimple.tsx2
-rw-r--r--components/project/ProjectList.tsx2
8 files changed, 2525 insertions, 532 deletions
diff --git a/components/file-manager/FileManager copy.tsx b/components/file-manager/FileManager copy.tsx
new file mode 100644
index 00000000..3f3d73a4
--- /dev/null
+++ b/components/file-manager/FileManager copy.tsx
@@ -0,0 +1,1852 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ Folder,
+ File,
+ FolderPlus,
+ Upload,
+ Trash2,
+ Edit2,
+ Download,
+ Share2,
+ Eye,
+ EyeOff,
+ Lock,
+ Unlock,
+ Globe,
+ Shield,
+ AlertCircle,
+ MoreVertical,
+ ChevronRight,
+ ChevronDown,
+ Search,
+ Grid,
+ List,
+ Copy,
+ X
+} from 'lucide-react';
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+ ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+} from '@/components/ui/context-menu';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Badge } from '@/components/ui/badge';
+import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb';
+import { useToast } from '@/hooks/use-toast';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { cn } from '@/lib/utils';
+import { useSession } from 'next-auth/react';
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} 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;
+ name: string;
+ type: 'file' | 'folder';
+ size?: number;
+ mimeType?: string;
+ category: 'public' | 'restricted' | 'confidential' | 'internal';
+ externalAccessLevel?: 'view_only' | 'view_download' | 'full_access';
+ updatedAt: Date;
+ permissions?: {
+ canView: boolean;
+ canDownload: boolean;
+ canEdit: boolean;
+ canDelete: boolean;
+ };
+ downloadCount?: number;
+ viewCount?: number;
+ parentId?: string | null;
+ children?: FileItem[];
+}
+
+interface UploadingFile {
+ file: File;
+ progress: number;
+ status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error';
+ error?: string;
+}
+
+interface FileManagerProps {
+ projectId: string;
+}
+
+// Category configuration with icons and colors
+const categoryConfig = {
+ 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
+const TreeItem: React.FC<{
+ item: FileItem;
+ level: number;
+ expandedFolders: Set<string>;
+ selectedItems: Set<string>;
+ 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,
+ onSelectItem,
+ onDoubleClick,
+ onView,
+ onDownload,
+ onDownloadFolder,
+ onDelete,
+ onShare,
+ onRename,
+ 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 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>
+ )}
+ {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' && (
+ <>
+ <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();
+ const [items, setItems] = useState<FileItem[]>([]);
+ const [treeItems, setTreeItems] = useState<FileItem[]>([]);
+ const [currentPath, setCurrentPath] = useState<string[]>([]);
+ const [currentParentId, setCurrentParentId] = useState<string | null>(null);
+ const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
+ 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);
+ const [shareSettings, setShareSettings] = useState({
+ accessLevel: 'view_only',
+ password: '',
+ 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[] = [];
+
+ // 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) {
+ // No parentId means root item
+ rootItems.push(mappedItem);
+ } else {
+ // 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: 'Error',
+ description: 'Failed to load files.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ }, [projectId, currentParentId, viewMode, toast]);
+
+ useEffect(() => {
+ fetchItems();
+ }, [fetchItems]);
+
+ // Create folder
+ const createFolder = async () => {
+ try {
+ const response = await fetch(`/api/data-room/${projectId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: dialogValue,
+ type: 'folder',
+ category: selectedCategory,
+ parentId: currentParentId,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to create folder');
+ }
+
+ await fetchItems();
+ setFolderDialogOpen(false);
+ setDialogValue('');
+
+ toast({
+ title: 'Success',
+ description: 'Folder created successfully.',
+ });
+ } catch (error: any) {
+ toast({
+ 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 {
+ // Update status: uploading
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'uploading', progress: 20 } : f
+ ));
+
+ // DRM decryption
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'processing', progress: 40 } : f
+ ));
+
+ const decryptedData = await decryptWithServerAction(file);
+
+ // 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()); // Pass file size
+ if (currentParentId) {
+ formData.append('parentId', currentParentId);
+ }
+
+ // Update upload progress
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, progress: 60 } : f
+ ));
+
+ // API call
+ const response = await fetch(`/api/data-room/${projectId}/upload`, {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Upload failed');
+ }
+
+ // Success
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'completed', progress: 100 } : f
+ ));
+
+ } catch (error: any) {
+ // 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: 'Upload Complete',
+ description: `${successCount} file(s) uploaded successfully.`,
+ });
+ }
+ };
+
+ // Download folder
+ const downloadFolder = async (folder: FileItem) => {
+ if (folder.type !== 'folder') return;
+
+ try {
+ toast({
+ title: 'Checking Permissions',
+ description: 'Verifying download permissions for folder contents...',
+ });
+
+ // 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: 'Insufficient Permissions',
+ description: `No permission for ${error.unauthorizedFiles.length} file(s): ${error.unauthorizedFiles.join(', ')}`,
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ 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: 'Download Complete',
+ description: `${folder.name} folder downloaded successfully.`,
+ });
+
+ } catch (error: any) {
+ toast({
+ title: 'Error',
+ description: error.message || 'Failed to download folder.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Share file
+ const shareFile = async () => {
+ if (!selectedFile) return;
+
+ try {
+ const response = await fetch(`/api/data-room/${projectId}/share`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ fileId: selectedFile.id,
+ ...shareSettings,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to create share link');
+ }
+
+ const data = await response.json();
+
+ // Copy share link to clipboard
+ await navigator.clipboard.writeText(data.shareUrl);
+
+ toast({
+ title: 'Share Link Created',
+ description: 'Link copied to clipboard.',
+ });
+
+ setShareDialogOpen(false);
+ setSelectedFile(null);
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: 'Failed to create share link.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Download multiple files
+ const downloadMultipleFiles = async (itemIds: string[]) => {
+ // 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: 'Notice',
+ description: 'No downloadable files selected.',
+ variant: 'default',
+ });
+ return;
+ }
+
+ // Use regular download for single file
+ if (filesToDownload.length === 1) {
+ await downloadFile(filesToDownload[0]);
+ return;
+ }
+
+ try {
+ toast({
+ title: 'Preparing Download',
+ description: `Compressing ${filesToDownload.length} files...`,
+ });
+
+ // 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('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: 'Download Complete',
+ description: `${filesToDownload.length} files downloaded successfully.`,
+ });
+
+ } catch (error) {
+ console.error('Multiple download error:', error);
+
+ // Offer individual downloads on failure
+ toast({
+ 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: 'Error',
+ description: 'Download failed.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Delete files
+ const deleteItems = async (itemIds: string[]) => {
+ try {
+ await Promise.all(
+ itemIds.map(id =>
+ fetch(`/api/data-room/${projectId}/${id}`, { method: 'DELETE' })
+ )
+ );
+
+ 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: 'Success',
+ description: 'Category updated successfully.',
+ });
+ } catch (error) {
+ toast({
+ 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 {
+ // In tree view, expand/collapse
+ const newExpanded = new Set(expandedFolders);
+ if (newExpanded.has(folder.id)) {
+ newExpanded.delete(folder.id);
+ } else {
+ newExpanded.add(folder.id);
+ }
+ setExpandedFolders(newExpanded);
+ }
+ setSelectedItems(new Set());
+ };
+
+ // Toggle folder expansion
+ const toggleFolderExpand = (folderId: string) => {
+ const newExpanded = new Set(expandedFolders);
+ if (newExpanded.has(folderId)) {
+ newExpanded.delete(folderId);
+ } else {
+ newExpanded.add(folderId);
+ }
+ setExpandedFolders(newExpanded);
+ };
+
+ // Toggle item selection
+ const toggleItemSelection = (itemId: string) => {
+ const newSelected = new Set(selectedItems);
+ if (newSelected.has(itemId)) {
+ newSelected.delete(itemId);
+ } else {
+ newSelected.add(itemId);
+ }
+ setSelectedItems(newSelected);
+ };
+
+ // Navigate to path
+ const navigateToPath = (index: number) => {
+ if (index === -1) {
+ setCurrentPath([]);
+ setCurrentParentId(null);
+ } else {
+ setCurrentPath(currentPath.slice(0, index + 1));
+ // Need to update parentId logic
+ }
+ };
+
+ // Filtered items
+ const filteredItems = items.filter(item =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ const filteredTreeItems = treeItems.filter(item =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ // Format file size
+ 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="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">
+ {isInternalUser && (
+ <>
+ <Button
+ size="sm"
+ onClick={() => setFolderDialogOpen(true)}
+ >
+ <FolderPlus className="h-4 w-4 mr-1" />
+ New Folder
+ </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => {
+ // 현재 폴더의 카테고리를 기본값으로 설정
+ if (currentParentId) {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ if (currentFolder) {
+ setUploadCategory(currentFolder.category);
+ }
+ }
+ setUploadDialogOpen(true);
+ }}
+ >
+ <Upload className="h-4 w-4 mr-1" />
+ Upload
+ </Button>
+ </>
+ )}
+
+ {selectedItems.size > 0 && (
+ <>
+ {/* 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" />
+ 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"
+ variant="destructive"
+ onClick={() => deleteItems(Array.from(selectedItems))}
+ >
+ <Trash2 className="h-4 w-4 mr-1" />
+ 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="Search..."
+ className="pl-8 w-64"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
+ >
+ {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
+ </Button>
+ </div>
+ </div>
+
+ {/* 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}
+ </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">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">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>
+ <div
+ className={cn(
+ "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ selectedItems.has(item.id) && "bg-accent"
+ )}
+ onClick={() => toggleItemSelection(item.id)}
+ onDoubleClick={() => {
+ if (item.type === 'folder') {
+ handleFolderOpen(item);
+ }
+ }}
+ >
+ <div className="relative">
+ {item.type === 'folder' ? (
+ <Folder className="h-12 w-12 text-blue-500" />
+ ) : (
+ <File className="h-12 w-12 text-gray-500" />
+ )}
+ <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">
+ <Eye className="h-3 w-3 mr-1" />
+ {item.viewCount}
+ </span>
+ {item.downloadCount !== undefined && (
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Download className="h-3 w-3 mr-1" />
+ {item.downloadCount}
+ </span>
+ )}
+ </div>
+ )}
+ </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' && (
+ <>
+ <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}
+ 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);
+ setShareDialogOpen(true);
+ }}
+ >
+ <Share2 className="h-4 w-4 mr-2" />
+ Share
+ </ContextMenuItem>
+
+ {item.permissions?.canEdit && (
+ <ContextMenuItem onClick={() => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}>
+ <Edit2 className="h-4 w-4 mr-2" />
+ Rename
+ </ContextMenuItem>
+ )}
+ </>
+ )}
+
+ {item.permissions?.canDelete && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ className="text-destructive"
+ onClick={() => deleteItems([item.id])}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete
+ </ContextMenuItem>
+ </>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+ })}
+ </div>
+ ) : (
+ // Tree View
+ <div className="space-y-1">
+ {filteredTreeItems.map((item) => (
+ <TreeItem
+ key={item.id}
+ item={item}
+ level={0}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={toggleFolderExpand}
+ onSelectItem={toggleItemSelection}
+ onDoubleClick={handleFolderOpen}
+ onView={viewFile}
+ onDownload={downloadFile}
+ onDownloadFolder={downloadFolder}
+ onDelete={deleteItems}
+ onShare={(item) => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ onRename={(item) => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+
+{/* Upload Dialog */}
+<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
+ <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>Upload Files</DialogTitle>
+ <DialogDescription>
+ Drag and drop files or click to select.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="flex-1 pr-4">
+ <div className="space-y-4">
+ {/* Category Selection */}
+ <div>
+ <Label htmlFor="upload-category">Category</Label>
+ <Select value={uploadCategory} onValueChange={setUploadCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig)
+ .filter(([key]) => {
+ // 현재 폴더가 있는 경우
+ if (currentParentId) {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ // 현재 폴더가 public이 아니면 public 옵션 제외
+ if (currentFolder && currentFolder.category !== 'public') {
+ return key !== 'public';
+ }
+ }
+ // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시
+ return true;
+ })
+ .map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {/* 현재 폴더 정보 표시 (선택사항) */}
+ {currentParentId && (() => {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ if (currentFolder && currentFolder.category !== 'public') {
+ return (
+ <p className="text-xs text-muted-foreground mt-1 flex items-center">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ Current folder is {categoryConfig[currentFolder.category].label}.
+ Public uploads are not allowed.
+ </p>
+ );
+ }
+ })()}
+ </div>
+
+ {/* Dropzone */}
+ <Dropzone
+ onDrop={(acceptedFiles: File[]) => {
+ handleFileUpload(acceptedFiles);
+ }}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/vnd.ms-powerpoint': ['.ppt'],
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
+ 'text/plain': ['.txt'],
+ 'text/csv': ['.csv'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
+ 'application/zip': ['.zip'],
+ 'application/x-rar-compressed': ['.rar'],
+ 'application/x-7z-compressed': ['.7z'],
+ 'application/x-dwg': ['.dwg'],
+ 'application/x-dxf': ['.dxf'],
+ }}
+ multiple={true}
+ disabled={false}
+ >
+ <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg">
+ <DropzoneInput />
+ <div className="flex flex-col items-center justify-center h-full">
+ <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
+ <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 && (
+ <div className="border rounded-lg p-4 bg-muted/50">
+ <div className="flex items-center justify-between mb-3">
+ <h4 className="font-medium text-sm">
+ Uploading Files ({uploadingFiles.filter(f => f.status === 'completed').length}/{uploadingFiles.length})
+ </h4>
+ {uploadingFiles.every(f => f.status === 'completed' || f.status === 'error') && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => setUploadingFiles([])}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ <div className="space-y-2 max-h-[300px] overflow-y-auto">
+ {uploadingFiles.map((uploadFile, index) => (
+ <div key={index} className="flex items-start gap-3 p-3 bg-background rounded-md">
+ <File className="h-5 w-5 mt-0.5 flex-shrink-0 text-muted-foreground" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm font-medium truncate">{uploadFile.file.name}</p>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ {formatFileSize(uploadFile.file.size)}
+ </span>
+ <span className="text-xs">
+ {uploadFile.status === 'pending' && 'Waiting...'}
+ {uploadFile.status === 'uploading' && 'Uploading...'}
+ {uploadFile.status === 'processing' && 'Processing...'}
+ {uploadFile.status === 'completed' && (
+ <span className="text-green-600 font-medium">✓ Complete</span>
+ )}
+ {uploadFile.status === 'error' && (
+ <span className="text-red-600 font-medium">✗ {uploadFile.error}</span>
+ )}
+ </span>
+ </div>
+ {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && (
+ <Progress value={uploadFile.progress} className="h-1.5 mt-2" />
+ )}
+ </div>
+ {uploadFile.status === 'error' && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ setUploadingFiles(prev =>
+ prev.filter((_, i) => i !== index)
+ );
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+
+ <DialogFooter className="mt-4">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setUploadDialogOpen(false);
+ setUploadingFiles([]);
+ }}
+ >
+ Close
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+</Dialog>
+
+ {/* Create Folder Dialog */}
+ <Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <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">Folder Name</Label>
+ <Input
+ id="folder-name"
+ value={dialogValue}
+ onChange={(e) => setDialogValue(e.target.value)}
+ placeholder="Enter folder name"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="folder-category">Category</Label>
+ <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setFolderDialogOpen(false)}>
+ Cancel
+ </Button>
+ <Button onClick={createFolder}>Create</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* File Share Dialog */}
+ <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Share File</DialogTitle>
+ <DialogDescription>
+ Sharing {selectedFile?.name}.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="link" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <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">Access Level</Label>
+ <Select
+ value={shareSettings.accessLevel}
+ onValueChange={(value) => setShareSettings({ ...shareSettings, accessLevel: value })}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <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">Password (Optional)</Label>
+ <Input
+ id="password"
+ type="password"
+ value={shareSettings.password}
+ onChange={(e) => setShareSettings({ ...shareSettings, password: e.target.value })}
+ placeholder="Enter password"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="expires">Expiry Date (Optional)</Label>
+ <Input
+ id="expires"
+ type="datetime-local"
+ value={shareSettings.expiresAt}
+ onChange={(e) => setShareSettings({ ...shareSettings, expiresAt: e.target.value })}
+ />
+ </div>
+
+ <div>
+ <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="Unlimited"
+ />
+ </div>
+ </TabsContent>
+
+ <TabsContent value="permission" className="space-y-4">
+ <div>
+ <Label htmlFor="target-domain">Target Domain</Label>
+ <Select>
+ <SelectTrigger>
+ <SelectValue placeholder="Select domain" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="partners">Partners</SelectItem>
+ <SelectItem value="internal">Internal</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>Permissions</Label>
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <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">Download</Label>
+ <Switch id="can-download" />
+ </div>
+ <div className="flex items-center justify-between">
+ <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">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={newCategory !== 'public' ? true : applyToChildren}
+ onCheckedChange={(checked) => {
+ if (newCategory === 'public') {
+ setApplyToChildren(checked);
+ }
+ }}
+ disabled={newCategory !== 'public'}
+ />
+ <Label htmlFor="apply-to-children" className={cn(
+ newCategory !== 'public' && "text-muted-foreground"
+ )}>
+ Apply to all files and subfolders
+ {newCategory !== 'public' && (
+ <span className="text-xs block mt-1">
+ (Required for security categories)
+ </span>
+ )}
+ </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/FileManager.tsx b/components/file-manager/FileManager.tsx
index fa2d8c38..f92f6b04 100644
--- a/components/file-manager/FileManager.tsx
+++ b/components/file-manager/FileManager.tsx
@@ -266,10 +266,6 @@ const TreeItem: React.FC<{
{isInternalUser && (
<>
- <DropdownMenuItem onClick={() => onShare(item)}>
- <Share2 className="h-4 w-4 mr-2" />
- Share
- </DropdownMenuItem>
{item.permissions?.canEdit && (
<DropdownMenuItem onClick={() => onRename(item)}>
@@ -624,44 +620,6 @@ export function FileManager({ projectId }: FileManagerProps) {
}
};
- // Share file
- const shareFile = async () => {
- if (!selectedFile) return;
-
- try {
- const response = await fetch(`/api/data-room/${projectId}/share`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- fileId: selectedFile.id,
- ...shareSettings,
- }),
- });
-
- if (!response.ok) {
- throw new Error('Failed to create share link');
- }
-
- const data = await response.json();
-
- // Copy share link to clipboard
- await navigator.clipboard.writeText(data.shareUrl);
-
- toast({
- title: 'Share Link Created',
- description: 'Link copied to clipboard.',
- });
-
- setShareDialogOpen(false);
- setSelectedFile(null);
- } catch (error) {
- toast({
- title: 'Error',
- description: 'Failed to create share link.',
- variant: 'destructive',
- });
- }
- };
// Download multiple files
const downloadMultipleFiles = async (itemIds: string[]) => {
@@ -974,9 +932,9 @@ export function FileManager({ projectId }: FileManagerProps) {
};
return (
- <div className="flex flex-col h-full">
- {/* Toolbar */}
- <div className="border-b p-4">
+ <div className="h-full flex flex-col min-h-0">
+ {/* Toolbar - 고정 */}
+ <div className="border-b p-4 bg-background shrink-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{isInternalUser && (
@@ -1091,204 +1049,114 @@ export function FileManager({ projectId }: FileManagerProps) {
</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">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">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>
- <div
- className={cn(
- "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors",
- "hover:bg-accent",
- selectedItems.has(item.id) && "bg-accent"
- )}
- onClick={() => toggleItemSelection(item.id)}
- onDoubleClick={() => {
- if (item.type === 'folder') {
- handleFolderOpen(item);
- }
- }}
- >
- <div className="relative">
- {item.type === 'folder' ? (
- <Folder className="h-12 w-12 text-blue-500" />
- ) : (
- <File className="h-12 w-12 text-gray-500" />
- )}
- <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>
+ {/* File List - 스크롤 가능 영역 */}
+ <div className="flex-1 min-h-0">
+ <ScrollArea className="h-full">
+ <div className="p-4">
+ {loading ? (
+ <div className="flex justify-center items-center h-64">
+ <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">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;
- {item.viewCount !== undefined && (
- <div className="flex items-center gap-2 mt-1">
- <span className="text-xs text-muted-foreground flex items-center">
- <Eye className="h-3 w-3 mr-1" />
- {item.viewCount}
- </span>
- {item.downloadCount !== undefined && (
- <span className="text-xs text-muted-foreground flex items-center">
- <Download className="h-3 w-3 mr-1" />
- {item.downloadCount}
- </span>
+ return (
+ <ContextMenu key={item.id}>
+ <ContextMenuTrigger>
+ <div
+ className={cn(
+ "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ selectedItems.has(item.id) && "bg-accent"
)}
- </div>
- )}
- </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' && (
- <>
- <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}
- 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);
- setShareDialogOpen(true);
+ onClick={() => toggleItemSelection(item.id)}
+ onDoubleClick={() => {
+ if (item.type === 'folder') {
+ handleFolderOpen(item);
+ }
}}
>
- <Share2 className="h-4 w-4 mr-2" />
- Share
- </ContextMenuItem>
-
- {item.permissions?.canEdit && (
- <ContextMenuItem onClick={() => {
- setSelectedFile(item);
- setDialogValue(item.name);
- setRenameDialogOpen(true);
- }}>
- <Edit2 className="h-4 w-4 mr-2" />
- Rename
- </ContextMenuItem>
- )}
- </>
- )}
+ <div className="relative">
+ {item.type === 'folder' ? (
+ <Folder className="h-12 w-12 text-blue-500" />
+ ) : (
+ <File className="h-12 w-12 text-gray-500" />
+ )}
+ <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.permissions?.canDelete && (
- <>
- <ContextMenuSeparator />
- <ContextMenuItem
- className="text-destructive"
- onClick={() => deleteItems([item.id])}
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete
- </ContextMenuItem>
- </>
- )}
- </ContextMenuContent>
- </ContextMenu>
- );
- })}
- </div>
- ) : (
- // Tree View
- <div className="space-y-1">
- {filteredTreeItems.map((item) => (
- <TreeItem
- key={item.id}
- item={item}
- level={0}
- expandedFolders={expandedFolders}
- selectedItems={selectedItems}
- onToggleExpand={toggleFolderExpand}
- onSelectItem={toggleItemSelection}
- onDoubleClick={handleFolderOpen}
- onView={viewFile}
- onDownload={downloadFile}
- onDownloadFolder={downloadFolder}
- onDelete={deleteItems}
- onShare={(item) => {
- setSelectedFile(item);
- setShareDialogOpen(true);
- }}
- onRename={(item) => {
- setSelectedFile(item);
- setDialogValue(item.name);
- setRenameDialogOpen(true);
- }}
- isInternalUser={isInternalUser}
- />
- ))}
+ {item.viewCount !== undefined && (
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Eye className="h-3 w-3 mr-1" />
+ {item.viewCount}
+ </span>
+ {item.downloadCount !== undefined && (
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Download className="h-3 w-3 mr-1" />
+ {item.downloadCount}
+ </span>
+ )}
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+
+ {/* ... ContextMenuContent는 동일 ... */}
+ </ContextMenu>
+ );
+ })}
+ </div>
+ ) : (
+ // Tree View
+ <div className="space-y-1">
+ {filteredTreeItems.map((item) => (
+ <TreeItem
+ key={item.id}
+ item={item}
+ level={0}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={toggleFolderExpand}
+ onSelectItem={toggleItemSelection}
+ onDoubleClick={handleFolderOpen}
+ onView={viewFile}
+ onDownload={downloadFile}
+ onDownloadFolder={downloadFolder}
+ onDelete={deleteItems}
+ onShare={(item) => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ onRename={(item) => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
</div>
- )}
- </ScrollArea>
+ </ScrollArea>
+ </div>
{/* Upload Dialog */}
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
- <DialogContent className="max-w-2xl">
+ <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Upload Files</DialogTitle>
<DialogDescription>
@@ -1296,138 +1164,154 @@ export function FileManager({ projectId }: FileManagerProps) {
</DialogDescription>
</DialogHeader>
- <div className="space-y-4">
- {/* Category Selection */}
- <div>
- <Label htmlFor="upload-category">Category</Label>
- <Select value={uploadCategory} onValueChange={setUploadCategory}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {Object.entries(categoryConfig)
- .filter(([key]) => {
- // 현재 폴더가 있는 경우
- if (currentParentId) {
- const currentFolder = items.find(item => item.parentId === currentParentId);
- // 현재 폴더가 public이 아니면 public 옵션 제외
- if (currentFolder && currentFolder.category !== 'public') {
- return key !== 'public';
+ <ScrollArea className="flex-1 pr-4">
+ <div className="space-y-4">
+ {/* Category Selection */}
+ <div>
+ <Label htmlFor="upload-category">Category</Label>
+ <Select value={uploadCategory} onValueChange={setUploadCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig)
+ .filter(([key]) => {
+ // 현재 폴더가 있는 경우
+ if (currentParentId) {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ // 현재 폴더가 public이 아니면 public 옵션 제외
+ if (currentFolder && currentFolder.category !== 'public') {
+ return key !== 'public';
+ }
}
- }
- // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시
- return true;
- })
- .map(([key, config]) => (
- <SelectItem key={key} value={key}>
- <div className="flex items-center">
- <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
- <span>{config.label}</span>
- </div>
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- {/* 현재 폴더 정보 표시 (선택사항) */}
- {currentParentId && (() => {
- const currentFolder = items.find(item => item.parentId === currentParentId);
- if (currentFolder && currentFolder.category !== 'public') {
- return (
- <p className="text-xs text-muted-foreground mt-1 flex items-center">
- <AlertCircle className="h-3 w-3 mr-1" />
- Current folder is {categoryConfig[currentFolder.category].label}.
- Public uploads are not allowed.
- </p>
- );
- }
- })()}
- </div>
+ // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시
+ return true;
+ })
+ .map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {/* 현재 폴더 정보 표시 (선택사항) */}
+ {currentParentId && (() => {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ if (currentFolder && currentFolder.category !== 'public') {
+ return (
+ <p className="text-xs text-muted-foreground mt-1 flex items-center">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ Current folder is {categoryConfig[currentFolder.category].label}.
+ Public uploads are not allowed.
+ </p>
+ );
+ }
+ })()}
+ </div>
- {/* Dropzone */}
- <Dropzone
- onDrop={(acceptedFiles: File[]) => {
- handleFileUpload(acceptedFiles);
- }}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- 'application/vnd.ms-powerpoint': ['.ppt'],
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
- 'text/plain': ['.txt'],
- 'text/csv': ['.csv'],
- 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
- 'application/zip': ['.zip'],
- 'application/x-rar-compressed': ['.rar'],
- 'application/x-7z-compressed': ['.7z'],
- 'application/x-dwg': ['.dwg'],
- 'application/x-dxf': ['.dxf'],
- }}
- multiple={true}
- disabled={false}
- >
- <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg">
- <DropzoneInput />
- <div className="flex flex-col items-center justify-center h-full">
- <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
- <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>Uploading Files</FileListHeader>
- {uploadingFiles.map((uploadFile, index) => (
- <FileListItem key={index}>
- <FileListIcon>
- <File className="h-4 w-4" />
- </FileListIcon>
- <FileListInfo>
- <FileListName>{uploadFile.file.name}</FileListName>
- <FileListDescription>
- <div className="flex items-center gap-2">
- <FileListSize>{uploadFile.file.size}</FileListSize>
- {uploadFile.status === 'uploading' && <span>Uploading...</span>}
- {uploadFile.status === 'processing' && <span>Processing...</span>}
- {uploadFile.status === 'completed' && (
- <span className="text-green-600">Complete</span>
- )}
- {uploadFile.status === 'error' && (
- <span className="text-red-600">{uploadFile.error}</span>
+ {/* Dropzone */}
+ <Dropzone
+ onDrop={(acceptedFiles: File[]) => {
+ handleFileUpload(acceptedFiles);
+ }}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/vnd.ms-powerpoint': ['.ppt'],
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
+ 'text/plain': ['.txt'],
+ 'text/csv': ['.csv'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
+ 'application/zip': ['.zip'],
+ 'application/x-rar-compressed': ['.rar'],
+ 'application/x-7z-compressed': ['.7z'],
+ 'application/x-dwg': ['.dwg'],
+ 'application/x-dxf': ['.dxf'],
+ }}
+ multiple={true}
+ disabled={false}
+ >
+ <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg">
+ <DropzoneInput />
+ <div className="flex flex-col items-center justify-center h-full">
+ <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
+ <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 && (
+ <div className="border rounded-lg p-4 bg-muted/50">
+ <div className="flex items-center justify-between mb-3">
+ <h4 className="font-medium text-sm">
+ Uploading Files ({uploadingFiles.filter(f => f.status === 'completed').length}/{uploadingFiles.length})
+ </h4>
+ {uploadingFiles.every(f => f.status === 'completed' || f.status === 'error') && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => setUploadingFiles([])}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ <div className="space-y-2 max-h-[300px] overflow-y-auto">
+ {uploadingFiles.map((uploadFile, index) => (
+ <div key={index} className="flex items-start gap-3 p-3 bg-background rounded-md">
+ <File className="h-5 w-5 mt-0.5 flex-shrink-0 text-muted-foreground" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm font-medium truncate">{uploadFile.file.name}</p>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ {formatFileSize(uploadFile.file.size)}
+ </span>
+ <span className="text-xs">
+ {uploadFile.status === 'pending' && 'Waiting...'}
+ {uploadFile.status === 'uploading' && 'Uploading...'}
+ {uploadFile.status === 'processing' && 'Processing...'}
+ {uploadFile.status === 'completed' && (
+ <span className="text-green-600 font-medium">✓ Complete</span>
+ )}
+ {uploadFile.status === 'error' && (
+ <span className="text-red-600 font-medium">✗ {uploadFile.error}</span>
+ )}
+ </span>
+ </div>
+ {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && (
+ <Progress value={uploadFile.progress} className="h-1.5 mt-2" />
)}
</div>
- {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && (
- <Progress value={uploadFile.progress} className="h-1 mt-1" />
+ {uploadFile.status === 'error' && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ setUploadingFiles(prev =>
+ prev.filter((_, i) => i !== index)
+ );
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction>
- {uploadFile.status === 'error' && (
- <Button
- size="sm"
- variant="ghost"
- onClick={() => {
- setUploadingFiles(prev =>
- prev.filter((_, i) => i !== index)
- );
- }}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- )}
- </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </ScrollArea>
- <DialogFooter>
+ <DialogFooter className="mt-4">
<Button
variant="outline"
onClick={() => {
@@ -1491,131 +1375,6 @@ export function FileManager({ projectId }: FileManagerProps) {
</DialogContent>
</Dialog>
- {/* File Share Dialog */}
- <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
- <DialogContent className="max-w-md">
- <DialogHeader>
- <DialogTitle>Share File</DialogTitle>
- <DialogDescription>
- Sharing {selectedFile?.name}.
- </DialogDescription>
- </DialogHeader>
-
- <Tabs defaultValue="link" className="w-full">
- <TabsList className="grid w-full grid-cols-2">
- <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">Access Level</Label>
- <Select
- value={shareSettings.accessLevel}
- onValueChange={(value) => setShareSettings({ ...shareSettings, accessLevel: value })}
- >
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <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">Password (Optional)</Label>
- <Input
- id="password"
- type="password"
- value={shareSettings.password}
- onChange={(e) => setShareSettings({ ...shareSettings, password: e.target.value })}
- placeholder="Enter password"
- />
- </div>
-
- <div>
- <Label htmlFor="expires">Expiry Date (Optional)</Label>
- <Input
- id="expires"
- type="datetime-local"
- value={shareSettings.expiresAt}
- onChange={(e) => setShareSettings({ ...shareSettings, expiresAt: e.target.value })}
- />
- </div>
-
- <div>
- <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="Unlimited"
- />
- </div>
- </TabsContent>
-
- <TabsContent value="permission" className="space-y-4">
- <div>
- <Label htmlFor="target-domain">Target Domain</Label>
- <Select>
- <SelectTrigger>
- <SelectValue placeholder="Select domain" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="partners">Partners</SelectItem>
- <SelectItem value="internal">Internal</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label>Permissions</Label>
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <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">Download</Label>
- <Switch id="can-download" />
- </div>
- <div className="flex items-center justify-between">
- <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">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}>
diff --git a/components/file-manager/SecurePDFViewer.tsx b/components/file-manager/SecurePDFViewer.tsx
index cd7c081a..707d95dc 100644
--- a/components/file-manager/SecurePDFViewer.tsx
+++ b/components/file-manager/SecurePDFViewer.tsx
@@ -5,6 +5,7 @@ import { useSession } from 'next-auth/react';
import { WebViewerInstance } from '@pdftron/webviewer';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
+import { createCustomWatermark } from './creaetWaterMarks';
interface SecurePDFViewerProps {
documentUrl: string;
@@ -194,14 +195,16 @@ export function SecurePDFViewer({ documentUrl, fileName, onClose }: SecurePDFVie
const watermarkText = `${session?.user?.email || 'CONFIDENTIAL'}\n${new Date().toLocaleString()}`;
// 대각선 워터마크
- documentViewer.setWatermark({
+ documentViewer.setWatermark(
+ {custom:createCustomWatermark({
text: watermarkText,
fontSize: 30,
fontFamily: 'Arial',
color: 'rgba(255, 0, 0, 0.3)',
opacity: 30,
- diagonal: true,
- });
+ // diagonal: true,
+ })}
+ );
// 각 페이지에 커스텀 워터마크 추가
const pageCount = documentViewer.getPageCount();
diff --git a/components/file-manager/creaetWaterMarks.tsx b/components/file-manager/creaetWaterMarks.tsx
new file mode 100644
index 00000000..524b18ee
--- /dev/null
+++ b/components/file-manager/creaetWaterMarks.tsx
@@ -0,0 +1,71 @@
+export const createCustomWatermark: CreateCustomWatermark = ({
+ text,
+ fontSize,
+ color,
+ opacity,
+ rotation = -45,
+ fontFamily = "Helvetica",
+ }) => {
+ return (ctx, pageNumber, pageWidth, pageHeight) => {
+ if (!text) return;
+
+ const lines = text.split("\n"); // 줄바꿈 기준 멀치 처리
+
+ ctx.save();
+ ctx.translate(pageWidth / 2, pageHeight / 2);
+ ctx.rotate((rotation * Math.PI) / 180);
+ ctx.fillStyle = color;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+
+ const lineHeights = lines.map((s) => {
+ return fontSize;
+ });
+
+ const totalHeight =
+ lineHeights.reduce((sum, h) => sum + h, 0) - lineHeights[0]; // 첫 줄은 기준선 0
+
+ let yOffset = -totalHeight / 2;
+
+ lines.forEach((line, i) => {
+ ctx.font = `900 ${fontSize}px ${fontFamily}`;
+ ctx.fillText(line, 0, yOffset);
+ yOffset += lineHeights[i];
+ });
+
+ ctx.restore();
+ };
+ };
+
+
+ import { Core, WebViewerInstance } from "@pdftron/webviewer";
+
+export interface WaterMarkOption {
+ fontSize: number;
+ color: string;
+ opacity: number;
+ rotation: number;
+ fontFamily: string;
+ split: boolean;
+ shipNameCheck: boolean;
+ shipName: string;
+ ownerNameCheck: boolean;
+ ownerName: string;
+ classNameCheck: boolean;
+ className: string;
+ classList: string[];
+ customCheck: boolean;
+ text: string;
+}
+
+type CreateCustomWatermark = ({
+ text,
+ fontSize,
+ color,
+ opacity,
+ rotation,
+ fontFamily,
+}: Pick<
+ WaterMarkOption,
+ "text" | "fontSize" | "color" | "opacity" | "rotation" | "fontFamily"
+>) => Core.DocumentViewer.CustomWatermarkCallback; \ No newline at end of file
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index d5d79735..9dbcb627 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -19,7 +19,7 @@ import {
Upload,
Plus,
Tag,
- TagsIcon,
+ TagsIcon,
FileOutput,
Clipboard,
Send,
@@ -115,7 +115,97 @@ export default function DynamicTable({
const [formStats, setFormStats] = React.useState<FormStatusByVendor | null>(null);
const [isLoadingStats, setIsLoadingStats] = React.useState(true);
+ const [activeFilter, setActiveFilter] = React.useState<string | null>(null);
+ const [filteredTableData, setFilteredTableData] = React.useState<GenericData[]>(tableData);
+ // 필터링 로직
+ React.useEffect(() => {
+ if (!activeFilter) {
+ setFilteredTableData(tableData);
+ return;
+ }
+
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const sevenDaysLater = new Date(today);
+ sevenDaysLater.setDate(sevenDaysLater.getDate() + 7);
+
+ let filtered = [...tableData];
+
+ switch (activeFilter) {
+ case 'completed':
+ // 모든 필수 필드가 완료된 태그만 표시
+ filtered = tableData.filter(item => {
+ const tagEditableFields = editableFieldsMap.get(item.TAG_NO) || [];
+ return columnsJSON
+ .filter(col => (col.shi === 'IN' || col.shi === 'BOTH') && tagEditableFields.includes(col.key))
+ .every(col => {
+ const value = item[col.key];
+ return value !== undefined && value !== null && value !== '';
+ });
+ });
+ break;
+
+ case 'remaining':
+ // 미완료 필드가 있는 태그만 표시
+ filtered = tableData.filter(item => {
+ const tagEditableFields = editableFieldsMap.get(item.TAG_NO) || [];
+ return columnsJSON
+ .filter(col => (col.shi === 'IN' || col.shi === 'BOTH') && tagEditableFields.includes(col.key))
+ .some(col => {
+ const value = item[col.key];
+ return value === undefined || value === null || value === '';
+ });
+ });
+ break;
+
+ case 'upcoming':
+ // 7일 이내 임박한 태그만 표시
+ filtered = tableData.filter(item => {
+ const dueDate = item.DUE_DATE;
+ if (!dueDate) return false;
+
+ const target = new Date(dueDate);
+ target.setHours(0, 0, 0, 0);
+
+ // 미완료이면서 7일 이내인 경우
+ const hasIncompleteFields = columnsJSON
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .some(col => !item[col.key]);
+
+ return hasIncompleteFields && target >= today && target <= sevenDaysLater;
+ });
+ break;
+
+ case 'overdue':
+ // 지연된 태그만 표시
+ filtered = tableData.filter(item => {
+ const dueDate = item.DUE_DATE;
+ if (!dueDate) return false;
+
+ const target = new Date(dueDate);
+ target.setHours(0, 0, 0, 0);
+
+ // 미완료이면서 지연된 경우
+ const hasIncompleteFields = columnsJSON
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .some(col => !item[col.key]);
+
+ return hasIncompleteFields && target < today;
+ });
+ break;
+
+ default:
+ filtered = tableData;
+ }
+
+ setFilteredTableData(filtered);
+ }, [activeFilter, tableData, columnsJSON, editableFieldsMap]);
+
+ // 카드 클릭 핸들러
+ const handleCardClick = (filterType: string | null) => {
+ setActiveFilter(prev => prev === filterType ? null : filterType);
+ };
React.useEffect(() => {
const fetchFormStats = async () => {
@@ -310,7 +400,7 @@ export default function DynamicTable({
isArray: Array.isArray(templateResult),
data: templateResult
});
-
+
if (Array.isArray(templateResult)) {
templateResult.forEach((tmpl, idx) => {
console.log(` [${idx}] TMPL_ID: ${tmpl?.TMPL_ID || 'MISSING'}, NAME: ${tmpl?.NAME || 'N/A'}, TYPE: ${tmpl?.TMPL_TYPE || 'N/A'}`);
@@ -687,11 +777,15 @@ export default function DynamicTable({
return (
<>
-
+
<div className="mb-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
- {/* Tag Count */}
- <Card>
+ {/* Total Tags Card - 클릭 시 전체 보기 */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === null ? 'ring-2 ring-primary' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick(null)}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Tags
@@ -707,35 +801,17 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Total Tag Count
+ {activeFilter === null ? 'Showing all' : 'Click to show all'}
</p>
</CardContent>
</Card>
- {/* Completion Rate */}
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">
- Completion
- </CardTitle>
- <Target className="h-4 w-4 text-muted-foreground" />
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">
- {isLoadingStats ? (
- <span className="animate-pulse">-</span>
- ) : (
- `${formStats?.completionRate || 0}%`
- )}
- </div>
- <p className="text-xs text-muted-foreground">
- {formStats ? `${formStats.completedFields} / ${formStats.totalFields}` : '-'}
- </p>
- </CardContent>
- </Card>
-
- {/* Completed Fields */}
- <Card>
+ {/* Completed Fields Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'completed' ? 'ring-2 ring-green-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('completed')}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Completed
@@ -751,13 +827,17 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Completed Fields
+ {activeFilter === 'completed' ? 'Filtering active' : 'Click to filter'}
</p>
</CardContent>
</Card>
- {/* Remaining Fields */}
- <Card>
+ {/* Remaining Fields Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'remaining' ? 'ring-2 ring-blue-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('remaining')}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Remaining
@@ -773,13 +853,17 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Remaining Fields
+ {activeFilter === 'remaining' ? 'Filtering active' : 'Click to filter'}
</p>
</CardContent>
</Card>
- {/* Upcoming (7 days) */}
- <Card>
+ {/* Upcoming Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'upcoming' ? 'ring-2 ring-yellow-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('upcoming')}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Upcoming
@@ -795,13 +879,17 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Due in 7 Days
+ {activeFilter === 'upcoming' ? 'Filtering active' : 'Click to filter'}
</p>
</CardContent>
</Card>
- {/* Overdue */}
- <Card>
+ {/* Overdue Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'overdue' ? 'ring-2 ring-red-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('overdue')}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Overdue
@@ -817,22 +905,40 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Overdue
+ {activeFilter === 'overdue' ? 'Filtering active' : 'Click to filter'}
</p>
</CardContent>
</Card>
</div>
</div>
-
+
<ClientDataTable
- data={tableData}
+ data={filteredTableData} // tableData 대신 filteredTableData 사용
columns={columns}
advancedFilterFields={advancedFilterFields}
autoSizeColumns
onSelectedRowsChange={setSelectedRowsData}
clearSelection={clearSelection}
>
+ {/* 필터 상태 표시 */}
+ {activeFilter && (
+ <div className="flex items-center gap-2 mr-auto">
+ <span className="text-sm text-muted-foreground">
+ Filter: {activeFilter === 'completed' ? 'Completed' :
+ activeFilter === 'remaining' ? 'Remaining' :
+ activeFilter === 'upcoming' ? 'Upcoming (7 days)' :
+ activeFilter === 'overdue' ? 'Overdue' : 'All'}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setActiveFilter(null)}
+ >
+ Clear filter
+ </Button>
+ </div>
+ )}
{/* 선택된 항목 수 표시 (선택된 항목이 있을 때만) */}
{selectedRowCount > 0 && (
<Button
diff --git a/components/layout/HeaderDataroom.tsx b/components/layout/HeaderDataroom.tsx
new file mode 100644
index 00000000..333e3768
--- /dev/null
+++ b/components/layout/HeaderDataroom.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+} from "@/components/ui/navigation-menu";
+import { SearchIcon, BellIcon, Menu } from "lucide-react";
+import { useParams, usePathname, useSearchParams } from "next/navigation";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { MobileMenu } from "./MobileMenu";
+import { CommandMenu } from "./command-menu";
+import { useSession, signOut } from "next-auth/react";
+import { NotificationDropdown } from "./NotificationDropdown";
+
+// 간단한 메뉴 배열
+const simpleMenus = [
+ { title: "발주처 목록", href: "/evcp/data-room/owner-companies" },
+ { title: "데이터룸", href: "/evcp/data-room" }
+];
+export function HeaderDataRoom() {
+ const params = useParams();
+ const lng = params?.lng as string;
+ const pathname = usePathname();
+ const { data: session } = useSession();
+
+ const userName = session?.user?.name || "";
+ const domain = session?.user?.domain || "";
+ const initials = userName
+ .split(" ")
+ .map((word) => word[0]?.toUpperCase())
+ .join("");
+
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
+
+ const toggleMobileMenu = () => {
+ setIsMobileMenuOpen(!isMobileMenuOpen);
+ };
+
+ return (
+ <>
+ <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+ <div className="container-wrapper">
+ <div className="container flex h-14 items-center">
+ {/* 햄버거 메뉴 버튼 (모바일) */}
+ <Button
+ onClick={toggleMobileMenu}
+ variant="ghost"
+ className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ strokeWidth="1.5"
+ stroke="currentColor"
+ className="!size-6"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ d="M3.75 9h16.5m-16.5 6.75h16.5"
+ />
+ </svg>
+ <span className="sr-only">메뉴 토글</span>
+ </Button>
+
+ {/* 로고 영역 */}
+ <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6">
+ <Link href={`/${lng}/evcp`} className="flex items-center gap-2">
+ <Image
+ className="dark:invert"
+ src="/images/vercel.svg"
+ alt="EVCP Logo"
+ width={20}
+ height={20}
+ />
+ <span className="hidden font-bold lg:inline-block">
+ EVCP
+ </span>
+ </Link>
+ </div>
+
+ {/* 네비게이션 메뉴 - 간단한 배열 */}
+ <div className="hidden md:block flex-1 min-w-0">
+ <nav className="flex items-center space-x-6">
+ {simpleMenus.map((menu) => (
+ <Link
+ key={menu.href}
+ href={`/${lng}${menu.href}`}
+ className="text-sm font-medium transition-colors hover:text-primary"
+ >
+ {menu.title}
+ </Link>
+ ))}
+ </nav>
+</div>
+
+ {/* 우측 영역 */}
+ <div className="ml-auto flex flex-shrink-0 items-center space-x-2">
+ {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */}
+
+ {/* 알림 버튼 */}
+ <NotificationDropdown />
+
+ {/* 사용자 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Avatar className="cursor-pointer h-8 w-8">
+ <AvatarImage src={`${session?.user?.image}` || "/user-avatar.jpg"} alt="User Avatar" />
+ <AvatarFallback>
+ {initials || "?"}
+ </AvatarFallback>
+ </Avatar>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuLabel>내 계정</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem asChild>
+ <Link href={`/${lng}/evcp/settings`}>설정</Link>
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/${domain}` })}>
+ 로그아웃
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </div>
+
+ {/* 모바일 메뉴 */}
+ {isMobileMenuOpen && (
+ <MobileMenu
+ lng={lng}
+ onClose={toggleMobileMenu}
+ activeMenus={[]}
+ domainMain={simpleMenus}
+ domainAdditional={[]}
+ t={(key: string) => key}
+ />
+ )}
+ </header>
+ </>
+ );
+}
+
+const ListItem = React.forwardRef<
+ React.ElementRef<"a">,
+ React.ComponentPropsWithoutRef<"a">
+>(({ className, title, children, ...props }, ref) => {
+ return (
+ <li>
+ <NavigationMenuLink asChild>
+ <a
+ ref={ref}
+ className={cn(
+ "block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
+ className
+ )}
+ {...props}
+ >
+ <div className="text-sm font-medium leading-none">{title}</div>
+ {children && (
+ <p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
+ {children}
+ </p>
+ )}
+ </a>
+ </NavigationMenuLink>
+ </li>
+ );
+});
+ListItem.displayName = "ListItem";
+
+
+export function RouteLogger() {
+ const path = usePathname();
+ const qs = useSearchParams().toString();
+ React.useEffect(() => {
+ console.log("[URL]", path + (qs ? "?" + qs : ""));
+ }, [path, qs]);
+ return null;
+} \ No newline at end of file
diff --git a/components/layout/HeaderSimple.tsx b/components/layout/HeaderSimple.tsx
index f099d3ef..989929ae 100644
--- a/components/layout/HeaderSimple.tsx
+++ b/components/layout/HeaderSimple.tsx
@@ -100,7 +100,7 @@ export function HeaderSimple() {
/>
<span className="hidden font-bold lg:inline-block">
{isPartnerRoute
- ? "eVCP Partners"
+ ? "Data Room"
: pathname?.includes("/evcp")
? "eVCP 삼성중공업"
: "eVCP"}
diff --git a/components/project/ProjectList.tsx b/components/project/ProjectList.tsx
index e267b21c..5c01070e 100644
--- a/components/project/ProjectList.tsx
+++ b/components/project/ProjectList.tsx
@@ -237,7 +237,7 @@ const fetchProjects = async () => {
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
- <h1 className="text-3xl font-bold">Projects</h1>
+ <h1 className="text-2xl font-bold">Projects</h1>
<p className="text-muted-foreground mt-1">
Manage files and collaborate with your team
</p>