summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-29 07:46:57 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-29 07:46:57 +0000
commitbbc3094e932e3d193d3223448c789461f4afc058 (patch)
treef28dd034b191ca78d0af15eccbbdf7a952141153 /components
parentd28c43b2d33bac51c69ac7417a14f9fe83f2a25f (diff)
(대표님) 데이터룸 관련 변경사항
Diffstat (limited to 'components')
-rw-r--r--components/file-manager/CreateSubfolderForm.tsx127
-rw-r--r--components/file-manager/FileManager.tsx156
-rw-r--r--components/file-manager/SecurePDFViewer.tsx8
-rw-r--r--components/form-data/spreadJS-dialog.tsx38
-rw-r--r--components/project/ProjectNav.tsx4
-rw-r--r--components/project/dataroom-members.ts74
6 files changed, 358 insertions, 49 deletions
diff --git a/components/file-manager/CreateSubfolderForm.tsx b/components/file-manager/CreateSubfolderForm.tsx
new file mode 100644
index 00000000..0afdf755
--- /dev/null
+++ b/components/file-manager/CreateSubfolderForm.tsx
@@ -0,0 +1,127 @@
+// CreateSubfolderForm component - English version
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Loader2 } from "lucide-react";
+
+interface CreateSubfolderFormProps {
+ parentFolder: any;
+ onSubmit: (name: string, category: string) => Promise<void>;
+ onCancel: () => void;
+}
+
+export function CreateSubfolderForm({
+ parentFolder,
+ onSubmit,
+ onCancel
+}: CreateSubfolderFormProps) {
+ const [folderName, setFolderName] = useState("");
+ const [category, setCategory] = useState(parentFolder?.category || "general");
+ const [isCreating, setIsCreating] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!folderName.trim()) {
+ setError("Please enter a folder name");
+ return;
+ }
+
+ // Special character validation
+ const invalidChars = /[<>:"|?*\/\\]/;
+ if (invalidChars.test(folderName)) {
+ setError("Folder name contains invalid characters");
+ return;
+ }
+
+ setIsCreating(true);
+ setError("");
+
+ try {
+ await onSubmit(folderName, category);
+ setFolderName("");
+ setCategory(parentFolder?.category || "general");
+ } catch (error: any) {
+ setError(error.message || "Failed to create folder");
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ return (
+ <form onSubmit={handleSubmit} className="space-y-4 py-4">
+ <div className="space-y-2">
+ <Label htmlFor="folder-name">Folder Name</Label>
+ <Input
+ id="folder-name"
+ value={folderName}
+ onChange={(e) => {
+ setFolderName(e.target.value);
+ setError("");
+ }}
+ placeholder="Enter folder name"
+ disabled={isCreating}
+ autoFocus
+ />
+ {error && (
+ <p className="text-sm text-destructive">{error}</p>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="folder-category">Security Category</Label>
+ <Select
+ value={category}
+ onValueChange={setCategory}
+ disabled={isCreating}
+ >
+ <SelectTrigger id="folder-category">
+ <SelectValue placeholder="Select category" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="general">General</SelectItem>
+ <SelectItem value="confidential">Confidential</SelectItem>
+ <SelectItem value="internal">Internal</SelectItem>
+ </SelectContent>
+ </Select>
+ <p className="text-xs text-muted-foreground">
+ Sub-folders inherit the security category from their parent folder by default
+ </p>
+ </div>
+
+ <div className="flex justify-end gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onCancel}
+ disabled={isCreating}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isCreating || !folderName.trim()}
+ >
+ {isCreating ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Create Folder"
+ )}
+ </Button>
+ </div>
+ </form>
+ );
+} \ No newline at end of file
diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx
index a95d8c06..c56bb16a 100644
--- a/components/file-manager/FileManager.tsx
+++ b/components/file-manager/FileManager.tsx
@@ -95,6 +95,7 @@ import { decryptWithServerAction } from '@/components/drm/drmUtils';
import { Progress } from '@/components/ui/progress';
// Import the secure viewer component
import { SecurePDFViewer } from './SecurePDFViewer';
+import { CreateSubfolderForm } from './CreateSubfolderForm';
interface FileItem {
id: string;
@@ -153,6 +154,7 @@ const TreeItem: React.FC<{
onShare: (item: FileItem) => void;
onRename: (item: FileItem) => void;
onCategoryChange: (item: FileItem) => void;
+ onCreateSubfolder: (item: FileItem) => void; // 추가
isInternalUser: boolean;
}> = ({
item,
@@ -169,6 +171,7 @@ const TreeItem: React.FC<{
onShare,
onRename,
onCategoryChange,
+ onCreateSubfolder, // 추가
isInternalUser
}) => {
const hasChildren = item.type === 'folder' && item.children && item.children.length > 0;
@@ -263,10 +266,19 @@ const TreeItem: React.FC<{
)}
{item.type === 'folder' && (
- <DropdownMenuItem onClick={() => onDownloadFolder(item)}>
- <Download className="h-4 w-4 mr-2" />
- Download Folder
- </DropdownMenuItem>
+ <>
+ {/* Create Sub-folder 추가 */}
+ {isInternalUser && (
+ <DropdownMenuItem onClick={() => onCreateSubfolder(item)}>
+ <FolderPlus className="h-4 w-4 mr-2" />
+ Create Sub-folder
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuItem onClick={() => onDownloadFolder(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ Download Folder
+ </DropdownMenuItem>
+ </>
)}
{isInternalUser && (
@@ -320,6 +332,13 @@ const TreeItem: React.FC<{
{item.type === 'folder' && (
<>
+ {/* Create Sub-folder 추가 (Context Menu) */}
+ {isInternalUser && (
+ <ContextMenuItem onClick={() => onCreateSubfolder(item)}>
+ <FolderPlus className="h-4 w-4 mr-2" />
+ Create Sub-folder
+ </ContextMenuItem>
+ )}
<ContextMenuItem onClick={() => onDoubleClick(item)}>
<Folder className="h-4 w-4 mr-2" />
{isExpanded ? 'Collapse' : 'Expand'}
@@ -381,6 +400,7 @@ const TreeItem: React.FC<{
onShare={onShare}
onRename={onRename}
onCategoryChange={onCategoryChange}
+ onCreateSubfolder={onCreateSubfolder} // 추가
isInternalUser={isInternalUser}
/>
))}
@@ -402,6 +422,59 @@ export function FileManager({ projectId }: FileManagerProps) {
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
+ const [subfolderDialogOpen, setSubfolderDialogOpen] = useState(false);
+ const [selectedParentFolder, setSelectedParentFolder] = useState(null);
+ const [newFolderName, setNewFolderName] = useState('');
+ const [newFolderCategory, setNewFolderCategory] = useState('general');
+
+ const handleCreateSubfolder = (parentFolder) => {
+ setSelectedParentFolder(parentFolder);
+ setSubfolderDialogOpen(true);
+ };
+
+ const createSubfolder = async (name, category) => {
+ try {
+ const response = await fetch(`/api/data-room/${projectId}/folders`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name,
+ parentId: selectedParentFolder?.id || null,
+ category: category || selectedParentFolder?.category || 'general',
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to create folder');
+ }
+
+ const newFolder = await response.json();
+
+ // 기존 fetchItems 함수를 재사용하여 목록 새로고침
+ await fetchItems(); // 기존에 있던 fetchItems 함수 재사용
+
+ toast({
+ title: "Folder Created",
+ description: `Folder "${name}" has been created successfully.`,
+ });
+
+ setSubfolderDialogOpen(false);
+ setSelectedParentFolder(null);
+
+ return newFolder;
+ } catch (error) {
+ toast({
+ title: "Failed to Create Folder",
+ description: error.message || "An error occurred while creating the folder.",
+ variant: "destructive",
+ });
+ throw error;
+ }
+ };
+
// Upload states
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
@@ -419,6 +492,10 @@ export function FileManager({ projectId }: FileManagerProps) {
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const [applyToChildren, setApplyToChildren] = useState(false);
const [newCategory, setNewCategory] = useState('confidential');
+ const [subfolderDialog, setSubfolderDialog] = useState<{
+ open: boolean;
+ parentFolder: FileItem | null;
+ }>({ open: false, parentFolder: null });
// Dialog data
const [dialogValue, setDialogValue] = useState('');
@@ -564,22 +641,22 @@ export function FileManager({ projectId }: FileManagerProps) {
if (preserveFolderStructure && fileArray.some((file: any) => file.webkitRelativePath)) {
// 폴더 구조를 먼저 생성
const folderMap = new Map<string, string>(); // path -> folderId
-
+
for (let i = 0; i < fileArray.length; i++) {
const file = fileArray[i];
const relativePath = (file as any).webkitRelativePath;
-
+
if (relativePath) {
const pathParts = relativePath.split('/');
const folders = pathParts.slice(0, -1); // 파일명 제외
-
+
let currentFolderPath = '';
let parentId = currentParentId;
-
+
// 각 폴더를 순차적으로 생성
for (const folderName of folders) {
currentFolderPath = currentFolderPath ? `${currentFolderPath}/${folderName}` : folderName;
-
+
if (!folderMap.has(currentFolderPath)) {
// 폴더 생성 API 호출
try {
@@ -593,7 +670,7 @@ export function FileManager({ projectId }: FileManagerProps) {
parentId: parentId,
}),
});
-
+
if (response.ok) {
const newFolder = await response.json();
folderMap.set(currentFolderPath, newFolder.id);
@@ -606,7 +683,7 @@ export function FileManager({ projectId }: FileManagerProps) {
parentId = folderMap.get(currentFolderPath) || null;
}
}
-
+
// 폴더가 생성되었으면 해당 폴더에 파일 업로드
await uploadSingleFile(file, i, parentId);
} else {
@@ -851,7 +928,7 @@ export function FileManager({ projectId }: FileManagerProps) {
// View file with PDFTron
const viewFile = async (file: FileItem) => {
- try {
+ try {
setViewerFileUrl(file.filePath || '');
setSelectedFile(file);
setViewerDialogOpen(true);
@@ -1051,17 +1128,17 @@ export function FileManager({ projectId }: FileManagerProps) {
// 재귀적으로 트리 항목 검색
const searchTreeItems = (items: FileItem[], query: string): FileItem[] => {
const result: FileItem[] = [];
-
+
for (const item of items) {
// 현재 항목이 검색어와 일치하는지 확인
const matches = item.name.toLowerCase().includes(query.toLowerCase());
-
+
// 하위 항목 재귀적으로 검색
let childrenMatches: FileItem[] = [];
if (item.children && item.children.length > 0) {
childrenMatches = searchTreeItems(item.children, query);
}
-
+
// 현재 항목이나 하위 항목 중 하나라도 일치하면 결과에 추가
if (matches || childrenMatches.length > 0) {
// 하위 항목이 일치하는 경우 현재 항목도 표시하기 위해 확장된 상태로 복제
@@ -1072,7 +1149,7 @@ export function FileManager({ projectId }: FileManagerProps) {
result.push(clonedItem);
}
}
-
+
return result;
};
@@ -1362,6 +1439,12 @@ export function FileManager({ projectId }: FileManagerProps) {
setSelectedFile(item);
setShareDialogOpen(true);
}}
+ onCreateSubfolder={(item) => {
+ setSelectedParentFolder(item);
+ setNewFolderName('');
+ setNewFolderCategory(item.category || 'general');
+ setSubfolderDialogOpen(true); // setCreateSubfolderDialogOpen 대신 setSubfolderDialogOpen 사용
+ }}
onRename={(item) => {
setSelectedFile(item);
setDialogValue(item.name);
@@ -1465,21 +1548,21 @@ export function FileManager({ projectId }: FileManagerProps) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove('border-primary', 'bg-accent');
-
+
const items = e.dataTransfer.items;
const files: File[] = [];
const filePromises: Promise<void>[] = [];
-
+
// 폴더 구조 감지를 위한 플래그
let hasFolderStructure = false;
-
+
// DataTransferItem을 통한 폴더 처리
for (let i = 0; i < items.length; i++) {
const item = items[i];
-
+
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry();
-
+
if (entry) {
filePromises.push(
new Promise<void>(async (resolve) => {
@@ -1509,7 +1592,7 @@ export function FileManager({ projectId }: FileManagerProps) {
});
}
};
-
+
await traverseFileTree(entry);
resolve();
})
@@ -1517,10 +1600,10 @@ export function FileManager({ projectId }: FileManagerProps) {
}
}
}
-
+
// 모든 파일 처리 완료 대기
await Promise.all(filePromises);
-
+
// 파일이 없으면 일반 파일 처리 (폴더가 아닌 경우)
if (files.length === 0) {
const droppedFiles = Array.from(e.dataTransfer.files);
@@ -1778,6 +1861,29 @@ export function FileManager({ projectId }: FileManagerProps) {
</DialogContent>
</Dialog>
+ <Dialog open={subfolderDialogOpen} onOpenChange={setSubfolderDialogOpen}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>Create Sub-folder</DialogTitle>
+ <DialogDescription>
+ {selectedParentFolder ? (
+ <>
+ Create a new sub-folder under <span className="font-medium">{selectedParentFolder.name}</span>
+ </>
+ ) : (
+ "Create a new folder in the current directory"
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ <CreateSubfolderForm
+ parentFolder={selectedParentFolder}
+ onSubmit={createSubfolder}
+ onCancel={() => setSubfolderDialogOpen(false)}
+ />
+ </DialogContent>
+ </Dialog>
+
{/* Rename Dialog */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent>
@@ -1908,7 +2014,7 @@ export function FileManager({ projectId }: FileManagerProps) {
<Button
onClick={() => {
if (selectedFile) {
- changeCategory(selectedFile.id, newCategory,
+ changeCategory(selectedFile.id, newCategory,
selectedFile.type === 'folder' && newCategory !== 'public' ? true : applyToChildren
);
setCategoryDialogOpen(false);
diff --git a/components/file-manager/SecurePDFViewer.tsx b/components/file-manager/SecurePDFViewer.tsx
index 704c4f24..0deb96b6 100644
--- a/components/file-manager/SecurePDFViewer.tsx
+++ b/components/file-manager/SecurePDFViewer.tsx
@@ -91,10 +91,6 @@ export function SecurePDFViewer({ documentUrl, fileName, category, onClose }: Se
'ribbons',
'toggleNotesButton'
]);
-
- const { Core } = instance;
- Core.Tools.Tool.disableAutoSwitch();
- Core.Tools.Tool.disableTextSelection();
// CSS 적용으로 추가 보안
const iframeWindow = instance.UI.iframeWindow;
@@ -241,6 +237,10 @@ export function SecurePDFViewer({ documentUrl, fileName, category, onClose }: Se
}
annotationManager.drawAnnotations(documentViewer.getCurrentPage());
+
+ const { Core } = instance;
+ Core.Tools.Tool.disableAutoSwitch();
+ Core.Tools.Tool.disableTextSelection();
}
// Pan 모드로 설정 (텍스트 선택 불가)
diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx
index 1249ebd5..0d1c258e 100644
--- a/components/form-data/spreadJS-dialog.tsx
+++ b/components/form-data/spreadJS-dialog.tsx
@@ -210,7 +210,7 @@ export function TemplateViewDialog({
length: Array.isArray(templateData) ? templateData.length : 'N/A',
data: templateData
});
-
+
// 템플릿 데이터가 없거나 빈 배열인 경우 기본 GRD_LIST 템플릿 생성
if (!templateData || (Array.isArray(templateData) && templateData.length === 0)) {
// columnsJSON이 있으면 기본 GRD_LIST 템플릿 생성
@@ -291,7 +291,7 @@ export function TemplateViewDialog({
}
setAvailableTemplates(validTemplates);
-
+
// 🔍 최종 availableTemplates 로깅
console.log('📋 availableTemplates set:', validTemplates.map(t => ({
TMPL_ID: t.TMPL_ID,
@@ -302,7 +302,7 @@ export function TemplateViewDialog({
if (validTemplates.length > 0) {
// 🔍 현재 선택된 템플릿이 availableTemplates에 있는지 확인
const selectedExists = selectedTemplateId && validTemplates.some(t => t.TMPL_ID === selectedTemplateId);
-
+
if (!selectedExists) {
// 선택된 템플릿이 없거나 유효하지 않으면 첫 번째 템플릿 선택
const firstTemplate = validTemplates[0];
@@ -325,13 +325,13 @@ export function TemplateViewDialog({
const handleTemplateChange = (templateId: string) => {
const template = availableTemplates.find(t => t?.TMPL_ID === templateId);
-
+
// 🔍 템플릿과 TMPL_ID 검증
if (!template || !template.TMPL_ID) {
console.error('❌ Template not found or invalid TMPL_ID:', templateId);
return;
}
-
+
const templateTypeToSet = determineTemplateType(template);
setSelectedTemplateId(templateId);
setTemplateType(templateTypeToSet);
@@ -349,9 +349,9 @@ export function TemplateViewDialog({
availableCount: availableTemplates.length,
availableIds: availableTemplates.map(t => t?.TMPL_ID)
});
-
+
const found = availableTemplates.find(t => t?.TMPL_ID === selectedTemplateId);
-
+
if (!found && selectedTemplateId) {
console.warn('⚠️ Selected template not found:', {
searching: selectedTemplateId,
@@ -365,7 +365,7 @@ export function TemplateViewDialog({
TYPE: found.TMPL_TYPE
});
}
-
+
return found;
}, [availableTemplates, selectedTemplateId]);
@@ -1370,7 +1370,7 @@ export function TemplateViewDialog({
// 🔍 안전성 검증: availableTemplates가 있고, selectedTemplateId가 없을 때만 실행
if (!selectedTemplateId && availableTemplates.length > 0) {
const only = availableTemplates[0];
-
+
// 🔍 TMPL_ID 검증
if (!only || !only.TMPL_ID) {
console.error('❌ First template has no TMPL_ID:', only);
@@ -1378,17 +1378,17 @@ export function TemplateViewDialog({
}
const type = determineTemplateType(only);
-
+
// 🔍 type이 null이 아닐 때만 진행
if (!type) {
console.warn('⚠️ Could not determine template type for:', only);
return;
}
-
+
// 선택되어 있지 않다면 자동 선택
setSelectedTemplateId(only.TMPL_ID);
setTemplateType(type);
-
+
// 이미 스프레드가 마운트되어 있다면 즉시 초기화
if (currentSpread) {
initSpread(currentSpread, only);
@@ -1602,7 +1602,7 @@ export function TemplateViewDialog({
{availableTemplates[0]?.NAME || 'Unnamed'} ({availableTemplates[0]?.TMPL_TYPE || 'Unknown'})
</span>
</div>
- ) : null}
+ ) : null}
{selectedTemplate && (
<div className="flex items-center gap-4 text-sm">
@@ -1666,11 +1666,13 @@ export function TemplateViewDialog({
/>
{selectedTemplate && isClient && isDataValid ? (
- <SpreadSheets
- key={`${templateType}-${selectedTemplate?.TMPL_ID || 'unknown'}-${selectedTemplateId}`}
- workbookInitialized={initSpread}
- hostStyle={hostStyle}
- />
+ <div style={{ height: '100%', width: '100%', position: 'relative' }}>
+ <SpreadSheets
+ key={`${templateType}-${selectedTemplate?.TMPL_ID || 'unknown'}-${selectedTemplateId}`}
+ workbookInitialized={initSpread}
+ hostStyle={hostStyle}
+ />
+ </div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
{!isClient ? (
diff --git a/components/project/ProjectNav.tsx b/components/project/ProjectNav.tsx
index 1654c30d..0486e9b5 100644
--- a/components/project/ProjectNav.tsx
+++ b/components/project/ProjectNav.tsx
@@ -123,10 +123,10 @@ export function ProjectNav({ projectId }: ProjectNavProps) {
<Badge variant="outline">
{projectRole || 'viewer'}
</Badge>
- <Button variant="outline" size="sm">
+ {/* <Button variant="outline" size="sm">
<Share2 className="h-4 w-4 mr-1" />
Share
- </Button>
+ </Button> */}
</div>
</div>
diff --git a/components/project/dataroom-members.ts b/components/project/dataroom-members.ts
new file mode 100644
index 00000000..2512a5cd
--- /dev/null
+++ b/components/project/dataroom-members.ts
@@ -0,0 +1,74 @@
+"use server";
+
+import { sendEmail } from "@/lib/mail/sendEmail";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+
+interface SendDataRoomInvitationInput {
+ email: string;
+ name: string;
+ dataRoomName: string;
+ role: string;
+ dataRoomUrl?: string;
+}
+
+export async function sendDataRoomInvitation(input: SendDataRoomInvitationInput) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const dataRoomUrl = input.dataRoomUrl || `${process.env.NEXT_PUBLIC_APP_URL}/data-rooms`;
+
+ const subject = `You've been invited to access "${input.dataRoomName}" Data Room`;
+
+ await sendEmail({
+ to: input.email,
+ subject,
+ template: "data-room-invitation",
+ context: {
+ name: input.name,
+ dataRoomName: input.dataRoomName,
+ role: input.role,
+ inviterName: session.user.name || "Admin",
+ dataRoomUrl,
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
+ },
+ });
+
+ return { success: true, message: "Invitation email sent successfully" };
+ } catch (error) {
+ console.error("Failed to send data room invitation:", error);
+ return { success: false, message: "Failed to send invitation email" };
+ }
+}
+
+// 여러 멤버에게 동시에 이메일 전송
+export async function sendBulkDataRoomInvitations(
+ members: SendDataRoomInvitationInput[]
+) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const results = await Promise.allSettled(
+ members.map((member) => sendDataRoomInvitation(member))
+ );
+
+ const successful = results.filter((r) => r.status === "fulfilled").length;
+ const failed = results.filter((r) => r.status === "rejected").length;
+
+ return {
+ success: true,
+ message: `Sent ${successful} invitations successfully. ${failed} failed.`,
+ details: { successful, failed, total: members.length },
+ };
+ } catch (error) {
+ console.error("Failed to send bulk invitations:", error);
+ return { success: false, message: "Failed to send invitations" };
+ }
+} \ No newline at end of file