summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-18 00:23:40 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-18 00:23:40 +0000
commitcf8dac0c6490469dab88a560004b0c07dbd48612 (patch)
treeb9e76061e80d868331e6b4277deecb9086f845f3 /lib
parente5745fc0268bbb5770bc14a55fd58a0ec30b466e (diff)
(대표님) rfq, 계약, 서명 등
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/actions.ts67
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx2
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx72
-rw-r--r--lib/forms/services.ts2
-rw-r--r--lib/itb/service.ts741
-rw-r--r--lib/itb/table/approve-purchase-request-dialog.tsx0
-rw-r--r--lib/itb/table/create-purchase-request-dialog.tsx995
-rw-r--r--lib/itb/table/create-rfq-dialog.tsx380
-rw-r--r--lib/itb/table/delete-purchase-request-dialog.tsx225
-rw-r--r--lib/itb/table/edit-purchase-request-sheet.tsx1081
-rw-r--r--lib/itb/table/items-dialog.tsx167
-rw-r--r--lib/itb/table/purchase-request-columns.tsx380
-rw-r--r--lib/itb/table/purchase-requests-table.tsx229
-rw-r--r--lib/itb/table/view-purchase-request-sheet.tsx809
-rw-r--r--lib/itb/validations.ts85
-rw-r--r--lib/items/service.ts32
-rw-r--r--lib/mail/templates/tbe-request.hbs198
-rw-r--r--lib/rfq-last/contract-actions.ts329
-rw-r--r--lib/rfq-last/quotation-compare-view.tsx447
-rw-r--r--lib/rfq-last/service.ts134
-rw-r--r--lib/rfq-last/table/create-general-rfq-dialog.tsx4
-rw-r--r--lib/rfq-last/table/rfq-assign-pic-dialog.tsx311
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx402
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx100
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx378
-rw-r--r--lib/shi-signature/buyer-signature.ts186
-rw-r--r--lib/shi-signature/signature-list.tsx149
-rw-r--r--lib/shi-signature/upload-form.tsx115
-rw-r--r--lib/tbe-last/service.ts181
-rw-r--r--lib/tbe-last/table/tbe-last-table-columns.tsx49
-rw-r--r--lib/tbe-last/table/tbe-last-table.tsx118
-rw-r--r--lib/tbe-last/vendor-tbe-service.ts4
32 files changed, 7663 insertions, 709 deletions
diff --git a/lib/basic-contract/actions.ts b/lib/basic-contract/actions.ts
index 0af9b948..eb4389ae 100644
--- a/lib/basic-contract/actions.ts
+++ b/lib/basic-contract/actions.ts
@@ -67,69 +67,4 @@ export async function restoreDocuments(documentIds: number[]) {
console.error('문서 복구 처리 오류:', error)
throw new Error('문서 복구 처리 중 오류가 발생했습니다.')
}
-}
-
-export async function createDocumentRevisionAction(input: {
- baseDocumentId: number;
- contractTemplateName: string;
- contractTemplateType: string;
- revision: number;
- legalReviewRequired: boolean;
- fileName: string;
- filePath: string;
-}) {
- try {
- const { createBasicContractTemplateRevision } = await import('./service');
-
- const { data, error } = await createBasicContractTemplateRevision({
- ...input,
- status: 'ACTIVE' as const
- });
-
- if (error) {
- throw new Error(error);
- }
-
- return {
- success: true,
- data,
- message: `${input.contractTemplateName} v${input.revision} 리비전이 성공적으로 생성되었습니다.`
- };
-
- } catch (error) {
- console.error('문서 리비전 생성 오류:', error);
- throw new Error(error instanceof Error ? error.message : '문서 리비전 생성 중 오류가 발생했습니다.');
- }
-}
-
-// 업로드 완료 후 문서 생성 (클라이언트에서 직접 호출 가능한 서버 액션)
-export async function createDocumentFromUpload(input: {
- contractTemplateType: string
- contractTemplateName: string
- legalReviewRequired: boolean
- fileName: string
- filePath: string
-}) {
- try {
- const { createBasicContractTemplate } = await import('./service');
-
- const { data, error } = await createBasicContractTemplate({
- contractTemplateType: input.contractTemplateType,
- contractTemplateName: input.contractTemplateName,
- revision: 1,
- status: 'ACTIVE',
- legalReviewRequired: input.legalReviewRequired,
- fileName: input.fileName,
- filePath: input.filePath,
- } as any)
-
- if (error) throw new Error(error)
-
- revalidateTag('basic-contract-templates')
- revalidatePath('/evcp/basic-contract-template')
-
- return { success: true, id: data?.id }
- } catch (e: any) {
- return { success: false, error: e?.message || '문서 생성 실패' }
- }
-}
+} \ No newline at end of file
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
index fa68c9c8..534a2705 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -86,8 +86,6 @@ export function BasicContractSignDialog({
isComplete?: boolean;
}>>({});
- console.log(gtcCommentStatus, "gtcCommentStatus")
-
const router = useRouter()
// 실제 사용할 open 상태 (외부 제어가 있으면 외부 상태 사용, 없으면 내부 상태 사용)
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index 5698428e..77bfaf41 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -25,6 +25,7 @@ import { getActiveSurveyTemplate, getVendorSignatureFile, type SurveyTemplateWit
import { useConditionalSurvey } from '../vendor-table/survey-conditional';
import { SurveyComponent } from './SurveyComponent';
import { GtcClausesComponent } from './GtcClausesComponent';
+import { getBuyerSignatureFileWithFallback } from "@/lib/shi-signature/buyer-signature";
interface FileInfo {
path: string;
@@ -256,6 +257,63 @@ class AutoSignatureFieldDetector {
}
}
+const applyBuyerSignatureAutomatically = async (instance: WebViewerInstance) => {
+ const { Core } = instance;
+ const { documentViewer, annotationManager } = Core;
+ const document = documentViewer.getDocument();
+
+ if (!document) return;
+
+ try {
+ console.log('🔍 구매자 서명란 자동 서명 시작...');
+
+ // "삼성중공업_서명란" 텍스트 검색
+ const searchText = '삼성중공업_서명란';
+ const textSearchIterator = await document.getTextSearchIterator();
+ textSearchIterator.begin(searchText, Core.Search.Mode.PAGE_STOP | Core.Search.Mode.HIGHLIGHT);
+
+ let searchResult = await textSearchIterator.next();
+
+ if (searchResult && searchResult.resultCode === Core.Search.ResultCode.FOUND) {
+ const pageNumber = searchResult.pageNum;
+ const quads = searchResult.quads;
+
+ if (quads && quads.length > 0) {
+ const quad = quads[0];
+ const x = Math.min(quad.x1, quad.x2, quad.x3, quad.x4);
+ const y = Math.min(quad.y1, quad.y2, quad.y3, quad.y4);
+ const textHeight = Math.abs(quad.y3 - quad.y1);
+
+ // 구매자 서명 이미지 가져오기
+ const buyerSignature = await getBuyerSignatureFileWithFallback();
+
+ if (buyerSignature) {
+ // 스탬프 어노테이션 생성
+ const stamp = new Core.Annotations.StampAnnotation();
+ stamp.PageNumber = pageNumber;
+ stamp.X = x;
+ stamp.Y = y + textHeight + 5; // 텍스트 아래 5픽셀
+ stamp.Width = 150;
+ stamp.Height = 50;
+
+ await stamp.setImageData(buyerSignature.data.dataUrl);
+
+ // 어노테이션 추가
+ annotationManager.addAnnotation(stamp);
+ annotationManager.drawAnnotationsFromList([stamp]);
+
+ console.log('✅ 구매자 서명 자동 적용 완료');
+ toast.info('삼성중공업 서명이 자동으로 적용되었습니다.', {
+ duration: 3000
+ });
+ }
+ }
+ }
+ } catch (error) {
+ console.error('구매자 자동 서명 처리 실패:', error);
+ }
+};
+
function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendor' | 'buyer' = 'vendor') {
const [signatureFields, setSignatureFields] = useState<string[]>([]);
@@ -790,10 +848,20 @@ export function BasicContractSignViewer({
const { WidgetFlags } = Annotations;
const FitMode = newInstance.UI.FitMode;
- const handleDocumentLoaded = () => {
+ const handleDocumentLoaded = async () => {
setFileLoading(false);
newInstance.UI.setFitMode(FitMode.FitWidth);
-
+
+ // GTC 템플릿이 아닌 경우에만 구매자 자동 서명 적용
+ if (!templateName.includes('GTC')) {
+ try {
+ // 구매자 서명란 찾아서 자동 서명
+ await applyBuyerSignatureAutomatically(newInstance);
+ } catch (error) {
+ console.error('구매자 자동 서명 실패:', error);
+ }
+ }
+
requestAnimationFrame(() => {
try {
documentViewer.refreshAll();
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index bf4fc3a0..6310b693 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -1632,7 +1632,7 @@ export async function sendDataToSEDP(
// Make the API call
const response = await fetch(
- `${SEDP_API_BASE_URL}/AdapterData/Create`,
+ `${SEDP_API_BASE_URL}/AdapterData/Overwrite`,
{
method: 'POST',
headers: {
diff --git a/lib/itb/service.ts b/lib/itb/service.ts
new file mode 100644
index 00000000..66732c02
--- /dev/null
+++ b/lib/itb/service.ts
@@ -0,0 +1,741 @@
+// app/actions/purchase-requests.ts
+"use server";
+
+import db from "@/db/db";
+import { purchaseRequestsView, purchaseRequests, purchaseRequestAttachments, rfqsLast, rfqLastAttachments, rfqLastAttachmentRevisions,users } from "@/db/schema";
+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'
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { GetPurchaseRequestsSchema } from "./validations";
+import { z } from "zod"
+
+const createRequestSchema = z.object({
+ requestTitle: z.string().min(1),
+ requestDescription: z.string().optional(),
+ projectId: z.number().optional(),
+ projectCode: z.string().optional(),
+ projectName: z.string().optional(),
+ projectCompany: z.string().optional(),
+ projectSite: z.string().optional(),
+ classNo: z.string().optional(),
+ packageNo: z.string().optional(),
+ packageName: z.string().optional(),
+ majorItemMaterialCategory: z.string().optional(),
+ majorItemMaterialDescription: z.string().optional(),
+ smCode: z.string().optional(),
+ estimatedBudget: z.string().optional(),
+ requestedDeliveryDate: z.date().optional(),
+ items: z.array(z.object({
+ id: z.string(),
+ itemCode: z.string(),
+ itemName: z.string(),
+ specification: z.string(),
+ quantity: z.number(),
+ unit: z.string(),
+ estimatedUnitPrice: z.number().optional(),
+ remarks: z.string().optional(),
+ })).optional(),
+ attachments: z.array(z.object({
+ fileName: z.string(),
+ originalFileName: z.string(),
+ filePath: z.string(),
+ fileSize: z.number(),
+ fileType: z.string(),
+ })).optional(),
+})
+
+// 구매요청 생성
+export async function createPurchaseRequest(input: z.infer<typeof createRequestSchema>) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return { error: "인증되지 않은 사용자입니다." }
+ }
+
+ const userId = Number(session.user.id)
+ const data = createRequestSchema.parse(input)
+
+ const result = await db.transaction(async (tx) => {
+ // 요청 번호 생성
+ const year = new Date().getFullYear()
+ const lastRequest = await tx
+ .select()
+ .from(purchaseRequests)
+ .where(ilike(purchaseRequests.requestCode, `PR-${year}-%`))
+ .orderBy(desc(purchaseRequests.requestCode))
+ .limit(1)
+
+ const nextNumber = lastRequest.length > 0
+ ? parseInt(lastRequest[0].requestCode.split('-')[2]) + 1
+ : 1
+
+ const requestCode = `PR-${year}-${String(nextNumber).padStart(5, '0')}`
+
+ // 요청 생성
+ const [request] = await tx
+ .insert(purchaseRequests)
+ .values({
+ requestCode,
+ projectId: data.projectId,
+ projectCode: data.projectCode,
+ projectName: data.projectName,
+ projectCompany: data.projectCompany,
+ projectSite: data.projectSite,
+ classNo: data.classNo,
+ packageNo: data.packageNo,
+ packageName: data.packageName,
+ majorItemMaterialCategory: data.majorItemMaterialCategory,
+ majorItemMaterialDescription: data.majorItemMaterialDescription,
+ smCode: data.smCode,
+ requestTitle: data.requestTitle,
+ requestDescription: data.requestDescription,
+ estimatedBudget: data.estimatedBudget,
+ requestedDeliveryDate: data.requestedDeliveryDate,
+ items: data.items || [],
+ engPicId: userId,
+ engPicName: session.user.name,
+ status: "작성중",
+ createdBy: userId,
+ updatedBy: userId,
+ })
+ .returning()
+
+ // 첨부파일 저장
+ if (data.attachments && data.attachments.length > 0) {
+ await tx.insert(purchaseRequestAttachments).values(
+ data.attachments.map(file => ({
+ requestId: request.id,
+ ...file,
+ category: "설계문서",
+ createdBy: userId,
+ }))
+ )
+ }
+
+ return request
+ })
+
+ revalidateTag("purchase-requests")
+
+ return {
+ success: true,
+ data: result,
+ message: `구매요청 ${result.requestCode}이(가) 생성되었습니다.`
+ }
+ } catch (error) {
+ console.error("Create purchase request error:", error)
+ return { error: "구매요청 생성 중 오류가 발생했습니다." }
+ }
+}
+
+// 구매요청 수정
+export async function updatePurchaseRequest(
+ id: number,
+ input: Partial<z.infer<typeof createRequestSchema>>
+) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return { error: "인증되지 않은 사용자입니다." }
+ }
+
+ const userId = Number(session.user.id)
+
+ const result = await db.transaction(async (tx) => {
+ // 수정 가능한 상태인지 확인
+ const [existing] = await tx
+ .select()
+ .from(purchaseRequests)
+ .where(eq(purchaseRequests.id, id))
+ .limit(1)
+
+ if (!existing) {
+ return { error: "구매요청을 찾을 수 없습니다." }
+ }
+
+ if (existing.status !== "작성중") {
+ return { error: "작성중 상태의 요청만 수정할 수 있습니다." }
+ }
+
+ // 구매요청 업데이트
+ const [updated] = await tx
+ .update(purchaseRequests)
+ .set({
+ projectId: input.projectId,
+ projectCode: input.projectCode,
+ projectName: input.projectName,
+ projectCompany: input.projectCompany,
+ projectSite: input.projectSite,
+ classNo: input.classNo,
+ packageNo: input.packageNo,
+ packageName: input.packageName,
+ majorItemMaterialCategory: input.majorItemMaterialCategory,
+ majorItemMaterialDescription: input.majorItemMaterialDescription,
+ smCode: input.smCode,
+ requestTitle: input.requestTitle,
+ requestDescription: input.requestDescription,
+ estimatedBudget: input.estimatedBudget,
+ requestedDeliveryDate: input.requestedDeliveryDate,
+ items: input.items || [],
+ updatedBy: userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(purchaseRequests.id, id))
+ .returning()
+
+ // 첨부파일 처리
+ if (input.attachments !== undefined) {
+ // 기존 첨부파일 모두 삭제
+ await tx
+ .delete(purchaseRequestAttachments)
+ .where(eq(purchaseRequestAttachments.requestId, id))
+
+ // 새 첨부파일 추가
+ if (input.attachments && input.attachments.length > 0) {
+ await tx.insert(purchaseRequestAttachments).values(
+ input.attachments.map(file => ({
+ requestId: id,
+ fileName: file.fileName,
+ originalFileName: file.originalFileName,
+ filePath: file.filePath,
+ fileSize: file.fileSize,
+ fileType: file.fileType,
+ category: "설계문서",
+ createdBy: userId,
+ }))
+ )
+ }
+ }
+
+ return updated
+ })
+
+ revalidateTag("purchase-requests")
+
+ return {
+ success: true,
+ data: result,
+ message: "구매요청이 수정되었습니다."
+ }
+ } catch (error) {
+ console.error("Update purchase request error:", error)
+ return { error: "구매요청 수정 중 오류가 발생했습니다." }
+ }
+}
+
+// 첨부파일 삭제
+export async function deletePurchaseRequestAttachment(attachmentId: number) {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) throw new Error("Unauthorized");
+ await db.delete(purchaseRequestAttachments)
+ .where(eq(purchaseRequestAttachments.id, attachmentId));
+
+ revalidatePath("/evcp/purchase-requests");
+}
+
+// 요청 확정 (작성중 → 요청완료)
+export async function confirmPurchaseRequest(requestId: number) {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) throw new Error("Unauthorized");
+
+ const userId = Number(session.user.id)
+
+ const [request] = await db
+ .select()
+ .from(purchaseRequests)
+ .where(eq(purchaseRequests.id, requestId));
+
+ if (!request) throw new Error("Request not found");
+ if (request.status !== "작성중") {
+ throw new Error("Only draft requests can be confirmed");
+ }
+
+ const [updated] = await db
+ .update(purchaseRequests)
+ .set({
+ status: "요청완료",
+ confirmedAt: new Date(),
+ confirmedBy: userId,
+ updatedBy: userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(purchaseRequests.id, requestId))
+ .returning();
+
+ revalidatePath("/evcp/purchase-requests");
+ return updated;
+}
+
+// 요청 승인 및 RFQ 생성 (첨부파일 이관 포함)
+export async function approvePurchaseRequestAndCreateRfq(
+ requestId: number,
+ purchasePicId?: number
+) {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) throw new Error("Unauthorized");
+ const userId = Number(session.user.id)
+
+
+ return await db.transaction(async (tx) => {
+ // 구매 요청 및 첨부파일 조회
+ const [request] = await tx
+ .select()
+ .from(purchaseRequests)
+ .where(eq(purchaseRequests.id, requestId));
+
+ const attachments = await tx
+ .select()
+ .from(purchaseRequestAttachments)
+ .where(eq(purchaseRequestAttachments.requestId, requestId));
+
+
+ const rfqCode = await generateItbRfqCode(purchasePicId);
+
+ 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 생성",
+ 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));
+ }
+
+ // 구매 요청 상태 업데이트
+ 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 rfq;
+ });
+}
+
+export async function getAllPurchaseRequests(input: GetPurchaseRequestsSchema) {
+ return unstable_cache(
+ async () => {
+ // 페이징
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10);
+ const limit = input.perPage ?? 10;
+
+ // 고급 필터
+ const advancedWhere = filterColumns({
+ table: purchaseRequestsView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ });
+
+ // 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ sql`${purchaseRequestsView.requestCode} ILIKE ${s}`,
+ sql`${purchaseRequestsView.requestTitle} ILIKE ${s}`,
+ sql`${purchaseRequestsView.projectCode} ILIKE ${s}`,
+ sql`${purchaseRequestsView.projectName} ILIKE ${s}`,
+ sql`${purchaseRequestsView.packageNo} ILIKE ${s}`,
+ sql`${purchaseRequestsView.packageName} ILIKE ${s}`,
+ sql`${purchaseRequestsView.engPicName} ILIKE ${s}`,
+ sql`${purchaseRequestsView.purchasePicName} ILIKE ${s}`,
+ sql`${purchaseRequestsView.majorItemMaterialCategory} ILIKE ${s}`,
+ sql`${purchaseRequestsView.smCode} ILIKE ${s}`
+ );
+ }
+
+ // 최종 WHERE
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ const col = (purchaseRequestsView as any)[s.id];
+ return s.desc ? desc(col) : asc(col);
+ })
+ : [desc(purchaseRequestsView.createdAt)];
+
+ // 메인 SELECT
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select()
+ .from(purchaseRequestsView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit);
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(purchaseRequestsView)
+ .where(finalWhere);
+
+ return [data, Number(count)];
+ });
+
+ const pageCount = Math.ceil(total / limit);
+ return { data: rows, pageCount };
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 60,
+ tags: ["purchase-requests"],
+ }
+ )();
+}
+
+// 통계 정보 조회 함수 (선택사항)
+export async function getPurchaseRequestStats() {
+ return unstable_cache(
+ async () => {
+ const stats = await db
+ .select({
+ total: sql<number>`count(*)`.as("total"),
+ draft: sql<number>`count(*) filter (where status = '작성중')`.as("draft"),
+ rfqCreated: sql<number>`count(*) filter (where status = 'RFQ생성완료')`.as("rfqCreated"),
+ })
+ .from(purchaseRequestsView);
+
+ return stats[0];
+ },
+ [],
+ {
+ revalidate: 60,
+ tags: ["purchase-request-stats"],
+ }
+ )();
+}
+
+
+// 스키마 정의
+const deleteSchema = z.object({
+ ids: z.array(z.number()),
+})
+
+
+// 구매요청 삭제 (단일 또는 복수)
+export async function deletePurchaseRequests(input: z.infer<typeof deleteSchema>) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return { error: "인증되지 않은 사용자입니다." }
+ }
+
+ const { ids } = deleteSchema.parse(input)
+
+ if (ids.length === 0) {
+ return { error: "삭제할 요청을 선택해주세요." }
+ }
+
+ // 삭제 가능한 상태인지 확인 (작성중 상태만 삭제 가능)
+ const requests = await db
+ .select({
+ id: purchaseRequests.id,
+ status: purchaseRequests.status,
+ requestCode: purchaseRequests.requestCode,
+ })
+ .from(purchaseRequests)
+ .where(inArray(purchaseRequests.id, ids))
+
+ const nonDeletable = requests.filter(req => req.status !== "작성중")
+
+ if (nonDeletable.length > 0) {
+ const codes = nonDeletable.map(req => req.requestCode).join(", ")
+ return {
+ error: `다음 요청은 삭제할 수 없습니다: ${codes}. 작성중 상태의 요청만 삭제 가능합니다.`
+ }
+ }
+
+ // 트랜잭션으로 삭제 처리
+ await db.transaction(async (tx) => {
+ // 1. 첨부파일 먼저 삭제
+ await tx
+ .delete(purchaseRequestAttachments)
+ .where(inArray(purchaseRequestAttachments.requestId, ids))
+
+ // 2. 구매요청 삭제
+ await tx
+ .delete(purchaseRequests)
+ .where(inArray(purchaseRequests.id, ids))
+ })
+
+ // 캐시 무효화
+ revalidateTag("purchase-requests")
+
+ return {
+ success: true,
+ message: ids.length === 1
+ ? "구매요청이 삭제되었습니다."
+ : `${ids.length}개의 구매요청이 삭제되었습니다.`
+ }
+ } catch (error) {
+ console.error("Delete purchase requests error:", error)
+ return { error: "구매요청 삭제 중 오류가 발생했습니다." }
+ }
+}
+
+export async function getPurchaseRequestAttachments(requestId: number) {
+ try {
+ const attachments = await db
+ .select({
+ id: purchaseRequestAttachments.id,
+ fileName: purchaseRequestAttachments.fileName,
+ originalFileName: purchaseRequestAttachments.originalFileName,
+ filePath: purchaseRequestAttachments.filePath,
+ fileSize: purchaseRequestAttachments.fileSize,
+ fileType: purchaseRequestAttachments.fileType,
+ category: purchaseRequestAttachments.category,
+ description: purchaseRequestAttachments.description,
+ createdBy: purchaseRequestAttachments.createdBy,
+ createdAt: purchaseRequestAttachments.createdAt,
+ })
+ .from(purchaseRequestAttachments)
+ .where(eq(purchaseRequestAttachments.requestId, requestId))
+ .orderBy(desc(purchaseRequestAttachments.createdAt))
+
+ return {
+ success: true,
+ data: attachments
+ }
+ } catch (error) {
+ console.error("Get attachments error:", error)
+ return {
+ success: false,
+ error: "첨부파일 조회 중 오류가 발생했습니다.",
+ data: []
+ }
+ }
+}
+
+
+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;
+ }
+ }
+
+ // 동일한 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}`;
+ } catch (error) {
+ console.error("Error generating ITB RFQ code:", error);
+ const fallback = Date.now().toString().slice(-5);
+ return `I???${fallback}`;
+ }
+ }
+
+
+ // 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)
+
+ 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 생성",
+ 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))
+ }
+
+ // 구매 요청 상태 업데이트
+ 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
+ } catch (err: any) {
+ console.error("approvePurchaseRequestsAndCreateRfqs 실행 오류:", err)
+ throw new Error(err.message || "구매 요청 처리 중 오류가 발생했습니다.")
+ }
+ }
+ \ No newline at end of file
diff --git a/lib/itb/table/approve-purchase-request-dialog.tsx b/lib/itb/table/approve-purchase-request-dialog.tsx
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/lib/itb/table/approve-purchase-request-dialog.tsx
diff --git a/lib/itb/table/create-purchase-request-dialog.tsx b/lib/itb/table/create-purchase-request-dialog.tsx
new file mode 100644
index 00000000..27b8a342
--- /dev/null
+++ b/lib/itb/table/create-purchase-request-dialog.tsx
@@ -0,0 +1,995 @@
+// components/purchase-requests/create-purchase-request-dialog.tsx
+"use client";
+
+import * as React from "react";
+import { useForm, useFieldArray } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { ProjectSelector } from "@/components/ProjectSelector";
+import { PackageSelector } from "@/components/PackageSelector";
+import type { PackageItem } from "@/lib/items/service";
+import { MaterialGroupSelector } from "@/components/common/material/material-group-selector";
+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 { Progress } from "@/components/ui/progress";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { CalendarIcon, X, Plus, FileText, Package, Trash2, Upload, FileIcon, AlertCircle, Paperclip } from "lucide-react";
+import { format } from "date-fns";
+import { cn } from "@/lib/utils";
+import { createPurchaseRequest } from "../service";
+import { toast } from "sonner";
+import { nanoid } from "nanoid";
+
+const attachmentSchema = z.object({
+ fileName: z.string(),
+ originalFileName: z.string(),
+ filePath: z.string(),
+ fileSize: z.number(),
+ fileType: z.string(),
+});
+
+const itemSchema = z.object({
+ id: z.string(),
+ itemCode: z.string(),
+ itemName: z.string().min(1, "아이템명을 입력해주세요"),
+ specification: z.string().default(""),
+ quantity: z.coerce.number().min(1, "수량은 1 이상이어야 합니다"),
+ unit: z.string().min(1, "단위를 입력해주세요"),
+ estimatedUnitPrice: z.coerce.number().optional(),
+ remarks: z.string().optional(),
+});
+
+const formSchema = z.object({
+ requestTitle: z.string().min(1, "요청 제목을 입력해주세요"),
+ requestDescription: z.string().optional(),
+ projectId: z.number({
+ required_error: "프로젝트를 선택해주세요",
+ }),
+ projectCode: z.string().min(1),
+ projectName: z.string().min(1),
+ projectCompany: z.string().optional(),
+ projectSite: z.string().optional(),
+ classNo: z.string().optional(),
+ packageNo: z.string({
+ required_error: "패키지를 선택해주세요",
+ }).min(1, "패키지를 선택해주세요"),
+ packageName: z.string().min(1),
+ majorItemMaterialCategory: z.string({
+ required_error: "자재그룹을 선택해주세요",
+ }).min(1, "자재그룹을 선택해주세요"),
+ majorItemMaterialDescription: z.string().min(1),
+ smCode: z.string({
+ required_error: "SM 코드가 필요합니다",
+ }).min(1, "SM 코드가 필요합니다"),
+ estimatedBudget: z.string().optional(),
+ requestedDeliveryDate: z.date().optional(),
+ items: z.array(itemSchema).optional().default([]),
+ attachments: z.array(attachmentSchema).min(1, "최소 1개 이상의 파일을 첨부해주세요"),
+});
+
+type FormData = z.infer<typeof formSchema>;
+
+interface CreatePurchaseRequestDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+}
+
+export function CreatePurchaseRequestDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+}: CreatePurchaseRequestDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [activeTab, setActiveTab] = React.useState("basic");
+ const [selectedPackage, setSelectedPackage] = React.useState<PackageItem | null>(null);
+ const [selectedMaterials, setSelectedMaterials] = React.useState<any[]>([]);
+ const [resetKey, setResetKey] = React.useState(0);
+ const [uploadedFiles, setUploadedFiles] = React.useState<File[]>([]);
+ const [isUploading, setIsUploading] = React.useState(false);
+ const [uploadProgress, setUploadProgress] = React.useState(0);
+
+ const form = useForm<FormData>({
+ resolver: zodResolver(formSchema),
+ mode: "onChange",
+ criteriaMode: "all",
+ defaultValues: {
+ requestTitle: "",
+ requestDescription: "",
+ projectId: undefined,
+ projectCode: "",
+ projectName: "",
+ projectCompany: "",
+ projectSite: "",
+ classNo: "",
+ packageNo: "",
+ packageName: "",
+ majorItemMaterialCategory: "",
+ majorItemMaterialDescription: "",
+ smCode: "",
+ estimatedBudget: "",
+ requestedDeliveryDate: undefined,
+ items: [],
+ attachments: [],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ });
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = async (files: File[]) => {
+ if (files.length === 0) return;
+
+ setIsUploading(true);
+ setUploadProgress(0);
+
+ try {
+ const formData = new FormData();
+ files.forEach(file => {
+ formData.append("files", file);
+ });
+
+ // 프로그레스 시뮬레이션 (실제로는 XMLHttpRequest 또는 fetch with progress를 사용)
+ const progressInterval = setInterval(() => {
+ setUploadProgress(prev => Math.min(prev + 10, 90));
+ }, 200);
+
+ const response = await fetch("/api/upload/purchase-request", {
+ method: "POST",
+ body: formData,
+ });
+
+ clearInterval(progressInterval);
+ setUploadProgress(100);
+
+ if (!response.ok) {
+ throw new Error("파일 업로드 실패");
+ }
+
+ const data = await response.json();
+
+ // 업로드된 파일 정보를 상태에 추가
+ const newFiles = [...uploadedFiles, ...data.files];
+ setUploadedFiles(newFiles);
+
+ // form의 attachments 필드 업데이트
+ form.setValue("attachments", newFiles);
+
+ toast.success(`${files.length}개 파일이 업로드되었습니다`);
+ } catch (error) {
+ console.error("Upload error:", error);
+ toast.error("파일 업로드 중 오류가 발생했습니다");
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ // 파일 삭제 핸들러
+ const handleFileRemove = (fileName: string) => {
+ const updatedFiles = uploadedFiles.filter(f => f.fileName !== fileName);
+ setUploadedFiles(updatedFiles);
+ form.setValue("attachments", updatedFiles);
+ };
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: any) => {
+ form.setValue("projectId", project.id);
+ form.setValue("projectCode", project.projectCode);
+ form.setValue("projectName", project.projectName);
+ form.setValue("projectCompany", project.projectCompany);
+ form.setValue("projectSite", project.projectSite);
+
+ // 프로젝트 변경시 패키지 초기화
+ setSelectedPackage(null);
+ form.setValue("packageNo", "");
+ form.setValue("packageName", "");
+ form.setValue("smCode", "");
+
+ // requestTitle 업데이트
+ updateRequestTitle(project.projectName, "", "");
+ };
+
+ // 패키지 선택 처리
+ const handlePackageSelect = (packageItem: PackageItem) => {
+ setSelectedPackage(packageItem);
+ form.setValue("packageNo", packageItem.packageCode);
+ form.setValue("packageName", packageItem.description);
+
+ // SM 코드가 패키지에 있으면 자동 설정
+ if (packageItem.smCode) {
+ form.setValue("smCode", packageItem.smCode);
+ }
+
+ // requestTitle 업데이트
+ const projectName = form.getValues("projectName");
+ const materialDesc = form.getValues("majorItemMaterialDescription");
+ updateRequestTitle(projectName, packageItem.description, materialDesc);
+ };
+
+ // 자재그룹 선택 처리
+ const handleMaterialsChange = (materials: any[]) => {
+ setSelectedMaterials(materials);
+ if (materials.length > 0) {
+ const material = materials[0]; // single select이므로 첫번째 항목만
+ form.setValue("majorItemMaterialCategory", material.materialGroupCode);
+ form.setValue("majorItemMaterialDescription", material.materialGroupDescription);
+
+ // requestTitle 업데이트
+ const projectName = form.getValues("projectName");
+ const packageName = form.getValues("packageName");
+ updateRequestTitle(projectName, packageName, material.materialGroupDescription);
+ } else {
+ form.setValue("majorItemMaterialCategory", "");
+ form.setValue("majorItemMaterialDescription", "");
+ }
+ };
+
+ // requestTitle 자동 생성
+ const updateRequestTitle = (projectName: string, packageName: string, materialDesc: string) => {
+ const parts = [];
+ if (projectName) parts.push(projectName);
+ if (packageName) parts.push(packageName);
+ if (materialDesc) parts.push(materialDesc);
+
+ if (parts.length > 0) {
+ const title = `${parts.join(" - ")} 구매 요청`;
+ form.setValue("requestTitle", title);
+ }
+ };
+
+ const addItem = () => {
+ append({
+ id: nanoid(),
+ itemCode: "",
+ itemName: "",
+ specification: "",
+ quantity: 1,
+ unit: "개",
+ estimatedUnitPrice: undefined,
+ remarks: "",
+ });
+ };
+
+ const calculateTotal = () => {
+ const items = form.watch("items");
+ return items?.reduce((sum, item) => {
+ const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0);
+ return sum + subtotal;
+ }, 0) || 0;
+ };
+
+ const onSubmit = async (values: FormData) => {
+ try {
+ setIsLoading(true);
+
+ const result = await createPurchaseRequest({
+ ...values,
+ items: values.items,
+ attachments: uploadedFiles, // 첨부파일 포함
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ return;
+ }
+
+ toast.success("구매 요청이 등록되었습니다");
+ handleClose();
+ onSuccess?.();
+ } catch (error) {
+ toast.error("구매 요청 등록에 실패했습니다");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ form.reset({
+ requestTitle: "",
+ requestDescription: "",
+ projectId: undefined,
+ projectCode: "",
+ projectName: "",
+ projectCompany: "",
+ projectSite: "",
+ classNo: "",
+ packageNo: "",
+ packageName: "",
+ majorItemMaterialCategory: "",
+ majorItemMaterialDescription: "",
+ smCode: "",
+ estimatedBudget: "",
+ requestedDeliveryDate: undefined,
+ items: [],
+ attachments: [],
+ });
+ setSelectedPackage(null);
+ setSelectedMaterials([]);
+ setUploadedFiles([]);
+ setActiveTab("basic");
+ setResetKey((k) => k + 1);
+ onOpenChange(false);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={handleClose}>
+ <DialogContent key={resetKey} className="max-w-5xl h-[85vh] overflow-hidden flex flex-col min-h-0">
+ <DialogHeader>
+ <DialogTitle>새 구매 요청</DialogTitle>
+ <DialogDescription>
+ 구매가 필요한 품목과 관련 정보를 입력해주세요
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 overflow-hidden flex flex-col min-h-0">
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
+ <TabsList className="grid w-full grid-cols-3 flex-shrink-0">
+ <TabsTrigger value="basic">
+ <FileText className="mr-2 h-4 w-4" />
+ 기본 정보
+ </TabsTrigger>
+ <TabsTrigger value="items">
+ <Package className="mr-2 h-4 w-4" />
+ 품목 정보
+ {fields.length > 0 && (
+ <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
+ {fields.length}
+ </span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="files">
+ <Paperclip className="mr-2 h-4 w-4" />
+ 첨부파일
+ {uploadedFiles.length > 0 && (
+ <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
+ {uploadedFiles.length}
+ </span>
+ )}
+ </TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 overflow-y-auto px-1 min-h-0">
+ <TabsContent value="basic" className="space-y-6 mt-6">
+ {/* 프로젝트 선택 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">프로젝트 정보 <span className="text-red-500">*</span></CardTitle>
+ <CardDescription>프로젝트, 패키지, 자재그룹을 순서대로 선택하세요</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트 <span className="text-red-500">*</span></FormLabel>
+ <ProjectSelector
+ key={`project-${resetKey}`}
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트를 검색하여 선택..."
+ />
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 패키지 선택 */}
+ <FormField
+ control={form.control}
+ name="packageNo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>패키지 <span className="text-red-500">*</span></FormLabel>
+ <PackageSelector
+ projectNo={form.watch("projectCode")}
+ selectedPackage={selectedPackage}
+ onPackageSelect={handlePackageSelect}
+ placeholder="패키지 선택..."
+ />
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재그룹 선택 */}
+ <FormField
+ control={form.control}
+ name="majorItemMaterialCategory"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>자재그룹 <span className="text-red-500">*</span></FormLabel>
+ <MaterialGroupSelector
+ selectedMaterials={selectedMaterials}
+ onMaterialsChange={handleMaterialsChange}
+ singleSelect={true}
+ placeholder="자재그룹을 검색하세요..."
+ noValuePlaceHolder="자재그룹을 선택해주세요"
+ />
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* SM 코드 */}
+ <FormField
+ control={form.control}
+ name="smCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SM 코드 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="SM 코드 입력"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선택된 정보 표시 */}
+ {form.watch("projectId") && (
+ <div className="grid grid-cols-2 gap-4 pt-4 border-t">
+ <div>
+ <p className="text-sm text-muted-foreground">프로젝트 코드</p>
+ <p className="font-medium">{form.watch("projectCode") || "-"}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground">프로젝트명</p>
+ <p className="font-medium">{form.watch("projectName") || "-"}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground">발주처</p>
+ <p className="font-medium">{form.watch("projectCompany") || "-"}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground">현장</p>
+ <p className="font-medium">{form.watch("projectSite") || "-"}</p>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="requestTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>요청 제목 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 프로젝트 A용 밸브 구매"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 자동 생성된 제목을 수정할 수 있습니다
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="requestDescription"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>요청 설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="구매 요청에 대한 상세 설명을 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 예산 및 납기 */}
+ <div className="space-y-4">
+ <h3 className="text-sm font-medium">예산 및 납기</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="estimatedBudget"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>예상 예산</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 10,000,000원" {...field} />
+ </FormControl>
+ <FormDescription>
+ 전체 구매 예상 예산을 입력하세요
+ </FormDescription>
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="requestedDeliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>희망 납기일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy-MM-dd")
+ ) : (
+ <span>날짜 선택</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) => date < new Date()}
+ />
+ </PopoverContent>
+ </Popover>
+ <FormDescription>
+ 자재가 필요한 날짜를 선택하세요
+ </FormDescription>
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="items" className="space-y-4 mt-6">
+ {/* 품목 목록 */}
+ <div className="flex justify-between items-center">
+ <h3 className="text-sm font-medium">품목 목록</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addItem}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 품목 추가
+ </Button>
+ </div>
+
+ {fields.length === 0 ? (
+ <Card>
+ <CardContent className="flex flex-col items-center justify-center py-8">
+ <Package className="h-12 w-12 text-muted-foreground mb-4" />
+ <p className="text-sm text-muted-foreground mb-4">
+ 아직 추가된 품목이 없습니다
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addItem}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 첫 품목 추가
+ </Button>
+ </CardContent>
+ </Card>
+ ) : (
+ <div className="border rounded-lg">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[120px]">아이템 코드</TableHead>
+ <TableHead className="w-[150px]">아이템명 <span className="text-red-500">*</span></TableHead>
+ <TableHead>사양</TableHead>
+ <TableHead className="w-[80px]">수량 <span className="text-red-500">*</span></TableHead>
+ <TableHead className="w-[80px]">단위 <span className="text-red-500">*</span></TableHead>
+ <TableHead className="w-[120px]">예상 단가</TableHead>
+ <TableHead className="w-[120px]">예상 금액</TableHead>
+ <TableHead>비고</TableHead>
+ <TableHead className="w-[50px]"></TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {fields.map((field, index) => (
+ <TableRow key={field.id}>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.itemCode`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="코드"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.itemName`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="아이템명"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.specification`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="사양"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.unit`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="개"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.estimatedUnitPrice`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ placeholder="0"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <div className="text-right font-medium">
+ {new Intl.NumberFormat('ko-KR').format(
+ (form.watch(`items.${index}.quantity`) || 0) *
+ (form.watch(`items.${index}.estimatedUnitPrice`) || 0)
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.remarks`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="비고"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => remove(index)}
+ disabled={fields.length === 1}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+
+ <div className="flex justify-between items-center p-4 border-t bg-muted/30">
+ <div className="text-sm text-muted-foreground">
+ 총 {fields.length}개 품목
+ </div>
+ <div className="text-right">
+ <p className="text-sm text-muted-foreground">예상 총액</p>
+ <p className="text-lg font-semibold">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: 'KRW'
+ }).format(calculateTotal())}
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+ </TabsContent>
+
+ <TabsContent value="files" className="space-y-4 mt-6">
+ {/* 파일 업로드 영역 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">첨부파일 업로드 <span className="text-red-500">*</span></CardTitle>
+ <CardDescription>
+ 설계 도면, 사양서, 견적서 등 구매 요청 관련 문서를 업로드하세요 (필수)
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 필수 알림 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={({ field }) => (
+ <FormItem>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 업로드 진행 상태 */}
+ {isUploading && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription className="space-y-2">
+ <p>파일을 업로드 중입니다...</p>
+ <Progress value={uploadProgress} className="w-full" />
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* Dropzone */}
+ <Dropzone
+ onDrop={handleFileUpload}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
+ 'application/x-zip-compressed': ['.zip'],
+ 'application/x-rar-compressed': ['.rar'],
+ }}
+ maxSize={50 * 1024 * 1024} // 50MB
+ disabled={isUploading}
+ >
+ <DropzoneInput />
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
+ <DropzoneTitle>
+ 파일을 드래그하거나 클릭하여 선택하세요
+ </DropzoneTitle>
+ <DropzoneDescription>
+ PDF, Excel, Word, 이미지 파일 등을 업로드할 수 있습니다 (최대 50MB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ </Dropzone>
+
+ {/* 업로드된 파일 목록 */}
+ {uploadedFiles.length > 0 && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <span className="text-sm text-muted-foreground">
+ 총 {uploadedFiles.length}개 파일
+ </span>
+ </div>
+
+ <FileList>
+ {uploadedFiles.map((file) => (
+ <FileListItem key={file.fileName}>
+ <FileListIcon>
+ <FileIcon className="h-4 w-4" />
+ </FileListIcon>
+ <FileListInfo>
+ <FileListHeader>
+ <FileListName>
+ {file.originalFileName}
+ </FileListName>
+ <FileListSize>
+ {file.fileSize}
+ </FileListSize>
+ </FileListHeader>
+ </FileListInfo>
+ <FileListAction>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleFileRemove(file.fileName)}
+ disabled={isLoading || isUploading}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
+ {/* 파일 업로드 안내 */}
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ <ul className="list-disc list-inside space-y-1 text-sm">
+ <li className="font-medium">최소 1개 이상의 파일 첨부가 필수입니다</li>
+ <li>최대 파일 크기: 50MB</li>
+ <li>지원 형식: PDF, Excel, Word, 이미지 파일</li>
+ <li>여러 파일을 한 번에 선택하여 업로드할 수 있습니다</li>
+ </ul>
+ </AlertDescription>
+ </Alert>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </div>
+ </Tabs>
+
+ <DialogFooter className="mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleClose}
+ disabled={isLoading || isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading || isUploading || !form.formState.isValid}
+ >
+ {isLoading ? "등록 중..." : "구매요청 등록"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/itb/table/create-rfq-dialog.tsx b/lib/itb/table/create-rfq-dialog.tsx
new file mode 100644
index 00000000..57a4b9d4
--- /dev/null
+++ b/lib/itb/table/create-rfq-dialog.tsx
@@ -0,0 +1,380 @@
+// components/purchase-requests/create-rfq-dialog.tsx
+"use client";
+
+import * as React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ FileText,
+ Package,
+ AlertCircle,
+ CheckCircle,
+ User,
+ ChevronsUpDown,
+ Check,
+ Loader2,
+ Info
+} from "lucide-react";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import type { PurchaseRequestView } from "@/db/schema";
+import { approvePurchaseRequestsAndCreateRfqs } from "../service";
+import { getPUsersForFilter } from "@/lib/rfq-last/service";
+import { useRouter } from "next/navigation";
+
+interface CreateRfqDialogProps {
+ requests: PurchaseRequestView[];
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+}
+
+export function CreateRfqDialog({
+ requests,
+ open,
+ onOpenChange,
+ onSuccess,
+}: CreateRfqDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [userPopoverOpen, setUserPopoverOpen] = React.useState(false);
+ const [users, setUsers] = React.useState<any[]>([]);
+ const [selectedUser, setSelectedUser] = React.useState<any>(null);
+ const [isLoadingUsers, setIsLoadingUsers] = React.useState(false);
+ const [userSearchTerm, setUserSearchTerm] = React.useState("");
+ const router = useRouter();
+
+ // 유저 목록 로드
+ React.useEffect(() => {
+ const loadUsers = async () => {
+ setIsLoadingUsers(true);
+ try {
+ const userList = await getPUsersForFilter();
+ setUsers(userList);
+ } catch (error) {
+ console.log("사용자 목록 로드 오류:", error);
+ toast.error("사용자 목록을 불러오는데 실패했습니다");
+ } finally {
+ setIsLoadingUsers(false);
+ }
+ };
+
+ if (open) {
+ loadUsers();
+ }
+ }, [open]);
+
+ // 검색된 사용자 필터링
+ const filteredUsers = React.useMemo(() => {
+ if (!userSearchTerm) return users;
+
+ return users.filter(user =>
+ user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) ||
+ user.userCode?.toLowerCase().includes(userSearchTerm.toLowerCase())
+ );
+ }, [users, userSearchTerm]);
+
+ // 유효한 요청만 필터링 (이미 RFQ 생성된 것 제외)
+ const validRequests = requests.filter(r => r.status !== "RFQ생성완료");
+ const invalidRequests = requests.filter(r => r.status === "RFQ생성완료");
+
+ const handleSelectUser = (user: any) => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ };
+
+ const handleSubmit = async () => {
+ if (validRequests.length === 0) {
+ toast.error("RFQ를 생성할 수 있는 구매 요청이 없습니다");
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+
+ const requestIds = validRequests.map(r => r.id);
+ const results = await approvePurchaseRequestsAndCreateRfqs(
+ requestIds,
+ selectedUser?.id
+ );
+
+ const successCount = results.filter(r => r.success).length;
+ const skipCount = results.filter(r => r.skipped).length;
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개의 RFQ가 생성되었습니다`);
+ }
+
+ if (skipCount > 0) {
+ toast.info(`${skipCount}개는 이미 RFQ가 생성되어 건너뛰었습니다`);
+ }
+
+ onOpenChange(false);
+ onSuccess?.();
+ router.refresh()
+ } catch (error) {
+ console.error("RFQ 생성 오류:", error);
+ toast.error("RFQ 생성 중 오류가 발생했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (!isLoading) {
+ setSelectedUser(null);
+ setUserSearchTerm("");
+ onOpenChange(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={handleClose}>
+ <DialogContent className="max-w-4xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ RFQ 생성
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 구매 요청을 기반으로 RFQ를 생성합니다.
+ {invalidRequests.length > 0 && " 이미 RFQ가 생성된 항목은 제외됩니다."}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 경고 메시지 */}
+ {invalidRequests.length > 0 && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ {invalidRequests.length}개 항목은 이미 RFQ가 생성되어 제외됩니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 구매 담당자 선택 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">
+ 구매 담당자 (선택사항)
+ </label>
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full justify-between h-10"
+ disabled={isLoadingUsers}
+ >
+ {isLoadingUsers ? (
+ <>
+ <span>담당자 로딩 중...</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ {selectedUser ? (
+ <>
+ {selectedUser.name}
+ {selectedUser.userCode && (
+ <span className="text-muted-foreground">
+ ({selectedUser.userCode})
+ </span>
+ )}
+ </>
+ ) : (
+ <span className="text-muted-foreground">
+ 구매 담당자를 선택하세요 (선택사항)
+ </span>
+ )}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름 또는 코드로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList className="max-h-[300px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandGroup>
+ {filteredUsers.map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => handleSelectUser(user)}
+ className="flex items-center justify-between"
+ >
+ <span className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ {user.name}
+ {user.userCode && (
+ <span className="text-muted-foreground text-sm">
+ ({user.userCode})
+ </span>
+ )}
+ </span>
+ <Check
+ className={cn(
+ "h-4 w-4",
+ selectedUser?.id === user.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <p className="text-xs text-muted-foreground">
+ 구매 담당자를 선택하지 않으면 나중에 지정할 수 있습니다
+ </p>
+ </div>
+
+ {/* RFQ 생성 대상 목록 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">
+ RFQ 생성 대상 ({validRequests.length}개)
+ </label>
+ <div className="border rounded-lg max-h-[300px] overflow-y-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[140px]">요청번호</TableHead>
+ <TableHead>요청제목</TableHead>
+ <TableHead className="w-[120px]">프로젝트</TableHead>
+ <TableHead className="w-[100px]">패키지</TableHead>
+ <TableHead className="w-[80px] text-center">품목</TableHead>
+ <TableHead className="w-[80px] text-center">첨부</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {validRequests.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={6} className="text-center text-muted-foreground py-8">
+ RFQ를 생성할 수 있는 구매 요청이 없습니다
+ </TableCell>
+ </TableRow>
+ ) : (
+ validRequests.map((request) => (
+ <TableRow key={request.id}>
+ <TableCell className="font-mono text-sm">
+ {request.requestCode}
+ </TableCell>
+ <TableCell className="max-w-[250px]">
+ <div className="truncate" title={request.requestTitle}>
+ {request.requestTitle}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="truncate" title={request.projectName}>
+ {request.projectCode}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="truncate" title={request.packageName}>
+ {request.packageNo}
+ </div>
+ </TableCell>
+ <TableCell className="text-center">
+ {request.itemCount > 0 && (
+ <Badge variant="secondary" className="gap-1">
+ <Package className="h-3 w-3" />
+ {request.itemCount}
+ </Badge>
+ )}
+ </TableCell>
+ <TableCell className="text-center">
+ {request.attachmentCount > 0 && (
+ <Badge variant="secondary" className="gap-1">
+ <FileText className="h-3 w-3" />
+ {request.attachmentCount}
+ </Badge>
+ )}
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+
+ {/* 안내 메시지 */}
+ <Alert>
+ <Info className="h-4 w-4" />
+ <AlertDescription>
+ <ul className="list-disc list-inside space-y-1 text-sm">
+ <li>RFQ 생성 시 구매 요청의 첨부파일이 자동으로 이관됩니다</li>
+ <li>구매 요청 상태가 "RFQ생성완료"로 변경됩니다</li>
+ <li>각 구매 요청별로 개별 RFQ가 생성됩니다</li>
+ </ul>
+ </AlertDescription>
+ </Alert>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={handleClose}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isLoading || validRequests.length === 0}
+ >
+ {isLoading ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ RFQ 생성 중...
+ </>
+ ) : (
+ <>
+ <CheckCircle className="mr-2 h-4 w-4" />
+ RFQ 생성 ({validRequests.length}개)
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/itb/table/delete-purchase-request-dialog.tsx b/lib/itb/table/delete-purchase-request-dialog.tsx
new file mode 100644
index 00000000..2f09cf70
--- /dev/null
+++ b/lib/itb/table/delete-purchase-request-dialog.tsx
@@ -0,0 +1,225 @@
+// components/purchase-requests/delete-purchase-request-dialog.tsx
+"use client"
+
+import * as React from "react"
+import { type PurchaseRequestView } from "@/db/schema/purchase-requests-view"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { deletePurchaseRequests } from "../service"
+
+interface DeletePurchaseRequestDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ requests?: Row<PurchaseRequestView>["original"][] | PurchaseRequestView[]
+ request?: PurchaseRequestView // 단일 삭제용
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeletePurchaseRequestDialog({
+ requests: requestsProp,
+ request,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeletePurchaseRequestDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ // 단일 또는 복수 요청 처리
+ const requests = requestsProp || (request ? [request] : [])
+ const isMultiple = requests.length > 1
+
+ // 삭제 불가능한 상태 체크
+ const nonDeletableRequests = requests.filter(
+ req => req.status !== "작성중"
+ )
+ const canDelete = nonDeletableRequests.length === 0
+
+ function onDelete() {
+ if (!canDelete) {
+ toast.error("작성중 상태의 요청만 삭제할 수 있습니다.")
+ return
+ }
+
+ startDeleteTransition(async () => {
+ const { error } = await deletePurchaseRequests({
+ ids: requests.map((req) => req.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success(
+ isMultiple
+ ? `${requests.length}개의 구매요청이 삭제되었습니다.`
+ : "구매요청이 삭제되었습니다."
+ )
+ onSuccess?.()
+ })
+ }
+
+ const dialogContent = (
+ <>
+ <div className="space-y-4">
+ <div>
+ <p className="text-sm text-muted-foreground">
+ 이 작업은 되돌릴 수 없습니다.
+ {isMultiple ? (
+ <>
+ 선택한 <span className="font-medium text-foreground">{requests.length}개</span>의 구매요청이 영구적으로 삭제됩니다.
+ </>
+ ) : (
+ <>
+ 구매요청 <span className="font-medium text-foreground">{requests[0]?.requestCode}</span>이(가) 영구적으로 삭제됩니다.
+ </>
+ )}
+ </p>
+ </div>
+
+ {/* 삭제할 요청 목록 표시 (복수인 경우) */}
+ {isMultiple && (
+ <div className="rounded-lg border bg-muted/30 p-3">
+ <p className="text-sm font-medium mb-2">삭제할 구매요청:</p>
+ <ul className="space-y-1 max-h-32 overflow-y-auto">
+ {requests.map((req) => (
+ <li key={req.id} className="text-sm text-muted-foreground">
+ <span className="font-mono">{req.requestCode}</span>
+ {" - "}
+ <span className="truncate">{req.requestTitle}</span>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+
+ {/* 삭제 불가능한 요청이 있는 경우 경고 */}
+ {nonDeletableRequests.length > 0 && (
+ <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
+ <p className="text-sm font-medium text-destructive mb-2">
+ 삭제할 수 없는 요청:
+ </p>
+ <ul className="space-y-1">
+ {nonDeletableRequests.map((req) => (
+ <li key={req.id} className="text-sm text-destructive/80">
+ <span className="font-mono">{req.requestCode}</span>
+ {" - 상태: "}
+ <span className="font-medium">{req.status}</span>
+ </li>
+ ))}
+ </ul>
+ <p className="text-xs text-destructive/60 mt-2">
+ ※ 작성중 상태의 요청만 삭제할 수 있습니다.
+ </p>
+ </div>
+ )}
+ </div>
+ </>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" disabled={requests.length === 0}>
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 {requests.length > 0 && `(${requests.length})`}
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>구매요청을 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ {dialogContent}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected requests"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending || !canDelete}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" disabled={requests.length === 0}>
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 {requests.length > 0 && `(${requests.length})`}
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>구매요청을 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ {dialogContent}
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected requests"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending || !canDelete}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/itb/table/edit-purchase-request-sheet.tsx b/lib/itb/table/edit-purchase-request-sheet.tsx
new file mode 100644
index 00000000..8a818ca5
--- /dev/null
+++ b/lib/itb/table/edit-purchase-request-sheet.tsx
@@ -0,0 +1,1081 @@
+// components/purchase-requests/edit-purchase-request-sheet.tsx
+"use client";
+
+import * as React from "react";
+import { useForm, useFieldArray } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetFooter,
+} from "@/components/ui/sheet";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { ProjectSelector } from "@/components/ProjectSelector";
+import { PackageSelector } from "@/components/PackageSelector";
+import type { PackageItem } from "@/lib/items/service";
+import { MaterialGroupSelector } from "@/components/common/material/material-group-selector";
+import { CalendarIcon, X, Plus, FileText, Package, Trash2, Upload, FileIcon, AlertCircle, Paperclip, Save } from "lucide-react";
+import { format } from "date-fns";
+import { cn } from "@/lib/utils";
+import { updatePurchaseRequest } from "../service";
+import { toast } from "sonner";
+import { nanoid } from "nanoid";
+import type { PurchaseRequestView } from "@/db/schema";
+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 { Progress } from "@/components/ui/progress";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+const attachmentSchema = z.object({
+ fileName: z.string(),
+ originalFileName: z.string(),
+ filePath: z.string(),
+ fileSize: z.number(),
+ fileType: z.string(),
+});
+
+const itemSchema = z.object({
+ id: z.string(),
+ itemCode: z.string(),
+ itemName: z.string().min(1, "아이템명을 입력해주세요"),
+ specification: z.string().default(""),
+ quantity: z.coerce.number().min(1, "수량은 1 이상이어야 합니다"),
+ unit: z.string().min(1, "단위를 입력해주세요"),
+ estimatedUnitPrice: z.coerce.number().optional(),
+ remarks: z.string().optional(),
+});
+
+const formSchema = z.object({
+ requestTitle: z.string().min(1, "요청 제목을 입력해주세요"),
+ requestDescription: z.string().optional(),
+ projectId: z.number({
+ required_error: "프로젝트를 선택해주세요",
+ }),
+ projectCode: z.string().min(1),
+ projectName: z.string().min(1),
+ projectCompany: z.string().optional(),
+ projectSite: z.string().optional(),
+ classNo: z.string().optional(),
+ packageNo: z.string({
+ required_error: "패키지를 선택해주세요",
+ }).min(1, "패키지를 선택해주세요"),
+ packageName: z.string().min(1),
+ majorItemMaterialCategory: z.string({
+ required_error: "자재그룹을 선택해주세요",
+ }).min(1, "자재그룹을 선택해주세요"),
+ majorItemMaterialDescription: z.string().min(1),
+ smCode: z.string({
+ required_error: "SM 코드가 필요합니다",
+ }).min(1, "SM 코드가 필요합니다"),
+ estimatedBudget: z.string().optional(),
+ requestedDeliveryDate: z.date().optional(),
+ items: z.array(itemSchema).optional().default([]),
+});
+
+type FormData = z.infer<typeof formSchema>;
+
+interface EditPurchaseRequestSheetProps {
+ request: PurchaseRequestView;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+}
+
+export function EditPurchaseRequestSheet({
+ request,
+ open,
+ onOpenChange,
+ onSuccess,
+}: EditPurchaseRequestSheetProps) {
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [activeTab, setActiveTab] = React.useState("basic");
+ const [selectedPackage, setSelectedPackage] = React.useState<PackageItem | null>(null);
+ const [selectedMaterials, setSelectedMaterials] = React.useState<any[]>([]);
+ const [resetKey, setResetKey] = React.useState(0);
+ const [newFiles, setNewFiles] = React.useState<File[]>([]); // 새로 추가할 파일
+ const [existingFiles, setExistingFiles] = React.useState<z.infer<typeof attachmentSchema>[]>([]); // 기존 파일
+ const [isUploading, setIsUploading] = React.useState(false);
+ const [uploadProgress, setUploadProgress] = React.useState(0);
+
+ // 기존 아이템 처리
+ const existingItems = React.useMemo(() => {
+ if (!request.items || !Array.isArray(request.items) || request.items.length === 0) {
+ return [];
+ }
+ return request.items.map(item => ({
+ ...item,
+ id: item.id || nanoid(),
+ }));
+ }, [request.items]);
+
+ // 기존 첨부파일 로드 (실제로는 API에서 가져와야 함)
+ React.useEffect(() => {
+ // TODO: 기존 첨부파일 로드 API 호출
+ // setExistingFiles(request.attachments || []);
+ }, [request.id]);
+
+ // 기존 자재그룹 데이터 설정
+ React.useEffect(() => {
+ if (request.majorItemMaterialCategory && request.majorItemMaterialDescription) {
+ setSelectedMaterials([{
+ materialGroupCode: request.majorItemMaterialCategory,
+ materialGroupDescription: request.majorItemMaterialDescription,
+ displayText:`${request.majorItemMaterialCategory} - ${request.majorItemMaterialDescription}`
+ }]);
+ }
+ }, [request.majorItemMaterialCategory, request.majorItemMaterialDescription]);
+
+ // 기존 패키지 데이터 설정
+ React.useEffect(() => {
+ if (request.packageNo && request.packageName) {
+ setSelectedPackage({
+ packageCode: request.packageNo,
+ description: request.packageName,
+ smCode: request.smCode || "",
+ } as PackageItem);
+ }
+ }, [request.packageNo, request.packageName, request.smCode]);
+
+ const form = useForm<FormData>({
+ resolver: zodResolver(formSchema),
+ mode: "onChange",
+ criteriaMode: "all",
+ defaultValues: {
+ requestTitle: request.requestTitle || "",
+ requestDescription: request.requestDescription || "",
+ projectId: request.projectId || undefined,
+ projectCode: request.projectCode || "",
+ projectName: request.projectName || "",
+ projectCompany: request.projectCompany || "",
+ projectSite: request.projectSite || "",
+ classNo: request.classNo || "",
+ packageNo: request.packageNo || "",
+ packageName: request.packageName || "",
+ majorItemMaterialCategory: request.majorItemMaterialCategory || "",
+ majorItemMaterialDescription: request.majorItemMaterialDescription || "",
+ smCode: request.smCode || "",
+ estimatedBudget: request.estimatedBudget || "",
+ requestedDeliveryDate: request.requestedDeliveryDate
+ ? new Date(request.requestedDeliveryDate)
+ : undefined,
+ items: existingItems,
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ });
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: any) => {
+ form.setValue("projectId", project.id);
+ form.setValue("projectCode", project.projectCode);
+ form.setValue("projectName", project.projectName);
+ form.setValue("projectCompany", project.projectCompany);
+ form.setValue("projectSite", project.projectSite);
+
+ // 프로젝트 변경시 패키지 초기화
+ setSelectedPackage(null);
+ form.setValue("packageNo", "");
+ form.setValue("packageName", "");
+ form.setValue("smCode", "");
+
+ // requestTitle 업데이트
+ updateRequestTitle(project.projectName, "", "");
+ };
+
+ // 패키지 선택 처리
+ const handlePackageSelect = (packageItem: PackageItem) => {
+ setSelectedPackage(packageItem);
+ form.setValue("packageNo", packageItem.packageCode);
+ form.setValue("packageName", packageItem.description);
+
+ // SM 코드가 패키지에 있으면 자동 설정
+ if (packageItem.smCode) {
+ form.setValue("smCode", packageItem.smCode);
+ }
+
+ // requestTitle 업데이트
+ const projectName = form.getValues("projectName");
+ const materialDesc = form.getValues("majorItemMaterialDescription");
+ updateRequestTitle(projectName, packageItem.description, materialDesc);
+ };
+
+ // 자재그룹 선택 처리
+ const handleMaterialsChange = (materials: any[]) => {
+ setSelectedMaterials(materials);
+ if (materials.length > 0) {
+ const material = materials[0]; // single select이므로 첫번째 항목만
+ form.setValue("majorItemMaterialCategory", material.materialGroupCode);
+ form.setValue("majorItemMaterialDescription", material.materialGroupDescription);
+
+ // requestTitle 업데이트
+ const projectName = form.getValues("projectName");
+ const packageName = form.getValues("packageName");
+ updateRequestTitle(projectName, packageName, material.materialGroupDescription);
+ } else {
+ form.setValue("majorItemMaterialCategory", "");
+ form.setValue("majorItemMaterialDescription", "");
+ }
+ };
+
+ // requestTitle 자동 생성
+ const updateRequestTitle = (projectName: string, packageName: string, materialDesc: string) => {
+ const parts = [];
+ if (projectName) parts.push(projectName);
+ if (packageName) parts.push(packageName);
+ if (materialDesc) parts.push(materialDesc);
+
+ if (parts.length > 0) {
+ const title = `${parts.join(" - ")} 구매 요청`;
+ form.setValue("requestTitle", title);
+ }
+ };
+
+ const addItem = () => {
+ append({
+ id: nanoid(),
+ itemCode: "",
+ itemName: "",
+ specification: "",
+ quantity: 1,
+ unit: "개",
+ estimatedUnitPrice: undefined,
+ remarks: "",
+ });
+ };
+
+ const calculateTotal = () => {
+ const items = form.watch("items");
+ return items?.reduce((sum, item) => {
+ const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0);
+ return sum + subtotal;
+ }, 0) || 0;
+ };
+
+ // 파일 추가 핸들러 (로컬 상태로만 저장)
+ const handleFileAdd = (files: File[]) => {
+ if (files.length === 0) return;
+
+ // 중복 파일 체크
+ const duplicates = files.filter(file =>
+ newFiles.some(existing => existing.name === file.name)
+ );
+
+ if (duplicates.length > 0) {
+ toast.warning(`${duplicates.length}개 파일이 이미 추가되어 있습니다`);
+ }
+
+ const uniqueFiles = files.filter(file =>
+ !newFiles.some(existing => existing.name === file.name)
+ );
+
+ if (uniqueFiles.length > 0) {
+ setNewFiles(prev => [...prev, ...uniqueFiles]);
+ toast.success(`${uniqueFiles.length}개 파일이 추가되었습니다`);
+ }
+ };
+
+ // 새 파일 삭제 핸들러
+ const handleNewFileRemove = (fileName: string) => {
+ setNewFiles(prev => prev.filter(f => f.name !== fileName));
+ };
+
+ // 기존 파일 삭제 핸들러 (실제로는 서버에 삭제 요청)
+ const handleExistingFileRemove = (fileName: string) => {
+ // TODO: 서버에 삭제 표시
+ setExistingFiles(prev => prev.filter(f => f.fileName !== fileName));
+ };
+
+ const onSubmit = async (values: FormData) => {
+ try {
+ setIsLoading(true);
+
+ // 새 파일이 있으면 먼저 업로드
+ let uploadedAttachments: z.infer<typeof attachmentSchema>[] = [];
+
+ if (newFiles.length > 0) {
+ setIsUploading(true);
+ setUploadProgress(10);
+
+ const formData = new FormData();
+ newFiles.forEach(file => {
+ formData.append("files", file);
+ });
+
+ setUploadProgress(30);
+
+ try {
+ const response = await fetch("/api/upload/purchase-request", {
+ method: "POST",
+ body: formData,
+ });
+
+ setUploadProgress(60);
+
+ if (!response.ok) {
+ throw new Error("파일 업로드 실패");
+ }
+
+ const data = await response.json();
+ uploadedAttachments = data.files;
+ setUploadProgress(80);
+ } catch (error) {
+ toast.error("파일 업로드 중 오류가 발생했습니다");
+ setIsUploading(false);
+ setIsLoading(false);
+ setUploadProgress(0);
+ return;
+ }
+ }
+
+ setUploadProgress(90);
+
+ // 구매 요청 업데이트 (기존 파일 + 새로 업로드된 파일)
+ const result = await updatePurchaseRequest(request.id, {
+ ...values,
+ items: values.items,
+ attachments: [...existingFiles, ...uploadedAttachments],
+ });
+
+ setUploadProgress(100);
+
+ if (result.error) {
+ toast.error(result.error);
+ return;
+ }
+
+ toast.success("구매 요청이 수정되었습니다");
+ handleClose();
+ onSuccess?.();
+ } catch (error) {
+ toast.error("구매 요청 수정에 실패했습니다");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ setIsUploading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ const handleClose = () => {
+ form.reset();
+ setSelectedPackage(null);
+ setSelectedMaterials([]);
+ setNewFiles([]);
+ setExistingFiles([]);
+ setActiveTab("basic");
+ setResetKey((k) => k + 1);
+ onOpenChange(false);
+ };
+
+ const canEdit = request.status === "작성중";
+ const totalFileCount = existingFiles.length + newFiles.length;
+
+ return (
+ <Sheet open={open} onOpenChange={handleClose}>
+ <SheetContent key={resetKey} className="w-[900px] max-w-[900px] overflow-hidden flex flex-col min-h-0" style={{width:900, maxWidth:900}}>
+ <SheetHeader>
+ <SheetTitle>구매 요청 수정</SheetTitle>
+ <SheetDescription>
+ 요청번호: {request.requestCode} | 상태: {request.status}
+ </SheetDescription>
+ </SheetHeader>
+
+ {!canEdit ? (
+ <div className="flex-1 flex items-center justify-center">
+ <Card>
+ <CardContent className="pt-6">
+ <p className="text-center text-muted-foreground">
+ 작성중 상태의 요청만 수정할 수 있습니다.
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ ) : (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 overflow-hidden flex flex-col min-h-0">
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
+ <TabsList className="grid w-full grid-cols-3 flex-shrink-0">
+ <TabsTrigger value="basic">
+ <FileText className="mr-2 h-4 w-4" />
+ 기본 정보
+ </TabsTrigger>
+ <TabsTrigger value="items">
+ <Package className="mr-2 h-4 w-4" />
+ 품목 정보
+ {fields.length > 0 && (
+ <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
+ {fields.length}
+ </span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="files">
+ <Paperclip className="mr-2 h-4 w-4" />
+ 첨부파일
+ {totalFileCount > 0 && (
+ <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
+ {totalFileCount}
+ </span>
+ )}
+ </TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 overflow-y-auto px-1 min-h-0">
+ <TabsContent value="basic" className="space-y-6 mt-6">
+ {/* 프로젝트 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">프로젝트 정보 <span className="text-red-500">*</span></CardTitle>
+ <CardDescription>프로젝트, 패키지, 자재그룹을 순서대로 선택하세요</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트 <span className="text-red-500">*</span></FormLabel>
+ <ProjectSelector
+ key={`project-${resetKey}`}
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트를 검색하여 선택..."
+ />
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 패키지 선택 */}
+ <FormField
+ control={form.control}
+ name="packageNo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>패키지 <span className="text-red-500">*</span></FormLabel>
+ <PackageSelector
+ projectNo={form.watch("projectCode")}
+ selectedPackage={selectedPackage}
+ onPackageSelect={handlePackageSelect}
+ placeholder="패키지 선택..."
+ />
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재그룹 선택 */}
+ <FormField
+ control={form.control}
+ name="majorItemMaterialCategory"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>자재그룹 <span className="text-red-500">*</span></FormLabel>
+ <MaterialGroupSelector
+ selectedMaterials={selectedMaterials}
+ onMaterialsChange={handleMaterialsChange}
+ singleSelect={true}
+ placeholder="자재그룹을 검색하세요..."
+ noValuePlaceHolder="자재그룹을 선택해주세요"
+ />
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* SM 코드 */}
+ <FormField
+ control={form.control}
+ name="smCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SM 코드 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="SM 코드 입력"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선택된 정보 표시 */}
+ {form.watch("projectId") && (
+ <div className="grid grid-cols-2 gap-4 pt-4 border-t">
+ <div>
+ <p className="text-sm text-muted-foreground">프로젝트 코드</p>
+ <p className="font-medium">{form.watch("projectCode") || "-"}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground">프로젝트명</p>
+ <p className="font-medium">{form.watch("projectName") || "-"}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground">발주처</p>
+ <p className="font-medium">{form.watch("projectCompany") || "-"}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted-foreground">현장</p>
+ <p className="font-medium">{form.watch("projectSite") || "-"}</p>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="requestTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>요청 제목 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 프로젝트 A용 밸브 구매"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 자동 생성된 제목을 수정할 수 있습니다
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="requestDescription"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>요청 설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="구매 요청에 대한 상세 설명을 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 예산 및 납기 */}
+ <div className="space-y-4">
+ <h3 className="text-sm font-medium">예산 및 납기</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="estimatedBudget"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>예상 예산</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 10,000,000원" {...field} />
+ </FormControl>
+ <FormDescription>
+ 전체 구매 예상 예산을 입력하세요
+ </FormDescription>
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="requestedDeliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>희망 납기일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy-MM-dd")
+ ) : (
+ <span>날짜 선택</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) => date < new Date()}
+ />
+ </PopoverContent>
+ </Popover>
+ <FormDescription>
+ 자재가 필요한 날짜를 선택하세요
+ </FormDescription>
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="items" className="space-y-4 mt-6">
+ {/* 품목 목록 */}
+ <div className="flex justify-between items-center">
+ <h3 className="text-sm font-medium">품목 목록</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addItem}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 품목 추가
+ </Button>
+ </div>
+
+ {fields.length === 0 ? (
+ <Card>
+ <CardContent className="flex flex-col items-center justify-center py-8">
+ <Package className="h-12 w-12 text-muted-foreground mb-4" />
+ <p className="text-sm text-muted-foreground mb-4">
+ 아직 추가된 품목이 없습니다
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addItem}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 첫 품목 추가
+ </Button>
+ </CardContent>
+ </Card>
+ ) : (
+ <div className="border rounded-lg">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[120px]">아이템 코드</TableHead>
+ <TableHead className="w-[150px]">아이템명 <span className="text-red-500">*</span></TableHead>
+ <TableHead>사양</TableHead>
+ <TableHead className="w-[80px]">수량 <span className="text-red-500">*</span></TableHead>
+ <TableHead className="w-[80px]">단위 <span className="text-red-500">*</span></TableHead>
+ <TableHead className="w-[120px]">예상 단가</TableHead>
+ <TableHead className="w-[120px]">예상 금액</TableHead>
+ <TableHead>비고</TableHead>
+ <TableHead className="w-[50px]"></TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {fields.map((field, index) => (
+ <TableRow key={field.id}>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.itemCode`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="코드"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.itemName`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="아이템명"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.specification`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="사양"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.unit`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="개"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.estimatedUnitPrice`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ placeholder="0"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <div className="text-right font-medium">
+ {new Intl.NumberFormat('ko-KR').format(
+ (form.watch(`items.${index}.quantity`) || 0) *
+ (form.watch(`items.${index}.estimatedUnitPrice`) || 0)
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <FormField
+ control={form.control}
+ name={`items.${index}.remarks`}
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ placeholder="비고"
+ {...field}
+ className="h-8"
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+ <TableCell>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => remove(index)}
+ disabled={fields.length === 1}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+
+ <div className="flex justify-between items-center p-4 border-t bg-muted/30">
+ <div className="text-sm text-muted-foreground">
+ 총 {fields.length}개 품목
+ </div>
+ <div className="text-right">
+ <p className="text-sm text-muted-foreground">예상 총액</p>
+ <p className="text-lg font-semibold">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: 'KRW'
+ }).format(calculateTotal())}
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+ </TabsContent>
+
+ <TabsContent value="files" className="space-y-4 mt-6">
+ {/* 파일 업로드 영역 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">첨부파일 관리</CardTitle>
+ <CardDescription>
+ 설계 도면, 사양서, 견적서 등 구매 요청 관련 문서를 관리하세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 업로드 진행 상태 */}
+ {isUploading && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription className="space-y-2">
+ <p>파일을 업로드하고 데이터를 저장 중입니다...</p>
+ <Progress value={uploadProgress} className="w-full" />
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 기존 파일 목록 */}
+ {existingFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">기존 첨부파일</h4>
+ <FileList>
+ {existingFiles.map((file) => (
+ <FileListItem key={file.fileName}>
+ <FileListIcon>
+ <FileIcon className="h-4 w-4" />
+ </FileListIcon>
+ <FileListInfo>
+ <FileListHeader>
+ <FileListName>
+ {file.originalFileName}
+ </FileListName>
+ <FileListSize>
+ {file.fileSize}
+ </FileListSize>
+ </FileListHeader>
+
+ </FileListInfo>
+ <FileListAction>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleExistingFileRemove(file.fileName)}
+ disabled={isLoading}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
+ {/* Dropzone */}
+ <Dropzone
+ onDrop={handleFileAdd}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
+ 'application/x-zip-compressed': ['.zip'],
+ 'application/x-rar-compressed': ['.rar'],
+ }}
+ maxSize={50 * 1024 * 1024} // 50MB
+ disabled={isLoading}
+ >
+ <DropzoneInput />
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
+ <DropzoneTitle>
+ 파일을 드래그하거나 클릭하여 선택하세요
+ </DropzoneTitle>
+ <DropzoneDescription>
+ PDF, Excel, Word, 이미지 파일 등을 업로드할 수 있습니다 (최대 50MB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ </Dropzone>
+
+ {/* 새로 추가할 파일 목록 */}
+ {newFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium text-blue-600">새로 추가할 파일</h4>
+ <FileList>
+ {newFiles.map((file) => (
+ <FileListItem key={file.name} className="bg-blue-50">
+ <FileListIcon>
+ <FileIcon className="h-4 w-4 text-blue-600" />
+ </FileListIcon>
+ <FileListInfo>
+ <FileListHeader>
+ <FileListName>
+ {file.name}
+ </FileListName>
+ <FileListSize>
+ {file.size}
+ </FileListSize>
+ </FileListHeader>
+ <FileListDescription>
+ {file.type || "application/octet-stream"}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleNewFileRemove(file.name)}
+ disabled={isLoading}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
+ {/* 파일 업로드 안내 */}
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ <ul className="list-disc list-inside space-y-1 text-sm">
+ <li>새 파일은 수정 완료 시 서버로 업로드됩니다</li>
+ <li>최대 파일 크기: 50MB</li>
+ <li>지원 형식: PDF, Excel, Word, 이미지 파일</li>
+ <li>여러 파일을 한 번에 선택하여 추가할 수 있습니다</li>
+ </ul>
+ </AlertDescription>
+ </Alert>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </div>
+ </Tabs>
+
+ <SheetFooter className="mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleClose}
+ disabled={isLoading || isUploading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading || isUploading || !form.formState.isValid}>
+ <Save className="mr-2 h-4 w-4" />
+ {isLoading ? "수정 중..." : "수정 완료"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ )}
+ </SheetContent>
+ </Sheet>
+ );
+} \ No newline at end of file
diff --git a/lib/itb/table/items-dialog.tsx b/lib/itb/table/items-dialog.tsx
new file mode 100644
index 00000000..dd688ce9
--- /dev/null
+++ b/lib/itb/table/items-dialog.tsx
@@ -0,0 +1,167 @@
+// components/purchase-requests/items-dialog.tsx
+"use client";
+
+import * as React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ TableFooter,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { Package } from "lucide-react";
+
+interface Item {
+ id: string;
+ itemCode: string;
+ itemName: string;
+ specification: string;
+ quantity: number;
+ unit: string;
+ estimatedUnitPrice?: number;
+ remarks?: string;
+}
+
+interface ItemsDialogProps {
+ requestId: number;
+ requestCode: string;
+ items: Item[] | any;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function ItemsDialog({
+ requestId,
+ requestCode,
+ items,
+ open,
+ onOpenChange,
+}: ItemsDialogProps) {
+ // items가 없거나 배열이 아닌 경우 처리
+ const itemList = React.useMemo(() => {
+ if (!items || !Array.isArray(items)) return [];
+ return items;
+ }, [items]);
+
+ // 총액 계산
+ const totalAmount = React.useMemo(() => {
+ return itemList.reduce((sum, item) => {
+ const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0);
+ return sum + subtotal;
+ }, 0);
+ }, [itemList]);
+
+ // 총 수량 계산
+ const totalQuantity = React.useMemo(() => {
+ return itemList.reduce((sum, item) => sum + (item.quantity || 0), 0);
+ }, [itemList]);
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Package className="h-5 w-5" />
+ 품목 상세 정보
+ </DialogTitle>
+ <DialogDescription>
+ 요청번호: {requestCode} | 총 {itemList.length}개 품목
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-auto">
+ {itemList.length === 0 ? (
+ <div className="flex items-center justify-center h-32">
+ <p className="text-muted-foreground">등록된 품목이 없습니다.</p>
+ </div>
+ ) : (
+ <Table>
+ <TableHeader className="sticky top-0 bg-background">
+ <TableRow>
+ <TableHead className="w-[50px]">번호</TableHead>
+ <TableHead className="w-[120px]">아이템 코드</TableHead>
+ <TableHead>아이템명</TableHead>
+ <TableHead>사양</TableHead>
+ <TableHead className="text-right w-[80px]">수량</TableHead>
+ <TableHead className="w-[60px]">단위</TableHead>
+ <TableHead className="text-right w-[120px]">예상 단가</TableHead>
+ <TableHead className="text-right w-[140px]">예상 금액</TableHead>
+ <TableHead className="w-[150px]">비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {itemList.map((item, index) => {
+ const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0);
+ return (
+ <TableRow key={item.id || index}>
+ <TableCell className="text-center text-muted-foreground">
+ {index + 1}
+ </TableCell>
+ <TableCell className="font-mono text-sm">
+ {item.itemCode}
+ </TableCell>
+ <TableCell className="font-medium">
+ {item.itemName}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.specification || "-"}
+ </TableCell>
+ <TableCell className="text-right font-medium">
+ {item.quantity?.toLocaleString('ko-KR')}
+ </TableCell>
+ <TableCell className="text-center">
+ {item.unit}
+ </TableCell>
+ <TableCell className="text-right">
+ {item.estimatedUnitPrice
+ ? new Intl.NumberFormat('ko-KR').format(item.estimatedUnitPrice)
+ : "-"}
+ </TableCell>
+ <TableCell className="text-right font-medium">
+ {subtotal > 0
+ ? new Intl.NumberFormat('ko-KR').format(subtotal)
+ : "-"}
+ </TableCell>
+ <TableCell className="text-sm text-muted-foreground">
+ {item.remarks || "-"}
+ </TableCell>
+ </TableRow>
+ );
+ })}
+ </TableBody>
+ <TableFooter>
+ <TableRow className="bg-muted/50">
+ <TableCell colSpan={4} className="font-medium">
+ 합계
+ </TableCell>
+ <TableCell className="text-right font-bold">
+ {totalQuantity.toLocaleString('ko-KR')}
+ </TableCell>
+ <TableCell></TableCell>
+ <TableCell></TableCell>
+ <TableCell className="text-right font-bold text-lg">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: 'KRW'
+ }).format(totalAmount)}
+ </TableCell>
+ <TableCell></TableCell>
+ </TableRow>
+ </TableFooter>
+ </Table>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/itb/table/purchase-request-columns.tsx b/lib/itb/table/purchase-request-columns.tsx
new file mode 100644
index 00000000..55321a21
--- /dev/null
+++ b/lib/itb/table/purchase-request-columns.tsx
@@ -0,0 +1,380 @@
+// components/purchase-requests/purchase-request-columns.tsx
+"use client";
+
+import { type ColumnDef } from "@tanstack/react-table";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Eye,
+ Edit,
+ Trash2,
+ CheckCircle,
+ XCircle,
+ FileText,
+ Package,
+ Send,
+ Clock
+} from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { MoreHorizontal } from "lucide-react";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import type { DataTableRowAction } from "@/types/table";
+import type { PurchaseRequestView } from "@/db/schema";
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PurchaseRequestView> | null>>;
+}
+
+const statusConfig = {
+ "작성중": {
+ label: "작성중",
+ variant: "secondary" as const,
+ icon: Edit,
+ color: "text-gray-500"
+ },
+ "요청완료": {
+ label: "요청완료",
+ variant: "default" as const,
+ icon: Send,
+ color: "text-blue-500"
+ },
+ "검토중": {
+ label: "검토중",
+ variant: "warning" as const,
+ icon: Clock,
+ color: "text-yellow-500"
+ },
+ "승인": {
+ label: "승인",
+ variant: "success" as const,
+ icon: CheckCircle,
+ color: "text-green-500"
+ },
+ "반려": {
+ label: "반려",
+ variant: "destructive" as const,
+ icon: XCircle,
+ color: "text-red-500"
+ },
+ "RFQ생성완료": {
+ label: "RFQ생성완료",
+ variant: "outline" as const,
+ icon: FileText,
+ color: "text-purple-500"
+ },
+};
+
+export function getPurchaseRequestColumns({
+ setRowAction
+}: GetColumnsProps): ColumnDef<PurchaseRequestView>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+ {
+ accessorKey: "requestCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="요청번호" />
+ ),
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ className="h-auto p-0 font-mono font-medium text-primary hover:underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ {row.getValue("requestCode")}
+ </Button>
+ ),
+ size: 130,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as keyof typeof statusConfig;
+ const config = statusConfig[status];
+ const Icon = config?.icon;
+
+ return (
+ <Badge variant={config?.variant} className="font-medium">
+ {Icon && <Icon className={`mr-1 h-3 w-3 ${config.color}`} />}
+ {config?.label || status}
+ </Badge>
+ );
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id));
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "requestTitle",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="요청제목" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-[300px]">
+ <div className="truncate font-medium" title={row.getValue("requestTitle")}>
+ {row.getValue("requestTitle")}
+ </div>
+ {row.original.requestDescription && (
+ <div className="truncate text-xs text-muted-foreground mt-0.5"
+ title={row.original.requestDescription}>
+ {row.original.requestDescription}
+ </div>
+ )}
+ </div>
+ ),
+ size: 300,
+ },
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col space-y-0.5">
+ {row.original.projectCode && (
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.projectCode}
+ </span>
+ )}
+ <span className="truncate text-sm" title={row.original.projectName}>
+ {row.original.projectName || "-"}
+ </span>
+ </div>
+ ),
+ size: 180,
+ },
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="패키지" />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col space-y-0.5">
+ {row.original.packageNo && (
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.packageNo}
+ </span>
+ )}
+ <span className="truncate text-sm" title={row.original.packageName}>
+ {row.original.packageName || "-"}
+ </span>
+ </div>
+ ),
+ size: 150,
+ },
+ {
+ id: "attachments",
+ header: "첨부",
+ cell: ({ row }) => {
+ const count = Number(row.original.attachmentCount) || 0;
+ return count > 0 ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 px-2"
+ onClick={() => setRowAction({ row, type: "attachments" })}
+ >
+ <FileText className="h-4 w-4 mr-1" />
+ {count}
+ </Button>
+ ) : (
+ <span className="text-muted-foreground text-sm">-</span>
+ );
+ },
+ size: 70,
+ },
+ {
+ id: "items",
+ header: "품목",
+ cell: ({ row }) => {
+ const count = row.original.itemCount || 0;
+ const totalQuantity = row.original.totalQuantity;
+ return count > 0 ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 px-2"
+ onClick={() => setRowAction({ row, type: "items" })}
+ >
+ <Package className="h-4 w-4 mr-1" />
+ <div className="flex flex-col items-start">
+ <span>{count}종</span>
+ {totalQuantity && (
+ <span className="text-xs text-muted-foreground">
+ 총 {totalQuantity}개
+ </span>
+ )}
+ </div>
+ </Button>
+ ) : (
+ <span className="text-muted-foreground text-sm">-</span>
+ );
+ },
+ size: 90,
+ },
+ {
+ accessorKey: "engPicName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설계담당" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.getValue("engPicName") || "-"}
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "purchasePicName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="구매담당" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.getValue("purchasePicName") || "-"}
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "estimatedBudget",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="예상예산" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm font-mono">
+ {row.getValue("estimatedBudget") || "-"}
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "requestedDeliveryDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="희망납기" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("requestedDeliveryDate") as Date | null;
+ return (
+ <div className="text-sm">
+ {date ? format(new Date(date), "yyyy-MM-dd", { locale: ko }) : "-"}
+ </div>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date;
+ return (
+ <div className="text-sm text-muted-foreground">
+ {date ? format(new Date(date), "yyyy-MM-dd", { locale: ko }) : "-"}
+ </div>
+ );
+ },
+ size: 100,
+ },
+ {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => {
+ const status = row.original.status;
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuLabel>작업</DropdownMenuLabel>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+
+
+
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "update" })}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "delete" })}
+ className="text-destructive"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+
+
+
+
+{status ==="작성중" &&
+<>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "createRfq" })}
+ className="text-primary"
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ RFQ 생성
+ </DropdownMenuItem>
+ </>
+ }
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ );
+ },
+ size: 80,
+ },
+ ];
+} \ No newline at end of file
diff --git a/lib/itb/table/purchase-requests-table.tsx b/lib/itb/table/purchase-requests-table.tsx
new file mode 100644
index 00000000..88f27666
--- /dev/null
+++ b/lib/itb/table/purchase-requests-table.tsx
@@ -0,0 +1,229 @@
+// components/purchase-requests/purchase-requests-table.tsx
+"use client";
+
+import * as React from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Plus, RefreshCw, FileText } from "lucide-react";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import { useDataTable } from "@/hooks/use-data-table";
+import { getPurchaseRequestColumns } from "./purchase-request-columns";
+import { CreatePurchaseRequestDialog } from "./create-purchase-request-dialog";
+import { EditPurchaseRequestSheet } from "./edit-purchase-request-sheet";
+import { DeletePurchaseRequestDialog } from "./delete-purchase-request-dialog";
+import { ViewPurchaseRequestSheet } from "./view-purchase-request-sheet";
+// import { AttachmentsDialog } from "./attachments-dialog";
+import { ItemsDialog } from "./items-dialog";
+import type { DataTableRowAction } from "@/types/table";
+import type { PurchaseRequestView } from "@/db/schema";
+import { CreateRfqDialog } from "./create-rfq-dialog";
+import { toast } from "sonner";
+
+interface PurchaseRequestsTableProps {
+ promises: Promise<[
+ {
+ data: PurchaseRequestView[];
+ pageCount: number;
+ },
+ any
+ ]>;
+}
+
+export function PurchaseRequestsTable({ promises }: PurchaseRequestsTableProps) {
+ const router = useRouter();
+ const [{ data, pageCount }] = React.use(promises);
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
+ const [isCreateRfqDialogOpen, setIsCreateRfqDialogOpen] = React.useState(false);
+ const [selectedRequestsForRfq, setSelectedRequestsForRfq] = React.useState<PurchaseRequestView[]>([]);
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PurchaseRequestView> | null>(null);
+
+ const columns = React.useMemo(
+ () => getPurchaseRequestColumns({ setRowAction }),
+ [setRowAction]
+ );
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ defaultPerPage: 10,
+ defaultSort: [{ id: "createdAt", desc: true }],
+ });
+
+ const refreshData = () => {
+ router.refresh();
+ };
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getSelectedRowModel().rows
+ .map(row => row.original)
+ .filter(row => row.status === "작성중")
+ ;
+
+ // 선택된 행들로 RFQ 생성 다이얼로그 열기
+ const handleBulkCreateRfq = () => {
+ const selectedRequests = selectedRows
+
+ if (selectedRequests.length === 0) {
+ toast.error("RFQ를 생성할 구매 요청을 선택해주세요");
+ return;
+ }
+
+ setSelectedRequestsForRfq(selectedRequests);
+ setIsCreateRfqDialogOpen(true);
+ };
+
+ // 개별 행 액션 처리
+ React.useEffect(() => {
+ if (rowAction?.type === "createRfq") {
+ setSelectedRequestsForRfq([rowAction.row.original]);
+ setIsCreateRfqDialogOpen(true);
+ setRowAction(null);
+ }
+ }, [rowAction]);
+
+ // RFQ 생성 성공 후 처리
+ const handleRfqSuccess = () => {
+ table.resetRowSelection(); // 선택 초기화
+ refreshData();
+ };
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={[
+ {
+ id: "requestCode",
+ label: "요청번호",
+ placeholder: "PR-2025-00001"
+ },
+ {
+ id: "requestTitle",
+ label: "요청제목",
+ placeholder: "요청 제목 검색"
+ },
+ {
+ id: "projectName",
+ label: "프로젝트명",
+ placeholder: "프로젝트명 검색"
+ },
+ {
+ id: "packageName",
+ label: "패키지명",
+ placeholder: "패키지명 검색"
+ },
+ {
+ id: "status",
+ label: "상태",
+ options: [
+ { label: "작성중", value: "작성중" },
+ { label: "RFQ생성완료", value: "RFQ생성완료" },
+ ]
+ },
+ {
+ id: "engPicName",
+ label: "설계 담당자",
+ placeholder: "담당자명 검색"
+ },
+ {
+ id: "purchasePicName",
+ label: "구매 담당자",
+ placeholder: "담당자명 검색"
+ },
+ ]}
+ >
+ <div className="flex items-center gap-2">
+ <Button
+ size="sm"
+ onClick={() => setIsCreateDialogOpen(true)}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 새 구매요청
+ </Button>
+
+ {/* 선택된 항목들로 RFQ 일괄 생성 버튼 */}
+ {selectedRows.length > 0 && (
+ <Button
+ size="sm"
+ variant="secondary"
+ onClick={handleBulkCreateRfq}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ RFQ 생성 ({selectedRows.length}개)
+ </Button>
+ )}
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={refreshData}
+ >
+ <RefreshCw className="h-4 w-4" />
+ </Button>
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 다이얼로그들 */}
+ <CreatePurchaseRequestDialog
+ open={isCreateDialogOpen}
+ onOpenChange={setIsCreateDialogOpen}
+ onSuccess={refreshData}
+ />
+
+ {/* RFQ 생성 다이얼로그 */}
+ <CreateRfqDialog
+ requests={selectedRequestsForRfq}
+ open={isCreateRfqDialogOpen}
+ onOpenChange={(open) => {
+ setIsCreateRfqDialogOpen(open);
+ if (!open) {
+ setSelectedRequestsForRfq([]);
+ }
+ }}
+ onSuccess={handleRfqSuccess}
+ />
+
+ {rowAction?.type === "view" && (
+ <ViewPurchaseRequestSheet
+ request={rowAction.row.original}
+ open={true}
+ onOpenChange={() => setRowAction(null)}
+ />
+ )}
+
+ {rowAction?.type === "update" && (
+ <EditPurchaseRequestSheet
+ request={rowAction.row.original}
+ open={true}
+ onOpenChange={() => setRowAction(null)}
+ onSuccess={refreshData}
+ />
+ )}
+
+ {rowAction?.type === "delete" && (
+ <DeletePurchaseRequestDialog
+ request={rowAction.row.original}
+ open={true}
+ onOpenChange={() => setRowAction(null)}
+ onSuccess={refreshData}
+ />
+ )}
+
+ {rowAction?.type === "items" && (
+ <ItemsDialog
+ requestId={rowAction.row.original.id}
+ requestCode={rowAction.row.original.requestCode}
+ items={rowAction.row.original.items}
+ open={true}
+ onOpenChange={() => setRowAction(null)}
+ />
+ )}
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/itb/table/view-purchase-request-sheet.tsx b/lib/itb/table/view-purchase-request-sheet.tsx
new file mode 100644
index 00000000..c4ff9416
--- /dev/null
+++ b/lib/itb/table/view-purchase-request-sheet.tsx
@@ -0,0 +1,809 @@
+// components/purchase-requests/view-purchase-request-sheet.tsx
+"use client";
+
+import * as React from "react";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetFooter,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ TableFooter,
+} from "@/components/ui/table";
+import { Separator } from "@/components/ui/separator";
+import {
+ FileList,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list";
+import {
+ FileText,
+ Package,
+ Edit,
+ Download,
+ Calendar,
+ User,
+ Building,
+ MapPin,
+ Hash,
+ DollarSign,
+ Clock,
+ CheckCircle,
+ XCircle,
+ AlertCircle,
+ Layers,
+ Tag,
+ Paperclip,
+ FileIcon,
+ Eye
+} from "lucide-react";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import type { PurchaseRequestView } from "@/db/schema";
+import { useRouter } from "next/navigation";
+import { getPurchaseRequestAttachments } from "../service";
+import { downloadFile, quickPreview, formatFileSize, getFileInfo } from "@/lib/file-download";
+
+interface ViewPurchaseRequestSheetProps {
+ request: PurchaseRequestView;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onEditClick?: () => void;
+}
+
+const statusConfig = {
+ "작성중": {
+ variant: "secondary" as const,
+ color: "text-gray-500",
+ icon: Edit,
+ bgColor: "bg-gray-100"
+ },
+ "요청완료": {
+ variant: "default" as const,
+ color: "text-blue-500",
+ icon: CheckCircle,
+ bgColor: "bg-blue-50"
+ },
+ "검토중": {
+ variant: "warning" as const,
+ color: "text-yellow-500",
+ icon: Clock,
+ bgColor: "bg-yellow-50"
+ },
+ "승인": {
+ variant: "success" as const,
+ color: "text-green-500",
+ icon: CheckCircle,
+ bgColor: "bg-green-50"
+ },
+ "반려": {
+ variant: "destructive" as const,
+ color: "text-red-500",
+ icon: XCircle,
+ bgColor: "bg-red-50"
+ },
+ "RFQ생성완료": {
+ variant: "outline" as const,
+ color: "text-purple-500",
+ icon: Package,
+ bgColor: "bg-purple-50"
+ },
+};
+
+export function ViewPurchaseRequestSheet({
+ request,
+ open,
+ onOpenChange,
+ onEditClick,
+}: ViewPurchaseRequestSheetProps) {
+ const router = useRouter();
+ const [activeTab, setActiveTab] = React.useState("overview");
+ const [attachments, setAttachments] = React.useState<any[]>([]);
+ const [isLoadingFiles, setIsLoadingFiles] = React.useState(false);
+
+ // 첨부파일 로드
+ React.useEffect(() => {
+ async function loadAttachments() {
+ if (open && request.id) {
+ setIsLoadingFiles(true);
+ try {
+ const result = await getPurchaseRequestAttachments(request.id);
+ if (result.success && result.data) {
+ setAttachments(result.data);
+ } else {
+ console.error("Failed to load attachments:", result.error);
+ setAttachments([]);
+ }
+ } catch (error) {
+ console.error("Error loading attachments:", error);
+ setAttachments([]);
+ } finally {
+ setIsLoadingFiles(false);
+ }
+ }
+ }
+
+ loadAttachments();
+ }, [open, request.id]);
+
+ // 파일 다운로드 핸들러
+ const handleFileDownload = async (file: any) => {
+ const result = await downloadFile(file.filePath, file.originalFileName, {
+ action: 'download',
+ showToast: true,
+ onError: (error) => {
+ console.error("Download failed:", error);
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`Successfully downloaded: ${fileName} (${fileSize} bytes)`);
+ }
+ });
+
+ return result;
+ };
+
+ // 파일 미리보기 핸들러
+ const handleFilePreview = async (file: any) => {
+ const fileInfo = getFileInfo(file.originalFileName);
+
+ if (!fileInfo.canPreview) {
+ // 미리보기가 지원되지 않는 파일은 다운로드
+ return handleFileDownload(file);
+ }
+
+ const result = await quickPreview(file.filePath, file.originalFileName);
+ return result;
+ };
+
+ // 전체 다운로드 핸들러
+ const handleDownloadAll = async () => {
+ for (const file of attachments) {
+ await handleFileDownload(file);
+ // 여러 파일 다운로드 시 간격 두기
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ };
+
+ // 아이템 총액 계산
+ const totalAmount = React.useMemo(() => {
+ if (!request.items || !Array.isArray(request.items)) return 0;
+ return request.items.reduce((sum, item) => {
+ const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0);
+ return sum + subtotal;
+ }, 0);
+ }, [request.items]);
+
+ const handleEdit = () => {
+ if (onEditClick) {
+ onEditClick();
+ } else {
+ onOpenChange(false);
+ }
+ };
+
+ const handleExport = () => {
+ // Export to Excel 기능
+ console.log("Export to Excel");
+ };
+
+ const StatusIcon = statusConfig[request.status]?.icon || AlertCircle;
+ const statusBgColor = statusConfig[request.status]?.bgColor || "bg-gray-50";
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[900px] max-w-[900px] overflow-hidden flex flex-col min-h-0" style={{width:900 , maxWidth:900}}>
+ <SheetHeader className="flex-shrink-0">
+ <div className="flex items-center justify-between">
+ <SheetTitle>구매요청 상세</SheetTitle>
+ <div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${statusBgColor}`}>
+ <StatusIcon className={`h-4 w-4 ${statusConfig[request.status]?.color}`} />
+ <span className={`text-sm font-medium ${statusConfig[request.status]?.color}`}>
+ {request.status}
+ </span>
+ </div>
+ </div>
+ <SheetDescription>
+ 요청번호: <span className="font-mono font-medium">{request.requestCode}</span> |
+ 작성일: {request.createdAt && format(new Date(request.createdAt), "yyyy-MM-dd")}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
+ <TabsList className="grid w-full grid-cols-4 flex-shrink-0">
+ <TabsTrigger value="overview">
+ <FileText className="mr-2 h-4 w-4" />
+ 개요
+ </TabsTrigger>
+ <TabsTrigger value="items">
+ <Package className="mr-2 h-4 w-4" />
+ 품목 정보
+ {request.itemCount > 0 && (
+ <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
+ {request.itemCount}
+ </span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="files">
+ <Paperclip className="mr-2 h-4 w-4" />
+ 첨부파일
+ {attachments.length > 0 && (
+ <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
+ {attachments.length}
+ </span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="history">
+ <Clock className="mr-2 h-4 w-4" />
+ 처리 이력
+ </TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 overflow-y-auto px-1 min-h-0">
+ <TabsContent value="overview" className="space-y-6 mt-6">
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">요청 제목</p>
+ <p className="text-lg font-semibold">{request.requestTitle}</p>
+ </div>
+
+ {request.requestDescription && (
+ <div>
+ <p className="text-sm text-muted-foreground mb-1">요청 설명</p>
+ <p className="text-sm bg-muted/30 p-3 rounded-lg">{request.requestDescription}</p>
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center gap-3">
+ <Calendar className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm text-muted-foreground">희망 납기일</p>
+ <p className="font-medium">
+ {request.requestedDeliveryDate
+ ? format(new Date(request.requestedDeliveryDate), "yyyy-MM-dd")
+ : "-"}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3">
+ <DollarSign className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm text-muted-foreground">예상 예산</p>
+ <p className="font-medium">{request.estimatedBudget || "-"}</p>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 프로젝트 & 패키지 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">프로젝트 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-start gap-3">
+ <Hash className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">프로젝트 코드</p>
+ <p className="font-medium">{request.projectCode || "-"}</p>
+ </div>
+ </div>
+
+ <div className="flex items-start gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">프로젝트명</p>
+ <p className="font-medium">{request.projectName || "-"}</p>
+ </div>
+ </div>
+
+ <div className="flex items-start gap-3">
+ <Building className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">발주처</p>
+ <p className="font-medium">{request.projectCompany || "-"}</p>
+ </div>
+ </div>
+
+ <div className="flex items-start gap-3">
+ <MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">현장</p>
+ <p className="font-medium">{request.projectSite || "-"}</p>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-start gap-3">
+ <Package className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">패키지</p>
+ <p className="font-medium">{request.packageNo} - {request.packageName || "-"}</p>
+ </div>
+ </div>
+
+ <div className="flex items-start gap-3">
+ <Tag className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">SM 코드</p>
+ <p className="font-medium">{request.smCode || "-"}</p>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 자재 정보 */}
+ {(request.majorItemMaterialCategory || request.majorItemMaterialDescription) && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">자재 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-start gap-3">
+ <Layers className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">자재 그룹</p>
+ <p className="font-medium">{request.majorItemMaterialCategory || "-"}</p>
+ </div>
+ </div>
+
+ <div className="flex items-start gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">자재 설명</p>
+ <p className="font-medium">{request.majorItemMaterialDescription || "-"}</p>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 담당자 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">담당자 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-start gap-3">
+ <User className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">설계 담당자</p>
+ <p className="font-medium">{request.engPicName || "-"}</p>
+ {request.engPicEmail && (
+ <p className="text-sm text-muted-foreground">{request.engPicEmail}</p>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-start gap-3">
+ <User className="h-4 w-4 text-muted-foreground mt-0.5" />
+ <div>
+ <p className="text-sm text-muted-foreground">구매 담당자</p>
+ <p className="font-medium">{request.purchasePicName || "미배정"}</p>
+ {request.purchasePicEmail && (
+ <p className="text-sm text-muted-foreground">{request.purchasePicEmail}</p>
+ )}
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 반려 사유 */}
+ {request.status === "반려" && request.rejectReason && (
+ <Card className="border-destructive">
+ <CardHeader className="bg-destructive/5">
+ <CardTitle className="text-base text-destructive flex items-center gap-2">
+ <XCircle className="h-4 w-4" />
+ 반려 사유
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="pt-4">
+ <p className="text-sm">{request.rejectReason}</p>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+
+ <TabsContent value="items" className="mt-6">
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle className="text-base">품목 목록</CardTitle>
+ <CardDescription className="mt-1">
+ 총 {request.itemCount || 0}개 품목 | 총 수량 {request.totalQuantity || 0}개
+ </CardDescription>
+ </div>
+ <Button variant="outline" size="sm" onClick={handleExport}>
+ <Download className="mr-2 h-4 w-4" />
+ Excel 다운로드
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ {(!request.items || request.items.length === 0) ? (
+ <div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
+ <Package className="h-8 w-8 mb-2" />
+ <p>등록된 품목이 없습니다</p>
+ </div>
+ ) : (
+ <div className="border rounded-lg">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px] text-center">번호</TableHead>
+ <TableHead>아이템 코드</TableHead>
+ <TableHead>아이템명</TableHead>
+ <TableHead>사양</TableHead>
+ <TableHead className="text-right">수량</TableHead>
+ <TableHead className="text-center">단위</TableHead>
+ <TableHead className="text-right">예상 단가</TableHead>
+ <TableHead className="text-right">예상 금액</TableHead>
+ {request.items[0]?.remarks && <TableHead>비고</TableHead>}
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {request.items.map((item: any, index: number) => {
+ const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0);
+ return (
+ <TableRow key={item.id || index}>
+ <TableCell className="text-center">{index + 1}</TableCell>
+ <TableCell className="font-mono text-sm">{item.itemCode || "-"}</TableCell>
+ <TableCell className="font-medium">{item.itemName}</TableCell>
+ <TableCell className="text-sm">{item.specification || "-"}</TableCell>
+ <TableCell className="text-right font-medium">
+ {item.quantity?.toLocaleString('ko-KR')}
+ </TableCell>
+ <TableCell className="text-center">{item.unit}</TableCell>
+ <TableCell className="text-right">
+ {item.estimatedUnitPrice
+ ? item.estimatedUnitPrice.toLocaleString('ko-KR') + "원"
+ : "-"}
+ </TableCell>
+ <TableCell className="text-right font-medium">
+ {subtotal > 0
+ ? subtotal.toLocaleString('ko-KR') + "원"
+ : "-"}
+ </TableCell>
+ {request.items[0]?.remarks && (
+ <TableCell className="text-sm">{item.remarks || "-"}</TableCell>
+ )}
+ </TableRow>
+ );
+ })}
+ </TableBody>
+ <TableFooter>
+ <TableRow>
+ <TableCell colSpan={6} className="text-right font-medium">
+ 총 합계
+ </TableCell>
+ <TableCell colSpan={2} className="text-right">
+ <div className="flex flex-col">
+ <span className="text-2xl font-bold text-primary">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: 'KRW'
+ }).format(totalAmount)}
+ </span>
+ </div>
+ </TableCell>
+ {request.items[0]?.remarks && <TableCell />}
+ </TableRow>
+ </TableFooter>
+ </Table>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="files" className="mt-6">
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle className="text-base">첨부파일</CardTitle>
+ <CardDescription className="mt-1">
+ 구매 요청 관련 문서 및 파일
+ </CardDescription>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent>
+ {isLoadingFiles ? (
+ <div className="flex items-center justify-center h-32">
+ <p className="text-muted-foreground">파일 목록을 불러오는 중...</p>
+ </div>
+ ) : attachments.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
+ <Paperclip className="h-8 w-8 mb-2" />
+ <p>첨부된 파일이 없습니다</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between mb-4">
+ <p className="text-sm text-muted-foreground">
+ 총 {attachments.length}개의 파일이 첨부되어 있습니다
+ </p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDownloadAll}
+ disabled={attachments.length === 0}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ 전체 다운로드
+ </Button>
+ </div>
+
+ <FileList>
+ {attachments.map((file, index) => {
+ const fileInfo = getFileInfo(file.originalFileName || file.fileName);
+ return (
+ <FileListItem key={file.id || file.fileName || index}>
+ <FileListIcon>
+ <FileIcon className="h-4 w-4" />
+ </FileListIcon>
+ <FileListInfo>
+ <FileListHeader>
+ <FileListName>
+ {file.originalFileName || file.fileName}
+ </FileListName>
+ <FileListSize>
+ {file.fileSize}
+ </FileListSize>
+ </FileListHeader>
+ <FileListDescription className="flex items-center gap-4">
+ {file.createdAt && (
+ <span className="text-xs">
+ {format(new Date(file.createdAt), "yyyy-MM-dd HH:mm")}
+ </span>
+ )}
+ {file.category && (
+ <Badge variant="secondary" className="text-xs">
+ {file.category}
+ </Badge>
+ )}
+ </FileListDescription>
+ </FileListInfo>
+ <div className="flex items-center gap-1">
+ {fileInfo.canPreview && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleFilePreview(file)}
+ title="미리보기"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ )}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleFileDownload(file)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </FileListItem>
+ );
+ })}
+ </FileList>
+
+ {/* 파일 종류별 요약 */}
+ {attachments.length > 0 && (
+ <div className="mt-6 p-4 bg-muted/30 rounded-lg">
+ <h4 className="text-sm font-medium mb-3">파일 요약</h4>
+ <div className="grid grid-cols-3 gap-4 text-sm">
+ <div>
+ <p className="text-muted-foreground">총 파일 수</p>
+ <p className="font-medium">{attachments.length}개</p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">총 용량</p>
+ <p className="font-medium">
+ {formatFileSize(
+ attachments.reduce((sum, file) => sum + (file.fileSize || 0), 0)
+ )}
+ </p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">최근 업로드</p>
+ <p className="font-medium">
+ {attachments[0]?.createdAt
+ ? format(new Date(attachments[0].createdAt), "yyyy-MM-dd")
+ : "-"}
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="history" className="mt-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base">처리 이력</CardTitle>
+ <CardDescription>요청서의 처리 현황을 시간순으로 확인할 수 있습니다</CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="relative">
+ <div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
+
+ <div className="space-y-6">
+ {/* 생성 */}
+ <div className="flex gap-4">
+ <div className="relative">
+ <div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
+ <div className="h-2 w-2 rounded-full bg-white" />
+ </div>
+ </div>
+ <div className="flex-1 -mt-0.5">
+ <p className="font-medium">요청서 작성</p>
+ <p className="text-sm text-muted-foreground">
+ {request.createdByName} ({request.createdByEmail})
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {request.createdAt && format(new Date(request.createdAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
+ </p>
+ </div>
+ </div>
+
+ {/* 확정 */}
+ {request.confirmedAt && (
+ <div className="flex gap-4">
+ <div className="relative">
+ <div className="h-6 w-6 rounded-full bg-blue-500 flex items-center justify-center">
+ <CheckCircle className="h-3 w-3 text-white" />
+ </div>
+ </div>
+ <div className="flex-1 -mt-0.5">
+ <p className="font-medium">요청 확정</p>
+ <p className="text-sm text-muted-foreground">
+ {request.confirmedByName}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {format(new Date(request.confirmedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
+ </p>
+ </div>
+ </div>
+ )}
+
+ {/* 반려 */}
+ {request.status === "반려" && request.rejectReason && (
+ <div className="flex gap-4">
+ <div className="relative">
+ <div className="h-6 w-6 rounded-full bg-destructive flex items-center justify-center">
+ <XCircle className="h-3 w-3 text-white" />
+ </div>
+ </div>
+ <div className="flex-1 -mt-0.5">
+ <p className="font-medium text-destructive">반려됨</p>
+ <p className="text-sm text-muted-foreground">
+ {request.rejectReason}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {request.updatedAt && format(new Date(request.updatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
+ </p>
+ </div>
+ </div>
+ )}
+
+ {/* RFQ 생성 */}
+ {request.rfqCreatedAt && (
+ <div className="flex gap-4">
+ <div className="relative">
+ <div className="h-6 w-6 rounded-full bg-green-500 flex items-center justify-center">
+ <Package className="h-3 w-3 text-white" />
+ </div>
+ </div>
+ <div className="flex-1 -mt-0.5">
+ <p className="font-medium">RFQ 생성 완료</p>
+ <p className="text-sm text-muted-foreground">
+ RFQ 번호: <span className="font-mono font-medium">{request.rfqCode}</span>
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {format(new Date(request.rfqCreatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
+ </p>
+ </div>
+ </div>
+ )}
+
+ {/* 최종 수정 */}
+ {request.updatedAt && request.updatedAt !== request.createdAt && !request.rfqCreatedAt && request.status !== "반려" && (
+ <div className="flex gap-4">
+ <div className="relative">
+ <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center">
+ <Edit className="h-3 w-3 text-muted-foreground" />
+ </div>
+ </div>
+ <div className="flex-1 -mt-0.5">
+ <p className="font-medium">최종 수정</p>
+ <p className="text-sm text-muted-foreground">
+ {request.updatedByName} ({request.updatedByEmail})
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {format(new Date(request.updatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
+ </p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </div>
+ </Tabs>
+
+ <SheetFooter className="mt-6 flex-shrink-0">
+ <div className="flex w-full justify-between">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ {request.status === "작성중" && (
+ <Button onClick={handleEdit}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정하기
+ </Button>
+ )}
+ </div>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ );
+} \ No newline at end of file
diff --git a/lib/itb/validations.ts b/lib/itb/validations.ts
new file mode 100644
index 00000000..e481fe73
--- /dev/null
+++ b/lib/itb/validations.ts
@@ -0,0 +1,85 @@
+// lib/purchase-requests/validations.ts
+
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+ parseAsBoolean
+ } from "nuqs/server";
+ import * as z from "zod";
+
+ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
+ import { type PurchaseRequestView } from "@/db/schema";
+
+ export const searchParamsPurchaseRequestCache = createSearchParamsCache({
+ // 1) 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 2) 페이지네이션
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 3) 정렬 (PurchaseRequestView 테이블)
+ sort: getSortingStateParser<PurchaseRequestView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 4) 상태 필터 (선택사항)
+ status: parseAsArrayOf(
+ z.enum(["작성중", "RFQ생성완료"])
+ ).withDefault([]),
+
+ // 5) 날짜 범위 필터 (선택사항)
+ dateFrom: parseAsString.withDefault(""),
+ dateTo: parseAsString.withDefault(""),
+
+ // 6) 고급 필터 (nuqs - filterColumns)
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 7) 글로벌 검색어
+ search: parseAsString.withDefault(""),
+ });
+
+ export type GetPurchaseRequestsSchema = Awaited<
+ ReturnType<typeof searchParamsPurchaseRequestCache.parse>
+ >;
+
+ // 구매요청 생성/수정 스키마
+ export const purchaseRequestFormSchema = z.object({
+ requestTitle: z.string().min(1, "요청 제목을 입력하세요"),
+ requestDescription: z.string().optional(),
+ projectId: z.number().optional(),
+ projectCode: z.string().optional(),
+ projectName: z.string().optional(),
+ projectCompany: z.string().optional(),
+ projectSite: z.string().optional(),
+ classNo: z.string().optional(),
+ packageNo: z.string().optional(),
+ packageName: z.string().optional(),
+ majorItemMaterialCategory: z.string().optional(),
+ majorItemMaterialDescription: z.string().optional(),
+ smCode: z.string().optional(),
+ estimatedBudget: z.string().optional(),
+ requestedDeliveryDate: z.date().optional(),
+ engPicId: z.number().optional(),
+ engPicName: z.string().optional(),
+ purchasePicId: z.number().optional(),
+ purchasePicName: z.string().optional(),
+ items: z.array(
+ z.object({
+ id: z.string(),
+ itemCode: z.string().min(1, "아이템 코드를 입력하세요"),
+ itemName: z.string().min(1, "아이템명을 입력하세요"),
+ specification: z.string(),
+ quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
+ unit: z.string().min(1, "단위를 입력하세요"),
+ estimatedUnitPrice: z.number().optional(),
+ remarks: z.string().optional(),
+ })
+ ).min(1, "최소 1개 이상의 아이템을 추가하세요"),
+ });
+
+ export type PurchaseRequestFormData = z.infer<typeof purchaseRequestFormSchema>; \ No newline at end of file
diff --git a/lib/items/service.ts b/lib/items/service.ts
index 1eab3e25..f4865cfa 100644
--- a/lib/items/service.ts
+++ b/lib/items/service.ts
@@ -457,3 +457,35 @@ export async function searchItemsForPQ(query: string): Promise<{ itemCode: strin
return [];
}
}
+
+
+export interface PackageItem {
+ packageCode: string;
+ description: string;
+ smCode: string | null;
+}
+
+export async function getPackagesByProject(projectNo: string): Promise<PackageItem[]> {
+ try {
+ // selectDistinct를 사용하여 중복 제거
+ const result = await db
+ .selectDistinct({
+ packageCode: items.packageCode,
+ description: items.description,
+ smCode: items.smCode,
+ })
+ .from(items)
+ .where(eq(items.ProjectNo, projectNo))
+ .orderBy(items.packageCode);
+
+ // null 값을 처리하고 타입을 정리
+ return result.map(item => ({
+ packageCode: item.packageCode,
+ description: item.description || item.packageCode,
+ smCode: item.smCode || null
+ }));
+ } catch (error) {
+ console.error("Failed to fetch packages:", error);
+ return [];
+ }
+} \ No newline at end of file
diff --git a/lib/mail/templates/tbe-request.hbs b/lib/mail/templates/tbe-request.hbs
new file mode 100644
index 00000000..580bf72c
--- /dev/null
+++ b/lib/mail/templates/tbe-request.hbs
@@ -0,0 +1,198 @@
+<!-- templates/tbe-request.hbs -->
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>[TBE 요청] {{rfqCode}} - 기술입찰평가 서류 제출 요청</title>
+ <style>
+ body {
+ font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+ .header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 30px;
+ border-radius: 10px 10px 0 0;
+ text-align: center;
+ }
+ .content {
+ background: #ffffff;
+ padding: 30px;
+ border: 1px solid #e0e0e0;
+ border-radius: 0 0 10px 10px;
+ }
+ .info-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 20px 0;
+ background: #f9f9f9;
+ border-radius: 8px;
+ overflow: hidden;
+ }
+ .info-table th {
+ background: #f0f0f0;
+ padding: 12px;
+ text-align: left;
+ font-weight: 600;
+ width: 30%;
+ border-bottom: 1px solid #e0e0e0;
+ }
+ .info-table td {
+ padding: 12px;
+ border-bottom: 1px solid #e0e0e0;
+ }
+ .highlight-box {
+ background: #fff3cd;
+ border-left: 4px solid #ffc107;
+ padding: 15px;
+ margin: 20px 0;
+ border-radius: 4px;
+ }
+ .requirements {
+ background: #f8f9fa;
+ padding: 20px;
+ border-radius: 8px;
+ margin: 20px 0;
+ }
+ .requirements h3 {
+ color: #495057;
+ margin-top: 0;
+ }
+ .requirements ul {
+ margin: 10px 0;
+ padding-left: 20px;
+ }
+ .requirements li {
+ margin: 8px 0;
+ }
+ .cta-button {
+ display: inline-block;
+ background: #667eea;
+ color: white;
+ padding: 12px 30px;
+ border-radius: 6px;
+ text-decoration: none;
+ font-weight: 600;
+ margin: 20px 0;
+ }
+ .footer {
+ margin-top: 30px;
+ padding-top: 20px;
+ border-top: 1px solid #e0e0e0;
+ text-align: center;
+ color: #666;
+ font-size: 14px;
+ }
+ .warning {
+ color: #dc3545;
+ font-weight: bold;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <h1>기술입찰평가(TBE) 요청</h1>
+ <p style="margin: 0; opacity: 0.9;">Technical Bid Evaluation Request</p>
+ </div>
+
+ <div class="content">
+ <p>안녕하세요, <strong>{{vendorName}}</strong> 담당자님</p>
+
+ <p>
+ 아래 RFQ 건에 대한 기술입찰평가(TBE) 서류 제출을 요청드립니다.
+ </p>
+
+ <table class="info-table">
+ <tr>
+ <th>RFQ 번호</th>
+ <td><strong>{{rfqCode}}</strong></td>
+ </tr>
+ <tr>
+ <th>RFQ 제목</th>
+ <td>{{rfqTitle}}</td>
+ </tr>
+ <tr>
+ <th>프로젝트</th>
+ <td>{{projectCode}} - {{projectName}}</td>
+ </tr>
+ <tr>
+ <th>패키지</th>
+ <td>{{packageNo}} - {{packageName}}</td>
+ </tr>
+ <tr>
+ <th>RFQ 마감일</th>
+ <td>{{rfqDueDate}}</td>
+ </tr>
+ <tr>
+ <th>구매 담당자</th>
+ <td>{{picName}} ({{picEmail}})</td>
+ </tr>
+ </table>
+
+ <div class="highlight-box">
+ <strong class="warning">⚠️ TBE 서류 제출 기한: {{tbeDeadline}}</strong>
+ <p style="margin: 5px 0 0 0; font-size: 14px;">
+ * RFQ 마감일 최소 7일 전까지 제출 필수
+ </p>
+ </div>
+
+ <div class="requirements">
+ <h3>📋 제출 필요 서류</h3>
+ <ul>
+ <li><strong>기술 제안서</strong> (Technical Proposal)</li>
+ <li><strong>제품 사양서</strong> (Product Specifications)</li>
+ <li><strong>품질 인증서</strong> (Quality Certificates)</li>
+ <li><strong>납기 일정표</strong> (Delivery Schedule)</li>
+ <li><strong>과거 실적 자료</strong> (Past Performance References)</li>
+ <li><strong>기타 요구 문서</strong> (Other Required Documents)</li>
+ </ul>
+ </div>
+
+ <div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0;">
+ <h4 style="margin-top: 0; color: #0066cc;">📌 제출 방법</h4>
+ <ol style="margin: 10px 0; padding-left: 20px;">
+ <li>모든 서류는 PDF 형식으로 준비해 주세요.</li>
+ <li>파일명은 "[{{vendorCode}}]_[{{rfqCode}}]_[문서명]" 형식으로 작성해 주세요.</li>
+ <li>이메일 제목: "TBE 서류 제출 - {{rfqCode}} - {{vendorName}}"</li>
+ <li>제출처: {{picEmail}}</li>
+ </ol>
+ </div>
+
+ <div style="text-align: center; margin: 30px 0;">
+ <a href="mailto:{{picEmail}}?subject=TBE 서류 제출 - {{rfqCode}} - {{vendorName}}" class="cta-button">
+ 이메일로 서류 제출하기
+ </a>
+ </div>
+
+ <div style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
+ <p style="margin: 0;">
+ <strong>문의사항이 있으신 경우:</strong><br>
+ 구매 담당자: {{picName}}<br>
+ 이메일: <a href="mailto:{{picEmail}}">{{picEmail}}</a><br>
+ {{#if picPhone}}
+ 전화: {{picPhone}}
+ {{/if}}
+ </p>
+ </div>
+ </div>
+
+ <div class="footer">
+ <p>
+ 본 메일은 기술입찰평가(TBE) 요청을 위한 공식 메일입니다.<br>
+ This is an official email for Technical Bid Evaluation (TBE) request.
+ </p>
+ <p style="margin-top: 10px; font-size: 12px; color: #999;">
+ © 2024 {{companyName}}. All rights reserved.
+ </p>
+ </div>
+</body>
+</html>
+
+{{!-- subject 템플릿 정의 --}}
+{{#*inline "subject"}}[TBE 요청] {{rfqCode}} - 기술입찰평가 서류 제출 요청{{/inline}} \ No newline at end of file
diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts
index 1144cf4f..082716a0 100644
--- a/lib/rfq-last/contract-actions.ts
+++ b/lib/rfq-last/contract-actions.ts
@@ -1,9 +1,15 @@
"use server";
import db from "@/db/db";
-import { rfqsLast, rfqLastDetails } from "@/db/schema";
+import { rfqsLast, rfqLastDetails,rfqPrItems,
+ prItemsForBidding,biddingConditions,biddingCompanies, projects,
+ biddings,generalContracts ,generalContractItems} from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { revalidatePath } from "next/cache";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { generateContractNumber } from "../general-contracts/service";
+import { generateBiddingNumber } from "../bidding/service";
// ===== PO (SAP) 생성 =====
interface CreatePOParams {
@@ -63,13 +69,13 @@ export async function createPO(params: CreatePOParams) {
);
// RFQ 상태 업데이트
- await tx
- .update(rfqsLast)
- .set({
- status: "PO 생성 완료",
- updatedAt: new Date(),
- })
- .where(eq(rfqsLast.id, params.rfqId));
+ // await tx
+ // .update(rfqsLast)
+ // .set({
+ // status: "PO 생성 완료",
+ // updatedAt: new Date(),
+ // })
+ // .where(eq(rfqsLast.id, params.rfqId));
});
revalidatePath(`/rfq/${params.rfqId}`);
@@ -89,14 +95,13 @@ export async function createPO(params: CreatePOParams) {
}
}
-// ===== 일반계약 생성 =====
interface CreateGeneralContractParams {
rfqId: number;
vendorId: number;
vendorName: string;
totalAmount: number;
currency: string;
- contractType?: string;
+ contractType: string; // 계약종류 추가 (UP, LE, IL 등)
contractStartDate?: Date;
contractEndDate?: Date;
contractTerms?: string;
@@ -104,40 +109,126 @@ interface CreateGeneralContractParams {
export async function createGeneralContract(params: CreateGeneralContractParams) {
try {
- const userId = 1; // TODO: 실제 사용자 ID 가져오기
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
- // 1. 선정된 업체 확인
- const [selectedVendor] = await db
- .select()
- .from(rfqLastDetails)
+ const userId = session.user.id;
+
+ // 1. RFQ 정보 및 선정된 업체 확인
+ const [rfqData] = await db
+ .select({
+ rfq: rfqsLast,
+ vendor: rfqLastDetails,
+ project: projects,
+ })
+ .from(rfqsLast)
+ .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id))
+ .leftJoin(projects, eq(projects.id, rfqsLast.projectId))
.where(
and(
- eq(rfqLastDetails.rfqsLastId, params.rfqId),
+ eq(rfqsLast.id, params.rfqId),
eq(rfqLastDetails.vendorsId, params.vendorId),
eq(rfqLastDetails.isSelected, true),
eq(rfqLastDetails.isLatest, true)
)
);
- if (!selectedVendor) {
- throw new Error("선정된 업체 정보를 찾을 수 없습니다.");
+ if (!rfqData || !rfqData.vendor) {
+ throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다.");
}
- // 2. 계약 생성 로직 (TODO: 실제 구현 필요)
- // - 계약서 템플릿 선택
- // - 계약 조건 설정
- // - 계약서 문서 생성
- // - 전자서명 프로세스 시작
+ // 2. PR 아이템 정보 조회 (계약 아이템으로 변환용)
+ const prItems = await db
+ .select()
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, params.rfqId));
- // 3. 계약 상태 업데이트
- await db.transaction(async (tx) => {
- // rfqLastDetails에 계약 정보 업데이트
+ // 3. 계약번호 생성 - generateContractNumber 함수 사용
+ const contractNumber = await generateContractNumber(
+ params.contractType, // 계약종류 (UP, LE, IL 등)
+ rfqData.rfq.picCode || undefined // 발주담당자 코드
+ );
+
+ // 4. 트랜잭션으로 계약 생성
+ const result = await db.transaction(async (tx) => {
+ // 4-1. generalContracts 테이블에 계약 생성
+ const [newContract] = await tx
+ .insert(generalContracts)
+ .values({
+ contractNumber,
+ contractSourceType: 'estimate', // 견적에서 넘어온 계약
+ status: 'Draft', // 초안 상태로 시작
+ category: '일반계약',
+ type: params.contractType, // 계약종류 저장 (UP, LE, IL 등)
+ executionMethod: '일반계약',
+ name: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 일반계약`,
+ selectionMethod: '견적',
+
+ // 업체 정보
+ vendorId: params.vendorId,
+
+ // 계약 기간
+ startDate: params.contractStartDate || new Date(),
+ endDate: params.contractEndDate || null,
+
+ // 연계 정보
+ linkedRfqOrItb: rfqData.rfq.rfqCode || undefined,
+
+ // 금액 정보
+ contractAmount: params.totalAmount,
+ currency: params.currency || 'KRW',
+ totalAmount: params.totalAmount,
+
+ // 조건 정보 (RFQ에서 가져옴)
+ contractCurrency: rfqData.vendor.currency || 'KRW',
+ paymentTerm: rfqData.vendor.paymentTermsCode || undefined,
+ taxType: rfqData.vendor.taxCode || undefined,
+ deliveryTerm: rfqData.vendor.incotermsCode || undefined,
+ shippingLocation: rfqData.vendor.placeOfShipping || undefined,
+ dischargeLocation: rfqData.vendor.placeOfDestination || undefined,
+ contractDeliveryDate: rfqData.vendor.deliveryDate || undefined,
+
+ // 연동제 적용 여부
+ interlockingSystem: rfqData.vendor.materialPriceRelatedYn ? 'Y' : 'N',
+
+ // 시스템 정보
+ registeredById: userId,
+ registeredAt: new Date(),
+ lastUpdatedById: userId,
+ lastUpdatedAt: new Date(),
+ notes: params.contractTerms || rfqData.vendor.remark || undefined,
+ })
+ .returning();
+
+ // 4-2. generalContractItems 테이블에 아이템 생성
+ if (prItems.length > 0) {
+ const contractItems = prItems.map(item => ({
+ contractId: newContract.id,
+ project: rfqData.project?.name || undefined,
+ itemCode: item.materialCode || undefined,
+ itemInfo: `${item.materialCategory || ''} / ${item.materialCode || ''}`,
+ specification: item.materialDescription || undefined,
+ quantity: item.quantity,
+ quantityUnit: item.uom || undefined,
+ contractDeliveryDate: item.deliveryDate || undefined,
+ contractCurrency: params.currency || 'KRW',
+ // 단가와 금액은 견적 데이터에서 가져와야 함 (현재는 총액을 아이템 수로 나눔)
+ contractUnitPrice: params.totalAmount / prItems.length,
+ contractAmount: params.totalAmount / prItems.length,
+ }));
+
+ await tx.insert(generalContractItems).values(contractItems);
+ }
+
+ // 4-3. rfqLastDetails 상태 업데이트
await tx
.update(rfqLastDetails)
.set({
- contractStatus: "진행중",
+ contractStatus: "일반계약 진행중",
contractCreatedAt: new Date(),
- contractNo: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호로 변경
+ contractNo: contractNumber,
updatedAt: new Date(),
updatedBy: userId,
})
@@ -149,23 +240,18 @@ export async function createGeneralContract(params: CreateGeneralContractParams)
)
);
- // RFQ 상태 업데이트
- await tx
- .update(rfqsLast)
- .set({
- status: "일반계약 진행중",
- updatedAt: new Date(),
- })
- .where(eq(rfqsLast.id, params.rfqId));
+ return newContract;
});
revalidatePath(`/rfq/${params.rfqId}`);
revalidatePath("/rfq");
+ revalidatePath("/contracts");
return {
success: true,
message: "일반계약이 성공적으로 생성되었습니다.",
- contractNumber: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호 반환
+ contractNumber: result.contractNumber,
+ contractId: result.id,
};
} catch (error) {
console.error("일반계약 생성 오류:", error);
@@ -183,49 +269,162 @@ interface CreateBiddingParams {
vendorName: string;
totalAmount: number;
currency: string;
- biddingType?: string; // 공개입찰, 제한입찰 등
- biddingStartDate?: Date;
- biddingEndDate?: Date;
+ contractType: "unit_price" | "general" | "sale"; // 계약구분
+ biddingType: string; // 입찰유형 (equipment, construction 등)
+ awardCount: "single" | "multiple"; // 낙찰수
+ biddingStartDate: Date;
+ biddingEndDate: Date;
biddingRequirements?: string;
}
export async function createBidding(params: CreateBiddingParams) {
try {
- const userId = 1; // TODO: 실제 사용자 ID 가져오기
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
- // 1. 선정된 업체 확인
- const [selectedVendor] = await db
- .select()
- .from(rfqLastDetails)
+ const userId = session.user.id;
+
+ // 1. RFQ 정보 및 선정된 업체 확인
+ const [rfqData] = await db
+ .select({
+ rfq: rfqsLast,
+ vendor: rfqLastDetails,
+ project: projects,
+ })
+ .from(rfqsLast)
+ .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id))
+ .leftJoin(projects, eq(projects.id, rfqsLast.projectId))
.where(
and(
- eq(rfqLastDetails.rfqsLastId, params.rfqId),
+ eq(rfqsLast.id, params.rfqId),
eq(rfqLastDetails.vendorsId, params.vendorId),
eq(rfqLastDetails.isSelected, true),
eq(rfqLastDetails.isLatest, true)
)
);
- if (!selectedVendor) {
- throw new Error("선정된 업체 정보를 찾을 수 없습니다.");
+ if (!rfqData || !rfqData.vendor) {
+ throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다.");
}
- // 2. 입찰 생성 로직 (TODO: 실제 구현 필요)
- // - 입찰 공고 생성
- // - 입찰 참가자격 설정
- // - 입찰 일정 등록
- // - 평가 기준 설정
- // - 입찰 시스템 등록
+ // 2. PR 아이템 정보 조회
+ const prItems = await db
+ .select()
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, params.rfqId));
- // 3. 입찰 상태 업데이트
- await db.transaction(async (tx) => {
- // rfqLastDetails에 입찰 정보 업데이트
+ // 3. 트랜잭션으로 입찰 생성
+ const result = await db.transaction(async (tx) => {
+ // 3-1. 입찰번호 생성 - generateBiddingNumber 함수 사용
+ const biddingNumber = await generateBiddingNumber(
+ rfqData.rfq.picCode || undefined, // 발주담당자 코드
+ tx // 트랜잭션 컨텍스트 전달
+ );
+
+ // 3-2. biddings 테이블에 입찰 생성
+ const [newBidding] = await tx
+ .insert(biddings)
+ .values({
+ biddingNumber,
+ revision: 0,
+ biddingSourceType: 'estimate',
+ projectId: rfqData.rfq.projectId || undefined,
+
+ // 기본 정보
+ projectName: rfqData.project?.name || undefined,
+ itemName: rfqData.rfq.itemName || undefined,
+ title: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 입찰`,
+ description: params.biddingRequirements || rfqData.rfq.remark || undefined,
+
+ // 계약 정보 - 파라미터에서 받은 값 사용
+ contractType: params.contractType,
+ biddingType: params.biddingType,
+ awardCount: params.awardCount,
+
+ // 일정 관리 - 파라미터에서 받은 날짜 사용
+ biddingRegistrationDate: new Date(),
+ submissionStartDate: params.biddingStartDate,
+ submissionEndDate: params.biddingEndDate,
+
+ // 예산 및 가격 정보
+ currency: params.currency || 'KRW',
+ budget: params.totalAmount,
+ targetPrice: params.totalAmount,
+
+ // PR 정보
+ prNumber: rfqData.rfq.prNumber || undefined,
+ hasPrDocument: false,
+
+ // 상태
+ status: 'bidding_generated',
+ isPublic: false, // 다이얼로그에서 체크박스로 받을 수 있음
+ isUrgent: false, // 다이얼로그에서 체크박스로 받을 수 있음
+
+ // 담당자 정보
+ managerName: rfqData.rfq.picName || undefined,
+
+ // 메타 정보
+ createdBy: String(userId),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ updatedBy: String(userId),
+ ANFNR: rfqData.rfq.ANFNR || undefined,
+ })
+ .returning();
+
+ // 3-3. PR 아이템을 입찰 아이템으로 변환
+ if (prItems.length > 0) {
+ const biddingPrItems = prItems.map(item => ({
+ biddingId: newBidding.id,
+ itemNumber: item.rfqItem || undefined,
+ projectInfo: rfqData.project?.name || undefined, // 프로젝트 이름 사용
+ itemInfo: item.materialDescription || undefined,
+ requestedDeliveryDate: item.deliveryDate || undefined,
+ currency: params.currency || 'KRW',
+ quantity: item.quantity,
+ quantityUnit: item.uom || undefined,
+ totalWeight: item.grossWeight || undefined,
+ weightUnit: item.gwUom || undefined,
+ materialDescription: item.materialDescription || undefined,
+ prNumber: item.prNo || undefined,
+ hasSpecDocument: !!item.specUrl,
+ }));
+
+ await tx.insert(prItemsForBidding).values(biddingPrItems);
+ }
+
+ // 3-4. 선정된 업체를 입찰 참여 업체로 등록
+ await tx.insert(biddingCompanies).values({
+ biddingId: newBidding.id,
+ companyId: params.vendorId,
+ invitationStatus: 'pending',
+ preQuoteAmount: params.totalAmount, // 견적 금액을 사전견적으로 사용
+ isPreQuoteSelected: true, // 본입찰 대상으로 자동 선정
+ isBiddingInvited: true,
+ notes: '견적에서 선정된 업체',
+ });
+
+ // 3-5. 입찰 조건 생성 (RFQ 조건 활용)
+ await tx.insert(biddingConditions).values({
+ biddingId: newBidding.id,
+ paymentTerms: JSON.stringify([rfqData.vendor.paymentTermsCode]),
+ taxConditions: JSON.stringify([rfqData.vendor.taxCode]),
+ contractDeliveryDate: rfqData.vendor.deliveryDate || undefined,
+ isPriceAdjustmentApplicable: rfqData.vendor.materialPriceRelatedYn || false,
+ incoterms: JSON.stringify([rfqData.vendor.incotermsCode]),
+ shippingPort: rfqData.vendor.placeOfShipping || undefined,
+ destinationPort: rfqData.vendor.placeOfDestination || undefined,
+ });
+
+ // 3-6. rfqLastDetails 상태 업데이트
await tx
.update(rfqLastDetails)
.set({
contractStatus: "입찰진행중",
contractCreatedAt: new Date(),
- contractNo: `BID-${Date.now()}`, // TODO: 실제 입찰번호로 변경
+ contractNo: biddingNumber,
updatedAt: new Date(),
updatedBy: userId,
})
@@ -237,23 +436,18 @@ export async function createBidding(params: CreateBiddingParams) {
)
);
- // RFQ 상태 업데이트
- await tx
- .update(rfqsLast)
- .set({
- status: "입찰 진행중",
- updatedAt: new Date(),
- })
- .where(eq(rfqsLast.id, params.rfqId));
+ return newBidding;
});
revalidatePath(`/rfq/${params.rfqId}`);
revalidatePath("/rfq");
+ revalidatePath("/biddings");
return {
success: true,
message: "입찰이 성공적으로 생성되었습니다.",
- biddingNumber: `BID-${Date.now()}`, // TODO: 실제 입찰번호 반환
+ biddingNumber: result.biddingNumber,
+ biddingId: result.id,
};
} catch (error) {
console.error("입찰 생성 오류:", error);
@@ -263,7 +457,6 @@ export async function createBidding(params: CreateBiddingParams) {
};
}
}
-
// ===== 계약 타입 확인 =====
export async function checkContractStatus(rfqId: number) {
try {
diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx
index 491a1962..28c8b3b1 100644
--- a/lib/rfq-last/quotation-compare-view.tsx
+++ b/lib/rfq-last/quotation-compare-view.tsx
@@ -46,6 +46,7 @@ import {
import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action";
import { createPO, createGeneralContract, createBidding } from "./contract-actions";
import { toast } from "sonner";
+import { useRouter } from "next/navigation"
interface QuotationCompareViewProps {
data: ComparisonData;
@@ -61,6 +62,61 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
const [selectionReason, setSelectionReason] = React.useState("");
const [cancelReason, setCancelReason] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const router = useRouter()
+
+ const [selectedGeneralContractType, setSelectedGeneralContractType] = React.useState("");
+ const [contractStartDate, setContractStartDate] = React.useState("");
+ const [contractEndDate, setContractEndDate] = React.useState("");
+
+ // 계약종류 옵션
+ const contractTypes = [
+ { value: 'UP', label: 'UP - 자재단가계약' },
+ { value: 'LE', label: 'LE - 임대차계약' },
+ { value: 'IL', label: 'IL - 개별운송계약' },
+ { value: 'AL', label: 'AL - 연간운송계약' },
+ { value: 'OS', label: 'OS - 외주용역계약' },
+ { value: 'OW', label: 'OW - 도급계약' },
+ { value: 'IS', label: 'IS - 검사계약' },
+ { value: 'LO', label: 'LO - LOI (의향서)' },
+ { value: 'FA', label: 'FA - Frame Agreement' },
+ { value: 'SC', label: 'SC - 납품합의계약' },
+ { value: 'OF', label: 'OF - 클레임상계계약' },
+ { value: 'AW', label: 'AW - 사전작업합의' },
+ { value: 'AD', label: 'AD - 사전납품합의' },
+ { value: 'AM', label: 'AM - 설계계약' },
+ { value: 'SC_SELL', label: 'SC - 폐기물매각계약' },
+ ];
+
+ // 입찰 관련 state 추가
+ const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale" | "">("");
+ const [biddingType, setBiddingType] = React.useState<string>("");
+ const [awardCount, setAwardCount] = React.useState<"single" | "multiple">("single");
+ const [biddingStartDate, setBiddingStartDate] = React.useState("");
+ const [biddingEndDate, setBiddingEndDate] = React.useState("");
+
+ // 입찰 옵션들
+ const biddingContractTypes = [
+ { value: 'unit_price', label: '단가계약' },
+ { value: 'general', label: '일반계약' },
+ { value: 'sale', label: '매각계약' }
+ ];
+
+ const biddingTypes = [
+ { value: 'equipment', label: '기자재' },
+ { value: 'construction', label: '공사' },
+ { value: 'service', label: '용역' },
+ { value: 'lease', label: '임차' },
+ { value: 'steel_stock', label: '형강스톡' },
+ { value: 'piping', label: '배관' },
+ { value: 'transport', label: '운송' },
+ { value: 'waste', label: '폐기물' },
+ { value: 'sale', label: '매각' }
+ ];
+
+ const awardCounts = [
+ { value: 'single', label: '단수' },
+ { value: 'multiple', label: '복수' }
+ ];
// 선정된 업체 정보 확인
const selectedVendor = data.vendors.find(v => v.isSelected);
@@ -76,15 +132,56 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
return;
}
+ // 일반계약인 경우 계약종류와 날짜 확인
+ if (selectedContractType === "CONTRACT") {
+ if (!selectedGeneralContractType) {
+ toast.error("계약종류를 선택해주세요.");
+ return;
+ }
+ if (!contractStartDate) {
+ toast.error("계약 시작일을 선택해주세요.");
+ return;
+ }
+ if (!contractEndDate) {
+ toast.error("계약 종료일을 선택해주세요.");
+ return;
+ }
+ if (new Date(contractStartDate) >= new Date(contractEndDate)) {
+ toast.error("계약 종료일은 시작일보다 이후여야 합니다.");
+ return;
+ }
+ }
+
+ // 입찰 검증
+ if (selectedContractType === "BIDDING") {
+ if (!biddingContractType) {
+ toast.error("계약구분을 선택해주세요.");
+ return;
+ }
+ if (!biddingType) {
+ toast.error("입찰유형을 선택해주세요.");
+ return;
+ }
+ if (!biddingStartDate || !biddingEndDate) {
+ toast.error("입찰 기간을 입력해주세요.");
+ return;
+ }
+ if (new Date(biddingStartDate) >= new Date(biddingEndDate)) {
+ toast.error("입찰 마감일은 시작일보다 이후여야 합니다.");
+ return;
+ }
+ }
+
if (!selectedVendor) {
toast.error("선정된 업체가 없습니다.");
return;
}
setIsSubmitting(true);
+
try {
let result;
-
+
switch (selectedContractType) {
case "PO":
result = await createPO({
@@ -94,9 +191,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
totalAmount: selectedVendor.totalAmount,
currency: selectedVendor.currency,
selectionReason: selectedVendor.selectionReason,
+
});
break;
-
+
case "CONTRACT":
result = await createGeneralContract({
rfqId: data.rfqInfo.id,
@@ -104,9 +202,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
vendorName: selectedVendor.vendorName,
totalAmount: selectedVendor.totalAmount,
currency: selectedVendor.currency,
+ contractStartDate: new Date(contractStartDate),
+ contractEndDate: new Date(contractEndDate),
+ contractType: selectedGeneralContractType, // 계약종류 추가
+
});
break;
-
+
case "BIDDING":
result = await createBidding({
rfqId: data.rfqInfo.id,
@@ -114,9 +216,14 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
vendorName: selectedVendor.vendorName,
totalAmount: selectedVendor.totalAmount,
currency: selectedVendor.currency,
+ contractType: biddingContractType,
+ biddingType: biddingType,
+ awardCount: awardCount,
+ biddingStartDate: new Date(biddingStartDate),
+ biddingEndDate: new Date(biddingEndDate),
});
break;
-
+
default:
throw new Error("올바른 계약 유형이 아닙니다.");
}
@@ -124,8 +231,17 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
if (result.success) {
toast.success(result.message || "계약 프로세스가 시작되었습니다.");
setShowContractDialog(false);
+ // 모든 state 초기화
setSelectedContractType("");
- window.location.reload();
+ setSelectedGeneralContractType("");
+ setContractStartDate("");
+ setContractEndDate("");
+ setBiddingContractType("");
+ setBiddingType("");
+ setAwardCount("single");
+ setBiddingStartDate("");
+ setBiddingEndDate("");
+ router.refresh();
} else {
throw new Error(result.error || "계약 진행 중 오류가 발생했습니다.");
}
@@ -224,7 +340,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
toast.success("업체가 성공적으로 선정되었습니다.");
setShowSelectionDialog(false);
setSelectionReason("");
- window.location.reload(); // 페이지 새로고침으로 선정 상태 반영
+ router.refresh()
} else {
throw new Error(result.error || "업체 선정 중 오류가 발생했습니다.");
}
@@ -246,13 +362,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
setIsSubmitting(true);
try {
// 파라미터를 올바르게 전달
- const result = await cancelVendorSelection(Number(data.rfqInfo.id),cancelReason);
+ const result = await cancelVendorSelection(Number(data.rfqInfo.id), cancelReason);
if (result.success) {
toast.success("업체 선정이 취소되었습니다.");
setShowCancelDialog(false);
setCancelReason("");
- window.location.reload();
+ router.refresh()
} else {
throw new Error(result.error || "선정 취소 중 오류가 발생했습니다.");
}
@@ -312,13 +428,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
{hasSelection && (
<Alert className={cn(
"border-2",
- hasContract
+ hasContract
? "border-purple-500 bg-purple-50"
- : isSelectionApproved
- ? "border-green-500 bg-green-50"
- : isPendingApproval
- ? "border-yellow-500 bg-yellow-50"
- : "border-blue-500 bg-blue-50"
+ : isSelectionApproved
+ ? "border-green-500 bg-green-50"
+ : isPendingApproval
+ ? "border-yellow-500 bg-yellow-50"
+ : "border-blue-500 bg-blue-50"
)}>
<div className="flex items-start justify-between">
<div className="flex gap-3">
@@ -335,11 +451,11 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<AlertTitle className="text-lg">
{hasContract
? "계약 진행중"
- : isSelectionApproved
- ? "업체 선정 승인 완료"
- : isPendingApproval
- ? "업체 선정 승인 대기중"
- : "업체 선정 완료"}
+ : isSelectionApproved
+ ? "업체 선정 승인 완료"
+ : isPendingApproval
+ ? "업체 선정 승인 대기중"
+ : "업체 선정 완료"}
</AlertTitle>
<AlertDescription className="space-y-1">
<p className="font-semibold">선정 업체: {selectedVendor.vendorName} ({selectedVendor.vendorCode})</p>
@@ -521,13 +637,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
key={vendor.vendorId}
className={cn(
"flex items-center justify-between p-4 border rounded-lg transition-colors",
- vendor.isSelected
- ? "bg-blue-100 border-blue-400 border-2"
+ vendor.isSelected
+ ? "bg-blue-100 border-blue-400 border-2"
: hasSelection
- ? "opacity-60"
- : selectedVendorId === vendor.vendorId.toString()
- ? "bg-blue-50 border-blue-300 cursor-pointer"
- : "hover:bg-gray-50 cursor-pointer"
+ ? "opacity-60"
+ : selectedVendorId === vendor.vendorId.toString()
+ ? "bg-blue-50 border-blue-300 cursor-pointer"
+ : "hover:bg-gray-50 cursor-pointer"
)}
onClick={() => {
if (!hasSelection) {
@@ -683,10 +799,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</TooltipContent>
</Tooltip>
</TooltipProvider>
- {vendor.vendorConditions.paymentTermsCode &&
- vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && (
- <Badge variant="outline" className="text-xs">변경</Badge>
- )}
+ {vendor.vendorConditions.paymentTermsCode &&
+ vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && (
+ <Badge variant="outline" className="text-xs">변경</Badge>
+ )}
</div>
</td>
))}
@@ -770,8 +886,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
{quote.deliveryDate
? format(new Date(quote.deliveryDate), "yyyy-MM-dd")
: quote.leadTime
- ? `${quote.leadTime}일`
- : "-"}
+ ? `${quote.leadTime}일`
+ : "-"}
</td>
<td className="p-2">
{quote.manufacturer && (
@@ -817,7 +933,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<div>
<p className="text-muted-foreground">가격 차이</p>
<p className="font-semibold">
- {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) /
+ {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) /
item.priceAnalysis.lowestPrice * 100).toFixed(1)}%
</p>
</div>
@@ -848,7 +964,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<div className="space-y-3">
{data.vendors.map((vendor) => {
if (!vendor.conditionDifferences.hasDifferences) return null;
-
+
return (
<div key={vendor.vendorId} className="p-3 border rounded-lg">
<p className="font-medium mb-2">{vendor.vendorName}</p>
@@ -930,12 +1046,12 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
// 가격 순위와 조건 차이를 고려한 점수 계산
const scoredVendors = data.vendors.map(v => ({
...v,
- score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 +
- v.conditionDifferences.differences.length
+ score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 +
+ v.conditionDifferences.differences.length
}));
scoredVendors.sort((a, b) => a.score - b.score);
const recommended = scoredVendors[0];
-
+
return (
<div>
<p className="text-sm text-purple-700">
@@ -963,7 +1079,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<div className="space-y-4">
{data.vendors.map((vendor) => {
if (!vendor.generalRemark && !vendor.technicalProposal) return null;
-
+
return (
<div key={vendor.vendorId} className="border rounded-lg p-4">
<p className="font-medium mb-2">{vendor.vendorName}</p>
@@ -996,7 +1112,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<h3 className="text-lg font-semibold mb-4">
{hasSelection ? "업체 재선정 확인" : "업체 선정 확인"}
</h3>
-
+
{selectedVendorId && (
<div className="space-y-4">
<div className="rounded-lg border p-4">
@@ -1076,7 +1192,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
<h3 className="text-lg font-semibold mb-4">업체 선정 취소</h3>
-
+
<Alert className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
@@ -1141,13 +1257,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
{/* 계약 진행 모달 */}
{showContractDialog && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
- <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
+ <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
{selectedContractType === "PO" && "PO (SAP) 생성"}
{selectedContractType === "CONTRACT" && "일반계약 생성"}
{selectedContractType === "BIDDING" && "입찰 생성"}
</h3>
-
+
{selectedVendor && (
<div className="space-y-4">
<div className="rounded-lg border p-4 bg-gray-50">
@@ -1180,32 +1296,222 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</AlertDescription>
</Alert>
- {/* 추가 옵션이 필요한 경우 여기에 추가 */}
+ {/* 일반계약 선택 시 계약종류 및 기간 선택 */}
{selectedContractType === "CONTRACT" && (
- <div className="space-y-2">
- <p className="text-sm font-medium">계약 옵션</p>
- <div className="space-y-2 text-sm">
- <label className="flex items-center gap-2">
- <input type="checkbox" className="rounded" />
- <span>표준계약서 사용</span>
- </label>
- <label className="flex items-center gap-2">
- <input type="checkbox" className="rounded" />
- <span>전자서명 요청</span>
+ <div className="space-y-4">
+ {/* 계약종류 선택 */}
+ <div className="space-y-2">
+ <label htmlFor="contract-type" className="text-sm font-medium">
+ 계약종류 선택 *
</label>
+ <select
+ id="contract-type"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ value={selectedGeneralContractType}
+ onChange={(e) => setSelectedGeneralContractType(e.target.value)}
+ required
+ >
+ <option value="">계약종류를 선택하세요</option>
+ {contractTypes.map(type => (
+ <option key={type.value} value={type.value}>
+ {type.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* 계약 기간 */}
+ <div className="grid grid-cols-2 gap-3">
+ <div className="space-y-2">
+ <label htmlFor="start-date" className="text-sm font-medium">
+ 계약 시작일 *
+ </label>
+ <input
+ id="start-date"
+ type="date"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ value={contractStartDate}
+ onChange={(e) => setContractStartDate(e.target.value)}
+ min={new Date().toISOString().split('T')[0]}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label htmlFor="end-date" className="text-sm font-medium">
+ 계약 종료일 *
+ </label>
+ <input
+ id="end-date"
+ type="date"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ value={contractEndDate}
+ onChange={(e) => setContractEndDate(e.target.value)}
+ min={contractStartDate || new Date().toISOString().split('T')[0]}
+ required
+ />
+ </div>
+ </div>
+
+ {/* 계약 기간 표시 */}
+ {contractStartDate && contractEndDate && (
+ <div className="p-3 bg-blue-50 rounded-md">
+ <p className="text-sm text-blue-700">
+ 계약 기간: {format(new Date(contractStartDate), "yyyy년 MM월 dd일", { locale: ko })} ~ {format(new Date(contractEndDate), "yyyy년 MM월 dd일", { locale: ko })}
+ <span className="ml-2 font-medium">
+ ({Math.ceil((new Date(contractEndDate).getTime() - new Date(contractStartDate).getTime()) / (1000 * 60 * 60 * 24))}일간)
+ </span>
+ </p>
+ </div>
+ )}
+
+ {/* 계약 옵션 */}
+ <div className="space-y-2">
+ <p className="text-sm font-medium">추가 옵션</p>
+ <div className="space-y-2 text-sm">
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>표준계약서 사용</span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>전자서명 요청</span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>자동 연장 조항 포함</span>
+ </label>
+ </div>
</div>
</div>
)}
-
+ {/* 입찰 옵션 */}
{selectedContractType === "BIDDING" && (
- <div className="space-y-2">
- <p className="text-sm font-medium">입찰 유형</p>
- <select className="w-full px-3 py-2 border rounded-md">
- <option value="">선택하세요</option>
- <option value="open">공개입찰</option>
- <option value="limited">제한입찰</option>
- <option value="private">지명입찰</option>
- </select>
+ <div className="space-y-4">
+ {/* 계약구분 선택 */}
+ <div className="space-y-2">
+ <label htmlFor="bidding-contract-type" className="text-sm font-medium">
+ 계약구분 *
+ </label>
+ <select
+ id="bidding-contract-type"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={biddingContractType}
+ onChange={(e) => setBiddingContractType(e.target.value as any)}
+ required
+ >
+ <option value="">계약구분을 선택하세요</option>
+ {biddingContractTypes.map(type => (
+ <option key={type.value} value={type.value}>
+ {type.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* 입찰유형 선택 */}
+ <div className="space-y-2">
+ <label htmlFor="bidding-type" className="text-sm font-medium">
+ 입찰유형 *
+ </label>
+ <select
+ id="bidding-type"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={biddingType}
+ onChange={(e) => setBiddingType(e.target.value)}
+ required
+ >
+ <option value="">입찰유형을 선택하세요</option>
+ {biddingTypes.map(type => (
+ <option key={type.value} value={type.value}>
+ {type.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* 낙찰수 선택 */}
+ <div className="space-y-2">
+ <label htmlFor="award-count" className="text-sm font-medium">
+ 낙찰수
+ </label>
+ <select
+ id="award-count"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={awardCount}
+ onChange={(e) => setAwardCount(e.target.value as any)}
+ >
+ {awardCounts.map(type => (
+ <option key={type.value} value={type.value}>
+ {type.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* 입찰 기간 */}
+ <div className="grid grid-cols-2 gap-3">
+ <div className="space-y-2">
+ <label htmlFor="bidding-start-date" className="text-sm font-medium">
+ 입찰 시작일 *
+ </label>
+ <input
+ id="bidding-start-date"
+ type="date"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={biddingStartDate}
+ onChange={(e) => setBiddingStartDate(e.target.value)}
+ min={new Date().toISOString().split('T')[0]}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label htmlFor="bidding-end-date" className="text-sm font-medium">
+ 입찰 마감일 *
+ </label>
+ <input
+ id="bidding-end-date"
+ type="date"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={biddingEndDate}
+ onChange={(e) => setBiddingEndDate(e.target.value)}
+ min={biddingStartDate || new Date().toISOString().split('T')[0]}
+ required
+ />
+ </div>
+ </div>
+
+ {/* 입찰 기간 표시 */}
+ {biddingStartDate && biddingEndDate && (
+ <div className="p-3 bg-purple-50 rounded-md">
+ <p className="text-sm text-purple-700">
+ 입찰 기간: {format(new Date(biddingStartDate), "yyyy년 MM월 dd일", { locale: ko })} ~ {format(new Date(biddingEndDate), "yyyy년 MM월 dd일", { locale: ko })}
+ <span className="ml-2 font-medium">
+ ({Math.ceil((new Date(biddingEndDate).getTime() - new Date(biddingStartDate).getTime()) / (1000 * 60 * 60 * 24))}일간)
+ </span>
+ </p>
+ </div>
+ )}
+
+ {/* 추가 옵션 */}
+ <div className="space-y-2">
+ <p className="text-sm font-medium">추가 옵션</p>
+ <div className="space-y-2 text-sm">
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>긴급입찰</span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>사양설명회 개최</span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>공개입찰</span>
+ </label>
+ </div>
+ </div>
</div>
)}
</div>
@@ -1216,7 +1522,16 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
variant="outline"
onClick={() => {
setShowContractDialog(false);
+ // 모든 state 초기화
setSelectedContractType("");
+ setSelectedGeneralContractType("");
+ setContractStartDate("");
+ setContractEndDate("");
+ setBiddingContractType("");
+ setBiddingType("");
+ setAwardCount("single");
+ setBiddingStartDate("");
+ setBiddingEndDate("");
}}
disabled={isSubmitting}
>
@@ -1224,7 +1539,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</Button>
<Button
onClick={handleContractCreation}
- disabled={isSubmitting}
+ disabled={
+ isSubmitting ||
+ (selectedContractType === "CONTRACT" &&
+ (!selectedGeneralContractType || !contractStartDate || !contractEndDate)) ||
+ (selectedContractType === "BIDDING" &&
+ (!biddingContractType || !biddingType || !biddingStartDate || !biddingEndDate))
+ }
>
{isSubmitting ? "처리 중..." : "진행"}
</Button>
@@ -1232,6 +1553,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</div>
</div>
)}
+
+
</div>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 723a69fe..85db1ea7 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -16,6 +16,7 @@ import { addDays, format } from "date-fns"
import { ko, enUS } from "date-fns/locale"
import { generateBasicContractsForVendor } from "../basic-contract/gen-service";
import { writeFile, mkdir } from "fs/promises";
+import { generateItbRfqCode } from "../itb/service";
export async function getRfqs(input: GetRfqsSchema) {
@@ -37,17 +38,14 @@ export async function getRfqs(input: GetRfqsSchema) {
break;
case "itb":
// ITB: projectCompany가 있는 경우
- typeFilter = and(
- isNotNull(rfqsLastView.projectCompany),
- ne(rfqsLastView.projectCompany, '')
- );
+ typeFilter =
+ like(rfqsLastView.rfqCode,'I%')
+
+ ;
break;
case "rfq":
// RFQ: prNumber가 있는 경우
- typeFilter = and(
- isNotNull(rfqsLastView.prNumber),
- ne(rfqsLastView.prNumber, '')
- );
+ typeFilter = like(rfqsLastView.rfqCode,'R%');
break;
}
}
@@ -1854,6 +1852,26 @@ export async function getRfqWithDetails(rfqId: number) {
)
.orderBy(desc(rfqLastDetailsView.detailId));
+ const tbeSessionsData = await db
+ .select({
+ vendorId: rfqLastTbeSessions.vendorId,
+ sessionCode: rfqLastTbeSessions.sessionCode,
+ status: rfqLastTbeSessions.status,
+ evaluationResult: rfqLastTbeSessions.evaluationResult,
+ conditionalRequirements: rfqLastTbeSessions.conditionalRequirements,
+ conditionsFulfilled: rfqLastTbeSessions.conditionsFulfilled,
+ plannedStartDate: rfqLastTbeSessions.plannedStartDate,
+ actualStartDate: rfqLastTbeSessions.actualStartDate,
+ actualEndDate: rfqLastTbeSessions.actualEndDate,
+ })
+ .from(rfqLastTbeSessions)
+ .where(eq(rfqLastTbeSessions.rfqsLastId, rfqId));
+
+ const tbeByVendor = tbeSessionsData.reduce((acc, tbe) => {
+ acc[tbe.vendorId] = tbe;
+ return acc;
+ }, {} as Record<number, typeof tbeSessionsData[0]>);
+
return {
success: true,
data: {
@@ -1990,6 +2008,12 @@ export async function getRfqWithDetails(rfqId: number) {
emailResentCount: d.emailResentCount,
lastEmailSentAt: d.lastEmailSentAt,
emailStatus: d.emailStatus,
+
+ // TBE 정보 추가
+ tbeSession: d.vendorId ? tbeByVendor[d.vendorId] : null,
+ tbeStatus: d.vendorId ? tbeByVendor[d.vendorId]?.status : null,
+ tbeEvaluationResult: d.vendorId ? tbeByVendor[d.vendorId]?.evaluationResult : null,
+ tbeSessionCode: d.vendorId ? tbeByVendor[d.vendorId]?.sessionCode : null,
})),
}
};
@@ -2987,19 +3011,25 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) {
for (const { attachment, revision } of attachments) {
if (revision?.filePath) {
+
+ const cleanPath = revision.filePath.startsWith('/api/files')
+ ? revision.filePath.slice('/api/files'.length)
+ : revision.filePath;
+
try {
const isProduction = process.env.NODE_ENV === "production";
- const fullPath = isProduction
+
+ const fullPath = !isProduction
? path.join(
process.cwd(),
`public`,
- revision.filePath
+ cleanPath
)
: path.join(
`${process.env.NAS_PATH}`,
- revision.filePath
+ cleanPath
);
const fileBuffer = await fs.readFile(fullPath);
@@ -3009,7 +3039,8 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) {
contentType: revision.fileType || 'application/octet-stream'
});
} catch (error) {
- console.error(`첨부파일 읽기 실패: ${revision.filePath}`, error);
+
+ console.error(`첨부파일 읽기 실패: ${cleanPath}`, error);
}
}
}
@@ -4320,4 +4351,83 @@ export async function updateShortList(
console.error("Short List 업데이트 실패:", error);
throw new Error("Short List 업데이트에 실패했습니다.");
}
+}
+
+interface AssignPicParams {
+ rfqIds: number[];
+ picUserId: number;
+}
+
+export async function assignPicToRfqs({ rfqIds, picUserId }: AssignPicParams) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ // 선택된 담당자 정보 조회
+ const picUser = await db.query.users.findFirst({
+ where: eq(users.id, picUserId),
+ });
+
+ if (!picUser) {
+ throw new Error("선택한 담당자를 찾을 수 없습니다.");
+ }
+
+ // RFQ 코드가 "I"로 시작하는 것들만 필터링 (추가 검증)
+ const targetRfqs = await db.query.rfqsLast.findMany({
+ where: inArray(rfqsLast.id, rfqIds),
+ });
+
+ // "I"로 시작하는 RFQ만 필터링
+ const validRfqs = targetRfqs.filter(rfq => rfq.rfqCode?.startsWith("I"));
+
+ if (validRfqs.length === 0) {
+ throw new Error("담당자를 지정할 수 있는 ITB가 없습니다.");
+ }
+
+ // 트랜잭션으로 처리하여 동시성 문제 방지
+ const updatedCount = await db.transaction(async (tx) => {
+ let successCount = 0;
+
+ for (const rfq of validRfqs) {
+ // 각 RFQ에 대해 새로운 코드 생성
+ const newRfqCode = await generateItbRfqCode(picUser.id);
+
+ // RFQ 업데이트
+ const result = await tx.update(rfqsLast)
+ .set({
+ rfqCode: newRfqCode, // 새로운 RFQ 코드로 업데이트
+ pic: picUser.id,
+ picCode: picUser.userCode || undefined,
+ picName: picUser.name,
+ status: "구매담당지정", // 상태도 업데이트
+ updatedBy: parseInt(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfq.id));
+
+ if (result) {
+ successCount++;
+ console.log(`RFQ ${rfq.rfqCode} -> ${newRfqCode} 업데이트 완료`);
+ }
+ }
+
+ return successCount;
+ });
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ message: `${updatedCount}건의 ITB에 담당자가 지정되고 코드가 재발급되었습니다.`,
+ updatedCount
+ };
+ } catch (error) {
+ console.error("담당자 지정 오류:", error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "담당자 지정 중 오류가 발생했습니다."
+ };
+ }
} \ No newline at end of file
diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx
index 14564686..7abf06a3 100644
--- a/lib/rfq-last/table/create-general-rfq-dialog.tsx
+++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx
@@ -60,7 +60,7 @@ import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } fro
// 아이템 스키마
const itemSchema = z.object({
- itemCode: z.string().min(1, "자재코드를 입력해주세요"),
+ itemCode: z.string().optional(),
itemName: z.string().min(1, "자재명을 입력해주세요"),
quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
uom: z.string().min(1, "단위를 입력해주세요"),
@@ -645,7 +645,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">
- 자재코드 <span className="text-red-500">*</span>
+ 자재코드
</FormLabel>
<FormControl>
<Input
diff --git a/lib/rfq-last/table/rfq-assign-pic-dialog.tsx b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx
new file mode 100644
index 00000000..89dda979
--- /dev/null
+++ b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import * as React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Check, ChevronsUpDown, Loader2, User, Users } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
+import { getPUsersForFilter } from "@/lib/rfq-last/service";
+import { assignPicToRfqs } from "../service";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+interface User {
+ id: number;
+ name: string;
+ userCode?: string;
+ email?: string;
+}
+
+interface RfqAssignPicDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfqIds: number[];
+ selectedRfqCodes: string[];
+ onSuccess?: () => void;
+}
+
+export function RfqAssignPicDialog({
+ open,
+ onOpenChange,
+ selectedRfqIds,
+ selectedRfqCodes,
+ onSuccess,
+}: RfqAssignPicDialogProps) {
+ const [users, setUsers] = React.useState<User[]>([]);
+ const [isLoadingUsers, setIsLoadingUsers] = React.useState(false);
+ const [isAssigning, setIsAssigning] = React.useState(false);
+ const [selectedUser, setSelectedUser] = React.useState<User | null>(null);
+ const [userPopoverOpen, setUserPopoverOpen] = React.useState(false);
+ const [userSearchTerm, setUserSearchTerm] = React.useState("");
+
+ // ITB만 필터링 (rfqCode가 "I"로 시작하는 것)
+ const itbCodes = React.useMemo(() => {
+ return selectedRfqCodes.filter(code => code.startsWith("I"));
+ }, [selectedRfqCodes]);
+
+ const itbIds = React.useMemo(() => {
+ return selectedRfqIds.filter((id, index) => selectedRfqCodes[index]?.startsWith("I"));
+ }, [selectedRfqIds, selectedRfqCodes]);
+
+ // 유저 목록 로드
+ React.useEffect(() => {
+ const loadUsers = async () => {
+ setIsLoadingUsers(true);
+ try {
+ const userList = await getPUsersForFilter();
+ setUsers(userList);
+ } catch (error) {
+ console.log("사용자 목록 로드 오류:", error);
+ toast.error("사용자 목록을 불러오는데 실패했습니다");
+ } finally {
+ setIsLoadingUsers(false);
+ }
+ };
+
+ if (open) {
+ loadUsers();
+ // 다이얼로그 열릴 때 초기화
+ setSelectedUser(null);
+ setUserSearchTerm("");
+ }
+ }, [open]);
+
+ // 유저 검색
+ const filteredUsers = React.useMemo(() => {
+ if (!userSearchTerm) return users;
+
+ const lowerSearchTerm = userSearchTerm.toLowerCase();
+ return users.filter(
+ (user) =>
+ user.name.toLowerCase().includes(lowerSearchTerm) ||
+ user.userCode?.toLowerCase().includes(lowerSearchTerm)
+ );
+ }, [users, userSearchTerm]);
+
+ const handleSelectUser = (user: User) => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ };
+
+ const handleAssign = async () => {
+ if (!selectedUser) {
+ toast.error("담당자를 선택해주세요");
+ return;
+ }
+
+ if (itbIds.length === 0) {
+ toast.error("선택한 항목 중 ITB가 없습니다");
+ return;
+ }
+
+ setIsAssigning(true);
+ try {
+ const result = await assignPicToRfqs({
+ rfqIds: itbIds,
+ picUserId: selectedUser.id,
+ });
+
+ if (result.success) {
+ toast.success(result.message);
+ onSuccess?.();
+ onOpenChange(false);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ console.error("담당자 지정 오류:", error);
+ toast.error("담당자 지정 중 오류가 발생했습니다");
+ } finally {
+ setIsAssigning(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="h-5 w-5" />
+ 담당자 지정
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 ITB에 구매 담당자를 지정합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 선택된 ITB 정보 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">선택된 ITB</label>
+ <div className="p-3 bg-muted rounded-md">
+ <div className="flex items-center gap-2 mb-2">
+ <Badge variant="secondary">{itbCodes.length}건</Badge>
+ {itbCodes.length !== selectedRfqCodes.length && (
+ <span className="text-xs text-muted-foreground">
+ (전체 {selectedRfqCodes.length}건 중)
+ </span>
+ )}
+ </div>
+ <div className="max-h-[100px] overflow-y-auto">
+ <div className="flex flex-wrap gap-1">
+ {itbCodes.slice(0, 10).map((code, index) => (
+ <Badge key={index} variant="outline" className="text-xs">
+ {code}
+ </Badge>
+ ))}
+ {itbCodes.length > 10 && (
+ <Badge variant="outline" className="text-xs">
+ +{itbCodes.length - 10}개
+ </Badge>
+ )}
+ </div>
+ </div>
+ </div>
+ {itbCodes.length === 0 && (
+ <Alert className="border-orange-200 bg-orange-50">
+ <AlertDescription className="text-orange-800">
+ 선택한 항목 중 ITB (I로 시작하는 코드)가 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+
+ {/* 담당자 선택 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">구매 담당자</label>
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full justify-between h-10"
+ disabled={isLoadingUsers || itbCodes.length === 0}
+ >
+ {isLoadingUsers ? (
+ <>
+ <span>담당자 로딩 중...</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ {selectedUser ? (
+ <>
+ {selectedUser.name}
+ {selectedUser.userCode && (
+ <span className="text-muted-foreground">
+ ({selectedUser.userCode})
+ </span>
+ )}
+ </>
+ ) : (
+ <span className="text-muted-foreground">
+ 구매 담당자를 선택하세요
+ </span>
+ )}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[460px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름 또는 코드로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList className="max-h-[300px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandGroup>
+ {filteredUsers.map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => handleSelectUser(user)}
+ className="flex items-center justify-between"
+ >
+ <span className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ {user.name}
+ {user.userCode && (
+ <span className="text-muted-foreground text-sm">
+ ({user.userCode})
+ </span>
+ )}
+ </span>
+ <Check
+ className={cn(
+ "h-4 w-4",
+ selectedUser?.id === user.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ {selectedUser && (
+ <p className="text-xs text-muted-foreground">
+ 선택한 담당자: {selectedUser.name}
+ {selectedUser.userCode && ` (${selectedUser.userCode})`}
+ </p>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isAssigning}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={handleAssign}
+ disabled={!selectedUser || itbCodes.length === 0 || isAssigning}
+ >
+ {isAssigning ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 지정 중...
+ </>
+ ) : (
+ "담당자 지정"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index 91b2798f..d933fa95 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -1,308 +1,148 @@
"use client";
import * as React from "react";
-import { type Table } from "@tanstack/react-table";
-import { Download, RefreshCw, Plus, Lock, LockOpen } from "lucide-react";
-
+import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { toast } from "sonner";
+import { Users, RefreshCw, FileDown, Plus } from "lucide-react";
import { RfqsLastView } from "@/db/schema";
-import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog";
-import { sealMultipleRfqs, unsealMultipleRfqs } from "../service";
-
-interface RfqTableToolbarActionsProps {
- table: Table<RfqsLastView>;
- onRefresh?: () => void;
+import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog";
+import { Badge } from "@/components/ui/badge";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+interface RfqTableToolbarActionsProps<TData> {
+ table: Table<TData>;
rfqCategory?: "general" | "itb" | "rfq";
+ onRefresh?: () => void;
}
-export function RfqTableToolbarActions({
- table,
- onRefresh,
+export function RfqTableToolbarActions<TData>({
+ table,
rfqCategory = "itb",
-}: RfqTableToolbarActionsProps) {
- const [isExporting, setIsExporting] = React.useState(false);
- const [isSealing, setIsSealing] = React.useState(false);
- const [sealDialogOpen, setSealDialogOpen] = React.useState(false);
- const [sealAction, setSealAction] = React.useState<"seal" | "unseal">("seal");
-
+ onRefresh
+}: RfqTableToolbarActionsProps<TData>) {
+ const [showAssignDialog, setShowAssignDialog] = React.useState(false);
+
+ // 선택된 행 가져오기
const selectedRows = table.getFilteredSelectedRowModel().rows;
- const selectedRfqIds = selectedRows.map(row => row.original.id);
- // 선택된 항목들의 밀봉 상태 확인
- const sealedCount = selectedRows.filter(row => row.original.rfqSealedYn).length;
- const unsealedCount = selectedRows.filter(row => !row.original.rfqSealedYn).length;
-
- const handleSealAction = React.useCallback(async (action: "seal" | "unseal") => {
- setSealAction(action);
- setSealDialogOpen(true);
- }, []);
-
- const confirmSealAction = React.useCallback(async () => {
- setIsSealing(true);
- try {
- const result = sealAction === "seal"
- ? await sealMultipleRfqs(selectedRfqIds)
- : await unsealMultipleRfqs(selectedRfqIds);
-
- if (result.success) {
- toast.success(result.message);
- table.toggleAllRowsSelected(false); // 선택 해제
- onRefresh?.(); // 데이터 새로고침
- } else {
- toast.error(result.error);
- }
- } catch (error) {
- toast.error("작업 중 오류가 발생했습니다.");
- } finally {
- setIsSealing(false);
- setSealDialogOpen(false);
- }
- }, [sealAction, selectedRfqIds, table, onRefresh]);
-
- const handleExportCSV = React.useCallback(async () => {
- setIsExporting(true);
- try {
- const data = table.getFilteredRowModel().rows.map((row) => {
- const original = row.original;
- return {
- "RFQ 코드": original.rfqCode || "",
- "상태": original.status || "",
- "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉",
- "프로젝트 코드": original.projectCode || "",
- "프로젝트명": original.projectName || "",
- "자재코드": original.itemCode || "",
- "자재명": original.itemName || "",
- "패키지 번호": original.packageNo || "",
- "패키지명": original.packageName || "",
- "구매담당자": original.picUserName || original.picName || "",
- "엔지니어링 담당": original.engPicName || "",
- "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "",
- "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "",
- "업체수": original.vendorCount || 0,
- "Short List": original.shortListedVendorCount || 0,
- "견적접수": original.quotationReceivedCount || 0,
- "PR Items": original.prItemsCount || 0,
- "주요 Items": original.majorItemsCount || 0,
- "시리즈": original.series || "",
- "견적 유형": original.rfqType || "",
- "견적 제목": original.rfqTitle || "",
- "프로젝트 회사": original.projectCompany || "",
- "프로젝트 사이트": original.projectSite || "",
- "SM 코드": original.smCode || "",
- "PR 번호": original.prNumber || "",
- "PR 발행일": original.prIssueDate ? new Date(original.prIssueDate).toLocaleDateString("ko-KR") : "",
- "생성자": original.createdByUserName || "",
- "생성일": original.createdAt ? new Date(original.createdAt).toLocaleDateString("ko-KR") : "",
- "수정자": original.updatedByUserName || "",
- "수정일": original.updatedAt ? new Date(original.updatedAt).toLocaleDateString("ko-KR") : "",
- };
- });
-
- const fileName = `RFQ_목록_${new Date().toISOString().split("T")[0]}.csv`;
- exportTableToCSV({ data, filename: fileName });
- } catch (error) {
- console.error("Export failed:", error);
- } finally {
- setIsExporting(false);
- }
- }, [table]);
-
- const handleExportSelected = React.useCallback(async () => {
- setIsExporting(true);
- try {
- const selectedRows = table.getFilteredSelectedRowModel().rows;
- if (selectedRows.length === 0) {
- alert("선택된 항목이 없습니다.");
- return;
- }
-
- const data = selectedRows.map((row) => {
- const original = row.original;
- return {
- "RFQ 코드": original.rfqCode || "",
- "상태": original.status || "",
- "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉",
- "프로젝트 코드": original.projectCode || "",
- "프로젝트명": original.projectName || "",
- "자재코드": original.itemCode || "",
- "자재명": original.itemName || "",
- "패키지 번호": original.packageNo || "",
- "패키지명": original.packageName || "",
- "구매담당자": original.picUserName || original.picName || "",
- "엔지니어링 담당": original.engPicName || "",
- "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "",
- "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "",
- "업체수": original.vendorCount || 0,
- "Short List": original.shortListedVendorCount || 0,
- "견적접수": original.quotationReceivedCount || 0,
- };
- });
-
- const fileName = `RFQ_선택항목_${new Date().toISOString().split("T")[0]}.csv`;
- exportTableToCSV({ data, filename: fileName });
- } catch (error) {
- console.error("Export failed:", error);
- } finally {
- setIsExporting(false);
- }
- }, [table]);
+ // 선택된 RFQ의 ID와 코드 추출
+ const selectedRfqData = React.useMemo(() => {
+ const rows = selectedRows.map(row => row.original as RfqsLastView);
+ return {
+ ids: rows.map(row => row.id),
+ codes: rows.map(row => row.rfqCode || ""),
+ // "I"로 시작하는 ITB만 필터링
+ itbCount: rows.filter(row => row.rfqCode?.startsWith("I")).length,
+ totalCount: rows.length
+ };
+ }, [selectedRows]);
+
+ // 담당자 지정 가능 여부 체크 ("I"로 시작하는 항목이 있는지)
+ const canAssignPic = selectedRfqData.itbCount > 0;
+
+ const handleAssignSuccess = () => {
+ // 테이블 선택 초기화
+ table.toggleAllPageRowsSelected(false);
+ // 데이터 새로고침
+ onRefresh?.();
+ };
return (
<>
<div className="flex items-center gap-2">
- {onRefresh && (
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="h-8 px-2 lg:px-3"
- >
- <RefreshCw className="mr-2 h-4 w-4" />
- 새로고침
- </Button>
+ {/* 담당자 지정 버튼 - 선택된 항목 중 ITB가 있을 때만 표시 */}
+ {selectedRfqData.totalCount > 0 && canAssignPic && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setShowAssignDialog(true)}
+ className="flex items-center gap-2"
+ >
+ <Users className="h-4 w-4" />
+ 담당자 지정
+ <Badge variant="secondary" className="ml-1">
+ {selectedRfqData.itbCount}건
+ </Badge>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택한 ITB에 구매 담당자를 지정합니다</p>
+ {selectedRfqData.itbCount !== selectedRfqData.totalCount && (
+ <p className="text-xs text-muted-foreground mt-1">
+ 전체 {selectedRfqData.totalCount}건 중 ITB {selectedRfqData.itbCount}건만 지정됩니다
+ </p>
+ )}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
)}
- {/* 견적 밀봉/해제 버튼 */}
- {selectedRfqIds.length > 0 && (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2 lg:px-3"
- disabled={isSealing}
- >
- <Lock className="mr-2 h-4 w-4" />
- 견적 밀봉
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => handleSealAction("seal")}
- disabled={unsealedCount === 0}
- >
- <Lock className="mr-2 h-4 w-4" />
- 선택 항목 밀봉 ({unsealedCount}개)
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() => handleSealAction("unseal")}
- disabled={sealedCount === 0}
- >
- <LockOpen className="mr-2 h-4 w-4" />
- 선택 항목 밀봉 해제 ({sealedCount}개)
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <div className="px-2 py-1.5 text-xs text-muted-foreground">
- 전체 {selectedRfqIds.length}개 선택됨
- </div>
- </DropdownMenuContent>
- </DropdownMenu>
+ {/* 선택된 항목 표시 */}
+ {selectedRfqData.totalCount > 0 && (
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md">
+ <span className="text-sm text-muted-foreground">
+ 선택된 항목:
+ </span>
+ <Badge variant="secondary">
+ {selectedRfqData.totalCount}건
+ </Badge>
+ {selectedRfqData.totalCount !== selectedRfqData.itbCount && (
+ <Badge variant="outline" className="text-xs">
+ ITB {selectedRfqData.itbCount}건
+ </Badge>
+ )}
+ </div>
)}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2 lg:px-3"
- disabled={isExporting}
- >
- <Download className="mr-2 h-4 w-4" />
- {isExporting ? "내보내는 중..." : "내보내기"}
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={handleExportCSV}>
- 전체 데이터 내보내기
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={handleExportSelected}
- disabled={table.getFilteredSelectedRowModel().rows.length === 0}
- >
- 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개)
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
+ {/* 기존 버튼들 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ className="flex items-center gap-2"
+ >
+ <RefreshCw className="h-4 w-4" />
+ 새로고침
+ </Button>
- {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */}
{rfqCategory === "general" && (
- <CreateGeneralRfqDialog onSuccess={onRefresh} />
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 일반견적 생성
+ </Button>
)}
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex items-center gap-2"
+ disabled={selectedRfqData.totalCount === 0}
+ >
+ <FileDown className="h-4 w-4" />
+ 엑셀 다운로드
+ </Button>
</div>
- {/* 밀봉 확인 다이얼로그 */}
- <AlertDialog open={sealDialogOpen} onOpenChange={setSealDialogOpen}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>
- {sealAction === "seal" ? "견적 밀봉 확인" : "견적 밀봉 해제 확인"}
- </AlertDialogTitle>
- <AlertDialogDescription>
- {sealAction === "seal"
- ? `선택한 ${unsealedCount}개의 견적을 밀봉하시겠습니까? 밀봉된 견적은 업체에서 수정할 수 없습니다.`
- : `선택한 ${sealedCount}개의 견적 밀봉을 해제하시겠습니까? 밀봉이 해제되면 업체에서 견적을 수정할 수 있습니다.`}
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isSealing}>취소</AlertDialogCancel>
- <AlertDialogAction
- onClick={confirmSealAction}
- disabled={isSealing}
- className={sealAction === "seal" ? "bg-red-600 hover:bg-red-700" : ""}
- >
- {isSealing ? "처리 중..." : "확인"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
+ {/* 담당자 지정 다이얼로그 */}
+ <RfqAssignPicDialog
+ open={showAssignDialog}
+ onOpenChange={setShowAssignDialog}
+ selectedRfqIds={selectedRfqData.ids}
+ selectedRfqCodes={selectedRfqData.codes}
+ onSuccess={handleAssignSuccess}
+ />
</>
);
-}
-
-// CSV 내보내기 유틸리티 함수
-function exportTableToCSV({ data, filename }: { data: any[]; filename: string }) {
- if (!data || data.length === 0) {
- console.warn("No data to export");
- return;
- }
-
- const headers = Object.keys(data[0]);
- const csvContent = [
- headers.join(","),
- ...data.map(row =>
- headers.map(header => {
- const value = row[header];
- // 값에 쉼표, 줄바꿈, 따옴표가 있으면 따옴표로 감싸기
- if (typeof value === "string" && (value.includes(",") || value.includes("\n") || value.includes('"'))) {
- return `"${value.replace(/"/g, '""')}"`;
- }
- return value;
- }).join(",")
- )
- ].join("\n");
-
- const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
- const link = document.createElement("a");
- link.href = URL.createObjectURL(blob);
- link.download = filename;
- link.click();
- URL.revokeObjectURL(link.href);
} \ No newline at end of file
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 88ae968a..72539113 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -657,6 +657,102 @@ export function RfqVendorTable({
},
{
+ accessorKey: "tbeStatus",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="TBE 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.tbeStatus;
+
+ if (!status || status === "준비중") {
+ return (
+ <Badge variant="outline" className="text-gray-500">
+ <Clock className="h-3 w-3 mr-1" />
+ 대기
+ </Badge>
+ );
+ }
+
+ const statusConfig = {
+ "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />, color: "text-blue-600" },
+ "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" />, color: "text-orange-600" },
+ "보류": { variant: "outline", icon: <AlertCircle className="h-3 w-3 mr-1" />, color: "text-yellow-600" },
+ "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" />, color: "text-green-600" },
+ "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" />, color: "text-red-600" },
+ }[status] || { variant: "outline", icon: null, color: "text-gray-600" };
+
+ return (
+ <Badge variant={statusConfig.variant as any} className={statusConfig.color}>
+ {statusConfig.icon}
+ {status}
+ </Badge>
+ );
+ },
+ size: 100,
+ },
+
+ {
+ accessorKey: "tbeEvaluationResult",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="TBE 평가" />
+ ),
+ cell: ({ row }) => {
+ const result = row.original.tbeEvaluationResult;
+ const status = row.original.tbeStatus;
+
+ // TBE가 완료되지 않았으면 표시하지 않음
+ if (status !== "완료" || !result) {
+ return <span className="text-xs text-muted-foreground">-</span>;
+ }
+
+ const resultConfig = {
+ "Acceptable": {
+ variant: "success",
+ icon: <CheckCircle className="h-3 w-3" />,
+ text: "적합",
+ color: "bg-green-50 text-green-700 border-green-200"
+ },
+ "Acceptable with Comment": {
+ variant: "warning",
+ icon: <AlertCircle className="h-3 w-3" />,
+ text: "조건부 적합",
+ color: "bg-yellow-50 text-yellow-700 border-yellow-200"
+ },
+ "Not Acceptable": {
+ variant: "destructive",
+ icon: <XCircle className="h-3 w-3" />,
+ text: "부적합",
+ color: "bg-red-50 text-red-700 border-red-200"
+ },
+ }[result];
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge className={cn("text-xs", resultConfig?.color)}>
+ {resultConfig?.icon}
+ <span className="ml-1">{resultConfig?.text}</span>
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="text-xs">
+ <p className="font-semibold">{result}</p>
+ {row.original.conditionalRequirements && (
+ <p className="mt-1 text-muted-foreground">
+ 조건: {row.original.conditionalRequirements}
+ </p>
+ )}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+ },
+ size: 120,
+ },
+
+ {
accessorKey: "contractRequirements",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />,
cell: ({ row }) => {
@@ -785,8 +881,8 @@ export function RfqVendorTable({
// emailSentTo JSON 파싱
let recipients = { to: [], cc: [], sentBy: "" };
try {
- if (response?.email?.emailSentTo) {
- recipients = JSON.parse(response.email.emailSentTo);
+ if (response?.emailSentTo) {
+ recipients = JSON.parse(response.emailSentTo);
}
} catch (e) {
console.error("Failed to parse emailSentTo", e);
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
index ce97dcde..34777864 100644
--- a/lib/rfq-last/vendor/send-rfq-dialog.tsx
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -123,13 +123,13 @@ interface Vendor {
contactsByPosition?: Record<string, ContactDetail[]>;
primaryEmail?: string | null;
currency?: string | null;
-
+
// 기본계약 정보
ndaYn?: boolean;
generalGtcYn?: boolean;
projectGtcYn?: boolean;
agreementYn?: boolean;
-
+
// 발송 정보
sendVersion?: number;
}
@@ -243,15 +243,15 @@ export function SendRfqDialog({
const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({});
const [showResendConfirmDialog, setShowResendConfirmDialog] = React.useState(false);
const [resendVendorsInfo, setResendVendorsInfo] = React.useState<{ count: number; names: string[] }>({ count: 0, names: [] });
-
+
const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false);
const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0);
const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState("");
const [generatedPdfs, setGeneratedPdfs] = React.useState<Map<string, { buffer: number[], fileName: string }>>(new Map());
-
+
// 재전송 시 기본계약 스킵 옵션 - 업체별 관리
const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({});
-
+
const generateContractPdf = async (
vendor: VendorWithRecipients,
contractType: string,
@@ -288,9 +288,9 @@ export function SendRfqDialog({
// 3. PDFtron WebViewer로 PDF 변환
const pdfBuffer = await convertToPdfWithWebViewer(templateFile, templateData);
-
+
const fileName = `${contractType}_${vendor.vendorCode || vendor.vendorId}_${Date.now()}.pdf`;
-
+
return {
buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
fileName
@@ -374,7 +374,7 @@ export function SendRfqDialog({
// 초기화
setCustomEmailInputs({});
setShowCustomEmailForm({});
-
+
// 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성)
const skipOptions: Record<number, boolean> = {};
selectedVendors.forEach(v => {
@@ -511,137 +511,137 @@ export function SendRfqDialog({
const proceedWithSend = React.useCallback(async () => {
try {
setIsSending(true);
-
- // 기본계약이 필요한 계약서 목록 수집
- const contractsToGenerate: ContractToGenerate[] = [];
-
- for (const vendor of vendorsWithRecipients) {
- // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기
- const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0;
- if (isResendVendor && skipContractsForVendor[vendor.vendorId]) {
- continue; // 이 벤더의 계약서 생성을 스킵
- }
-
- if (vendor.ndaYn) {
- contractsToGenerate.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- type: "NDA",
- templateName: "비밀"
- });
- }
- if (vendor.generalGtcYn) {
- contractsToGenerate.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- type: "General_GTC",
- templateName: "General GTC"
- });
- }
- if (vendor.projectGtcYn && rfqInfo?.projectCode) {
- contractsToGenerate.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- type: "Project_GTC",
- templateName: rfqInfo.projectCode
- });
- }
- if (vendor.agreementYn) {
- contractsToGenerate.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- type: "기술자료",
- templateName: "기술"
- });
- }
- }
-
- let pdfsMap = new Map<string, { buffer: number[], fileName: string }>();
-
- // PDF 생성이 필요한 경우
- if (contractsToGenerate.length > 0) {
- setIsGeneratingPdfs(true);
- setPdfGenerationProgress(0);
-
- try {
- let completed = 0;
-
- for (const contract of contractsToGenerate) {
- setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`);
-
- const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId);
- if (!vendor) continue;
-
- const pdf = await generateContractPdf(vendor, contract.type, contract.templateName);
- pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf);
-
- completed++;
- setPdfGenerationProgress((completed / contractsToGenerate.length) * 100);
-
- await new Promise(resolve => setTimeout(resolve, 100));
+
+ // 기본계약이 필요한 계약서 목록 수집
+ const contractsToGenerate: ContractToGenerate[] = [];
+
+ for (const vendor of vendorsWithRecipients) {
+ // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기
+ const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0;
+ if (isResendVendor && skipContractsForVendor[vendor.vendorId]) {
+ continue; // 이 벤더의 계약서 생성을 스킵
+ }
+
+ if (vendor.ndaYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "NDA",
+ templateName: "비밀"
+ });
+ }
+ if (vendor.generalGtcYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "General_GTC",
+ templateName: "General GTC"
+ });
+ }
+ if (vendor.projectGtcYn && rfqInfo?.projectCode) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "Project_GTC",
+ templateName: rfqInfo.projectCode
+ });
+ }
+ if (vendor.agreementYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "기술자료",
+ templateName: "기술"
+ });
+ }
}
- setGeneratedPdfs(pdfsMap); // UI 업데이트용
+ let pdfsMap = new Map<string, { buffer: number[], fileName: string }>();
+
+ // PDF 생성이 필요한 경우
+ if (contractsToGenerate.length > 0) {
+ setIsGeneratingPdfs(true);
+ setPdfGenerationProgress(0);
+
+ try {
+ let completed = 0;
+
+ for (const contract of contractsToGenerate) {
+ setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`);
+
+ const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId);
+ if (!vendor) continue;
+
+ const pdf = await generateContractPdf(vendor, contract.type, contract.templateName);
+ pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf);
+
+ completed++;
+ setPdfGenerationProgress((completed / contractsToGenerate.length) * 100);
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+
+ setGeneratedPdfs(pdfsMap); // UI 업데이트용
+ } catch (error) {
+ console.error("PDF 생성 실패:", error);
+ toast.error("기본계약서 생성에 실패했습니다.");
+ setIsGeneratingPdfs(false);
+ setPdfGenerationProgress(0);
+ return;
+ }
+ }
+
+ // RFQ 발송 - pdfsMap을 직접 사용
+ setIsGeneratingPdfs(false);
+ setIsSending(true);
+
+ await onSend({
+ vendors: vendorsWithRecipients.map(v => ({
+ vendorId: v.vendorId,
+ vendorName: v.vendorName,
+ vendorCode: v.vendorCode,
+ vendorCountry: v.vendorCountry,
+ selectedMainEmail: v.selectedMainEmail,
+ additionalEmails: v.additionalEmails,
+ customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })),
+ currency: v.currency,
+ contractRequirements: {
+ ndaYn: v.ndaYn || false,
+ generalGtcYn: v.generalGtcYn || false,
+ projectGtcYn: v.projectGtcYn || false,
+ agreementYn: v.agreementYn || false,
+ projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined,
+ },
+ isResend: (v.sendVersion || 0) > 0,
+ sendVersion: v.sendVersion,
+ contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId],
+ })),
+ attachments: selectedAttachments,
+ message: additionalMessage,
+ // 생성된 PDF 데이터 추가
+ generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({
+ key,
+ ...data
+ })),
+ });
+
+ toast.success(
+ `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` +
+ (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '')
+ );
+ onOpenChange(false);
+
} catch (error) {
- console.error("PDF 생성 실패:", error);
- toast.error("기본계약서 생성에 실패했습니다.");
+ console.error("RFQ 발송 실패:", error);
+ toast.error("RFQ 발송에 실패했습니다.");
+ } finally {
+ setIsSending(false);
setIsGeneratingPdfs(false);
setPdfGenerationProgress(0);
- return;
+ setCurrentGeneratingContract("");
+ setSkipContractsForVendor({}); // 초기화
}
- }
-
- // RFQ 발송 - pdfsMap을 직접 사용
- setIsGeneratingPdfs(false);
- setIsSending(true);
-
- await onSend({
- vendors: vendorsWithRecipients.map(v => ({
- vendorId: v.vendorId,
- vendorName: v.vendorName,
- vendorCode: v.vendorCode,
- vendorCountry: v.vendorCountry,
- selectedMainEmail: v.selectedMainEmail,
- additionalEmails: v.additionalEmails,
- customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })),
- currency: v.currency,
- contractRequirements: {
- ndaYn: v.ndaYn || false,
- generalGtcYn: v.generalGtcYn || false,
- projectGtcYn: v.projectGtcYn || false,
- agreementYn: v.agreementYn || false,
- projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined,
- },
- isResend: (v.sendVersion || 0) > 0,
- sendVersion: v.sendVersion,
- contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId],
- })),
- attachments: selectedAttachments,
- message: additionalMessage,
- // 생성된 PDF 데이터 추가
- generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({
- key,
- ...data
- })),
- });
-
- toast.success(
- `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` +
- (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '')
- );
- onOpenChange(false);
-
- } catch (error) {
- console.error("RFQ 발송 실패:", error);
- toast.error("RFQ 발송에 실패했습니다.");
- } finally {
- setIsSending(false);
- setIsGeneratingPdfs(false);
- setPdfGenerationProgress(0);
- setCurrentGeneratingContract("");
- setSkipContractsForVendor({}); // 초기화
- }
-}, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]);
+ }, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]);
// 전송 처리
const handleSend = async () => {
@@ -712,7 +712,7 @@ export function SendRfqDialog({
<li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li>
<li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li>
</ul>
-
+
{/* 기본계약 재발송 정보 */}
<div className="mt-3 pt-3 border-t border-yellow-400">
<div className="space-y-2">
@@ -836,8 +836,8 @@ export function SendRfqDialog({
setSkipContractsForVendor(newSkipOptions);
}}
>
- {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" :
- Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"}
+ {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" :
+ Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"}
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -993,7 +993,7 @@ export function SendRfqDialog({
[vendor.vendorId]: !checked
}));
}}
- // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
+ // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
/>
<span className="text-xs">
{skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"}
@@ -1002,8 +1002,8 @@ export function SendRfqDialog({
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
- {skipContractsForVendor[vendor.vendorId]
- ? "기존 계약서를 그대로 유지합니다"
+ {skipContractsForVendor[vendor.vendorId]
+ ? "기존 계약서를 그대로 유지합니다"
: "기존 계약서를 삭제하고 새로 생성합니다"}
</p>
</TooltipContent>
@@ -1306,9 +1306,9 @@ export function SendRfqDialog({
onChange={(e) => setAdditionalMessage(e.target.value)}
/>
</div>
-
- {/* PDF 생성 진행 상황 표시 */}
- {isGeneratingPdfs && (
+
+ {/* PDF 생성 진행 상황 표시 */}
+ {isGeneratingPdfs && (
<Alert className="border-blue-500 bg-blue-50">
<div className="space-y-3">
<div className="flex items-center gap-2">
@@ -1327,8 +1327,8 @@ export function SendRfqDialog({
</div>
</Alert>
)}
-
-
+
+
</div>
</div>
@@ -1371,7 +1371,7 @@ export function SendRfqDialog({
</Button>
</DialogFooter>
</DialogContent>
-
+
{/* 재발송 확인 다이얼로그 */}
<AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}>
<AlertDialogContent className="max-w-2xl">
@@ -1385,7 +1385,7 @@ export function SendRfqDialog({
<p className="text-sm">
<span className="font-semibold text-yellow-700">{resendVendorsInfo.count}개 업체</span>가 재발송 대상입니다.
</p>
-
+
{/* 재발송 대상 업체 목록 및 계약서 설정 */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm font-medium text-yellow-800 mb-3">재발송 대상 업체 및 계약서 설정:</p>
@@ -1398,7 +1398,7 @@ export function SendRfqDialog({
if (vendor.generalGtcYn) contracts.push("General GTC");
if (vendor.projectGtcYn) contracts.push("Project GTC");
if (vendor.agreementYn) contracts.push("기술자료");
-
+
return (
<div key={vendor.vendorId} className="flex items-center justify-between p-2 bg-white rounded border border-yellow-100">
<div className="flex items-center gap-3">
@@ -1422,7 +1422,7 @@ export function SendRfqDialog({
[vendor.vendorId]: !checked
}));
}}
- // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
+ // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
/>
<span className="text-xs text-yellow-800">
{skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"}
@@ -1433,43 +1433,43 @@ export function SendRfqDialog({
);
})}
</div>
-
+
{/* 전체 선택 버튼 */}
- {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 &&
+ {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 &&
(v.ndaYn || v.generalGtcYn || v.projectGtcYn || v.agreementYn)) && (
- <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
- const newSkipOptions: Record<number, boolean> = {};
- resendVendors.forEach(v => {
- newSkipOptions[v.vendorId] = true; // 모두 유지
- });
- setSkipContractsForVendor(newSkipOptions);
- }}
- >
- 전체 계약서 유지
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
- const newSkipOptions: Record<number, boolean> = {};
- resendVendors.forEach(v => {
- newSkipOptions[v.vendorId] = false; // 모두 재생성
- });
- setSkipContractsForVendor(newSkipOptions);
- }}
- >
- 전체 계약서 재생성
- </Button>
- </div>
- )}
+ <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
+ const newSkipOptions: Record<number, boolean> = {};
+ resendVendors.forEach(v => {
+ newSkipOptions[v.vendorId] = true; // 모두 유지
+ });
+ setSkipContractsForVendor(newSkipOptions);
+ }}
+ >
+ 전체 계약서 유지
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
+ const newSkipOptions: Record<number, boolean> = {};
+ resendVendors.forEach(v => {
+ newSkipOptions[v.vendorId] = false; // 모두 재생성
+ });
+ setSkipContractsForVendor(newSkipOptions);
+ }}
+ >
+ 전체 계약서 재생성
+ </Button>
+ </div>
+ )}
</div>
-
+
{/* 경고 메시지 */}
<Alert className="border-red-200 bg-red-50">
<AlertCircle className="h-4 w-4 text-red-600" />
@@ -1479,17 +1479,17 @@ export function SendRfqDialog({
<li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li>
<li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li>
<li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li>
- {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip &&
+ {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip &&
vendorsWithRecipients.find(v => v.vendorId === Number(vendorId))) && (
- <li className="text-orange-700 font-medium">
- ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다.
- </li>
- )}
+ <li className="text-orange-700 font-medium">
+ ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다.
+ </li>
+ )}
<li>이 작업은 <strong>취소할 수 없습니다</strong>.</li>
</ul>
</AlertDescription>
</Alert>
-
+
<p className="text-sm text-muted-foreground">
재발송을 진행하시겠습니까?
</p>
@@ -1503,7 +1503,7 @@ export function SendRfqDialog({
}}>
취소
</AlertDialogCancel>
- <AlertDialogAction
+ <AlertDialogAction
onClick={() => {
setShowResendConfirmDialog(false);
proceedWithSend();
diff --git a/lib/shi-signature/buyer-signature.ts b/lib/shi-signature/buyer-signature.ts
new file mode 100644
index 00000000..d464ae54
--- /dev/null
+++ b/lib/shi-signature/buyer-signature.ts
@@ -0,0 +1,186 @@
+'use server';
+
+import db from '@/db/db';
+import { buyerSignatures } from '@/db/schema';
+import { eq } from 'drizzle-orm';
+import { revalidatePath } from 'next/cache';
+import { writeFile, mkdir } from 'fs/promises';
+import path from 'path';
+import { v4 as uuidv4 } from 'uuid';
+
+export async function uploadBuyerSignature(formData: FormData) {
+ try {
+ const file = formData.get('file') as File;
+ if (!file) {
+ return { success: false, error: '파일이 없습니다.' };
+ }
+
+ // 파일 크기 체크 (5MB)
+ if (file.size > 5 * 1024 * 1024) {
+ return { success: false, error: '파일 크기는 5MB 이하여야 합니다.' };
+ }
+
+ // 파일 타입 체크
+ if (!file.type.startsWith('image/')) {
+ return { success: false, error: '이미지 파일만 업로드 가능합니다.' };
+ }
+
+ const bytes = await file.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ // Base64 변환
+ const base64 = `data:${file.type};base64,${buffer.toString('base64')}`;
+
+ // 파일 저장 경로
+ const fileName = `${uuidv4()}-${file.name}`;
+ const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'signatures');
+
+ // 디렉토리 생성
+ await mkdir(uploadDir, { recursive: true });
+
+ const filePath = path.join(uploadDir, fileName);
+ await writeFile(filePath, buffer);
+
+ // 기존 활성 서명 비활성화
+ await db.update(buyerSignatures)
+ .set({ isActive: false })
+ .where(eq(buyerSignatures.isActive, true));
+
+ // 새 서명 저장
+ const [newSignature] = await db.insert(buyerSignatures)
+ .values({
+ name: '삼성중공업',
+ imageUrl: `/uploads/signatures/${fileName}`,
+ dataUrl: base64,
+ mimeType: file.type,
+ fileSize: file.size,
+ isActive: true,
+ })
+ .returning();
+
+ revalidatePath('/admin/buyer-signature');
+
+ return { success: true, signature: newSignature };
+ } catch (error) {
+ console.error('서명 업로드 실패:', error);
+ return { success: false, error: '서명 업로드에 실패했습니다.' };
+ }
+}
+
+export async function getActiveSignature() {
+ try {
+ const [signature] = await db
+ .select()
+ .from(buyerSignatures)
+ .where(eq(buyerSignatures.isActive, true))
+ .limit(1);
+
+ return signature;
+ } catch (error) {
+ console.error('활성 서명 조회 실패:', error);
+ return null;
+ }
+}
+
+export async function getAllSignatures() {
+ try {
+ const signatures = await db
+ .select()
+ .from(buyerSignatures)
+ .orderBy(buyerSignatures.createdAt);
+
+ return signatures;
+ } catch (error) {
+ console.error('서명 목록 조회 실패:', error);
+ return [];
+ }
+}
+
+export async function setActiveSignature(id: number) {
+ try {
+ // 모든 서명 비활성화
+ await db.update(buyerSignatures)
+ .set({ isActive: false })
+ .where(eq(buyerSignatures.isActive, true));
+
+ // 선택한 서명 활성화
+ await db.update(buyerSignatures)
+ .set({ isActive: true })
+ .where(eq(buyerSignatures.id, id));
+
+ revalidatePath('/admin/buyer-signature');
+
+ return { success: true };
+ } catch (error) {
+ console.error('활성 서명 설정 실패:', error);
+ return { success: false, error: '활성 서명 설정에 실패했습니다.' };
+ }
+}
+
+export async function deleteSignature(id: number) {
+ try {
+ await db.delete(buyerSignatures)
+ .where(eq(buyerSignatures.id, id));
+
+ revalidatePath('/admin/buyer-signature');
+
+ return { success: true };
+ } catch (error) {
+ console.error('서명 삭제 실패:', error);
+ return { success: false, error: '서명 삭제에 실패했습니다.' };
+ }
+}
+
+
+// 클라이언트에서 직접 호출할 수 있는 서버 액션
+export async function getBuyerSignatureFile() {
+ try {
+ const [signature] = await db
+ .select()
+ .from(buyerSignatures)
+ .where(eq(buyerSignatures.isActive, true))
+ .limit(1);
+
+ if (!signature || !signature.dataUrl) {
+ console.log('활성화된 구매자 서명이 없습니다.');
+ return null;
+ }
+
+ return {
+ data: {
+ dataUrl: signature.dataUrl,
+ imageUrl: signature.imageUrl,
+ mimeType: signature.mimeType
+ }
+ };
+ } catch (error) {
+ console.error('구매자 서명 조회 실패:', error);
+ return null;
+ }
+}
+
+// 대체 서명이나 기본 서명이 필요한 경우
+export async function getBuyerSignatureFileWithFallback() {
+ try {
+ // 먼저 DB에서 활성 서명 조회
+ const signature = await getBuyerSignatureFile();
+
+ if (signature) {
+ return signature;
+ }
+
+ // DB에 서명이 없으면 기본 서명 반환 (선택사항)
+ const defaultSignature = {
+ data: {
+ dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', // 1x1 투명 픽셀 또는 실제 기본 서명
+ imageUrl: '/images/default-buyer-signature.png',
+ mimeType: 'image/png'
+ }
+ };
+
+ return defaultSignature;
+ } catch (error) {
+ console.error('서명 조회 실패 (fallback 포함):', error);
+ return null;
+ }
+} \ No newline at end of file
diff --git a/lib/shi-signature/signature-list.tsx b/lib/shi-signature/signature-list.tsx
new file mode 100644
index 00000000..93cd3dbe
--- /dev/null
+++ b/lib/shi-signature/signature-list.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import { useState } from 'react';
+import { BuyerSignature } from '@/db/schemae';
+import { setActiveSignature, deleteSignature } from './buyer-signature';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Trash2, CheckCircle, Circle,Loader2 } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+
+interface SignatureListProps {
+ signatures: BuyerSignature[];
+}
+
+export function SignatureList({ signatures }: SignatureListProps) {
+ const [isUpdating, setIsUpdating] = useState<number | null>(null);
+
+ const handleSetActive = async (id: number) => {
+ setIsUpdating(id);
+ try {
+ const result = await setActiveSignature(id);
+ if (result.success) {
+ toast.success('활성 서명이 변경되었습니다.');
+ } else {
+ toast.error(result.error || '변경에 실패했습니다.');
+ }
+ } catch (error) {
+ toast.error('오류가 발생했습니다.');
+ } finally {
+ setIsUpdating(null);
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ try {
+ const result = await deleteSignature(id);
+ if (result.success) {
+ toast.success('서명이 삭제되었습니다.');
+ } else {
+ toast.error(result.error || '삭제에 실패했습니다.');
+ }
+ } catch (error) {
+ toast.error('오류가 발생했습니다.');
+ }
+ };
+
+ if (signatures.length === 0) {
+ return (
+ <Card>
+ <CardContent className="py-8 text-center text-muted-foreground">
+ 아직 업로드된 서명이 없습니다.
+ </CardContent>
+ </Card>
+ );
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>서명 목록</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {signatures.map((signature) => (
+ <div
+ key={signature.id}
+ className="flex items-center justify-between p-4 border rounded-lg"
+ >
+ <div className="flex items-center space-x-4">
+ <img
+ src={signature.imageUrl}
+ alt="서명"
+ className="h-12 object-contain border rounded p-1"
+ />
+ <div>
+ <div className="flex items-center space-x-2">
+ <span className="font-medium">{signature.name}</span>
+ {signature.isActive && (
+ <Badge variant="default" className="text-xs">
+ 활성
+ </Badge>
+ )}
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {new Date(signature.createdAt).toLocaleDateString()}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ {!signature.isActive && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleSetActive(signature.id)}
+ disabled={isUpdating === signature.id}
+ >
+ {isUpdating === signature.id ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Circle className="h-4 w-4" />
+ )}
+ <span className="ml-2">활성화</span>
+ </Button>
+ )}
+
+ <AlertDialog>
+ <AlertDialogTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={signature.isActive}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>서명 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 이 서명을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction onClick={() => handleDelete(signature.id)}>
+ 삭제
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ );
+} \ No newline at end of file
diff --git a/lib/shi-signature/upload-form.tsx b/lib/shi-signature/upload-form.tsx
new file mode 100644
index 00000000..642cd1a5
--- /dev/null
+++ b/lib/shi-signature/upload-form.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { useState } from 'react';
+import { uploadBuyerSignature } from './buyer-signature';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Upload, Loader2, CheckCircle } from 'lucide-react';
+import { toast } from 'sonner';
+
+export function BuyerSignatureUploadForm() {
+ const [isUploading, setIsUploading] = useState(false);
+ const [preview, setPreview] = useState<string | null>(null);
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ setPreview(reader.result as string);
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+ e.preventDefault();
+ setIsUploading(true);
+
+ const formData = new FormData(e.currentTarget);
+
+ try {
+ const result = await uploadBuyerSignature(formData);
+
+ if (result.success) {
+ toast.success('서명이 성공적으로 업로드되었습니다.');
+ setPreview(null);
+ (e.target as HTMLFormElement).reset();
+ } else {
+ toast.error(result.error || '업로드에 실패했습니다.');
+ }
+ } catch (error) {
+ toast.error('업로드 중 오류가 발생했습니다.');
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>구매자 서명 업로드</CardTitle>
+ <CardDescription>
+ 삼성중공업 서명 이미지를 업로드하세요. 이 서명은 계약서에 자동으로 적용됩니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="file">서명 이미지</Label>
+ <Input
+ id="file"
+ name="file"
+ type="file"
+ accept="image/*"
+ onChange={handleFileChange}
+ required
+ disabled={isUploading}
+ />
+ <p className="text-sm text-muted-foreground">
+ PNG, JPG, JPEG 형식 (최대 5MB)
+ </p>
+ </div>
+
+ {preview && (
+ <div className="border rounded-lg p-4 bg-gray-50">
+ <Label className="text-sm font-medium mb-2 block">미리보기</Label>
+ <img
+ src={preview}
+ alt="서명 미리보기"
+ className="max-h-32 object-contain"
+ />
+ </div>
+ )}
+
+ <Alert>
+ <AlertDescription>
+ 업로드한 서명은 즉시 활성화되며, 새로운 계약서에 자동으로 적용됩니다.
+ </AlertDescription>
+ </Alert>
+
+ <Button
+ type="submit"
+ disabled={isUploading || !preview}
+ className="w-full"
+ >
+ {isUploading ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 업로드 중...
+ </>
+ ) : (
+ <>
+ <Upload className="mr-2 h-4 w-4" />
+ 서명 업로드
+ </>
+ )}
+ </Button>
+ </form>
+ </CardContent>
+ </Card>
+ );
+} \ No newline at end of file
diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts
index 32d5a5f5..34c274f5 100644
--- a/lib/tbe-last/service.ts
+++ b/lib/tbe-last/service.ts
@@ -1,7 +1,7 @@
// lib/tbe-last/service.ts
'use server'
-import { unstable_cache } from "next/cache";
+import { revalidatePath, unstable_cache } from "next/cache";
import db from "@/db/db";
import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm";
import { tbeLastView, tbeDocumentsView } from "@/db/schema";
@@ -547,4 +547,181 @@ function getReviewStatusClass(status?: string): string {
default:
return "unreviewed"
}
-} \ No newline at end of file
+}
+
+
+interface RfqInfo {
+ rfqCode: string;
+ rfqTitle: string;
+ rfqDueDate: Date | null;
+ projectCode: string;
+ projectName: string;
+ packageNo: string;
+ packageName: string;
+ picName: string;
+ rfqId: number; // rfqLastId 추가
+}
+
+interface VendorInfo {
+ sessionId: number;
+ vendorId: number; // vendor ID 추가
+ vendorCode: string;
+ vendorName: string;
+}
+
+export async function requestTBEForRFQ(
+ rfqInfo: RfqInfo,
+ vendors: VendorInfo[]
+) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return { success: false, error: "인증이 필요합니다" }
+ }
+
+ // 벤더별 이메일 정보 조회
+ const vendorEmails = await db
+ .select({
+ vendorId: rfqLastDetails.vendorsId,
+ emailSentTo: rfqLastDetails.emailSentTo,
+ })
+ .from(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqInfo.rfqId),
+ inArray(rfqLastDetails.vendorsId, vendors.map(v => v.vendorId)),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ );
+
+ // 이메일 정보 매핑
+ const vendorEmailMap = new Map();
+ vendorEmails.forEach(ve => {
+ if (ve.emailSentTo) {
+ try {
+ const emailData = JSON.parse(ve.emailSentTo);
+ vendorEmailMap.set(ve.vendorId, emailData);
+ } catch (error) {
+ console.error(`이메일 파싱 실패 - vendorId: ${ve.vendorId}`, error);
+ }
+ }
+ });
+
+ // 1. 트랜잭션으로 모든 세션 상태 업데이트
+ await db.transaction(async (tx) => {
+ // 세션 상태 업데이트
+ const sessionIds = vendors.map(v => v.sessionId);
+
+ await tx
+ .update(rfqLastTbeSessions)
+ .set({
+ status: "진행중",
+ updatedAt: new Date(),
+ })
+ .where(inArray(rfqLastTbeSessions.id, sessionIds));
+ });
+
+ // 2. 각 벤더에게 이메일 발송
+ const emailPromises = vendors.map(async (vendor) => {
+ const emailInfo = vendorEmailMap.get(vendor.vendorId);
+
+ if (!emailInfo) {
+ console.warn(`벤더 ${vendor.vendorName}의 이메일 정보가 없습니다.`);
+ return { success: false, vendor: vendor.vendorName, error: "이메일 정보 없음" };
+ }
+
+ try {
+ // to와 cc 이메일 추출
+ const toEmails = Array.isArray(emailInfo.to) ? emailInfo.to : [emailInfo.to];
+ const ccEmails = Array.isArray(emailInfo.cc) ? emailInfo.cc : [];
+
+ // 모든 to 이메일 주소로 발송
+ const emailResults = await Promise.all(
+ toEmails.filter(Boolean).map(toEmail =>
+ sendEmail({
+ to: toEmail,
+ template: "tbe-request",
+ subject: `[TBE 요청] ${rfqInfo.rfqCode} - 기술입찰평가 서류 제출 요청`,
+ context: {
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ rfqCode: rfqInfo.rfqCode,
+ rfqTitle: rfqInfo.rfqTitle,
+ rfqDueDate: rfqInfo.rfqDueDate ?
+ new Date(rfqInfo.rfqDueDate).toLocaleDateString("ko-KR") :
+ "미정",
+ projectCode: rfqInfo.projectCode,
+ projectName: rfqInfo.projectName,
+ packageNo: rfqInfo.packageNo,
+ packageName: rfqInfo.packageName,
+ picName: rfqInfo.picName,
+ picEmail: session.user.email,
+ picPhone: process.env.DEFAULT_PIC_PHONE || "",
+ tbeDeadline: calculateTBEDeadline(rfqInfo.rfqDueDate),
+ companyName: process.env.COMPANY_NAME || "Your Company",
+ },
+ cc: [
+ ...ccEmails.filter(Boolean)
+ ],
+ })
+ )
+ );
+
+ return {
+ success: true,
+ vendor: vendor.vendorName,
+ emailsSent: emailResults.length
+ };
+
+ } catch (error) {
+ console.error(`이메일 발송 실패 - ${vendor.vendorName}:`, error);
+ return { success: false, vendor: vendor.vendorName, error };
+ }
+ });
+
+ const emailResults = await Promise.allSettled(emailPromises);
+
+ // 3. 결과 확인
+ const successResults = emailResults.filter(
+ r => r.status === "fulfilled" && r.value?.success
+ );
+ const failedResults = emailResults.filter(
+ r => r.status === "rejected" || (r.status === "fulfilled" && !r.value?.success)
+ );
+
+ if (failedResults.length > 0) {
+ console.warn(`${failedResults.length}개 벤더의 이메일 발송 실패`);
+ }
+
+ revalidatePath("/evcp/tbe-last");
+
+ return {
+ success: true,
+ message: `${successResults.length}개 벤더에 TBE 요청 완료`,
+ emailsSent: successResults.length,
+ emailsFailed: failedResults.length
+ };
+
+ } catch (error) {
+ console.error("TBE 요청 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "TBE 요청 중 오류가 발생했습니다."
+ };
+ }
+}
+
+// TBE 제출 기한 계산 (RFQ 마감일 7일 전)
+function calculateTBEDeadline(rfqDueDate: Date | null): string {
+ if (!rfqDueDate) return "추후 공지";
+
+ const deadline = new Date(rfqDueDate);
+ deadline.setDate(deadline.getDate() - 7); // 7일 전
+
+ return deadline.toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+}
+
diff --git a/lib/tbe-last/table/tbe-last-table-columns.tsx b/lib/tbe-last/table/tbe-last-table-columns.tsx
index 726d8925..b18e51c0 100644
--- a/lib/tbe-last/table/tbe-last-table-columns.tsx
+++ b/lib/tbe-last/table/tbe-last-table-columns.tsx
@@ -86,6 +86,55 @@ export function getColumns({
cell: ({ row }) => row.original.rfqCode,
size: 120,
},
+
+ {
+ id: "tbeRequired",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 필요" />
+ ),
+ cell: ({ row, table }) => {
+ const rfqCode = row.original.rfqCode;
+ const sessionStatus = row.original.sessionStatus;
+
+ // 같은 RFQ의 첫 번째 행에만 체크박스 표시
+ const allRows = table.getRowModel().rows;
+ const isFirstInGroup = allRows.findIndex(
+ r => r.original.rfqCode === rfqCode
+ ) === allRows.indexOf(row);
+
+ if (!isFirstInGroup) return null;
+
+ // 같은 RFQ의 모든 row
+ const rfqRows = allRows.filter(
+ r => r.original.rfqCode === rfqCode
+ );
+
+ const vendorCount = rfqRows.length;
+
+ // 같은 RFQ의 row들이 선택되었는지 확인
+ const isChecked = rfqRows.every(r => r.getIsSelected());
+ const isIndeterminate = rfqRows.some(r => r.getIsSelected()) && !isChecked;
+
+ return (
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={isChecked}
+ indeterminate={isIndeterminate}
+ onCheckedChange={(checked) => {
+ // RFQ의 모든 벤더 선택/해제
+ rfqRows.forEach(r => {
+ r.toggleSelected(!!checked);
+ });
+ }}
+ />
+ <span className="text-xs text-muted-foreground">
+ ({vendorCount} vendors)
+ </span>
+ </div>
+ );
+ },
+ size: 120,
+ },
// RFQ Title
{
diff --git a/lib/tbe-last/table/tbe-last-table.tsx b/lib/tbe-last/table/tbe-last-table.tsx
index a9328bdf..c18768cd 100644
--- a/lib/tbe-last/table/tbe-last-table.tsx
+++ b/lib/tbe-last/table/tbe-last-table.tsx
@@ -10,7 +10,7 @@ import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { getColumns } from "./tbe-last-table-columns"
import { TbeLastView } from "@/db/schema"
-import { getAllTBELast, getTBESessionDetail } from "@/lib/tbe-last/service"
+import { getAllTBELast, getTBESessionDetail, requestTBEForRFQ } from "@/lib/tbe-last/service"
import { Button } from "@/components/ui/button"
import { Download, RefreshCw } from "lucide-react"
import { exportTableToExcel } from "@/lib/export"
@@ -20,6 +20,7 @@ import { SessionDetailDialog } from "./session-detail-dialog"
import { DocumentsSheet } from "./documents-sheet"
import { PrItemsDialog } from "./pr-items-dialog"
import { EvaluationDialog } from "./evaluation-dialog"
+import { toast } from "sonner"
interface TbeLastTableProps {
promises: Promise<[
@@ -30,21 +31,21 @@ interface TbeLastTableProps {
export function TbeLastTable({ promises }: TbeLastTableProps) {
const router = useRouter()
const [{ data, pageCount }] = React.use(promises)
-
- console.log(data,"data")
+
+ console.log(data, "data")
// Dialog states
const [sessionDetailOpen, setSessionDetailOpen] = React.useState(false)
const [documentsOpen, setDocumentsOpen] = React.useState(false)
const [prItemsOpen, setPrItemsOpen] = React.useState(false)
const [evaluationOpen, setEvaluationOpen] = React.useState(false)
-
+
const [selectedSessionId, setSelectedSessionId] = React.useState<number | null>(null)
const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
const [selectedSession, setSelectedSession] = React.useState<TbeLastView | null>(null)
const [sessionDetail, setSessionDetail] = React.useState<any>(null)
const [isLoadingDetail, setIsLoadingDetail] = React.useState(false)
-
+
// Load session detail when needed
const loadSessionDetail = React.useCallback(async (sessionId: number) => {
setIsLoadingDetail(true)
@@ -57,37 +58,37 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
setIsLoadingDetail(false)
}
}, [])
-
+
// Handlers
const handleOpenSessionDetail = React.useCallback((sessionId: number) => {
setSelectedSessionId(sessionId)
setSessionDetailOpen(true)
loadSessionDetail(sessionId)
}, [loadSessionDetail])
-
+
const handleOpenDocuments = React.useCallback((sessionId: number) => {
setSelectedSessionId(sessionId)
setDocumentsOpen(true)
loadSessionDetail(sessionId)
}, [loadSessionDetail])
-
+
const handleOpenPrItems = React.useCallback((rfqId: number) => {
setSelectedRfqId(rfqId)
setPrItemsOpen(true)
loadSessionDetail(rfqId)
}, [loadSessionDetail])
-
+
const handleOpenEvaluation = React.useCallback((session: TbeLastView) => {
setSelectedSession(session)
setEvaluationOpen(true)
loadSessionDetail(session.rfqId)
}, [])
-
+
const handleRefresh = React.useCallback(() => {
router.refresh()
}, [router])
-
+
// Table columns
const columns = React.useMemo(
() =>
@@ -99,7 +100,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
}),
[handleOpenSessionDetail, handleOpenDocuments, handleOpenPrItems, handleOpenEvaluation]
)
-
+
// Filter fields
const filterFields: DataTableAdvancedFilterField<TbeLastView>[] = [
{
@@ -125,7 +126,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
],
},
]
-
+
// Data table
const { table } = useDataTable({
data,
@@ -142,7 +143,64 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
shallow: false,
clearOnDefault: true,
})
-
+
+ const handleBulkTBERequest = React.useCallback(async (rfqGroups: Map<string, TbeLastView[]>) => {
+ try {
+ const promises = Array.from(rfqGroups.entries()).map(async ([rfqCode, sessions]) => {
+ // 준비중 상태인 세션만 필터링
+ const pendingSessions = sessions.filter(s => s.sessionStatus === "준비중");
+
+ if (pendingSessions.length === 0) {
+ toast.info(`RFQ ${rfqCode}: 이미 TBE가 요청되었습니다.`);
+ return null;
+ }
+
+ const vendors = pendingSessions.map(session => ({
+ sessionId: session.tbeSessionId,
+ vendorId: session.vendorId, // vendor ID 추가
+ vendorCode: session.vendorCode,
+ vendorName: session.vendorName,
+ }));
+
+ const rfqInfo = {
+ rfqId: sessions[0].rfqId, // rfqLastId 추가
+ rfqCode: sessions[0].rfqCode,
+ rfqTitle: sessions[0].rfqTitle || "",
+ rfqDueDate: sessions[0].rfqDueDate,
+ projectCode: sessions[0].projectCode || "",
+ projectName: sessions[0].projectName || "",
+ packageNo: sessions[0].packageNo || "",
+ packageName: sessions[0].packageName || "",
+ picName: sessions[0].picName || "",
+ };
+
+ return requestTBEForRFQ(rfqInfo, vendors);
+ });
+
+ const results = await Promise.allSettled(promises);
+
+ const successCount = results.filter(r => r.status === "fulfilled" && r.value?.success).length;
+ const failCount = results.filter(r => r.status === "rejected" || (r.status === "fulfilled" && !r.value?.success)).length;
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개 RFQ에 대한 TBE 요청이 완료되었습니다.`);
+ }
+
+ if (failCount > 0) {
+ toast.error(`${failCount}개 RFQ에 대한 TBE 요청이 실패했습니다.`);
+ }
+
+ // 테이블 새로고침
+ router.refresh();
+ table.resetRowSelection();
+
+ } catch (error) {
+ console.error("TBE 요청 처리 중 오류:", error);
+ toast.error("TBE 요청 처리 중 오류가 발생했습니다.");
+ }
+ }, [router, table]);
+
+
return (
<>
<DataTable table={table}>
@@ -152,6 +210,30 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
shallow={false}
>
<div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const rfqGroups = new Map();
+
+ // RFQ별로 그룹핑
+ selectedRows.forEach(row => {
+ const rfqCode = row.original.rfqCode;
+ if (!rfqGroups.has(rfqCode)) {
+ rfqGroups.set(rfqCode, []);
+ }
+ rfqGroups.get(rfqCode).push(row.original);
+ });
+
+ handleBulkTBERequest(rfqGroups);
+ }}
+ >
+ 선택된 항목 TBE 요청 ({table.getFilteredSelectedRowModel().rows.length})
+ </Button>
+ )}
<Button
variant="outline"
size="sm"
@@ -178,7 +260,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
</div>
</DataTableAdvancedToolbar>
</DataTable>
-
+
{/* Session Detail Dialog */}
<SessionDetailDialog
open={sessionDetailOpen}
@@ -186,7 +268,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
sessionDetail={sessionDetail}
isLoading={isLoadingDetail}
/>
-
+
{/* Documents Sheet */}
<DocumentsSheet
open={documentsOpen}
@@ -194,7 +276,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
sessionDetail={sessionDetail}
isLoading={isLoadingDetail}
/>
-
+
{/* PR Items Dialog */}
<PrItemsDialog
open={prItemsOpen}
@@ -202,7 +284,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) {
sessionDetail={sessionDetail}
isLoading={isLoadingDetail}
/>
-
+
{/* Evaluation Dialog */}
<EvaluationDialog
open={evaluationOpen}
diff --git a/lib/tbe-last/vendor-tbe-service.ts b/lib/tbe-last/vendor-tbe-service.ts
index 8335eb4f..858a5817 100644
--- a/lib/tbe-last/vendor-tbe-service.ts
+++ b/lib/tbe-last/vendor-tbe-service.ts
@@ -4,7 +4,7 @@
import { unstable_cache } from "next/cache"
import db from "@/db/db"
-import { and, desc, asc, eq, sql, or } from "drizzle-orm"
+import { and, desc, asc, eq, sql, ne } from "drizzle-orm"
import { tbeLastView, rfqLastTbeSessions } from "@/db/schema"
import { rfqPrItems } from "@/db/schema/rfqLast"
import { getServerSession } from "next-auth"
@@ -42,7 +42,7 @@ export async function getTBEforVendor(
const limit = input.perPage ?? 10
// 벤더 필터링
- const vendorWhere = eq(tbeLastView.vendorId, vendorId)
+ const vendorWhere =and(eq(tbeLastView.vendorId, vendorId),ne(tbeLastView.sessionStatus, "준비중"))
// 데이터 조회
const [rows, total] = await db.transaction(async (tx) => {