summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/gtc-contract/service.ts1
-rw-r--r--lib/itb/service.ts417
-rw-r--r--lib/rfq-last/service.ts6
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx2
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx36
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx2
-rw-r--r--lib/rfq-last/vendor/vendor-detail-dialog.tsx10
-rw-r--r--lib/services/fileService.ts516
-rw-r--r--lib/services/projectService.ts471
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx169
10 files changed, 1323 insertions, 307 deletions
diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts
index c8417901..61545d95 100644
--- a/lib/gtc-contract/service.ts
+++ b/lib/gtc-contract/service.ts
@@ -319,6 +319,7 @@ export async function getUsersForFilter(): Promise<UserForFilter[]> {
id: users.id,
name: users.name,
email: users.email,
+ domain: users.domain,
})
.from(users)
.where(eq(users.isActive, true)) // 활성 사용자만
diff --git a/lib/itb/service.ts b/lib/itb/service.ts
index f649bdf5..181285cc 100644
--- a/lib/itb/service.ts
+++ b/lib/itb/service.ts
@@ -3,7 +3,7 @@
import db from "@/db/db";
import { purchaseRequestsView, purchaseRequests, purchaseRequestAttachments, rfqsLast, rfqLastAttachments, rfqLastAttachmentRevisions, rfqPrItems, users } from "@/db/schema";
-import { eq, and, desc, ilike, or, sql, asc, inArray ,like} from "drizzle-orm";
+import { eq, and, desc, ilike, or, sql, asc, inArray, like } from "drizzle-orm";
import { revalidatePath, revalidateTag } from "next/cache";
import { getServerSession } from 'next-auth/next'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'
@@ -293,7 +293,7 @@ export async function approvePurchaseRequestAndCreateRfq(
.where(eq(purchaseRequestAttachments.requestId, requestId));
- const rfqCode = await generateItbRfqCode(purchasePicId);
+ const rfqCode = await generateItbRfqCode(purchasePicId);
const [rfq] = await tx.insert(rfqsLast).values({
rfqCode,
@@ -547,10 +547,10 @@ export async function getPurchaseRequestAttachments(requestId: number) {
}
} catch (error) {
console.error("Get attachments error:", error)
- return {
+ return {
success: false,
error: "첨부파일 조회 중 오류가 발생했습니다.",
- data: []
+ data: []
}
}
}
@@ -558,223 +558,226 @@ export async function getPurchaseRequestAttachments(requestId: number) {
export async function generateItbRfqCode(purchasePicId?: number): Promise<string> {
try {
- let userCode = "???";
-
- // purchasePicId가 있으면 users 테이블에서 userCode 조회
- if (purchasePicId) {
- const [user] = await db
- .select({ userCode: users.userCode })
- .from(users)
- .where(eq(users.id, purchasePicId))
- .limit(1);
-
- if (user?.userCode) {
- userCode = user.userCode;
+ let userCode = "???";
+
+ // purchasePicId가 있으면 users 테이블에서 userCode 조회
+ if (purchasePicId) {
+ const [user] = await db
+ .select({ userCode: users.userCode })
+ .from(users)
+ .where(eq(users.id, purchasePicId))
+ .limit(1);
+
+ if (user?.userCode) {
+ userCode = user.userCode;
+ }
}
- }
-
- // 동일한 userCode로 시작하는 마지막 RFQ 조회
- const lastRfq = await db
- .select({ rfqCode: rfqsLast.rfqCode })
- .from(rfqsLast)
- .where(like(rfqsLast.rfqCode, `I${userCode}%`))
- .orderBy(desc(rfqsLast.createdAt))
- .limit(1);
-
- let nextNumber = 1;
-
- if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
- const rfqCode = lastRfq[0].rfqCode;
- const serialNumber = rfqCode.slice(-5); // 마지막 5자리
-
- if (/^\d{5}$/.test(serialNumber)) {
- nextNumber = parseInt(serialNumber) + 1;
+
+ // 동일한 userCode로 시작하는 마지막 RFQ 조회
+ const lastRfq = await db
+ .select({ rfqCode: rfqsLast.rfqCode })
+ .from(rfqsLast)
+ .where(like(rfqsLast.rfqCode, `I${userCode}%`))
+ .orderBy(desc(rfqsLast.createdAt))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
+ const rfqCode = lastRfq[0].rfqCode;
+ const serialNumber = rfqCode.slice(-5); // 마지막 5자리
+
+ if (/^\d{5}$/.test(serialNumber)) {
+ nextNumber = parseInt(serialNumber) + 1;
+ }
}
- }
-
- const paddedNumber = String(nextNumber).padStart(5, "0");
-
- return `I${userCode}${paddedNumber}`;
+
+ const paddedNumber = String(nextNumber).padStart(5, "0");
+
+ return `I${userCode}${paddedNumber}`;
} catch (error) {
- console.error("Error generating ITB RFQ code:", error);
- const fallback = Date.now().toString().slice(-5);
- return `I???${fallback}`;
+ console.error("Error generating ITB RFQ code:", error);
+ const fallback = Date.now().toString().slice(-5);
+ return `I???${fallback}`;
}
- }
-
+}
+
- // lib/purchase-requests/service.ts에 추가
+// lib/purchase-requests/service.ts에 추가
// 여러 구매 요청 승인 및 RFQ 생성
export async function approvePurchaseRequestsAndCreateRfqs(
requestIds: number[],
purchasePicId?: number
- ) {
+) {
try {
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) throw new Error("Unauthorized");
- const userId = Number(session.user.id)
-
- const results = []
-
- for (const requestId of requestIds) {
- try {
- const result = await db.transaction(async (tx) => {
- // 구매 요청 조회
- const [request] = await tx
- .select()
- .from(purchaseRequests)
- .where(eq(purchaseRequests.id, requestId))
-
- if (!request) {
- throw new Error(`구매 요청 ${requestId}를 찾을 수 없습니다.`)
- }
-
- if (request.status === "RFQ생성완료") {
- return { skipped: true, requestId, message: "이미 RFQ가 생성되었습니다." }
- }
-
- const attachments = await tx
- .select()
- .from(purchaseRequestAttachments)
- .where(eq(purchaseRequestAttachments.requestId, requestId))
-
- const rfqCode = await generateItbRfqCode(purchasePicId)
-
- // 마감일 기본값 설정 (입력값 없으면 생성일 + 7일)
- const defaultDueDate = getDefaultDueDate();
-
- const [rfq] = await tx
- .insert(rfqsLast)
- .values({
- rfqCode,
- projectId: request.projectId,
- itemCode: request.items?.[0]?.itemCode,
- itemName: request.items?.[0]?.itemName,
- packageNo: request.packageNo,
- packageName: request.packageName,
- EngPicName: request.engPicName,
- pic: purchasePicId || null,
- status: "RFQ 생성",
- dueDate: defaultDueDate, // 마감일 기본값 설정
- projectCompany: request.projectCompany,
- projectSite: request.projectSite,
- smCode: request.smCode,
- createdBy: userId,
- updatedBy: userId,
- })
- .returning()
-
- // 첨부파일 이관
- for (const [index, attachment] of attachments.entries()) {
- const [rfqAttachment] = await tx
- .insert(rfqLastAttachments)
- .values({
- attachmentType: "설계",
- serialNo: `ENG-${String(index + 1).padStart(3, "0")}`,
- rfqId: rfq.id,
- description:
- attachment.description ||
- `설계문서 - ${attachment.originalFileName}`,
- currentRevision: "Rev.0",
- createdBy: userId,
- })
- .returning()
-
- const [revision] = await tx
- .insert(rfqLastAttachmentRevisions)
- .values({
- attachmentId: rfqAttachment.id,
- revisionNo: "Rev.0",
- revisionComment: "구매 요청에서 이관된 설계 문서",
- isLatest: true,
- fileName: attachment.fileName,
- originalFileName: attachment.originalFileName,
- filePath: attachment.filePath,
- fileSize: attachment.fileSize,
- fileType: attachment.fileType,
- createdBy: userId,
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) throw new Error("Unauthorized");
+ const userId = Number(session.user.id)
+
+ const results = []
+
+ for (const requestId of requestIds) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 구매 요청 조회
+ const [request] = await tx
+ .select()
+ .from(purchaseRequests)
+ .where(eq(purchaseRequests.id, requestId))
+
+ if (!request) {
+ throw new Error(`구매 요청 ${requestId}를 찾을 수 없습니다.`)
+ }
+
+ if (request.status === "RFQ생성완료") {
+ return { skipped: true, requestId, message: "이미 RFQ가 생성되었습니다." }
+ }
+
+ const attachments = await tx
+ .select()
+ .from(purchaseRequestAttachments)
+ .where(eq(purchaseRequestAttachments.requestId, requestId))
+
+ const rfqCode = await generateItbRfqCode(purchasePicId)
+
+ const defaultDueDate = (() => {
+ const d = new Date();
+ d.setDate(d.getDate() + 15);
+ return d;
+ })();
+
+
+ const [rfq] = await tx
+ .insert(rfqsLast)
+ .values({
+ rfqCode,
+ projectId: request.projectId,
+ itemCode: request.items?.[0]?.itemCode,
+ itemName: request.items?.[0]?.itemName,
+ packageNo: request.packageNo,
+ packageName: request.packageName,
+ EngPicName: request.engPicName,
+ pic: purchasePicId || null,
+ status: "RFQ 생성",
+ dueDate: defaultDueDate, // 마감일 기본값 설정
+ projectCompany: request.projectCompany,
+ projectSite: request.projectSite,
+ smCode: request.smCode,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+ .returning()
+
+ // 첨부파일 이관
+ for (const [index, attachment] of attachments.entries()) {
+ const [rfqAttachment] = await tx
+ .insert(rfqLastAttachments)
+ .values({
+ attachmentType: "설계",
+ serialNo: `ENG-${String(index + 1).padStart(3, "0")}`,
+ rfqId: rfq.id,
+ description:
+ attachment.description ||
+ `설계문서 - ${attachment.originalFileName}`,
+ currentRevision: "Rev.0",
+ createdBy: userId,
+ })
+ .returning()
+
+ const [revision] = await tx
+ .insert(rfqLastAttachmentRevisions)
+ .values({
+ attachmentId: rfqAttachment.id,
+ revisionNo: "Rev.0",
+ revisionComment: "구매 요청에서 이관된 설계 문서",
+ isLatest: true,
+ fileName: attachment.fileName,
+ originalFileName: attachment.originalFileName,
+ filePath: attachment.filePath,
+ fileSize: attachment.fileSize,
+ fileType: attachment.fileType,
+ createdBy: userId,
+ })
+ .returning()
+
+ await tx
+ .update(rfqLastAttachments)
+ .set({ latestRevisionId: revision.id })
+ .where(eq(rfqLastAttachments.id, rfqAttachment.id))
+ }
+
+ // 품목 이관
+ if (request.items && request.items.length > 0) {
+ console.log("🚀 품목 이관 시작:", {
+ requestId,
+ itemsCount: request.items.length,
+ items: request.items
+ });
+
+ const prItemsData = request.items.map((item, index) => ({
+ rfqsLastId: rfq.id,
+ rfqItem: `${index + 1}`.padStart(3, '0'),
+ prItem: `${index + 1}`.padStart(3, '0'),
+ prNo: rfqCode,
+ materialCategory: request.majorItemMaterialCategory,
+ materialCode: item.itemCode,
+ materialDescription: item.itemName,
+ quantity: item.quantity,
+ uom: item.unit,
+ majorYn: index === 0,
+ remark: item.remarks || null,
+ }));
+
+ console.log("🔍 삽입할 데이터:", prItemsData);
+
+ const insertedItems = await tx.insert(rfqPrItems).values(prItemsData).returning();
+ console.log("✅ 품목 이관 완료:", insertedItems);
+ } else {
+ console.log("❌ 품목이 없음:", {
+ requestId,
+ hasItems: !!request.items,
+ itemsLength: request.items?.length || 0
+ });
+ }
+
+ // 구매 요청 상태 업데이트
+ await tx
+ .update(purchaseRequests)
+ .set({
+ status: "RFQ생성완료",
+ rfqId: rfq.id,
+ rfqCode: rfq.rfqCode,
+ rfqCreatedAt: new Date(),
+ purchasePicId,
+ updatedBy: userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(purchaseRequests.id, requestId))
+
+ return { success: true, rfq, requestId }
})
- .returning()
-
- await tx
- .update(rfqLastAttachments)
- .set({ latestRevisionId: revision.id })
- .where(eq(rfqLastAttachments.id, rfqAttachment.id))
- }
-
- // 품목 이관
- if (request.items && request.items.length > 0) {
- console.log("🚀 품목 이관 시작:", {
- requestId,
- itemsCount: request.items.length,
- items: request.items
- });
-
- const prItemsData = request.items.map((item, index) => ({
- rfqsLastId: rfq.id,
- rfqItem: `${index + 1}`.padStart(3, '0'),
- prItem: `${index + 1}`.padStart(3, '0'),
- prNo: rfqCode,
- materialCategory:request.majorItemMaterialCategory,
- materialCode: item.itemCode,
- materialDescription: item.itemName,
- quantity: item.quantity,
- uom: item.unit,
- majorYn: index === 0,
- remark: item.remarks || null,
- }));
-
- console.log("🔍 삽입할 데이터:", prItemsData);
-
- const insertedItems = await tx.insert(rfqPrItems).values(prItemsData).returning();
- console.log("✅ 품목 이관 완료:", insertedItems);
- } else {
- console.log("❌ 품목이 없음:", {
+
+ results.push(result)
+ } catch (err: any) {
+ console.error(`구매 요청 ${requestId} 처리 중 오류:`, err)
+ results.push({
+ success: false,
requestId,
- hasItems: !!request.items,
- itemsLength: request.items?.length || 0
- });
+ error: err.message || "알 수 없는 오류 발생",
+ })
}
-
- // 구매 요청 상태 업데이트
- await tx
- .update(purchaseRequests)
- .set({
- status: "RFQ생성완료",
- rfqId: rfq.id,
- rfqCode: rfq.rfqCode,
- rfqCreatedAt: new Date(),
- purchasePicId,
- updatedBy: userId,
- updatedAt: new Date(),
- })
- .where(eq(purchaseRequests.id, requestId))
-
- return { success: true, rfq, requestId }
- })
-
- results.push(result)
- } catch (err: any) {
- console.error(`구매 요청 ${requestId} 처리 중 오류:`, err)
- results.push({
- success: false,
- requestId,
- error: err.message || "알 수 없는 오류 발생",
- })
}
- }
-
- // 캐시 무효화
- revalidateTag("purchase-requests")
- revalidateTag( "purchase-request-stats")
-
- revalidateTag("rfqs")
-
- return results
+
+ // 캐시 무효화
+ revalidateTag("purchase-requests")
+ revalidateTag("purchase-request-stats")
+
+ revalidateTag("rfqs")
+
+ return results
} catch (err: any) {
- console.error("approvePurchaseRequestsAndCreateRfqs 실행 오류:", err)
- throw new Error(err.message || "구매 요청 처리 중 오류가 발생했습니다.")
+ console.error("approvePurchaseRequestsAndCreateRfqs 실행 오류:", err)
+ throw new Error(err.message || "구매 요청 처리 중 오류가 발생했습니다.")
}
- }
- \ No newline at end of file
+}
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 82f8837a..be8e13e6 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -357,6 +357,8 @@ export async function createGeneralRfqAction(input: CreateGeneralRfqInput) {
// 5. 마감일 기본값 설정 (입력값 없으면 생성일 + 7일)
const dueDate = input.dueDate || getDefaultDueDate();
+ console.log(dueDate,"dueDate")
+
// 6. rfqsLast 테이블에 기본 정보 삽입
const [newRfq] = await tx
.insert(rfqsLast)
@@ -3798,8 +3800,8 @@ export async function updateRfqDueDate(
}
// 6. 각 vendor별로 이메일 발송
- const emailPromises = []
-
+ const emailPromises: Promise<any>[] = []
+
for (const detail of rfqDetailsData) {
if (!detail.emailSentTo) continue
diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
index c9790880..4a8960ff 100644
--- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
+++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
@@ -53,6 +53,8 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
const currency = watch("vendorCurrency") || "USD"
const quotationItems = watch("quotationItems")
+
+ console.log(prItems,"prItems")
// PR 아이템 정보를 quotationItems에 초기화
useEffect(() => {
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
index 6da704cd..569546dd 100644
--- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
+++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
@@ -19,6 +19,22 @@ import { Shield, FileText, CheckCircle, XCircle, Clock, Download, Eye, Save, Sen
import { Progress } from "@/components/ui/progress"
import { Alert, AlertDescription } from "@/components/ui/alert"
+
+const quotationItemSchema = z.object({
+ rfqPrItemId: z.number(),
+ unitPrice: z.number().min(0),
+ totalPrice: z.number().min(0),
+ vendorDeliveryDate: z.date().optional().nullable(),
+ leadTime: z.number().optional(),
+ manufacturer: z.string().optional(),
+ manufacturerCountry: z.string().optional(),
+ modelNo: z.string().optional(),
+ technicalCompliance: z.boolean(),
+ alternativeProposal: z.string().optional(),
+ discountRate: z.number().optional(),
+ itemRemark: z.string().optional(),
+ deviationReason: z.string().optional(),
+}).passthrough(); // ⬅️ 여기가 핵심: 정의 안 된 키도 유지
// 폼 스키마 정의
const vendorResponseSchema = z.object({
// 상업 조건
@@ -59,21 +75,7 @@ const vendorResponseSchema = z.object({
technicalProposal: z.string().optional(),
// 견적 아이템
- quotationItems: z.array(z.object({
- rfqPrItemId: z.number(),
- unitPrice: z.number().min(0),
- totalPrice: z.number().min(0),
- vendorDeliveryDate: z.date().optional().nullable(),
- leadTime: z.number().optional(),
- manufacturer: z.string().optional(),
- manufacturerCountry: z.string().optional(),
- modelNo: z.string().optional(),
- technicalCompliance: z.boolean(),
- alternativeProposal: z.string().optional(),
- discountRate: z.number().optional(),
- itemRemark: z.string().optional(),
- deviationReason: z.string().optional(),
- }))
+quotationItems: z.array(quotationItemSchema),
})
type VendorResponseFormData = z.infer<typeof vendorResponseSchema>
@@ -104,6 +106,8 @@ export default function VendorResponseEditor({
const [attachments, setAttachments] = useState<File[]>([])
const [uploadProgress, setUploadProgress] = useState(0) // 추가
+ console.log(existingResponse,"existingResponse")
+
// Form 초기값 설정
const defaultValues: VendorResponseFormData = {
@@ -175,6 +179,8 @@ export default function VendorResponseEditor({
}
}, [errors])
+ console.log(methods.getValues())
+
const handleFormSubmit = (isSubmit: boolean = false) => {
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 98d53f5d..ef906ed6 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -1206,7 +1206,7 @@ export function RfqVendorTable({
<Button
variant="ghost"
size="sm"
- onClick={() => handleAction("response-detail", row.original)}
+ onClick={() => handleAction("view", row.original)}
className="h-7 px-2"
>
<Eye className="h-3 w-3 mr-1" />
diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
index 54aada1d..17eed54c 100644
--- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx
+++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
@@ -148,8 +148,8 @@ export function VendorResponseDetailDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
+ <DialogContent className="max-w-5xl max-h-[90vh] p-0 flex flex-col">
+ <DialogHeader className="flex-shrink-0 sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b px-6 py-4">
<div className="flex items-center justify-between">
<div>
<DialogTitle className="text-xl font-bold">
@@ -170,7 +170,9 @@ export function VendorResponseDetailDialog({
</div>
</DialogHeader>
- <Tabs defaultValue="overview" className="mt-4">
+ <div className="flex-1 overflow-y-auto px-6 ">
+
+ <Tabs defaultValue="overview" className="mb-2">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">개요</TabsTrigger>
<TabsTrigger value="quotation">견적정보</TabsTrigger>
@@ -689,6 +691,8 @@ export function VendorResponseDetailDialog({
)}
</TabsContent>
</Tabs>
+ </div>
+
</DialogContent>
</Dialog>
);
diff --git a/lib/services/fileService.ts b/lib/services/fileService.ts
new file mode 100644
index 00000000..56966a86
--- /dev/null
+++ b/lib/services/fileService.ts
@@ -0,0 +1,516 @@
+// lib/services/fileService.ts
+import db from "@/db/db";
+import {
+ fileItems,
+ filePermissions,
+ fileShares,
+ fileActivityLogs,
+ projects,
+ type FileItem,
+ type NewFileItem,
+ type FilePermission,
+} from "@/db/schema/fileSystem";
+import { users } from "@/db/schema/users";
+import { eq, and, or, isNull, lte, gte, sql, inArray } from "drizzle-orm";
+import crypto from "crypto";
+
+export interface FileAccessContext {
+ userId: number;
+ userDomain: string;
+ userEmail: string;
+ ipAddress?: string;
+ userAgent?: string;
+}
+
+export class FileService {
+ // 사용자가 내부 사용자인지 확인
+ private isInternalUser(domain: string): boolean {
+ // partners가 아닌 경우 내부 사용자로 간주
+ return domain !== "partners";
+ }
+
+ // 파일 접근 권한 확인
+ async checkFileAccess(
+ fileId: string,
+ context: FileAccessContext,
+ requiredAction: "view" | "download" | "edit" | "delete" | "share"
+ ): Promise<boolean> {
+ // 내부 사용자는 모든 권한 보유
+ if (this.isInternalUser(context.userDomain)) {
+ return true;
+ }
+
+ // 파일 정보 조회
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (!file) return false;
+
+ // 외부 사용자 권한 체크
+ // 1. 파일 카테고리별 기본 권한 체크
+ switch (file.category) {
+ case "public":
+ // public 파일은 열람과 다운로드 가능
+ if (requiredAction === "view" || requiredAction === "download") {
+ return true;
+ }
+ break;
+ case "restricted":
+ // restricted 파일은 열람만 가능
+ if (requiredAction === "view") {
+ return true;
+ }
+ break;
+ case "confidential":
+ case "internal":
+ // 기본적으로 접근 불가
+ break;
+ }
+
+ // 2. 개별 권한 설정 체크
+ const permission = await db.query.filePermissions.findFirst({
+ where: and(
+ eq(filePermissions.fileItemId, fileId),
+ or(
+ eq(filePermissions.userId, context.userId),
+ eq(filePermissions.userDomain, context.userDomain)
+ ),
+ or(
+ isNull(filePermissions.validFrom),
+ lte(filePermissions.validFrom, new Date())
+ ),
+ or(
+ isNull(filePermissions.validUntil),
+ gte(filePermissions.validUntil, new Date())
+ )
+ ),
+ });
+
+ if (permission) {
+ switch (requiredAction) {
+ case "view": return permission.canView;
+ case "download": return permission.canDownload;
+ case "edit": return permission.canEdit;
+ case "delete": return permission.canDelete;
+ case "share": return permission.canShare;
+ }
+ }
+
+ return false;
+ }
+
+ // 파일 목록 조회 (트리 뷰 지원)
+async getFileList(
+ projectId: string,
+ parentId: string | null,
+ context: FileAccessContext,
+ options?: {
+ includeAll?: boolean; // 전체 파일 가져오기 옵션
+ }
+) {
+ const isInternal = this.isInternalUser(context.userDomain);
+
+ // 기본 쿼리 빌드
+ let baseConditions = [eq(fileItems.projectId, projectId)];
+
+ // includeAll이 false이거나 명시되지 않은 경우에만 parentId 조건 추가
+ if (!options?.includeAll) {
+ baseConditions.push(
+ parentId ? eq(fileItems.parentId, parentId) : isNull(fileItems.parentId)
+ );
+ }
+
+ let query = db
+ .select({
+ file: fileItems,
+ canView: sql<boolean>`true`,
+ canDownload: sql<boolean>`${isInternal}`,
+ canEdit: sql<boolean>`${isInternal}`,
+ canDelete: sql<boolean>`${isInternal}`,
+ })
+ .from(fileItems)
+ .where(and(...baseConditions));
+
+ if (!isInternal) {
+ // 외부 사용자는 접근 가능한 파일만 표시
+ let externalConditions = [eq(fileItems.projectId, projectId)];
+
+ if (!options?.includeAll) {
+ externalConditions.push(
+ parentId ? eq(fileItems.parentId, parentId) : isNull(fileItems.parentId)
+ );
+ }
+
+ query = db
+ .select({
+ file: fileItems,
+ canView: sql<boolean>`
+ CASE
+ WHEN ${fileItems.category} IN ('public', 'restricted') THEN true
+ WHEN ${filePermissions.canView} = true THEN true
+ ELSE false
+ END
+ `,
+ canDownload: sql<boolean>`
+ CASE
+ WHEN ${fileItems.category} = 'public' THEN true
+ WHEN ${filePermissions.canDownload} = true THEN true
+ ELSE false
+ END
+ `,
+ canEdit: sql<boolean>`COALESCE(${filePermissions.canEdit}, false)`,
+ canDelete: sql<boolean>`COALESCE(${filePermissions.canDelete}, false)`,
+ })
+ .from(fileItems)
+ .leftJoin(
+ filePermissions,
+ and(
+ eq(filePermissions.fileItemId, fileItems.id),
+ or(
+ eq(filePermissions.userId, context.userId),
+ eq(filePermissions.userDomain, context.userDomain)
+ )
+ )
+ )
+ .where(
+ and(
+ ...externalConditions,
+ or(
+ inArray(fileItems.category, ["public", "restricted"]),
+ eq(filePermissions.canView, true)
+ )
+ )
+ );
+ }
+
+ const results = await query;
+
+ // 활동 로그 기록 (전체 목록 조회시에는 로그 생략)
+ if (!options?.includeAll) {
+ for (const result of results) {
+ await this.logActivity(result.file.id, projectId, "view", context);
+ }
+ }
+
+ return results.map(r => ({
+ ...r.file,
+ permissions: {
+ canView: r.canView,
+ canDownload: r.canDownload,
+ canEdit: r.canEdit,
+ canDelete: r.canDelete,
+ },
+ }));
+}
+
+
+ // 파일/폴더 생성
+ async createFileItem(
+ data: NewFileItem,
+ context: FileAccessContext
+ ): Promise<FileItem> {
+ // 내부 사용자만 파일 생성 가능
+ if (!this.isInternalUser(context.userDomain)) {
+ throw new Error("권한이 없습니다");
+ }
+
+ // 경로 계산
+ let path = "/";
+ let depth = 0;
+
+ if (data.parentId) {
+ const parent = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, data.parentId),
+ });
+ if (parent) {
+ path = `${parent.path}${parent.name}/`;
+ depth = parent.depth + 1;
+ }
+ }
+
+ const [newFile] = await db
+ .insert(fileItems)
+ .values({
+ ...data,
+ path,
+ depth,
+ createdBy: context.userId,
+ updatedBy: context.userId,
+ })
+ .returning();
+
+ await this.logActivity(newFile.id, newFile.projectId, "upload", context);
+
+ return newFile;
+ }
+
+ // 파일 다운로드
+ async downloadFile(
+ fileId: string,
+ context: FileAccessContext
+ ): Promise<FileItem | null> {
+ const hasAccess = await this.checkFileAccess(fileId, context, "download");
+
+ if (!hasAccess) {
+ throw new Error("다운로드 권한이 없습니다");
+ }
+
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (!file) return null;
+
+ // 다운로드 카운트 증가
+ await db
+ .update(fileItems)
+ .set({
+ downloadCount: sql`${fileItems.downloadCount} + 1`,
+ })
+ .where(eq(fileItems.id, fileId));
+
+ // 활동 로그 기록
+ await this.logActivity(fileId, file.projectId, "download", context);
+
+ return file;
+ }
+
+ // 파일 공유 링크 생성
+ async createShareLink(
+ fileId: string,
+ options: {
+ accessLevel?: "view_only" | "view_download";
+ password?: string;
+ expiresAt?: Date;
+ maxDownloads?: number;
+ sharedWithEmail?: string;
+ },
+ context: FileAccessContext
+ ): Promise<string> {
+ const hasAccess = await this.checkFileAccess(fileId, context, "share");
+
+ if (!hasAccess) {
+ throw new Error("공유 권한이 없습니다");
+ }
+
+ const shareToken = crypto.randomBytes(32).toString("hex");
+
+ const [share] = await db
+ .insert(fileShares)
+ .values({
+ fileItemId: fileId,
+ shareToken,
+ accessLevel: options.accessLevel || "view_only",
+ password: options.password,
+ expiresAt: options.expiresAt,
+ maxDownloads: options.maxDownloads,
+ sharedWithEmail: options.sharedWithEmail,
+ createdBy: context.userId,
+ })
+ .returning();
+
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (file) {
+ await this.logActivity(fileId, file.projectId, "share", context, {
+ shareId: share.id,
+ sharedWithEmail: options.sharedWithEmail,
+ });
+ }
+
+ return shareToken;
+ }
+
+ // 공유 링크로 파일 접근
+ async accessFileByShareToken(
+ shareToken: string,
+ password?: string
+ ): Promise<{ file: FileItem; accessLevel: string } | null> {
+ const share = await db.query.fileShares.findFirst({
+ where: eq(fileShares.shareToken, shareToken),
+ with: {
+ fileItem: true,
+ },
+ });
+
+ if (!share || !share.fileItem) return null;
+
+ // 유효성 검사
+ if (share.expiresAt && share.expiresAt < new Date()) {
+ throw new Error("공유 링크가 만료되었습니다");
+ }
+
+ if (share.password && share.password !== password) {
+ throw new Error("비밀번호가 일치하지 않습니다");
+ }
+
+ if (
+ share.maxDownloads &&
+ share.currentDownloads >= share.maxDownloads
+ ) {
+ throw new Error("최대 다운로드 횟수를 초과했습니다");
+ }
+
+ // 접근 기록 업데이트
+ await db
+ .update(fileShares)
+ .set({
+ lastAccessedAt: new Date(),
+ })
+ .where(eq(fileShares.id, share.id));
+
+ // 조회수 증가
+ await db
+ .update(fileItems)
+ .set({
+ viewCount: sql`${fileItems.viewCount} + 1`,
+ })
+ .where(eq(fileItems.id, share.fileItemId));
+
+ return {
+ file: share.fileItem,
+ accessLevel: share.accessLevel,
+ };
+ }
+
+ // 파일 권한 부여
+ async grantPermission(
+ fileId: string,
+ targetUserId: number | null,
+ targetDomain: string | null,
+ permissions: {
+ canView?: boolean;
+ canDownload?: boolean;
+ canEdit?: boolean;
+ canDelete?: boolean;
+ canShare?: boolean;
+ },
+ context: FileAccessContext
+ ): Promise<void> {
+ // 내부 사용자만 권한 부여 가능
+ if (!this.isInternalUser(context.userDomain)) {
+ throw new Error("권한 부여 권한이 없습니다");
+ }
+
+ await db
+ .insert(filePermissions)
+ .values({
+ fileItemId: fileId,
+ userId: targetUserId,
+ userDomain: targetDomain,
+ ...permissions,
+ grantedBy: context.userId,
+ })
+ .onConflictDoUpdate({
+ target: [filePermissions.fileItemId, filePermissions.userId],
+ set: {
+ ...permissions,
+ updatedAt: new Date(),
+ },
+ });
+ }
+
+ // 활동 로그 기록
+ private async logActivity(
+ fileItemId: string,
+ projectId: string,
+ action: string,
+ context: FileAccessContext,
+ details: any = {}
+ ): Promise<void> {
+ await db.insert(fileActivityLogs).values({
+ fileItemId,
+ projectId,
+ action,
+ actionDetails: details,
+ userId: context.userId,
+ userEmail: context.userEmail,
+ userDomain: context.userDomain,
+ ipAddress: context.ipAddress,
+ userAgent: context.userAgent,
+ });
+ }
+
+ // 파일 이동
+ async moveFile(
+ fileId: string,
+ newParentId: string | null,
+ context: FileAccessContext
+ ): Promise<void> {
+ const hasAccess = await this.checkFileAccess(fileId, context, "edit");
+
+ if (!hasAccess) {
+ throw new Error("이동 권한이 없습니다");
+ }
+
+ // 새 경로 계산
+ let newPath = "/";
+ let newDepth = 0;
+
+ if (newParentId) {
+ const newParent = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, newParentId),
+ });
+ if (newParent) {
+ newPath = `${newParent.path}${newParent.name}/`;
+ newDepth = newParent.depth + 1;
+ }
+ }
+
+ await db
+ .update(fileItems)
+ .set({
+ parentId: newParentId,
+ path: newPath,
+ depth: newDepth,
+ updatedBy: context.userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(fileItems.id, fileId));
+
+ // 하위 항목들의 경로도 재귀적으로 업데이트 필요 (생략)
+ }
+
+ // 파일 삭제
+ async deleteFile(
+ fileId: string,
+ context: FileAccessContext
+ ): Promise<void> {
+ const hasAccess = await this.checkFileAccess(fileId, context, "delete");
+
+ if (!hasAccess) {
+ throw new Error("삭제 권한이 없습니다");
+ }
+
+ const file = await db.query.fileItems.findFirst({
+ where: eq(fileItems.id, fileId),
+ });
+
+ if (file) {
+ await this.logActivity(fileId, file.projectId, "delete", context);
+ }
+
+ await db.delete(fileItems).where(eq(fileItems.id, fileId));
+ }
+
+ // 프로젝트별 스토리지 사용량 계산
+ async getProjectStorageUsage(projectId: string): Promise<{
+ totalSize: number;
+ fileCount: number;
+ folderCount: number;
+ }> {
+ const result = await db
+ .select({
+ totalSize: sql<number>`COALESCE(SUM(${fileItems.size}), 0)`,
+ fileCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'file' THEN 1 END)`,
+ folderCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'folder' THEN 1 END)`,
+ })
+ .from(fileItems)
+ .where(eq(fileItems.projectId, projectId));
+
+ return result[0] || { totalSize: 0, fileCount: 0, folderCount: 0 };
+ }
+} \ No newline at end of file
diff --git a/lib/services/projectService.ts b/lib/services/projectService.ts
new file mode 100644
index 00000000..55ddcf0e
--- /dev/null
+++ b/lib/services/projectService.ts
@@ -0,0 +1,471 @@
+// lib/services/projectService.ts
+import db from "@/db/db";
+import {
+ fileSystemProjects,
+ fileItems,
+ projectMembers,
+ fileActivityLogs,
+ type FileSystemProject,
+ type NewFileSystemProject,
+} from "@/db/schema/fileSystem";
+import { users } from "@/db/schema/users";
+import { eq, and, or, inArray, gte, sql, not } from "drizzle-orm";
+
+// 프로젝트 멤버 역할 타입
+export type ProjectRole = "owner" | "admin" | "editor" | "viewer";
+
+export class ProjectService {
+ // 프로젝트 생성 (생성자가 자동으로 owner가 됨)
+ async createProject(
+ data: {
+ name: string;
+ description?: string;
+ isPublic?: boolean;
+ },
+ userId: number
+ ): Promise<FileSystemProject> {
+ const [project] = await db.transaction(async (tx) => {
+ // 1. 프로젝트 생성
+ const [newProject] = await tx
+ .insert(fileSystemProjects)
+ .values({
+ ...data,
+ ownerId: userId,
+ })
+ .returning();
+
+ // 2. 생성자를 owner로 프로젝트 멤버에 추가
+ await tx.insert(projectMembers).values({
+ projectId: newProject.id,
+ userId: userId,
+ role: "owner",
+ addedBy: userId,
+ });
+
+ return [newProject];
+ });
+
+ return project;
+ }
+
+ // 프로젝트 Owner 확인
+ async isProjectOwner(projectId: string, userId: number): Promise<boolean> {
+ const project = await db.query.fileSystemProjects.findFirst({
+ where: and(
+ eq(fileSystemProjects.id, projectId),
+ eq(fileSystemProjects.ownerId, userId)
+ ),
+ });
+
+ return !!project;
+ }
+
+ // 프로젝트 접근 권한 확인
+ async checkProjectAccess(
+ projectId: string,
+ userId: number,
+ requiredRole?: ProjectRole
+ ): Promise<{
+ hasAccess: boolean;
+ role?: ProjectRole;
+ isOwner: boolean;
+ }> {
+ // 1. Owner 확인
+ const project = await db.query.fileSystemProjects.findFirst({
+ where: eq(fileSystemProjects.id, projectId),
+ });
+
+ if (!project) {
+ return { hasAccess: false, isOwner: false };
+ }
+
+ const isOwner = project.ownerId === userId;
+
+ // Owner는 모든 권한 보유
+ if (isOwner) {
+ return { hasAccess: true, role: "owner", isOwner: true };
+ }
+
+ // 2. 프로젝트 멤버 확인
+ const member = await db.query.projectMembers.findFirst({
+ where: and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.userId, userId)
+ ),
+ });
+
+ if (!member) {
+ // 공개 프로젝트인 경우 viewer 권한
+ if (project.isPublic) {
+ return {
+ hasAccess: !requiredRole || requiredRole === "viewer",
+ role: "viewer",
+ isOwner: false
+ };
+ }
+ return { hasAccess: false, isOwner: false };
+ }
+
+ // 3. 역할 계층 확인
+ const roleHierarchy: Record<ProjectRole, number> = {
+ owner: 4,
+ admin: 3,
+ editor: 2,
+ viewer: 1,
+ };
+
+ const hasRequiredRole = !requiredRole ||
+ roleHierarchy[member.role] >= roleHierarchy[requiredRole];
+
+ return {
+ hasAccess: hasRequiredRole,
+ role: member.role as ProjectRole,
+ isOwner: false,
+ };
+ }
+
+ // 프로젝트 멤버 추가 (Owner만 가능)
+ async addProjectMember(
+ projectId: string,
+ newMemberId: number,
+ role: ProjectRole,
+ addedByUserId: number
+ ): Promise<void> {
+ // Owner 권한 확인
+ const isOwner = await this.isProjectOwner(projectId, addedByUserId);
+
+ if (!isOwner) {
+ throw new Error("프로젝트 소유자만 멤버를 추가할 수 있습니다");
+ }
+
+ // Owner 역할은 양도를 통해서만 가능
+ if (role === "owner") {
+ throw new Error("Owner 역할은 직접 할당할 수 없습니다. transferOwnership을 사용하세요.");
+ }
+
+ await db.insert(projectMembers).values({
+ projectId,
+ userId: newMemberId,
+ role,
+ addedBy: addedByUserId,
+ });
+ }
+
+ // 프로젝트 소유권 이전 (Owner만 가능)
+ async transferOwnership(
+ projectId: string,
+ currentOwnerId: number,
+ newOwnerId: number
+ ): Promise<void> {
+ await db.transaction(async (tx) => {
+ // 1. 현재 Owner 확인
+ const project = await tx.query.fileSystemProjects.findFirst({
+ where: and(
+ eq(fileSystemProjects.id, projectId),
+ eq(fileSystemProjects.ownerId, currentOwnerId)
+ ),
+ });
+
+ if (!project) {
+ throw new Error("프로젝트 소유자만 소유권을 이전할 수 있습니다");
+ }
+
+ // 2. 프로젝트 owner 업데이트
+ await tx
+ .update(fileSystemProjects)
+ .set({ ownerId: newOwnerId })
+ .where(eq(fileSystemProjects.id, projectId));
+
+ // 3. 프로젝트 멤버 역할 업데이트
+ // 이전 owner를 admin으로 변경
+ await tx
+ .update(projectMembers)
+ .set({ role: "admin" })
+ .where(
+ and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.userId, currentOwnerId)
+ )
+ );
+
+ // 새 owner를 owner 역할로 설정 (없으면 추가)
+ await tx
+ .insert(projectMembers)
+ .values({
+ projectId,
+ userId: newOwnerId,
+ role: "owner",
+ addedBy: currentOwnerId,
+ })
+ .onConflictDoUpdate({
+ target: [projectMembers.projectId, projectMembers.userId],
+ set: { role: "owner", updatedAt: new Date() },
+ });
+ });
+ }
+
+ // 프로젝트 삭제 (Owner만 가능)
+ async deleteProject(projectId: string, userId: number): Promise<void> {
+ const isOwner = await this.isProjectOwner(projectId, userId);
+
+ if (!isOwner) {
+ throw new Error("프로젝트 소유자만 프로젝트를 삭제할 수 있습니다");
+ }
+
+ // 프로젝트 삭제 (cascade로 관련 파일, 멤버 등도 삭제됨)
+ await db.delete(fileSystemProjects).where(eq(fileSystemProjects.id, projectId));
+ }
+
+ // 프로젝트 설정 변경 (Owner와 Admin만 가능)
+ async updateProjectSettings(
+ projectId: string,
+ userId: number,
+ settings: {
+ name?: string;
+ description?: string;
+ isPublic?: boolean;
+ externalAccessEnabled?: boolean;
+ }
+ ): Promise<void> {
+ const access = await this.checkProjectAccess(projectId, userId, "admin");
+
+ if (!access.hasAccess) {
+ throw new Error("프로젝트 설정을 변경할 권한이 없습니다");
+ }
+
+ await db
+ .update(fileSystemProjects)
+ .set({
+ ...settings,
+ updatedAt: new Date(),
+ })
+ .where(eq(fileSystemProjects.id, projectId));
+ }
+
+ // 사용자의 프로젝트 목록 조회
+ async getUserProjects(userId: number): Promise<{
+ owned: FileSystemProject[];
+ member: Array<FileSystemProject & { role: ProjectRole }>;
+ public: FileSystemProject[];
+ }> {
+ // 1. 소유한 프로젝트
+ const ownedProjects = await db.query.fileSystemProjects.findMany({
+ where: eq(fileSystemProjects.ownerId, userId),
+ orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)],
+ });
+
+ // 2. 멤버로 참여한 프로젝트
+ const memberProjects = await db
+ .select({
+ project: fileSystemProjects,
+ role: projectMembers.role,
+ })
+ .from(projectMembers)
+ .innerJoin(fileSystemProjects, eq(fileSystemProjects.id, projectMembers.projectId))
+ .where(
+ and(
+ eq(projectMembers.userId, userId),
+ // Owner가 아닌 경우만 (중복 방지) - not 사용
+ not(eq(fileSystemProjects.ownerId, userId))
+ )
+ );
+
+ // 3. 공개 프로젝트 (참여하지 않은)
+ const memberProjectIds = memberProjects.map(mp => mp.project.id);
+ const ownedProjectIds = ownedProjects.map(p => p.id);
+ const allUserProjectIds = [...memberProjectIds, ...ownedProjectIds];
+
+ let publicProjects;
+ if (allUserProjectIds.length > 0) {
+ publicProjects = await db.query.fileSystemProjects.findMany({
+ where: and(
+ eq(fileSystemProjects.isPublic, true),
+ not(eq(fileSystemProjects.ownerId, userId)),
+ not(inArray(fileSystemProjects.id, allUserProjectIds))
+ ),
+ orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)],
+ });
+ } else {
+ // 사용자가 참여한 프로젝트가 없는 경우
+ publicProjects = await db.query.fileSystemProjects.findMany({
+ where: and(
+ eq(fileSystemProjects.isPublic, true),
+ not(eq(fileSystemProjects.ownerId, userId))
+ ),
+ orderBy: (fileSystemProjects, { desc }) => [desc(fileSystemProjects.createdAt)],
+ });
+ }
+
+ return {
+ owned: ownedProjects,
+ member: memberProjects.map(mp => ({
+ ...mp.project,
+ role: mp.role as ProjectRole,
+ })),
+ public: publicProjects,
+ };
+ }
+
+ // 프로젝트 통계 (Owner용)
+ async getProjectStats(projectId: string, userId: number) {
+ const isOwner = await this.isProjectOwner(projectId, userId);
+
+ if (!isOwner) {
+ throw new Error("프로젝트 통계는 소유자만 볼 수 있습니다");
+ }
+
+ // 파일 통계
+ const fileStats = await db
+ .select({
+ totalFiles: sql<number>`COUNT(*)`,
+ totalSize: sql<number>`COALESCE(SUM(size), 0)`,
+ publicFiles: sql<number>`COUNT(CASE WHEN category = 'public' THEN 1 END)`,
+ restrictedFiles: sql<number>`COUNT(CASE WHEN category = 'restricted' THEN 1 END)`,
+ confidentialFiles: sql<number>`COUNT(CASE WHEN category = 'confidential' THEN 1 END)`,
+ })
+ .from(fileItems)
+ .where(eq(fileItems.projectId, projectId));
+
+ // 멤버 통계
+ const memberStats = await db
+ .select({
+ totalMembers: sql<number>`COUNT(*)`,
+ admins: sql<number>`COUNT(CASE WHEN role = 'admin' THEN 1 END)`,
+ editors: sql<number>`COUNT(CASE WHEN role = 'editor' THEN 1 END)`,
+ viewers: sql<number>`COUNT(CASE WHEN role = 'viewer' THEN 1 END)`,
+ })
+ .from(projectMembers)
+ .where(eq(projectMembers.projectId, projectId));
+
+ // 활동 통계 (최근 30일)
+ const activityStats = await db
+ .select({
+ totalViews: sql<number>`COUNT(CASE WHEN action = 'view' THEN 1 END)`,
+ totalDownloads: sql<number>`COUNT(CASE WHEN action = 'download' THEN 1 END)`,
+ totalUploads: sql<number>`COUNT(CASE WHEN action = 'upload' THEN 1 END)`,
+ uniqueUsers: sql<number>`COUNT(DISTINCT user_id)`,
+ })
+ .from(fileActivityLogs)
+ .where(
+ and(
+ eq(fileActivityLogs.projectId, projectId),
+ gte(fileActivityLogs.createdAt, new Date(Date.now() - 30 * 24 * 60 * 60 * 1000))
+ )
+ );
+
+ return {
+ files: fileStats[0],
+ members: memberStats[0],
+ activity: activityStats[0],
+ };
+ }
+
+ // 개별 프로젝트 정보 조회
+ async getProject(projectId: string): Promise<FileSystemProject | null> {
+ const project = await db.query.fileSystemProjects.findFirst({
+ where: eq(fileSystemProjects.id, projectId),
+ with: {
+ owner: true,
+ },
+ });
+
+ return project || null;
+ }
+
+ // 프로젝트 보관
+ async archiveProject(projectId: string, userId: number): Promise<void> {
+ const isOwner = await this.isProjectOwner(projectId, userId);
+
+ if (!isOwner) {
+ throw new Error("프로젝트 소유자만 보관할 수 있습니다");
+ }
+
+ // 프로젝트를 보관 상태로 변경
+ await db
+ .update(fileSystemProjects)
+ .set({
+ metadata: sql`jsonb_set(metadata, '{archived}', 'true')`,
+ updatedAt: new Date(),
+ })
+ .where(eq(fileSystemProjects.id, projectId));
+ }
+
+ // 멤버 역할 업데이트
+ async updateMemberRole(
+ projectId: string,
+ memberId: string,
+ newRole: ProjectRole
+ ): Promise<void> {
+ // Owner 역할은 transferOwnership를 통해서만 가능
+ if (newRole === 'owner') {
+ throw new Error("Owner 역할은 소유권 이전을 통해서만 가능합니다");
+ }
+
+ await db
+ .update(projectMembers)
+ .set({
+ role: newRole,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.id, memberId)
+ )
+ );
+ }
+
+ // 프로젝트 멤버 제거
+ async removeMember(projectId: string, memberId: string): Promise<void> {
+ // Owner는 제거할 수 없음
+ const member = await db.query.projectMembers.findFirst({
+ where: and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.id, memberId)
+ ),
+ });
+
+ if (member?.role === 'owner') {
+ throw new Error("Owner는 제거할 수 없습니다");
+ }
+
+ await db
+ .delete(projectMembers)
+ .where(
+ and(
+ eq(projectMembers.projectId, projectId),
+ eq(projectMembers.id, memberId)
+ )
+ );
+ }
+
+ // 프로젝트 멤버 목록 조회
+ async getProjectMembers(projectId: string): Promise<any[]> {
+ const members = await db
+ .select({
+ id: projectMembers.id,
+ userId: projectMembers.userId,
+ role: projectMembers.role,
+ addedAt: projectMembers.createdAt,
+ user: {
+ name: users.name,
+ email: users.email,
+ imageUrl: users.imageUrl,
+ domain: users.domain,
+ },
+ })
+ .from(projectMembers)
+ .innerJoin(users, eq(users.id, projectMembers.userId))
+ .where(eq(projectMembers.projectId, projectId))
+ .orderBy(
+ sql`CASE
+ WHEN ${projectMembers.role} = 'owner' THEN 1
+ WHEN ${projectMembers.role} = 'admin' THEN 2
+ WHEN ${projectMembers.role} = 'editor' THEN 3
+ ELSE 4
+ END`
+ );
+
+ return members;
+ }
+}
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index 4c1861b9..14035562 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -429,7 +429,7 @@ React.useEffect(() => {
{/* Document Number Preview */}
<div className="mt-3 p-2 bg-white dark:bg-gray-900 border rounded">
<Label className="text-xs text-gray-600 dark:text-gray-400">
- {activeTab === "SHI" ? "Document Number" : "Vendor Document Number"} Preview:
+ {activeTab === "SHI" ? "Document Number" : "Project Document Number"} Preview:
</Label>
<div className="font-mono text-sm font-medium text-blue-600 dark:text-blue-400">
{generatePreviewDocNumber()}
@@ -525,85 +525,96 @@ React.useEffect(() => {
)
}
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col">
- <DialogHeader className="flex-shrink-0">
- <DialogTitle>Add New Document</DialogTitle>
- <DialogDescription>
- Enter the basic information for the new document.
- </DialogDescription>
- </DialogHeader>
+ return (
+<Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col overflow-hidden">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>Add New Document</DialogTitle>
+ <DialogDescription>
+ Enter the basic information for the new document.
+ </DialogDescription>
+ </DialogHeader>
+
+ {!shiType && !cpyType ? (
+ <div className="flex-1 flex items-center justify-center">
+ <Alert className="max-w-md">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ Required Document Number Type (SHI, CPY) is not configured. Please configure it first in the Number Types management.
+ </AlertDescription>
+ </Alert>
+ </div>
+ ) : (
+ <>
+ <Tabs
+ value={activeTab}
+ onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")}
+ className="flex-1 min-h-0 flex flex-col"
+ >
+ {/* 고정 영역 */}
+ <TabsList className="grid w-full grid-cols-2 flex-shrink-0">
+ <TabsTrigger value="SHI" disabled={!shiType}>
+ SHI (Document No.)
+ {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />}
+ </TabsTrigger>
+ <TabsTrigger value="CPY" disabled={!cpyType}>
+ CPY (Project Document No.)
+ {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />}
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 스크롤 영역 */}
+ <div className="flex-1 min-h-0 mt-4 overflow-y-auto pr-2">
+ <TabsContent
+ value="SHI"
+ className="data-[state=inactive]:hidden"
+ >
+ {shiType ? (
+ <DocumentForm />
+ ) : (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ SHI Document Number Type is not configured.
+ </AlertDescription>
+ </Alert>
+ )}
+ </TabsContent>
- {!shiType && !cpyType ? (
- <div className="flex-1 flex items-center justify-center">
- <Alert className="max-w-md">
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- 필수 Document Number Type (SHI, CPY)이 설정되지 않았습니다.
- 먼저 Number Types 관리에서 설정해주세요.
- </AlertDescription>
- </Alert>
+ <TabsContent
+ value="CPY"
+ className="data-[state=inactive]:hidden"
+ >
+ {cpyType ? (
+ <DocumentForm />
+ ) : (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ CPY Document Number Type is not configured.
+ </AlertDescription>
+ </Alert>
+ )}
+ </TabsContent>
</div>
- ) : (
- <>
- <Tabs value={activeTab} onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")} className="flex-1 flex flex-col">
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="SHI" disabled={!shiType}>
- SHI (삼성중공업 도서번호)
- {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />}
- </TabsTrigger>
- <TabsTrigger value="CPY" disabled={!cpyType}>
- CPY (프로젝트 문서번호)
- {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />}
- </TabsTrigger>
- </TabsList>
-
- <div className="flex-1 overflow-y-auto pr-2 mt-4">
- <TabsContent value="SHI" className="mt-0">
- {shiType ? (
- <DocumentForm />
- ) : (
- <Alert>
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- SHI Document Number Type이 설정되지 않았습니다.
- </AlertDescription>
- </Alert>
- )}
- </TabsContent>
-
- <TabsContent value="CPY" className="mt-0">
- {cpyType ? (
- <DocumentForm />
- ) : (
- <Alert>
- <AlertTriangle className="h-4 w-4" />
- <AlertDescription>
- CPY Document Number Type이 설정되지 않았습니다.
- </AlertDescription>
- </Alert>
- )}
- </TabsContent>
- </div>
- </Tabs>
+ </Tabs>
- <DialogFooter className="flex-shrink-0">
- <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
- Cancel
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)}
- >
- {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
- Add Document
- </Button>
- </DialogFooter>
- </>
- )}
- </DialogContent>
- </Dialog>
+ <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4">
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)}
+ >
+ {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
+ Add Document
+ </Button>
+ </DialogFooter>
+ </>
+ )}
+ </DialogContent>
+</Dialog>
)
}
// =============================================================================
@@ -736,12 +747,12 @@ export function EditDocumentDialog({
{/* Vendor Document Number (Plant project only) */}
{isPlantProject && (
<div className="grid gap-2">
- <Label htmlFor="edit-vendorDocNumber">Vendor Document Number</Label>
+ <Label htmlFor="edit-vendorDocNumber">Project Document Number</Label>
<Input
id="edit-vendorDocNumber"
value={formData.vendorDocNumber}
onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })}
- placeholder="Vendor provided document number"
+ placeholder="Project provided document number"
/>
</div>
)}