summaryrefslogtreecommitdiff
path: root/lib/vendor-regular-registrations
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-13 11:05:09 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-13 11:05:09 +0000
commit33be47506f0aa62b969d82521580a29e95080268 (patch)
tree6b7e232f2d78ef8775944ea085a36b3ccbce7d95 /lib/vendor-regular-registrations
parent2ac95090157c355ea1bd0b8eb1e1e5e2bd56faf4 (diff)
(대표님) 입찰, 법무검토, EDP 변경사항 대응, dolce 개선, form-data 개선, 정규업체 등록관리 추가
(최겸) pq 미사용 컴포넌트 및 페이지 제거, 파일 라우트에 pq 적용
Diffstat (limited to 'lib/vendor-regular-registrations')
-rw-r--r--lib/vendor-regular-registrations/repository.ts209
-rw-r--r--lib/vendor-regular-registrations/service.ts825
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx270
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx248
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx104
5 files changed, 1656 insertions, 0 deletions
diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts
new file mode 100644
index 00000000..d4c979a5
--- /dev/null
+++ b/lib/vendor-regular-registrations/repository.ts
@@ -0,0 +1,209 @@
+import db from "@/db/db";
+import {
+ vendorRegularRegistrations,
+ vendors,
+ vendorAttachments,
+ vendorInvestigationAttachments,
+ basicContract,
+ vendorPQSubmissions,
+ vendorInvestigations,
+} from "@/db/schema";
+import { eq, desc, and, sql, inArray } from "drizzle-orm";
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
+
+export async function getVendorRegularRegistrations(
+): Promise<VendorRegularRegistration[]> {
+ try {
+ // DB 레코드 기준으로 정규업체등록 데이터를 가져옴
+ const registrations = await db
+ .select({
+ // 정규업체등록 정보
+ id: vendorRegularRegistrations.id,
+ vendorId: vendorRegularRegistrations.vendorId,
+ status: vendorRegularRegistrations.status,
+ potentialCode: vendorRegularRegistrations.potentialCode,
+ majorItems: vendorRegularRegistrations.majorItems,
+ registrationRequestDate: vendorRegularRegistrations.registrationRequestDate,
+ assignedDepartment: vendorRegularRegistrations.assignedDepartment,
+ assignedUser: vendorRegularRegistrations.assignedUser,
+ remarks: vendorRegularRegistrations.remarks,
+ // 벤더 기본 정보
+ businessNumber: vendors.taxId,
+ companyName: vendors.vendorName,
+ establishmentDate: vendors.createdAt,
+ representative: vendors.representativeName,
+ })
+ .from(vendorRegularRegistrations)
+ .innerJoin(vendors, eq(vendorRegularRegistrations.vendorId, vendors.id))
+ .orderBy(desc(vendorRegularRegistrations.createdAt));
+
+ // 벤더 ID 배열 생성
+ const vendorIds = registrations.map(r => r.vendorId);
+
+ // 벤더 첨부파일 정보 조회 - 벤더별로 그룹화
+ const vendorAttachmentsList = vendorIds.length > 0 ? await db
+ .select()
+ .from(vendorAttachments)
+ .where(inArray(vendorAttachments.vendorId, vendorIds)) : [];
+
+ // 실사 첨부파일 정보 조회 - 실사 ID를 통해 벤더 ID 매핑
+ const investigationAttachmentsList = vendorIds.length > 0 ? await db
+ .select({
+ vendorId: vendorInvestigations.vendorId,
+ attachmentId: vendorInvestigationAttachments.id,
+ fileName: vendorInvestigationAttachments.fileName,
+ attachmentType: vendorInvestigationAttachments.attachmentType,
+ createdAt: vendorInvestigationAttachments.createdAt,
+ })
+ .from(vendorInvestigationAttachments)
+ .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
+ .where(inArray(vendorInvestigations.vendorId, vendorIds)) : [];
+
+ // 각 등록 레코드별로 데이터를 매핑하여 결과 반환
+ return registrations.map((registration) => {
+ // 벤더별 첨부파일 필터링
+ const vendorFiles = vendorAttachmentsList.filter(att => att.vendorId === registration.vendorId);
+ const investigationFiles = investigationAttachmentsList.filter(att => att.vendorId === registration.vendorId);
+
+ // 디버깅을 위한 로그
+ console.log(`📋 벤더 ID ${registration.vendorId} (${registration.companyName}) 첨부파일 현황:`, {
+ vendorFiles: vendorFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName })),
+ investigationFiles: investigationFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName }))
+ });
+
+ // 문서 제출 현황 - 실제 첨부파일 존재 여부 확인 (DB 타입명과 정확히 매칭)
+ const documentSubmissionsStatus = {
+ businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"),
+ bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
+ auditResult: investigationFiles.length > 0, // 실사 첨부파일이 하나라도 있으면 true
+ };
+
+ // 문서별 파일 정보 (다운로드용)
+ const documentFiles = {
+ businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"),
+ bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
+ auditResult: investigationFiles,
+ };
+
+ // 문서 제출 현황 로그
+ console.log(`📊 벤더 ID ${registration.vendorId} 문서 제출 현황:`, documentSubmissionsStatus);
+
+ // 계약 동의 현황 (기본값 - 추후 실제 계약 테이블과 연동)
+ const contractAgreementsStatus = {
+ cp: "not_submitted",
+ gtc: "not_submitted",
+ standardSubcontract: "not_submitted",
+ safetyHealth: "not_submitted",
+ ethics: "not_submitted",
+ domesticCredit: "not_submitted",
+ safetyQualification: "not_submitted",
+ };
+
+ return {
+ id: registration.id,
+ vendorId: registration.vendorId,
+ status: registration.status || "audit_pass",
+ potentialCode: registration.potentialCode,
+ businessNumber: registration.businessNumber || "",
+ companyName: registration.companyName || "",
+ majorItems: registration.majorItems,
+ establishmentDate: registration.establishmentDate?.toISOString() || null,
+ representative: registration.representative,
+ documentSubmissions: documentSubmissionsStatus,
+ documentFiles: documentFiles, // 파일 정보 추가
+ contractAgreements: contractAgreementsStatus,
+ additionalInfo: true, // TODO: 추가정보 로직 구현 필요
+ registrationRequestDate: registration.registrationRequestDate || null,
+ assignedDepartment: registration.assignedDepartment,
+ assignedUser: registration.assignedUser,
+ remarks: registration.remarks,
+ };
+ });
+ } catch (error) {
+ console.error("Error fetching vendor regular registrations:", error);
+ throw new Error("정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.");
+ }
+}
+
+export async function createVendorRegularRegistration(data: {
+ vendorId: number;
+ status?: string;
+ potentialCode?: string;
+ majorItems?: string;
+ assignedDepartment?: string;
+ assignedDepartmentCode?: string;
+ assignedUser?: string;
+ assignedUserCode?: string;
+ remarks?: string;
+}) {
+ try {
+ const [registration] = await db
+ .insert(vendorRegularRegistrations)
+ .values({
+ vendorId: data.vendorId,
+ status: data.status || "audit_pass",
+ potentialCode: data.potentialCode,
+ majorItems: data.majorItems,
+ assignedDepartment: data.assignedDepartment,
+ assignedDepartmentCode: data.assignedDepartmentCode,
+ assignedUser: data.assignedUser,
+ assignedUserCode: data.assignedUserCode,
+ remarks: data.remarks,
+ })
+ .returning();
+
+ return registration;
+ } catch (error) {
+ console.error("Error creating vendor regular registration:", error);
+ throw new Error("정규업체 등록을 생성하는 중 오류가 발생했습니다.");
+ }
+}
+
+export async function updateVendorRegularRegistration(
+ id: number,
+ data: Partial<{
+ status: string;
+ potentialCode: string;
+ majorItems: string;
+ registrationRequestDate: string;
+ assignedDepartment: string;
+ assignedDepartmentCode: string;
+ assignedUser: string;
+ assignedUserCode: string;
+ remarks: string;
+ }>
+) {
+ try {
+ const [registration] = await db
+ .update(vendorRegularRegistrations)
+ .set({
+ ...data,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorRegularRegistrations.id, id))
+ .returning();
+
+ return registration;
+ } catch (error) {
+ console.error("Error updating vendor regular registration:", error);
+ throw new Error("정규업체 등록을 업데이트하는 중 오류가 발생했습니다.");
+ }
+}
+
+export async function getVendorRegularRegistrationById(id: number) {
+ try {
+ const [registration] = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.id, id));
+
+ return registration;
+ } catch (error) {
+ console.error("Error fetching vendor regular registration by id:", error);
+ throw new Error("정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.");
+ }
+}
+
+
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts
new file mode 100644
index 00000000..b587ec23
--- /dev/null
+++ b/lib/vendor-regular-registrations/service.ts
@@ -0,0 +1,825 @@
+"use server"
+import { revalidateTag, unstable_cache } from "next/cache";
+import {
+ getVendorRegularRegistrations,
+ createVendorRegularRegistration,
+ updateVendorRegularRegistration,
+ getVendorRegularRegistrationById,
+} from "./repository";
+
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { headers } from "next/headers";
+import { sendEmail } from "@/lib/mail/sendEmail";
+import {
+ vendors,
+ vendorRegularRegistrations,
+ vendorAttachments,
+ vendorInvestigations,
+ vendorInvestigationAttachments,
+ basicContract,
+ vendorPQSubmissions,
+ vendorBusinessContacts,
+ vendorAdditionalInfo
+} from "@/db/schema";
+import db from "@/db/db";
+import { inArray, eq, desc } from "drizzle-orm";
+
+// 캐싱과 에러 핸들링이 포함된 조회 함수
+export async function fetchVendorRegularRegistrations(input?: {
+ search?: string;
+ status?: string[];
+ page?: number;
+ perPage?: number;
+}) {
+ return unstable_cache(
+ async () => {
+ try {
+ const registrations = await getVendorRegularRegistrations();
+
+ let filteredData = registrations;
+
+ // 검색 필터링
+ if (input?.search) {
+ const searchLower = input.search.toLowerCase();
+ filteredData = filteredData.filter(
+ (reg) =>
+ reg.companyName.toLowerCase().includes(searchLower) ||
+ reg.businessNumber.toLowerCase().includes(searchLower) ||
+ reg.potentialCode?.toLowerCase().includes(searchLower) ||
+ reg.representative?.toLowerCase().includes(searchLower)
+ );
+ }
+
+ // 상태 필터링
+ if (input?.status && input.status.length > 0) {
+ filteredData = filteredData.filter((reg) =>
+ input.status!.includes(reg.status)
+ );
+ }
+
+ // 페이지네이션
+ const page = input?.page || 1;
+ const perPage = input?.perPage || 50;
+ const offset = (page - 1) * perPage;
+ const paginatedData = filteredData.slice(offset, offset + perPage);
+ const pageCount = Math.ceil(filteredData.length / perPage);
+
+ return {
+ success: true,
+ data: paginatedData,
+ pageCount,
+ total: filteredData.length,
+ };
+ } catch (error) {
+ console.error("Error in fetchVendorRegularRegistrations:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.",
+ };
+ }
+ },
+ [JSON.stringify(input || {})],
+ {
+ revalidate: 300, // 5분 캐시
+ tags: ["vendor-regular-registrations"],
+ }
+ )();
+}
+
+export async function getCurrentUserInfo() {
+ const session = await getServerSession(authOptions);
+ return {
+ userId: session?.user?.id ? String(session.user.id) : null,
+ userName: session?.user?.name || null,
+ };
+}
+
+export async function createVendorRegistration(data: {
+ vendorId: number;
+ status?: string;
+ potentialCode?: string;
+ majorItems?: Record<string, unknown>[];
+ assignedDepartment?: string;
+ assignedDepartmentCode?: string;
+ assignedUser?: string;
+ assignedUserCode?: string;
+ remarks?: string;
+}) {
+ try {
+ const majorItemsJson = data.majorItems ? JSON.stringify(data.majorItems) : undefined;
+
+ const registration = await createVendorRegularRegistration({
+ ...data,
+ majorItems: majorItemsJson,
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return { success: true, data: registration };
+ } catch (error) {
+ console.error("Error in createVendorRegistration:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록을 생성하는 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+export async function updateVendorRegistration(
+ id: number,
+ data: Partial<{
+ status: string;
+ potentialCode: string;
+ majorItems: Record<string, unknown>[];
+ registrationRequestDate: string;
+ assignedDepartment: string;
+ assignedDepartmentCode: string;
+ assignedUser: string;
+ assignedUserCode: string;
+ remarks: string;
+ }>
+) {
+ try {
+ const updateData: Partial<{
+ status: string;
+ potentialCode: string;
+ majorItems: string;
+ registrationRequestDate: string;
+ assignedDepartment: string;
+ assignedDepartmentCode: string;
+ assignedUser: string;
+ assignedUserCode: string;
+ remarks: string;
+ }> = {};
+
+ // majorItems를 제외한 다른 필드들을 복사
+ Object.keys(data).forEach(key => {
+ if (key !== 'majorItems') {
+ updateData[key as keyof typeof updateData] = data[key as keyof typeof data] as never;
+ }
+ });
+
+ if (data.majorItems) {
+ updateData.majorItems = JSON.stringify(data.majorItems);
+ }
+
+ const registration = await updateVendorRegularRegistration(id, updateData);
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return { success: true, data: registration };
+ } catch (error) {
+ console.error("Error in updateVendorRegistration:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록을 수정하는 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+export async function fetchVendorRegistrationById(id: number) {
+ try {
+ const registration = await getVendorRegularRegistrationById(id);
+ return { success: true, data: registration };
+ } catch (error) {
+ console.error("Error in fetchVendorRegistrationById:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+
+
+export async function requestRegularRegistration(registrationId: number) {
+ try {
+ // 정규업체 등록 요청 처리
+ const now = new Date().toISOString().split('T')[0];
+
+ const registration = await updateVendorRegularRegistration(registrationId, {
+ status: "in_review",
+ registrationRequestDate: now,
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return { success: true, message: "정규업체 등록 요청이 완료되었습니다.", data: registration };
+ } catch (error) {
+ console.error("Error in requestRegularRegistration:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 요청 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+export async function approveRegularRegistration(registrationId: number) {
+ try {
+ // 정규업체 등록 승인 처리
+ const registration = await updateVendorRegularRegistration(registrationId, {
+ status: "approval_ready",
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendor-regular-registrations");
+
+ return { success: true, message: "정규업체 등록이 승인되었습니다.", data: registration };
+ } catch (error) {
+ console.error("Error in approveRegularRegistration:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 승인 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+
+
+// 누락계약요청 이메일 발송
+export async function sendMissingContractRequestEmails(vendorIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ // 벤더 정보 조회
+ const vendorList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ if (vendorList.length === 0) {
+ return { success: false, error: "선택된 업체를 찾을 수 없습니다." };
+ }
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
+ const baseUrl = `${protocol}://${host}`;
+ const contractManagementUrl = `${baseUrl}/ko/login`; // 실제 기본계약 관리 페이지 URL로 수정 필요
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ // 각 벤더에게 이메일 발송
+ await Promise.all(
+ vendorList.map(async (vendor) => {
+ if (!vendor.email) {
+ errorCount++;
+ return;
+ }
+
+ try {
+
+ await sendEmail({
+ to: vendor.email,
+ subject: "[SHI] 정규업체 등록을 위한 기본계약/서약 진행 요청",
+ template: "vendor-missing-contract-request",
+ context: {
+ vendorName: vendor.vendorName,
+ contractManagementUrl,
+ senderName: session.user.name || "구매담당자",
+ senderEmail: session.user.email || "",
+ currentYear: new Date().getFullYear(),
+ },
+ });
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to send email to ${vendor.vendorName}:`, error);
+ errorCount++;
+ }
+ })
+ );
+
+ if (errorCount > 0) {
+ return {
+ success: false,
+ error: `${successCount}개 업체에 발송 성공, ${errorCount}개 업체 발송 실패`,
+ };
+ }
+
+ return {
+ success: true,
+ message: `${successCount}개 업체에 누락계약요청 이메일을 발송했습니다.`,
+ };
+ } catch (error) {
+ console.error("Error sending missing contract request emails:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "이메일 발송 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 추가정보요청 이메일 발송
+export async function sendAdditionalInfoRequestEmails(vendorIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ // 벤더 정보 조회
+ const vendorList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ if (vendorList.length === 0) {
+ return { success: false, error: "선택된 업체를 찾을 수 없습니다." };
+ }
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
+ const baseUrl = `${protocol}://${host}`;
+ const vendorInfoUrl = `${baseUrl}/ko/login`; // 실제 업체정보 관리 페이지 URL로 수정 필요
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ // 각 벤더에게 이메일 발송
+ await Promise.all(
+ vendorList.map(async (vendor) => {
+ if (!vendor.email) {
+ errorCount++;
+ return;
+ }
+
+ try {
+ await sendEmail({
+ to: vendor.email,
+ subject: "[SHI] 정규업체 등록을 위한 추가정보 입력 요청",
+ template: "vendor-regular-registration-request",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorInfoUrl,
+ senderName: session.user.name || "구매담당자",
+ senderEmail: session.user.email || "",
+ currentYear: new Date().getFullYear(),
+ },
+ });
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to send email to ${vendor.vendorName}:`, error);
+ errorCount++;
+ }
+ })
+ );
+
+ if (errorCount > 0) {
+ return {
+ success: false,
+ error: `${successCount}개 업체에 발송 성공, ${errorCount}개 업체 발송 실패`,
+ };
+ }
+
+ return {
+ success: true,
+ message: `${successCount}개 업체에 추가정보요청 이메일을 발송했습니다.`,
+ };
+ } catch (error) {
+ console.error("Error sending additional info request emails:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "이메일 발송 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 법무검토 Skip 기능
+export async function skipLegalReview(vendorIds: number[], skipReason: string) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const vendorId of vendorIds) {
+ try {
+ // 해당 벤더의 registration 찾기 또는 생성
+ const vendorList = await db
+ .select({ id: vendors.id })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId));
+
+ if (vendorList.length === 0) {
+ errorCount++;
+ continue;
+ }
+
+ // registration 조회
+ const existingRegistrations = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, vendorId));
+
+ let registrationId;
+ if (existingRegistrations.length === 0) {
+ // 새로 생성
+ const newRegistration = await createVendorRegularRegistration({
+ vendorId: vendorId,
+ status: "cp_finished", // CP완료로 변경
+ remarks: `법무검토 Skip: ${skipReason}`,
+ });
+ registrationId = newRegistration.id;
+ } else {
+ // 기존 registration 업데이트
+ registrationId = existingRegistrations[0].id;
+ const currentRemarks = existingRegistrations[0].remarks || "";
+ const newRemarks = currentRemarks
+ ? `${currentRemarks}\n법무검토 Skip: ${skipReason}`
+ : `법무검토 Skip: ${skipReason}`;
+
+ await updateVendorRegularRegistration(registrationId, {
+ status: "cp_finished", // CP완료로 변경
+ remarks: newRemarks,
+ });
+ }
+
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to skip legal review for vendor ${vendorId}:`, error);
+ errorCount++;
+ }
+ }
+
+ if (errorCount > 0) {
+ return {
+ success: false,
+ error: `${successCount}개 업체 처리 성공, ${errorCount}개 업체 처리 실패`,
+ };
+ }
+
+ return {
+ success: true,
+ message: `${successCount}개 업체의 법무검토를 Skip 처리했습니다.`,
+ };
+ } catch (error) {
+ console.error("Error skipping legal review:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "법무검토 Skip 처리 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 안전적격성평가 Skip 기능
+export async function skipSafetyQualification(vendorIds: number[], skipReason: string) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return { success: false, error: "로그인이 필요합니다." };
+ }
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const vendorId of vendorIds) {
+ try {
+ // 해당 벤더의 registration 찾기 또는 생성
+ const vendorList = await db
+ .select({ id: vendors.id })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId));
+
+ if (vendorList.length === 0) {
+ errorCount++;
+ continue;
+ }
+
+ // registration 조회
+ const existingRegistrations = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, vendorId));
+
+ let registrationId;
+ if (existingRegistrations.length === 0) {
+ // 새로 생성
+ const newRegistration = await createVendorRegularRegistration({
+ vendorId: vendorId,
+ status: "audit_pass",
+ remarks: `안전적격성평가 Skip: ${skipReason}`,
+ });
+ registrationId = newRegistration.id;
+ } else {
+ // 기존 registration 업데이트
+ registrationId = existingRegistrations[0].id;
+ const currentRemarks = existingRegistrations[0].remarks || "";
+ const newRemarks = currentRemarks
+ ? `${currentRemarks}\n안전적격성평가 Skip: ${skipReason}`
+ : `안전적격성평가 Skip: ${skipReason}`;
+
+ await updateVendorRegularRegistration(registrationId, {
+ remarks: newRemarks,
+ });
+ }
+
+ // 안전적격성평가 상태를 완료로 처리 (계약 동의 현황은 이제 실시간으로 조회하므로 별도 처리 불필요)
+ // updateContractAgreement 함수는 제거되었으므로 계약 동의 현황은 basic_contract와 vendor_pq_submissions에서 실시간으로 조회됩니다.
+
+ successCount++;
+ } catch (error) {
+ console.error(`Failed to skip safety qualification for vendor ${vendorId}:`, error);
+ errorCount++;
+ }
+ }
+
+ if (errorCount > 0) {
+ return {
+ success: false,
+ error: `${successCount}개 업체 처리 성공, ${errorCount}개 업체 처리 실패`,
+ };
+ }
+
+ return {
+ success: true,
+ message: `${successCount}개 업체의 안전적격성평가를 Skip 처리했습니다.`,
+ };
+ } catch (error) {
+ console.error("Error skipping safety qualification:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "안전적격성평가 Skip 처리 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 벤더용 현황 조회 함수들
+export async function fetchVendorRegistrationStatus(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 벤더 기본 정보
+ const vendor = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1)
+
+ if (!vendor[0]) {
+ return {
+ success: false,
+ error: "벤더 정보를 찾을 수 없습니다.",
+ }
+ }
+
+ // 정규업체 등록 정보
+ const registration = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, vendorId))
+ .limit(1)
+
+ // 벤더 첨부파일 조회
+ const vendorFiles = await db
+ .select()
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendorId))
+
+ // 실사 결과 조회 (vendor_investigation_attachments)
+ const investigationFiles = await db
+ .select({
+ attachmentId: vendorInvestigationAttachments.id,
+ createdAt: vendorInvestigationAttachments.createdAt,
+ })
+ .from(vendorInvestigationAttachments)
+ .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
+ .where(eq(vendorInvestigations.vendorId, vendorId))
+
+ // PQ 제출 정보
+ const pqSubmission = await db
+ .select()
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .orderBy(desc(vendorPQSubmissions.createdAt))
+ .limit(1)
+
+ // 기본계약 정보
+ const contractInfo = await db
+ .select()
+ .from(basicContract)
+ .where(eq(basicContract.vendorId, vendorId))
+ .limit(1)
+
+ // 업무담당자 정보
+ const businessContacts = await db
+ .select()
+ .from(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendorId))
+
+ // 추가정보
+ const additionalInfo = await db
+ .select()
+ .from(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ .limit(1)
+
+ // 문서 제출 현황 계산
+ const documentStatus = {
+ businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_EVALUATION"),
+ bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_COPY"),
+ auditResult: investigationFiles.length > 0, // DocumentStatusDialog에서 사용하는 키
+ cpDocument: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ gtc: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ standardSubcontract: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ safetyHealth: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ ethics: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ domesticCredit: !!contractInfo[0]?.status && contractInfo[0].status === "completed",
+ safetyQualification: investigationFiles.length > 0,
+ }
+
+ // 미완성 항목 계산
+ const missingDocuments = Object.entries(documentStatus)
+ .filter(([, value]) => !value)
+ .map(([key]) => key)
+
+ const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"]
+ const existingContactTypes = businessContacts.map(contact => contact.contactType)
+ const missingContactTypes = requiredContactTypes.filter(type => !existingContactTypes.includes(type))
+
+ return {
+ success: true,
+ data: {
+ vendor: vendor[0],
+ registration: registration[0] || null,
+ documentStatus,
+ missingDocuments,
+ businessContacts,
+ missingContactTypes,
+ additionalInfo: additionalInfo[0] || null,
+ pqSubmission: pqSubmission[0] || null,
+ auditPassed: investigationFiles.length > 0,
+ contractInfo: contractInfo[0] || null,
+ incompleteItemsCount: {
+ documents: missingDocuments.length,
+ contacts: missingContactTypes.length,
+ additionalInfo: !additionalInfo[0] ? 1 : 0,
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Error in fetchVendorRegistrationStatus:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "현황 조회 중 오류가 발생했습니다.",
+ }
+ }
+ },
+ [`vendor-registration-status-${vendorId}`],
+ {
+ revalidate: 300, // 5분 캐시
+ tags: ["vendor-registration-status", `vendor-${vendorId}`],
+ }
+ )()
+}
+
+// 서명/직인 업로드 (임시 - 실제로는 파일 업로드 로직 필요)
+export async function uploadVendorSignature(vendorId: number, signatureData: {
+ type: "signature" | "seal"
+ signerName?: string
+ imageFile: string // base64 or file path
+}) {
+ try {
+ // TODO: 실제 파일 업로드 및 저장 로직 구현
+ console.log("Signature upload for vendor:", vendorId, signatureData)
+
+ // 캐시 무효화
+ revalidateTag(`vendor-registration-status`)
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "서명/직인이 등록되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error uploading signature:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "서명/직인 등록 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 업무담당자 정보 저장
+export async function saveVendorBusinessContacts(
+ vendorId: number,
+ contacts: Array<{
+ contactType: "sales" | "design" | "delivery" | "quality" | "tax_invoice"
+ contactName: string
+ position: string
+ department: string
+ responsibility: string
+ email: string
+ }>
+) {
+ try {
+ // 기존 데이터 삭제
+ await db
+ .delete(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendorId))
+
+ // 새 데이터 삽입
+ if (contacts.length > 0) {
+ await db
+ .insert(vendorBusinessContacts)
+ .values(contacts.map(contact => ({
+ ...contact,
+ vendorId,
+ })))
+ }
+
+ // 캐시 무효화
+ revalidateTag("vendor-registration-status")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "업무담당자 정보가 저장되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error saving business contacts:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "업무담당자 정보 저장 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+// 추가정보 저장
+export async function saveVendorAdditionalInfo(
+ vendorId: number,
+ info: {
+ businessType?: string
+ industryType?: string
+ companySize?: string
+ revenue?: string
+ factoryEstablishedDate?: string
+ preferredContractTerms?: string
+ }
+) {
+ try {
+ const existing = await db
+ .select()
+ .from(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ .limit(1)
+
+ if (existing[0]) {
+ // 업데이트
+ await db
+ .update(vendorAdditionalInfo)
+ .set({
+ ...info,
+ factoryEstablishedDate: info.factoryEstablishedDate || null,
+ revenue: info.revenue || null,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ } else {
+ // 신규 삽입
+ await db
+ .insert(vendorAdditionalInfo)
+ .values({
+ ...info,
+ vendorId,
+ factoryEstablishedDate: info.factoryEstablishedDate || null,
+ revenue: info.revenue || null,
+ })
+ }
+
+ // 캐시 무효화
+ revalidateTag("vendor-registration-status")
+ revalidateTag(`vendor-${vendorId}`)
+
+ return {
+ success: true,
+ message: "추가정보가 저장되었습니다.",
+ }
+ } catch (error) {
+ console.error("Error saving additional info:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "추가정보 저장 중 오류가 발생했습니다.",
+ }
+ }
+}
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
new file mode 100644
index 00000000..023bcfba
--- /dev/null
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
@@ -0,0 +1,270 @@
+"use client"
+
+import { type ColumnDef } from "@tanstack/react-table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { format } from "date-fns"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
+import { DocumentStatusDialog } from "@/components/vendor-regular-registrations/document-status-dialog"
+import { Button } from "@/components/ui/button"
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
+import { Eye, FileText, Ellipsis } from "lucide-react"
+import { toast } from "sonner"
+import { useState } from "react"
+
+const statusLabels = {
+ audit_pass: "실사통과",
+ cp_submitted: "CP등록",
+ cp_review: "CP검토",
+ cp_finished: "CP완료",
+ approval_ready: "조건충족",
+ in_review: "정규등록검토",
+ pending_approval: "장기미등록",
+}
+
+const statusColors = {
+ audit_pass: "bg-blue-100 text-blue-800",
+ cp_submitted: "bg-green-100 text-green-800",
+ cp_review: "bg-yellow-100 text-yellow-800",
+ cp_finished: "bg-purple-100 text-purple-800",
+ approval_ready: "bg-emerald-100 text-emerald-800",
+ in_review: "bg-orange-100 text-orange-800",
+ pending_approval: "bg-red-100 text-red-800",
+}
+
+export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
+
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-[2px]"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-[2px]"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string
+ return (
+ <Badge
+ variant="secondary"
+ className={statusColors[status as keyof typeof statusColors]}
+ >
+ {statusLabels[status as keyof typeof statusLabels] || status}
+ </Badge>
+ )
+ },
+ filterFn: (row, id, value) => {
+ return Array.isArray(value) && value.includes(row.getValue(id))
+ },
+ },
+ {
+ accessorKey: "potentialCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="잠재코드" />
+ ),
+ cell: ({ row }) => row.getValue("potentialCode") || "-",
+ },
+ {
+ accessorKey: "businessNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="사업자번호" />
+ ),
+ },
+ {
+ accessorKey: "companyName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체명" />
+ ),
+ },
+ {
+ accessorKey: "majorItems",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="주요품목" />
+ ),
+ cell: ({ row }) => {
+ const majorItems = row.getValue("majorItems") as string
+ try {
+ const items = majorItems ? JSON.parse(majorItems) : []
+ if (items.length === 0) return "-"
+
+ // 첫 번째 아이템을 itemCode-itemName 형태로 표시
+ const firstItem = items[0]
+ let displayText = ""
+
+ if (typeof firstItem === 'string') {
+ displayText = firstItem
+ } else if (typeof firstItem === 'object') {
+ const code = firstItem.itemCode || firstItem.code || ""
+ const name = firstItem.itemName || firstItem.name || firstItem.materialGroupName || ""
+ if (code && name) {
+ displayText = `${code}-${name}`
+ } else {
+ displayText = name || code || String(firstItem)
+ }
+ } else {
+ displayText = String(firstItem)
+ }
+
+ // 나머지 개수 표시
+ if (items.length > 1) {
+ displayText += ` 외 ${items.length - 1}개`
+ }
+
+ return displayText
+ } catch {
+ return majorItems || "-"
+ }
+ },
+ },
+ {
+ accessorKey: "establishmentDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설립일자" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("establishmentDate") as string
+ return date ? format(new Date(date), "yyyy.MM.dd") : "-"
+ },
+ },
+ {
+ accessorKey: "representative",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="대표자명" />
+ ),
+ cell: ({ row }) => row.getValue("representative") || "-",
+ },
+ {
+ id: "documentStatus",
+ header: "문서/자료 접수 현황",
+ cell: ({ row }) => {
+ const DocumentStatusCell = () => {
+ const [documentDialogOpen, setDocumentDialogOpen] = useState(false)
+ const registration = row.original
+
+ return (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setDocumentDialogOpen(true)}
+ >
+ <Eye className="w-4 h-4" />
+ 현황보기
+ </Button>
+ <DocumentStatusDialog
+ open={documentDialogOpen}
+ onOpenChange={setDocumentDialogOpen}
+ registration={registration}
+ />
+ </>
+ )
+ }
+
+ return <DocumentStatusCell />
+ },
+ },
+ {
+ accessorKey: "registrationRequestDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록요청일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("registrationRequestDate") as string
+ return date ? format(new Date(date), "yyyy.MM.dd") : "-"
+ },
+ },
+ {
+ accessorKey: "assignedDepartment",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당부서" />
+ ),
+ cell: ({ row }) => row.getValue("assignedDepartment") || "-",
+ },
+ {
+ accessorKey: "assignedUser",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자" />
+ ),
+ cell: ({ row }) => row.getValue("assignedUser") || "-",
+ },
+ {
+ accessorKey: "remarks",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => row.getValue("remarks") || "-",
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ const ActionsDropdownCell = () => {
+ const [documentDialogOpen, setDocumentDialogOpen] = useState(false)
+ const registration = row.original
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ <DropdownMenuItem
+ onClick={() => setDocumentDialogOpen(true)}
+ >
+ <Eye className="mr-2 h-4 w-4" />
+ 현황보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => {
+ toast.info("정규업체 등록 요청 기능은 준비 중입니다.")
+ }}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 등록요청
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <DocumentStatusDialog
+ open={documentDialogOpen}
+ onOpenChange={setDocumentDialogOpen}
+ registration={registration}
+ />
+ </>
+ )
+ }
+
+ return <ActionsDropdownCell />
+ },
+ },
+ ]
+}
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
new file mode 100644
index 00000000..c3b4739a
--- /dev/null
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx
@@ -0,0 +1,248 @@
+"use client"
+
+import { type Table } from "@tanstack/react-table"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import { FileText, RefreshCw, Download, Mail, FileWarning, Scale, Shield } from "lucide-react"
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
+import {
+ sendMissingContractRequestEmails,
+ sendAdditionalInfoRequestEmails,
+ skipLegalReview,
+ skipSafetyQualification
+} from "../service"
+import { useState } from "react"
+import { SkipReasonDialog } from "@/components/vendor-regular-registrations/skip-reason-dialog"
+
+interface VendorRegularRegistrationsTableToolbarActionsProps {
+ table: Table<VendorRegularRegistration>
+}
+
+export function VendorRegularRegistrationsTableToolbarActions({
+ table,
+}: VendorRegularRegistrationsTableToolbarActionsProps) {
+ const [syncLoading, setSyncLoading] = useState<{
+ missingContract: boolean;
+ additionalInfo: boolean;
+ legalSkip: boolean;
+ safetySkip: boolean;
+ }>({
+ missingContract: false,
+ additionalInfo: false,
+ legalSkip: false,
+ safetySkip: false,
+ })
+
+ const [skipDialogs, setSkipDialogs] = useState<{
+ legalReview: boolean;
+ safetyQualification: boolean;
+ }>({
+ legalReview: false,
+ safetyQualification: false,
+ })
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
+
+
+
+ const handleSendMissingContractRequest = async () => {
+ if (selectedRows.length === 0) {
+ toast.error("이메일을 발송할 업체를 선택해주세요.")
+ return
+ }
+
+ setSyncLoading(prev => ({ ...prev, missingContract: true }))
+ try {
+ const vendorIds = selectedRows.map(row => row.vendorId)
+ const result = await sendMissingContractRequestEmails(vendorIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ console.error("Error sending missing contract request:", error)
+ toast.error("누락계약요청 이메일 발송 중 오류가 발생했습니다.")
+ } finally {
+ setSyncLoading(prev => ({ ...prev, missingContract: false }))
+ }
+ }
+
+ const handleSendAdditionalInfoRequest = async () => {
+ if (selectedRows.length === 0) {
+ toast.error("이메일을 발송할 업체를 선택해주세요.")
+ return
+ }
+
+ setSyncLoading(prev => ({ ...prev, additionalInfo: true }))
+ try {
+ const vendorIds = selectedRows.map(row => row.vendorId)
+ const result = await sendAdditionalInfoRequestEmails(vendorIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ console.error("Error sending additional info request:", error)
+ toast.error("추가정보요청 이메일 발송 중 오류가 발생했습니다.")
+ } finally {
+ setSyncLoading(prev => ({ ...prev, additionalInfo: false }))
+ }
+ }
+
+ const handleLegalReviewSkip = async (reason: string) => {
+ const cpReviewRows = selectedRows.filter(row => row.status === "cp_review");
+ if (cpReviewRows.length === 0) {
+ toast.error("CP검토 상태인 업체를 선택해주세요.");
+ return;
+ }
+
+ setSyncLoading(prev => ({ ...prev, legalSkip: true }));
+ try {
+ const vendorIds = cpReviewRows.map(row => row.vendorId);
+ const result = await skipLegalReview(vendorIds, reason);
+
+ if (result.success) {
+ toast.success(result.message);
+ window.location.reload();
+ } else {
+ toast.error(result.error);
+ }
+ } catch (error) {
+ console.error("Error skipping legal review:", error);
+ toast.error("법무검토 Skip 처리 중 오류가 발생했습니다.");
+ } finally {
+ setSyncLoading(prev => ({ ...prev, legalSkip: false }));
+ }
+ };
+
+ const handleSafetyQualificationSkip = async (reason: string) => {
+ if (selectedRows.length === 0) {
+ toast.error("업체를 선택해주세요.");
+ return;
+ }
+
+ setSyncLoading(prev => ({ ...prev, safetySkip: true }));
+ try {
+ const vendorIds = selectedRows.map(row => row.vendorId);
+ const result = await skipSafetyQualification(vendorIds, reason);
+
+ if (result.success) {
+ toast.success(result.message);
+ window.location.reload();
+ } else {
+ toast.error(result.error);
+ }
+ } catch (error) {
+ console.error("Error skipping safety qualification:", error);
+ toast.error("안전적격성평가 Skip 처리 중 오류가 발생했습니다.");
+ } finally {
+ setSyncLoading(prev => ({ ...prev, safetySkip: false }));
+ }
+ };
+
+ // CP검토 상태인 선택된 행들 개수
+ const cpReviewCount = selectedRows.filter(row => row.status === "cp_review").length;
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSyncDocuments}
+ disabled={syncLoading.documents || selectedRows.length === 0}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ {syncLoading.documents ? "동기화 중..." : "문서 동기화"}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSyncAgreements}
+ disabled={syncLoading.agreements || selectedRows.length === 0}
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ {syncLoading.agreements ? "동기화 중..." : "계약 동기화"}
+ </Button> */}
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendMissingContractRequest}
+ disabled={syncLoading.missingContract || selectedRows.length === 0}
+ >
+ <FileWarning className="mr-2 h-4 w-4" />
+ {syncLoading.missingContract ? "발송 중..." : "누락계약요청"}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendAdditionalInfoRequest}
+ disabled={syncLoading.additionalInfo || selectedRows.length === 0}
+ >
+ <Mail className="mr-2 h-4 w-4" />
+ {syncLoading.additionalInfo ? "발송 중..." : "추가정보요청"}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ if (selectedRows.length === 0) {
+ toast.error("내보낼 항목을 선택해주세요.")
+ return
+ }
+ toast.info("엑셀 내보내기 기능은 준비 중입니다.")
+ }}
+ disabled={selectedRows.length === 0}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ 엑셀 내보내기
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setSkipDialogs(prev => ({ ...prev, legalReview: true }))}
+ disabled={syncLoading.legalSkip || cpReviewCount === 0}
+ >
+ <Scale className="mr-2 h-4 w-4" />
+ {syncLoading.legalSkip ? "처리 중..." : "법무검토 Skip"}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setSkipDialogs(prev => ({ ...prev, safetyQualification: true }))}
+ disabled={syncLoading.safetySkip || selectedRows.length === 0}
+ >
+ <Shield className="mr-2 h-4 w-4" />
+ {syncLoading.safetySkip ? "처리 중..." : "안전 Skip"}
+ </Button>
+
+ <SkipReasonDialog
+ open={skipDialogs.legalReview}
+ onOpenChange={(open) => setSkipDialogs(prev => ({ ...prev, legalReview: open }))}
+ title="법무검토 Skip"
+ description={`선택된 ${cpReviewCount}개 업체의 법무검토를 Skip하고 CP완료 상태로 변경합니다. Skip 사유를 입력해주세요.`}
+ onConfirm={handleLegalReviewSkip}
+ loading={syncLoading.legalSkip}
+ />
+
+ <SkipReasonDialog
+ open={skipDialogs.safetyQualification}
+ onOpenChange={(open) => setSkipDialogs(prev => ({ ...prev, safetyQualification: open }))}
+ title="안전적격성평가 Skip"
+ description={`선택된 ${selectedRows.length}개 업체의 안전적격성평가를 Skip하고 완료 상태로 변경합니다. Skip 사유를 입력해주세요.`}
+ onConfirm={handleSafetyQualificationSkip}
+ loading={syncLoading.safetySkip}
+ />
+ </div>
+ )
+}
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx
new file mode 100644
index 00000000..8b477dba
--- /dev/null
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table.tsx
@@ -0,0 +1,104 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./vendor-regular-registrations-table-columns"
+import { VendorRegularRegistrationsTableToolbarActions } from "./vendor-regular-registrations-table-toolbar-actions"
+import { fetchVendorRegularRegistrations } from "../service"
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"
+
+interface VendorRegularRegistrationsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof fetchVendorRegularRegistrations>>,
+ ]
+ >
+}
+
+export function VendorRegularRegistrationsTable({ promises }: VendorRegularRegistrationsTableProps) {
+ // Suspense로 받아온 데이터
+ const [result] = React.use(promises)
+
+ if (!result.success || !result.data) {
+ throw new Error(result.error || "데이터를 불러오는데 실패했습니다.")
+ }
+
+ const data = result.data
+ const pageCount = Math.ceil(data.length / 10) // 임시로 10개씩 페이징
+
+
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const filterFields: DataTableFilterField<VendorRegularRegistration>[] = [
+ { id: "companyName", label: "업체명" },
+ { id: "businessNumber", label: "사업자번호" },
+ { id: "status", label: "상태" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorRegularRegistration>[] = [
+ { id: "companyName", label: "업체명", type: "text" },
+ { id: "businessNumber", label: "사업자번호", type: "text" },
+ { id: "potentialCode", label: "잠재코드", type: "text" },
+ { id: "representative", label: "대표자명", type: "text" },
+ {
+ id: "status",
+ label: "상태",
+ type: "select",
+ options: [
+ { label: "실사통과", value: "audit_pass" },
+ { label: "CP등록", value: "cp_submitted" },
+ { label: "CP검토", value: "cp_review" },
+ { label: "CP완료", value: "cp_finished" },
+ { label: "조건충족", value: "approval_ready" },
+ { label: "정규등록검토", value: "in_review" },
+ { label: "장기미등록", value: "pending_approval" },
+ ]
+ },
+ { id: "assignedDepartment", label: "담당부서", type: "text" },
+ { id: "assignedUser", label: "담당자", type: "text" },
+ { id: "registrationRequestDate", label: "등록요청일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "id", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorRegularRegistrationsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+}